From 7d6e5b210cde5632f94b9c74f99eb0d5f52ae407 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Mon, 26 Oct 2026 13:22:41 -0400 Subject: [PATCH 1/6] Add Azure Artifact Signing publisher ID extraction and pinned-root validation Artifact Signing certificates are renewed daily (72-hour validity), so the durable identity is the certificate-profile EKU under 1.3.6.1.4.1.311.97. Decode it into a publisher ID and add a validation policy that pins the signer and RFC 3161 timestamp chains to Microsoft Identity Verification Root Certificate Authority 2020 instead of trusting AuthRoot. - psign-authenticode-trust: new artifact_signing module (hand-decoded EKU arcs, embedded pinned root checked against SHA-1/SHA-256, Public Trust, Public Trust Test, Private Trust and CI Policy kinds). The policy is a TrustVerifyPeOptions field applied by every trust_verify_* format through one shared per-PKCS#7 helper; trust_verify_path dispatches by extension. - CRL/OCSP: accept RSA PKCS#1 v1.5 with SHA-384/SHA-512 (Microsoft PKI CRLs are sha384WithRSAEncryption) and require the CRL outer signature algorithm to match TBSCertList.signature, so --revocation-mode require works. - psign-tool verify --artifact-signing[-publisher-id|-private-trust-root| -allow-missing-timestamp]: same check in Windows mode (after WinVerifyTrust, forced to /pa) and portable mode; portable trust-verify-* take the same flags; portable artifact-signing-publisher-id extracts the ID. - Portable core / FFI: artifact_signing request options, decoded identity and verification result in signature responses, and psign_core_get_artifact_signing_identity. - PowerShell: Get-PsignArtifactSigningPublisherId, Test-PsignArtifactSigning, and Get-PsignSignature -ArtifactSigning* parameters. - Docs (docs/artifact-signing-validation.md and related), CLI matrix, Rust CLI tests (both modes; real-sample test gated on PSIGN_ARTIFACT_SIGNING_SAMPLE), Pester tests, and public fixtures. Co-Authored-By: Claude Opus 5.5 --- .gitattributes | 1 + AGENTS.md | 1 + .../Devolutions.Psign/Devolutions.Psign.psd1 | 2 + .../Devolutions.Psign/Devolutions.Psign.psm1 | 2 +- .../en-US/about_Devolutions.Psign.help.txt | 16 + PowerShell/README.md | 14 + .../PsignArtifactSigning.Cmdlets.Tests.ps1 | 138 +++ ...oft-identity-verification-root-ca-2020.pem | 33 + .../src/artifact_signing.rs | 887 ++++++++++++++++++ crates/psign-authenticode-trust/src/lib.rs | 9 + crates/psign-authenticode-trust/src/online.rs | 185 +++- .../src/trust_pkcs7.rs | 40 + .../src/trust_verify_cab.rs | 34 +- .../src/trust_verify_catalog.rs | 34 +- .../src/trust_verify_detached.rs | 43 +- .../src/trust_verify_esd.rs | 34 +- .../src/trust_verify_file.rs | 41 + .../src/trust_verify_msi.rs | 34 +- .../src/trust_verify_pe.rs | 118 ++- .../src/trust_verify_script.rs | 34 +- .../src/trust_verify_zip.rs | 34 +- crates/psign-digest-cli/src/main.rs | 101 +- crates/psign-portable-core/src/lib.rs | 197 +++- crates/psign-portable-ffi/src/lib.rs | 22 +- docs/artifact-signing-validation.md | 305 ++++++ docs/authenticode-trust-stack.md | 5 +- docs/migration-artifact-signing.md | 19 + docs/portable-core-ffi.md | 3 + docs/portable-powershell-module.md | 18 + docs/psign-cli-matrix.json | 7 +- docs/rust-sip-gaps.md | 2 +- ...tPsignArtifactSigningPublisherIdCommand.cs | 111 +++ .../Cmdlets/GetPsignSignatureCommand.cs | 25 +- .../TestPsignArtifactSigningCommand.cs | 95 ++ .../Models/ArtifactSigning.cs | 90 ++ .../Models/PortableRequests.cs | 3 + .../Models/PortableSignature.cs | 11 + .../Native/PsignNative.cs | 8 + .../Utilities/ArtifactSigningOptions.cs | 33 + src/artifact_signing_verify.rs | 129 +++ src/cli.rs | 20 +- src/lib.rs | 36 +- src/portable_sign.rs | 2 + tests/artifact_signing_cli.rs | 153 +++ tests/fixtures/README.md | 11 + .../artifact-signing/devolutions-signer.pem | 38 + ...rosoft-public-rsa-timestamping-ca-2020.crl | Bin 0 -> 784 bytes ...rosoft-public-rsa-timestamping-ca-2020.pem | 43 + .../artifact-signing/psign-tool.pkcs7.der | Bin 0 -> 14000 bytes 49 files changed, 2963 insertions(+), 258 deletions(-) create mode 100644 PowerShell/tests/PsignArtifactSigning.Cmdlets.Tests.ps1 create mode 100644 crates/psign-authenticode-trust/certs/microsoft-identity-verification-root-ca-2020.pem create mode 100644 crates/psign-authenticode-trust/src/artifact_signing.rs create mode 100644 crates/psign-authenticode-trust/src/trust_verify_file.rs create mode 100644 docs/artifact-signing-validation.md create mode 100644 dotnet/Devolutions.Psign.PowerShell/Cmdlets/GetPsignArtifactSigningPublisherIdCommand.cs create mode 100644 dotnet/Devolutions.Psign.PowerShell/Cmdlets/TestPsignArtifactSigningCommand.cs create mode 100644 dotnet/Devolutions.Psign.PowerShell/Models/ArtifactSigning.cs create mode 100644 dotnet/Devolutions.Psign.PowerShell/Utilities/ArtifactSigningOptions.cs create mode 100644 src/artifact_signing_verify.rs create mode 100644 tests/artifact_signing_cli.rs create mode 100644 tests/fixtures/artifact-signing/devolutions-signer.pem create mode 100644 tests/fixtures/artifact-signing/microsoft-public-rsa-timestamping-ca-2020.crl create mode 100644 tests/fixtures/artifact-signing/microsoft-public-rsa-timestamping-ca-2020.pem create mode 100644 tests/fixtures/artifact-signing/psign-tool.pkcs7.der diff --git a/.gitattributes b/.gitattributes index 49ebc2f..5858eb0 100644 --- a/.gitattributes +++ b/.gitattributes @@ -15,6 +15,7 @@ tests/fixtures/**/*.appx binary tests/fixtures/**/*.appxbundle binary tests/fixtures/**/*.cab binary tests/fixtures/**/*.cat binary +tests/fixtures/**/*.crl binary tests/fixtures/**/*.crt binary tests/fixtures/**/*.der binary tests/fixtures/**/*.dll binary diff --git a/AGENTS.md b/AGENTS.md index 0e9c809..fb435ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,6 +48,7 @@ On Linux/macOS, match **`ci-unix`** by running **`bash scripts/linux-portable-va | **`docs/linux-signing-pipelines.md`** | Linux / portable verify, REST hash-sign, and hybrid embed workflows. | | **`docs/migration-azuresigntool.md`** | Migration notes for AzureSignTool-style Key Vault signing. | | **`docs/migration-artifact-signing.md`** | Migration notes for Azure Artifact Signing / Trusted Signing. | +| **`docs/artifact-signing-validation.md`** | Artifact Signing verification: publisher ID (certificate profile EKU) encoding, pinned Microsoft root CAs, validation algorithm. | | **`docs/roadmap-authenticode-linux.md`** | Unix/portable subset and **`psign-tool portable`**. | | **`docs/authenticode-trust-stack.md`** | Portable trust crate split (picky vs digest vs CMS). | | **`docs/authroot-linux-verify.md`** | Anchor dir + AuthRoot CAB usage on Linux. | diff --git a/PowerShell/Devolutions.Psign/Devolutions.Psign.psd1 b/PowerShell/Devolutions.Psign/Devolutions.Psign.psd1 index ca7e4ae..bcaa471 100644 --- a/PowerShell/Devolutions.Psign/Devolutions.Psign.psd1 +++ b/PowerShell/Devolutions.Psign/Devolutions.Psign.psd1 @@ -11,9 +11,11 @@ NestedModules = @('lib/net8.0/Devolutions.Psign.PowerShell.dll') FormatsToProcess = @('Devolutions.Psign.Format.ps1xml') CmdletsToExport = @( + 'Get-PsignArtifactSigningPublisherId', 'Get-PsignSignature', 'New-PsignFileCatalog', 'Set-PsignSignature', + 'Test-PsignArtifactSigning', 'Test-PsignFileCatalog', 'Test-PsignModule', 'Protect-PsignModule', diff --git a/PowerShell/Devolutions.Psign/Devolutions.Psign.psm1 b/PowerShell/Devolutions.Psign/Devolutions.Psign.psm1 index b5da8f1..8a43da9 100644 --- a/PowerShell/Devolutions.Psign/Devolutions.Psign.psm1 +++ b/PowerShell/Devolutions.Psign/Devolutions.Psign.psm1 @@ -56,7 +56,7 @@ Register-ArgumentCompleter -CommandName Set-PsignSignature, Protect-PsignModule } } -Register-ArgumentCompleter -CommandName Get-PsignSignature -ParameterName RevocationMode -ScriptBlock { +Register-ArgumentCompleter -CommandName Get-PsignSignature, Test-PsignArtifactSigning -ParameterName RevocationMode -ScriptBlock { param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) @('Off', 'BestEffort', 'Require') | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) diff --git a/PowerShell/Devolutions.Psign/en-US/about_Devolutions.Psign.help.txt b/PowerShell/Devolutions.Psign/en-US/about_Devolutions.Psign.help.txt index 067ffd9..c654b62 100644 --- a/PowerShell/Devolutions.Psign/en-US/about_Devolutions.Psign.help.txt +++ b/PowerShell/Devolutions.Psign/en-US/about_Devolutions.Psign.help.txt @@ -21,6 +21,20 @@ LONG DESCRIPTION Sign files with a local certificate, PFX, portable cert store thumbprint, Azure Key Vault, or Azure Trusted Signing. + AZURE ARTIFACT SIGNING CMDLETS + + Get-PsignArtifactSigningPublisherId + Read the Azure Artifact Signing publisher ID (certificate profile + EKU 1.3.6.1.4.1.311.97.*) from a signed file or a certificate. + Decoding only; no trust decision. + + Test-PsignArtifactSigning + Validate an Azure Artifact Signing signature: identity EKU, signer + and timestamp chains pinned to Microsoft Identity Verification Root + Certificate Authority 2020, validity at the trusted timestamp, and + optionally one of the expected -PublisherId values. Get-PsignSignature + accepts the same policy through -ArtifactSigning* parameters. + MODULE MANAGEMENT CMDLETS Test-PsignModule @@ -113,6 +127,8 @@ EXAMPLES SEE ALSO Get-PsignSignature + Get-PsignArtifactSigningPublisherId + Test-PsignArtifactSigning Set-PsignSignature Test-PsignModule Protect-PsignModule diff --git a/PowerShell/README.md b/PowerShell/README.md index b55f13e..230e7e6 100644 --- a/PowerShell/README.md +++ b/PowerShell/README.md @@ -17,6 +17,8 @@ Import-Module ./PowerShell/Devolutions.Psign/Devolutions.Psign.psd1 | Cmdlet | Description | |--------|-------------| | `Get-PsignSignature` | Inspect Authenticode signatures (PE, scripts, packages) | +| `Get-PsignArtifactSigningPublisherId` | Read the Azure Artifact Signing publisher ID from a signed file or certificate | +| `Test-PsignArtifactSigning` | Validate Azure Artifact Signing signatures with pinned Microsoft roots and publisher ID pinning | | `Set-PsignSignature` | Sign files using local keys, PFX, cert store, Azure KV, or Trusted Signing | | `Test-PsignModule` | Validate a module against AllSigned/RemoteSigned execution policy | | `Protect-PsignModule` | Batch-sign all policy-checked files in a module | @@ -107,6 +109,18 @@ Get-PsignSignature ./app.exe -AnchorDirectory ./trusted-roots/ Get-PsignSignature ./app.exe -AuthRootCab ./authroot.cab ``` +Azure Artifact Signing validation pins its own Microsoft root instead of using AuthRoot or explicit anchors: + +```powershell +# Publisher ID (certificate profile EKU suffix) of the signer; no trust decision: +Get-PsignArtifactSigningPublisherId ./tool.exe +Get-PsignArtifactSigningPublisherId -Certificate $signerCertificate + +# Azure Artifact Signing validation pinned to Microsoft's root and to one certificate profile: +Test-PsignArtifactSigning ./tool.exe -PublisherId 285218949.549339218.252840042.443064405 +Get-PsignSignature ./tool.exe -ArtifactSigningPublisherId 285218949.549339218.252840042.443064405 +``` + ## Signing Sources | Source | Parameters | diff --git a/PowerShell/tests/PsignArtifactSigning.Cmdlets.Tests.ps1 b/PowerShell/tests/PsignArtifactSigning.Cmdlets.Tests.ps1 new file mode 100644 index 0000000..9c1ea33 --- /dev/null +++ b/PowerShell/tests/PsignArtifactSigning.Cmdlets.Tests.ps1 @@ -0,0 +1,138 @@ +Set-StrictMode -Version Latest + +$env:PSIGN_NO_AUTO_TRUST = '1' + +<# + .SYNOPSIS + Tests for Get-PsignArtifactSigningPublisherId, Test-PsignArtifactSigning, and the + -ArtifactSigning* parameters of Get-PsignSignature. + + A self-signed certificate carrying forged Azure Artifact Signing EKUs proves that the + publisher ID is decoded from any certificate but only validates when the chain ends at the + pinned Microsoft root. Set PSIGN_ARTIFACT_SIGNING_SAMPLE to a real Artifact Signing PE (and + optionally PSIGN_ARTIFACT_SIGNING_SAMPLE_PUBLISHER_ID) to also run the positive cases. +#> + +BeforeDiscovery { + $script:HasSample = -not [string]::IsNullOrWhiteSpace($env:PSIGN_ARTIFACT_SIGNING_SAMPLE) +} + +BeforeAll { + $repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) + $modulePath = Join-Path $repoRoot 'PowerShell\Devolutions.Psign\Devolutions.Psign.psd1' + Import-Module $modulePath -Force + + $script:FixtureDir = Join-Path $repoRoot 'tests\fixtures\artifact-signing' + $script:RealPublisherId = '285218949.549339218.252840042.443064405' + $script:ForgedPublisherId = '111.222.333.444' + + $script:TestDir = Join-Path ([System.IO.Path]::GetTempPath()) "psign-aas-$([System.Guid]::NewGuid().ToString('N').Substring(0,8))" + New-Item -ItemType Directory -Path $script:TestDir -Force | Out-Null + + $rsa = [System.Security.Cryptography.RSA]::Create(2048) + $request = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new( + 'CN=Forged Artifact Signing', + $rsa, + [System.Security.Cryptography.HashAlgorithmName]::SHA256, + [System.Security.Cryptography.RSASignaturePadding]::Pkcs1) + $request.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509KeyUsageExtension]::new( + [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::DigitalSignature, $true)) + $ekuOids = [System.Security.Cryptography.OidCollection]::new() + foreach ($oid in @('1.3.6.1.4.1.311.97.1.0', "1.3.6.1.4.1.311.97.$script:ForgedPublisherId", '1.3.6.1.5.5.7.3.3')) { + $null = $ekuOids.Add([System.Security.Cryptography.Oid]::new($oid)) + } + $request.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension]::new($ekuOids, $false)) + $script:ForgedCert = $request.CreateSelfSigned( + [System.DateTimeOffset]::UtcNow.AddDays(-1), + [System.DateTimeOffset]::UtcNow.AddDays(30)) + + $certPath = Join-Path $script:TestDir 'forged.cer' + $keyPath = Join-Path $script:TestDir 'forged.key' + [System.IO.File]::WriteAllBytes($certPath, $script:ForgedCert.Export( + [System.Security.Cryptography.X509Certificates.X509ContentType]::Cert)) + [System.IO.File]::WriteAllText($keyPath, + [System.Security.Cryptography.PemEncoding]::WriteString('PRIVATE KEY', $rsa.ExportPkcs8PrivateKey())) + + $script:ForgedScript = Join-Path $script:TestDir 'forged.ps1' + Set-Content -LiteralPath $script:ForgedScript -Value "Write-Output 'forged'" -Encoding UTF8 + Set-PsignSignature -FilePath $script:ForgedScript -CertificatePath $certPath -PrivateKeyPath $keyPath | Out-Null +} + +AfterAll { + if ($script:TestDir -and (Test-Path $script:TestDir)) { + Remove-Item -LiteralPath $script:TestDir -Recurse -Force + } +} + +Describe 'Get-PsignArtifactSigningPublisherId' { + It 'decodes the publisher ID from a certificate' { + $cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new( + (Join-Path $script:FixtureDir 'devolutions-signer.pem')) + $id = Get-PsignArtifactSigningPublisherId -Certificate $cert + $id.PublisherId | Should -Be $script:RealPublisherId + $id.Kind | Should -Be 'public_trust' + $id.HasPublicTrustMarker | Should -BeTrue + $id.IdentityEku | Should -Be "1.3.6.1.4.1.311.97.$script:RealPublisherId" + } + + It 'decodes the publisher ID from a signed file without validating it' { + $id = Get-PsignArtifactSigningPublisherId -LiteralPath $script:ForgedScript + $id.PublisherId | Should -Be $script:ForgedPublisherId + $id.Path | Should -Be $script:ForgedScript + } + + It 'writes an error for a certificate without an Artifact Signing identity' { + $plain = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new( + (Join-Path $repoRoot 'tests\fixtures\devolutions-authenticode\authenticode-test-ca.crt')) + $result = Get-PsignArtifactSigningPublisherId -Certificate $plain -ErrorAction SilentlyContinue -ErrorVariable err + $result | Should -BeNullOrEmpty + $err[0].FullyQualifiedErrorId | Should -BeLike 'NotArtifactSigningCertificate*' + } +} + +Describe 'Test-PsignArtifactSigning' { + It 'rejects forged Artifact Signing EKUs that do not chain to the pinned Microsoft root' { + Test-PsignArtifactSigning -LiteralPath $script:ForgedScript | Should -BeFalse + Test-PsignArtifactSigning -LiteralPath $script:ForgedScript -PublisherId $script:ForgedPublisherId | Should -BeFalse + } + + It 'returns the signature object with -Detailed' { + $detail = Test-PsignArtifactSigning -LiteralPath $script:ForgedScript -Detailed + $detail.Status | Should -Be 'NotTrusted' + $detail.ArtifactSigningVerification | Should -BeNullOrEmpty + $detail.ArtifactSigningPublisherId | Should -Be $script:ForgedPublisherId + } + + It 'validates a real Artifact Signing sample with publisher pinning' -Skip:(-not $script:HasSample) { + $sample = $env:PSIGN_ARTIFACT_SIGNING_SAMPLE + $expected = if ($env:PSIGN_ARTIFACT_SIGNING_SAMPLE_PUBLISHER_ID) { $env:PSIGN_ARTIFACT_SIGNING_SAMPLE_PUBLISHER_ID } else { $script:RealPublisherId } + Test-PsignArtifactSigning -LiteralPath $sample | Should -BeTrue + Test-PsignArtifactSigning -LiteralPath $sample -PublisherId $expected | Should -BeTrue + Test-PsignArtifactSigning -LiteralPath $sample -PublisherId '1.2.3.4' | Should -BeFalse + + $detail = Test-PsignArtifactSigning -LiteralPath $sample -PublisherId $expected -Detailed + $detail.ArtifactSigningVerification.RootSha1 | Should -Be 'f40042e2e5f7e8ef8189fed15519aece42c3bfa2' + $detail.ArtifactSigningVerification.Timestamped | Should -BeTrue + } +} + +Describe 'Get-PsignSignature Artifact Signing parameters' { + It 'exposes the decoded publisher ID without validation' { + (Get-PsignSignature -LiteralPath $script:ForgedScript -SkipTrust).ArtifactSigningPublisherId | + Should -Be $script:ForgedPublisherId + } + + It 'reports NotTrusted for forged EKUs under -ArtifactSigningPublisherId' { + $signature = Get-PsignSignature -LiteralPath $script:ForgedScript -ArtifactSigningPublisherId $script:ForgedPublisherId + $signature.Status | Should -Be 'NotTrusted' + $signature.TrustStatus | Should -Be 'NotTrusted' + } + + It 'rejects generic trust anchors combined with Artifact Signing' { + $null = Get-PsignSignature -LiteralPath $script:ForgedScript -ArtifactSigning ` + -TrustedCertificate $script:ForgedCert -ErrorAction SilentlyContinue -ErrorVariable err + $err[0].Exception.Message | Should -BeLike '*cannot be combined with Artifact Signing*' + } +} diff --git a/crates/psign-authenticode-trust/certs/microsoft-identity-verification-root-ca-2020.pem b/crates/psign-authenticode-trust/certs/microsoft-identity-verification-root-ca-2020.pem new file mode 100644 index 0000000..5c5267e --- /dev/null +++ b/crates/psign-authenticode-trust/certs/microsoft-identity-verification-root-ca-2020.pem @@ -0,0 +1,33 @@ +-----BEGIN CERTIFICATE----- +MIIFzDCCA7SgAwIBAgIQVJjS0dRbGZVIE3nIEcCHmTANBgkqhkiG9w0BAQwFADB3 +MQswCQYDVQQGEwJVUzEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMUgw +RgYDVQQDEz9NaWNyb3NvZnQgSWRlbnRpdHkgVmVyaWZpY2F0aW9uIFJvb3QgQ2Vy +dGlmaWNhdGUgQXV0aG9yaXR5IDIwMjAwHhcNMjAwNDE2MTgzNjE2WhcNNDUwNDE2 +MTg0NDQwWjB3MQswCQYDVQQGEwJVUzEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBv +cmF0aW9uMUgwRgYDVQQDEz9NaWNyb3NvZnQgSWRlbnRpdHkgVmVyaWZpY2F0aW9u +IFJvb3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5IDIwMjAwggIiMA0GCSqGSIb3DQEB +AQUAA4ICDwAwggIKAoICAQCzkSoHgwZn/Z6d4MfAt6TmQgR/D6bbX/vVWtdFoPt3 +C/CA86ZtWk15U9ighoRXRSDHolT7x6K/isduNfOiFcQvTuNKhZZJDf++mdgU9rwn +B+5Cmyv1C5IG5P1pE2WokXLymITrgz0O5NdxEkghyw3t9kdJt5v5yccXtoRP/7is +mtdzZ0mF44a9N0DQJYbU3rXCbWJq1al4vC1vSfnlbBQU/RTH02UWN97LbrxeKY39 +YpsVLNYF5rmJMjOjYsfX1lJnCMQu9FYrnguHzOyntKaq6wXNGVelOgsEJxyRZ54t +Yi0vHr7awCDLBBnKM/uJvpjicqByNb554ZyDb+RtF2+Q8z0AhnU4jtDgSZq729P4 +MMrVV4hoTXLTv21/cdj9vQ2ukmRIt1tveSa1zZuVIYTR7w8yPXtXjPNFB0x84F4Y +DjV2i22eyzZ0qwX44HNdMlaUZ5clCsY1PZSX58FEi4D9wfj0dBnlMPYG+yFXPgYc +i2sVhidJe4KTylnodUfoPzj0x1N5oLa04lxR771fOMET5ngMlVouxUBZKMwPJMDs +ugl3I5k4prYc2se6ILbXN9h/N68I4ztx225zG32ZcrDkhjNZdLUWAHtQbcaGE9r9 +xDmCPSQAmmDaupTABVEsNKxQmROHu7MFgLJNMAJcuCaDXbRjc++uI5VPYCi+N9Vb +pQIDAQABo1QwUjAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNV +HQ4EFgQUyH7SaoUqG8oZmAQHJ89QEE9oqKIwEAYJKwYBBAGCNxUBBAMCAQAwDQYJ +KoZIhvcNAQEMBQADggIBAK9q3eYZ5y2UQxlOy+lQlWSlA5ECi+I2gDsVolLCFhm2 +alpddEMw9Jv/YHQJsSEekBZtxSSPXGaIY/RPzH3yEkxAEIsBn9qpyK7ylRvPnQXr +ST50oGhb5VYsZRyCflPaVtlGF3mSRcQQNghSKRfLL6byftRpJIoej7BzDcwcSquy +qu2nkWMBZCKoMrh+MiizZ3MtkbTcMQEL90cKpvHXSu1WYMQsCKN7QLC8dCdSh9a+ +iN03ioluZ4gd9cldoP62qzqA1xqXPBc2IkEerE3Vg+Y8OL1PMOlUqdO2BMMydmG7 +sBjFKxizwIDVt5WwXlFNIvzsWKro2JS0pS7tkt7nGHwhV91VY/e/bc0f0qZ3KHDH +4ls6WwjSW07IAJaz4YM2r4YKZVx09ursemp0oPBL7u+Uo6xQ8oft1zowg8n7fVe+ +5eP4QcrlZK6zo+xY7IWazO+56vNWGLlcc5qvxXcXg1nbNxoYclSlQdK2I3WjQ5rl +d3yWebdBjb/s3ICgn9F3dVhfNRPgJRpnC33OJfoHCuRhIdjUHOUHxjaZ9JbQxhX+ +Ts3Xroud2xb9BMaSvdSI5qmjqrv3ZDg7X8wM0DW+dBkDpsWqTKJhNoI+HfMrvJdd +20t4Oy31O+9gI+j17AsjNpWvmGa/U9N7uGlKKpZmacSUxvRfbqyYeIiABlyisu2i +-----END CERTIFICATE----- diff --git a/crates/psign-authenticode-trust/src/artifact_signing.rs b/crates/psign-authenticode-trust/src/artifact_signing.rs new file mode 100644 index 0000000..8060f54 --- /dev/null +++ b/crates/psign-authenticode-trust/src/artifact_signing.rs @@ -0,0 +1,887 @@ +//! Azure **Artifact Signing** (formerly Trusted Signing) certificate identity and pinned-root trust. +//! +//! Artifact Signing certificates are renewed daily and live for 72 hours, so pinning a thumbprint +//! or public key is not durable. Each certificate profile instead carries a custom EKU under +//! **`1.3.6.1.4.1.311.97.`** whose remaining arcs stay stable for the lifetime of the profile; +//! this module exposes that suffix as the **publisher ID**. +//! +//! | Profile type | Identity EKU | Notes | +//! |--------------|--------------|-------| +//! | Public Trust | `1.3.6.1.4.1.311.97.` | Also carries the `1.3.6.1.4.1.311.97.1.0` marker EKU. | +//! | Public Trust Test | `1.3.6.1.4.1.311.97.1.2.` | Not chained to a production Microsoft root. | +//! | Private Trust | `1.3.6.1.4.1.311.97.1.3.1.` | Chains to a subscriber-specific root. | +//! | Private Trust CI Policy | `1.3.6.1.4.1.311.97.1.4.1.` | No code signing EKU (WDAC policy signing only). | +//! +//! Public Trust signatures (and the `timestamp.acs.microsoft.com` TSA) chain to +//! **Microsoft Identity Verification Root Certificate Authority 2020**, which is pinned here instead +//! of trusting the full AuthRoot program. +//! +//! References: +//! - +//! - + +use crate::anchor::{AnchorStore, cert_sha1_thumbprint, parse_cert_bytes}; +use crate::chain::{issuer_chain_excluding_leaf, merge_unique_certs, terminal_root_cert}; +use crate::policy::AuthenticodeTrustPolicy; +use crate::rfc3161_extract::trusted_utc_date_from_authenticode_timestamp_token; +use crate::trust_pkcs7::{Pkcs7DigestBinding, verify_pkcs7_trust_with_binding}; +use crate::trust_verify_pe::TrustVerifyPeOptions; +use anyhow::{Context, Result, anyhow}; +use der::asn1::ObjectIdentifier; +use der::{Decode, Header, Reader, SliceReader, Tag}; +use picky::x509::certificate::Cert; +use picky::x509::date::UtcDate; +use psign_sip_digest::pkcs7::{ + parse_pkcs7_signed_data_der, signed_data_certificate_for_signer_identifier, +}; +use psign_sip_digest::pkcs7_wire::normalize_pkcs7_der_for_authenticode; +use psign_sip_digest::verify_pe::for_each_pe_pkcs7_signed_data; +use serde::{Deserialize, Serialize}; +use sha2::Digest; +use std::path::PathBuf; +use x509_cert::Certificate; + +const EKU_EXTENSION_OID: ObjectIdentifier = ObjectIdentifier::new_unwrap("2.5.29.37"); + +/// Arcs shared by every Artifact Signing EKU (`1.3.6.1.4.1.311.97`). +const ARTIFACT_SIGNING_EKU_ARCS: &[u64] = &[1, 3, 6, 1, 4, 1, 311, 97]; +const CODE_SIGNING_EKU_ARCS: &[u64] = &[1, 3, 6, 1, 5, 5, 7, 3, 3]; + +/// Prefix of every Artifact Signing EKU, including the trailing dot. +pub const ARTIFACT_SIGNING_EKU_PREFIX: &str = "1.3.6.1.4.1.311.97."; +/// EKU present on every Artifact Signing **Public Trust** certificate. +pub const PUBLIC_TRUST_MARKER_EKU: &str = "1.3.6.1.4.1.311.97.1.0"; + +/// A Microsoft root certificate pinned for Artifact Signing validation. +#[derive(Debug, Clone, Copy)] +pub struct PinnedRoot { + pub name: &'static str, + pub sha1: [u8; 20], + pub sha256: [u8; 32], + pem: &'static str, +} + +impl PinnedRoot { + /// Parse the embedded certificate and confirm it matches both pinned thumbprints. + pub fn certificate(&self) -> Result { + let cert = parse_cert_bytes(self.pem.as_bytes()) + .with_context(|| format!("parse pinned root {}", self.name))?; + let der = cert + .to_der() + .map_err(|e| anyhow!("encode pinned root {}: {e}", self.name))?; + if cert_sha1_thumbprint(&cert)? != self.sha1 + || sha2::Sha256::digest(&der).as_slice() != self.sha256 + { + return Err(anyhow!( + "embedded pinned root {} does not match its pinned thumbprints", + self.name + )); + } + Ok(cert) + } +} + +/// **Microsoft Identity Verification Root Certificate Authority 2020**: issues +/// *Microsoft ID Verified Code Signing PCA 2021* (Artifact Signing Public Trust) and +/// *Microsoft Public RSA Timestamping CA 2020* (`timestamp.acs.microsoft.com`). +pub const MICROSOFT_IDENTITY_VERIFICATION_ROOT_2020: PinnedRoot = PinnedRoot { + name: "Microsoft Identity Verification Root Certificate Authority 2020", + sha1: hex_array("f40042e2e5f7e8ef8189fed15519aece42c3bfa2"), + sha256: hex_array("5367f20c7ade0e2bca790915056d086b720c33c1fa2a2661acf787e3292e1270"), + pem: include_str!("../certs/microsoft-identity-verification-root-ca-2020.pem"), +}; + +/// Roots accepted for Artifact Signing **Public Trust** code signing and its timestamps. +pub const PUBLIC_TRUST_ROOTS: &[PinnedRoot] = &[MICROSOFT_IDENTITY_VERIFICATION_ROOT_2020]; + +const fn hex_array(hex: &str) -> [u8; N] { + const fn nibble(c: u8) -> u8 { + match c { + b'0'..=b'9' => c - b'0', + b'a'..=b'f' => c - b'a' + 10, + _ => panic!("invalid lowercase hex digit"), + } + } + let bytes = hex.as_bytes(); + assert!(bytes.len() == N * 2, "hex length mismatch"); + let mut out = [0u8; N]; + let mut i = 0; + while i < N { + out[i] = (nibble(bytes[i * 2]) << 4) | nibble(bytes[i * 2 + 1]); + i += 1; + } + out +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ArtifactSigningCertificateKind { + PublicTrust, + PublicTrustTest, + PrivateTrust, + PrivateTrustCiPolicy, +} + +impl ArtifactSigningCertificateKind { + pub fn as_str(self) -> &'static str { + match self { + Self::PublicTrust => "public_trust", + Self::PublicTrustTest => "public_trust_test", + Self::PrivateTrust => "private_trust", + Self::PrivateTrustCiPolicy => "private_trust_ci_policy", + } + } +} + +/// Durable Artifact Signing identity decoded from a certificate's EKU extension. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ArtifactSigningIdentity { + pub kind: ArtifactSigningCertificateKind, + /// Dotted arcs that follow the profile-type prefix (stable across daily certificate renewals). + pub publisher_id: String, + /// Full identity EKU OID (`1.3.6.1.4.1.311.97.…`). + pub identity_eku: String, + pub has_public_trust_marker: bool, + pub has_code_signing_eku: bool, +} + +/// Decode the EKU `SEQUENCE OF OBJECT IDENTIFIER` into arcs. +/// +/// Decoded by hand because Artifact Signing identity arcs can need five base-128 octets, which +/// `const-oid 0.9` rejects (see `trust_pkcs7::extended_key_usage_contains`). +fn decode_eku_arcs(der: &[u8]) -> Result>> { + let mut outer = SliceReader::new(der).map_err(|e| anyhow!("ExtendedKeyUsage reader: {e}"))?; + let header = Header::decode(&mut outer).map_err(|e| anyhow!("ExtendedKeyUsage header: {e}"))?; + if header.tag != Tag::Sequence { + return Err(anyhow!("ExtendedKeyUsage extension is not a SEQUENCE")); + } + let body = outer + .read_slice(header.length) + .map_err(|e| anyhow!("ExtendedKeyUsage body: {e}"))?; + outer + .finish(()) + .map_err(|e| anyhow!("ExtendedKeyUsage trailing data: {e}"))?; + + let mut entries = + SliceReader::new(body).map_err(|e| anyhow!("ExtendedKeyUsage entries: {e}"))?; + let mut out = Vec::new(); + while !entries.is_finished() { + let header = Header::decode(&mut entries) + .map_err(|e| anyhow!("ExtendedKeyUsage OID header: {e}"))?; + if header.tag != Tag::ObjectIdentifier { + return Err(anyhow!( + "ExtendedKeyUsage entry is not an OBJECT IDENTIFIER" + )); + } + let value = entries + .read_slice(header.length) + .map_err(|e| anyhow!("ExtendedKeyUsage OID value: {e}"))?; + out.push(decode_oid_arcs(value)?); + } + Ok(out) +} + +fn decode_oid_arcs(value: &[u8]) -> Result> { + let mut subidentifiers = Vec::new(); + let mut current: u64 = 0; + let mut starts_subidentifier = true; + for &byte in value { + if starts_subidentifier && byte == 0x80 { + return Err(anyhow!("ExtendedKeyUsage contains a non-canonical OID")); + } + if current > (u64::MAX >> 7) { + return Err(anyhow!("ExtendedKeyUsage OID arc exceeds 64 bits")); + } + current = (current << 7) | u64::from(byte & 0x7f); + starts_subidentifier = byte & 0x80 == 0; + if starts_subidentifier { + subidentifiers.push(current); + current = 0; + } + } + if !starts_subidentifier || subidentifiers.is_empty() { + return Err(anyhow!("ExtendedKeyUsage contains a truncated OID")); + } + + let first = subidentifiers[0]; + let (a, b) = match first { + 0..=39 => (0, first), + 40..=79 => (1, first - 40), + _ => (2, first - 80), + }; + let mut arcs = vec![a, b]; + arcs.extend_from_slice(&subidentifiers[1..]); + Ok(arcs) +} + +fn dotted(arcs: &[u64]) -> String { + arcs.iter() + .map(u64::to_string) + .collect::>() + .join(".") +} + +/// Classify the arcs after `1.3.6.1.4.1.311.97`; `None` for the marker and unknown sub-types. +fn classify_identity_suffix(rest: &[u64]) -> Option<(ArtifactSigningCertificateKind, &[u64])> { + use ArtifactSigningCertificateKind::*; + let (kind, id) = match rest { + [1, 2, id @ ..] => (PublicTrustTest, id), + [1, 3, 1, id @ ..] => (PrivateTrust, id), + [1, 4, 1, id @ ..] => (PrivateTrustCiPolicy, id), + [1, ..] => return None, + id => (PublicTrust, id), + }; + (!id.is_empty()).then_some((kind, id)) +} + +/// Extract the Artifact Signing identity from an X.509 certificate. +/// +/// Returns `Ok(None)` when the certificate has no Artifact Signing identity EKU, and an error when it +/// carries more than one distinct identity EKU. +pub fn artifact_signing_identity_from_certificate( + cert: &Certificate, +) -> Result> { + let Some(exts) = &cert.tbs_certificate.extensions else { + return Ok(None); + }; + + let mut identity: Option = None; + let mut has_public_trust_marker = false; + let mut has_code_signing_eku = false; + for ext in exts.iter().filter(|e| e.extn_id == EKU_EXTENSION_OID) { + for arcs in decode_eku_arcs(ext.extn_value.as_bytes())? { + if arcs == CODE_SIGNING_EKU_ARCS { + has_code_signing_eku = true; + continue; + } + let Some(rest) = arcs.strip_prefix(ARTIFACT_SIGNING_EKU_ARCS) else { + continue; + }; + if rest == [1, 0] { + has_public_trust_marker = true; + continue; + } + let Some((kind, id)) = classify_identity_suffix(rest) else { + continue; + }; + let found = ArtifactSigningIdentity { + kind, + publisher_id: dotted(id), + identity_eku: dotted(&arcs), + has_public_trust_marker: false, + has_code_signing_eku: false, + }; + match &identity { + Some(prev) if prev.identity_eku != found.identity_eku => { + return Err(anyhow!( + "certificate carries multiple Artifact Signing identity EKUs ({} and {})", + prev.identity_eku, + found.identity_eku + )); + } + Some(_) => {} + None => identity = Some(found), + } + } + } + + Ok(identity.map(|mut id| { + id.has_public_trust_marker = has_public_trust_marker; + id.has_code_signing_eku = has_code_signing_eku; + id + })) +} + +/// [`artifact_signing_identity_from_certificate`] for a DER or PEM encoded certificate. +pub fn artifact_signing_identity_from_cert_bytes( + raw: &[u8], +) -> Result> { + let der = parse_cert_bytes(raw)? + .to_der() + .map_err(|e| anyhow!("certificate DER encode failed: {e}"))?; + let cert = Certificate::from_der(&der).map_err(|e| anyhow!("X.509 decode: {e}"))?; + artifact_signing_identity_from_certificate(&cert) +} + +/// Subject DN via picky (decodes BMPString attributes that `x509-cert` prints as hex). +fn subject_display(cert: &Certificate) -> String { + der::Encode::to_der(cert) + .ok() + .and_then(|der| Cert::from_der(&der).ok()) + .map(|c| c.subject_name().to_string()) + .unwrap_or_else(|| cert.tbs_certificate.subject.to_string()) +} + +/// Signer certificate (first `SignerInfo`) and its Artifact Signing identity from a PKCS#7 blob. +fn pkcs7_signer_identity( + pkcs7_der: &[u8], +) -> Result<(Certificate, Option)> { + let sd = parse_pkcs7_signed_data_der(pkcs7_der)?; + let si = sd + .signer_infos + .0 + .as_slice() + .first() + .ok_or_else(|| anyhow!("SignedData has no SignerInfo"))?; + let cert = signed_data_certificate_for_signer_identifier(&sd, &si.sid)?.clone(); + let identity = artifact_signing_identity_from_certificate(&cert)?; + Ok((cert, identity)) +} + +/// Artifact Signing identity of the primary signer in a PKCS#7 / CMS `SignedData` blob. +pub fn artifact_signing_identity_from_pkcs7_der( + pkcs7_der: &[u8], +) -> Result> { + Ok(pkcs7_signer_identity(pkcs7_der)?.1) +} + +/// Signer identity for one PE certificate-table PKCS#7 entry. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArtifactSigningSignerIdentity { + pub pkcs7_index: usize, + pub signer_subject: String, + pub identity: Option, +} + +/// Artifact Signing identities of every embedded PE Authenticode signature (no trust checks). +pub fn artifact_signing_identities_from_pe( + pe_bytes: &[u8], +) -> Result> { + let mut out = Vec::new(); + for_each_pe_pkcs7_signed_data(pe_bytes, |pkcs7_index, pkcs7_der| { + let (cert, identity) = pkcs7_signer_identity(pkcs7_der) + .with_context(|| format!("PKCS#7 index {pkcs7_index}"))?; + out.push(ArtifactSigningSignerIdentity { + pkcs7_index, + signer_subject: subject_display(&cert), + identity, + }); + Ok(()) + })?; + Ok(out) +} + +/// Artifact Signing policy applied by every `trust_verify_*` entry point when +/// [`TrustVerifyPeOptions::artifact_signing`] is set. +/// +/// Replaces the generic anchor set (`--anchor-dir`, `--trusted-ca`, AuthRoot) with +/// [`PUBLIC_TRUST_ROOTS`] (Public Trust) or [`Self::private_trust_root_files`] (Private Trust / +/// Public Trust Test). `verification_instant_override`, `online`, and `verbose_chain` still come +/// from [`TrustVerifyPeOptions`]. +#[derive(Debug, Clone)] +pub struct ArtifactSigningPolicy { + /// When non-empty, the signer's publisher ID must equal one of these values. + pub expected_publisher_ids: Vec, + /// Subscriber roots for Private Trust / Public Trust Test signatures (PEM or DER files). + /// Public Trust signatures always use [`PUBLIC_TRUST_ROOTS`] and ignore these. + pub private_trust_root_files: Vec, + /// Require a timestamp countersignature chained to [`PUBLIC_TRUST_ROOTS`] (default `true`). + /// + /// When `false` and no timestamp is present, the wall clock is used; Artifact Signing + /// certificates expire after 72 hours so such signatures fail soon after signing. + pub require_timestamp: bool, +} + +impl Default for ArtifactSigningPolicy { + fn default() -> Self { + Self { + expected_publisher_ids: Vec::new(), + private_trust_root_files: Vec::new(), + require_timestamp: true, + } + } +} + +/// One verified Artifact Signing signature. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArtifactSigningVerifiedSignature { + pub pkcs7_index: usize, + pub signer_subject: String, + pub identity: ArtifactSigningIdentity, + /// Subject of the trust anchor that terminates the signer chain. + pub root_subject: String, + /// Lowercase hex SHA-1 thumbprint of that trust anchor. + pub root_sha1: String, + /// `true` when the verification time came from a trusted timestamp countersignature. + pub timestamped: bool, + /// RFC 3339 instant used for certificate validity checks. + pub verification_time: String, +} + +impl std::fmt::Display for ArtifactSigningVerifiedSignature { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "PKCS#7[{}] {} publisher_id={} kind={} root={} time={}{}", + self.pkcs7_index, + self.signer_subject, + self.identity.publisher_id, + self.identity.kind.as_str(), + self.root_sha1, + self.verification_time, + if self.timestamped { " (timestamp)" } else { "" } + ) + } +} + +fn hex_lower(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +fn anchors_from_certs(certs: Vec) -> Result<(AnchorStore, Vec)> { + let mut store = AnchorStore::empty(); + store.merge_cert_thumbprints(&certs)?; + Ok((store, certs)) +} + +/// Pinned Microsoft roots used for Public Trust signer chains and timestamp countersignatures. +pub fn public_trust_anchor_certs() -> Result> { + PUBLIC_TRUST_ROOTS + .iter() + .map(PinnedRoot::certificate) + .collect() +} + +/// Anchor store holding only [`PUBLIC_TRUST_ROOTS`]. +pub fn public_trust_anchors() -> Result<(AnchorStore, Vec)> { + anchors_from_certs(public_trust_anchor_certs()?) +} + +/// Verify one PKCS#7 as an Artifact Signing signature whose digest binding equals `subject_digest`. +/// +/// Checks, in order: the signer carries an Artifact Signing identity EKU (and the Public Trust +/// marker for Public Trust profiles), the publisher ID matches +/// [`ArtifactSigningPolicy::expected_publisher_ids`], the timestamp countersignature chains to a +/// pinned Microsoft root, and the signer chain terminates at a pinned Microsoft root (Public +/// Trust) or a supplied subscriber root (Private Trust / Public Trust Test) with the CMS +/// signature and code-signing EKU valid at the timestamp. +pub fn verify_artifact_signing_pkcs7( + pkcs7_der: &[u8], + pkcs7_index: usize, + subject_digest: &[u8], + binding: Pkcs7DigestBinding, + policy: &ArtifactSigningPolicy, + opts: &TrustVerifyPeOptions, +) -> Result { + use ArtifactSigningCertificateKind::*; + + let (signer, identity) = pkcs7_signer_identity(pkcs7_der)?; + let signer_subject = subject_display(&signer); + let identity = identity.ok_or_else(|| { + anyhow!( + "signer {signer_subject:?} has no Artifact Signing identity EKU ({ARTIFACT_SIGNING_EKU_PREFIX}…)" + ) + })?; + + match identity.kind { + PublicTrust if !identity.has_public_trust_marker => { + return Err(anyhow!( + "Public Trust signer lacks the {PUBLIC_TRUST_MARKER_EKU} marker EKU" + )); + } + PrivateTrustCiPolicy => { + return Err(anyhow!( + "Private Trust CI Policy certificates sign WDAC policies, not Authenticode code" + )); + } + _ => {} + } + if !identity.has_code_signing_eku { + return Err(anyhow!("signer lacks the code signing EKU")); + } + if !policy.expected_publisher_ids.is_empty() + && !policy + .expected_publisher_ids + .iter() + .any(|id| id.trim() == identity.publisher_id) + { + return Err(anyhow!( + "Artifact Signing publisher ID {} is not one of the expected IDs ({})", + identity.publisher_id, + policy.expected_publisher_ids.join(", ") + )); + } + + let (ms_anchors, ms_anchor_certs) = public_trust_anchors()?; + let (timestamped, verification_instant) = match &opts.verification_instant_override { + Some(instant) => (false, instant.clone()), + None => match trusted_utc_date_from_authenticode_timestamp_token( + pkcs7_der, + &ms_anchors, + &ms_anchor_certs, + &opts.online, + opts.verbose_chain, + ) + .context("Artifact Signing timestamp countersignature")? + { + Some(instant) => (true, instant), + None if policy.require_timestamp => { + return Err(anyhow!( + "Artifact Signing signature has no RFC 3161 timestamp countersignature" + )); + } + None => (false, UtcDate::now()), + }, + }; + + let (anchors, anchor_certs) = match identity.kind { + PublicTrust => (ms_anchors, ms_anchor_certs), + _ => { + if policy.private_trust_root_files.is_empty() { + return Err(anyhow!( + "{} signature requires the subscriber root certificate (private trust root)", + identity.kind.as_str() + )); + } + let (_, certs) = AnchorStore::load_files(&policy.private_trust_root_files)?; + // Microsoft production roots must never anchor a non-Public-Trust profile. + let certs: Vec = certs + .into_iter() + .filter(|c| { + cert_sha1_thumbprint(c) + .map(|t| !PUBLIC_TRUST_ROOTS.iter().any(|r| r.sha1 == t)) + .unwrap_or(false) + }) + .collect(); + if certs.is_empty() { + return Err(anyhow!( + "no usable private trust roots (Microsoft Public Trust roots are not accepted for {})", + identity.kind.as_str() + )); + } + anchors_from_certs(certs)? + } + }; + + let trust_policy = AuthenticodeTrustPolicy { + strict_code_signing_eku: true, + prefer_timestamp_signing_time: false, + require_valid_timestamp: false, + }; + verify_pkcs7_trust_with_binding( + binding, + pkcs7_der, + pkcs7_index, + subject_digest, + &anchors, + &anchor_certs, + &trust_policy, + &opts.online, + &verification_instant, + opts.verbose_chain, + )?; + + let root = signer_terminal_root(pkcs7_der, &anchors, &anchor_certs)?; + Ok(ArtifactSigningVerifiedSignature { + pkcs7_index, + signer_subject, + identity, + root_subject: root.subject_name().to_string(), + root_sha1: hex_lower(&cert_sha1_thumbprint(&root)?), + timestamped, + verification_time: utc_date_rfc3339(&verification_instant), + }) +} + +/// Re-walk the (already verified) signer chain to report which anchor terminated it. +fn signer_terminal_root( + pkcs7_der: &[u8], + anchors: &AnchorStore, + anchor_certs: &[Cert], +) -> Result { + let normalized = normalize_pkcs7_der_for_authenticode(pkcs7_der); + let sd = parse_pkcs7_signed_data_der(normalized.as_ref())?; + let si = sd + .signer_infos + .0 + .as_slice() + .first() + .ok_or_else(|| anyhow!("SignedData has no SignerInfo"))?; + let leaf_der = + der::Encode::to_der(signed_data_certificate_for_signer_identifier(&sd, &si.sid)?) + .map_err(|e| anyhow!("encode signer certificate: {e}"))?; + let leaf = Cert::from_der(&leaf_der).map_err(|e| anyhow!("picky signer certificate: {e}"))?; + let embedded: Vec = sd + .certificates + .iter() + .flat_map(|set| set.0.iter()) + .filter_map(|choice| match choice { + cms::cert::CertificateChoices::Certificate(c) => der::Encode::to_der(c).ok(), + _ => None, + }) + .filter_map(|der| Cert::from_der(&der).ok()) + .collect(); + let pool = merge_unique_certs(anchor_certs.to_vec(), embedded)?; + let chain = issuer_chain_excluding_leaf(&leaf, &pool, Some(anchors))?; + Ok(terminal_root_cert(&leaf, &chain).clone()) +} + +fn utc_date_rfc3339(d: &UtcDate) -> String { + format!( + "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", + d.year(), + d.month(), + d.day(), + d.hour(), + d.minute(), + d.second() + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture(name: &str) -> Vec { + let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../tests/fixtures/artifact-signing") + .join(name); + std::fs::read(&p).unwrap_or_else(|e| panic!("read {}: {e}", p.display())) + } + + fn eku(oids: &[&[u64]]) -> Vec { + fn base128(mut v: u64) -> Vec { + let mut out = vec![(v & 0x7f) as u8]; + v >>= 7; + while v != 0 { + out.push(((v & 0x7f) as u8) | 0x80); + v >>= 7; + } + out.reverse(); + out + } + let mut body = Vec::new(); + for arcs in oids { + let mut value = base128(arcs[0] * 40 + arcs[1]); + for &a in &arcs[2..] { + value.extend(base128(a)); + } + body.push(0x06); + body.push(value.len() as u8); + body.extend(value); + } + let mut der = vec![0x30, body.len() as u8]; + der.extend(body); + der + } + + fn identity_from_eku( + oids: &[&[u64]], + ) -> Result> { + let mut kinds = None; + for arcs in decode_eku_arcs(&eku(oids))? { + if let Some(rest) = arcs.strip_prefix(ARTIFACT_SIGNING_EKU_ARCS) + && let Some((kind, id)) = classify_identity_suffix(rest) + { + kinds = Some((kind, dotted(id))); + } + } + Ok(kinds) + } + + const MS: [u64; 8] = [1, 3, 6, 1, 4, 1, 311, 97]; + + fn arcs(suffix: &[u64]) -> Vec { + let mut v = MS.to_vec(); + v.extend_from_slice(suffix); + v + } + + #[test] + fn pinned_root_matches_thumbprints() { + let roots = public_trust_anchor_certs().expect("pinned roots"); + assert_eq!(roots.len(), 1); + assert_eq!( + roots[0].subject_name().to_string(), + roots[0].issuer_name().to_string(), + "pinned root must be self-signed" + ); + } + + #[test] + fn classifies_documented_identity_ekus() { + use ArtifactSigningCertificateKind::*; + let cases: &[(&[u64], ArtifactSigningCertificateKind, &str)] = &[ + ( + &[990309390, 766961637, 194916062, 941502583], + PublicTrust, + "990309390.766961637.194916062.941502583", + ), + ( + &[1, 2, 548_345_392, 570_456_524, 348_779_735, 628_493_769], + PublicTrustTest, + "548345392.570456524.348779735.628493769", + ), + ( + &[ + 1, 3, 1, 29433, 35007, 34545, 16815, 37291, 11644, 53265, 56135, + ], + PrivateTrust, + "29433.35007.34545.16815.37291.11644.53265.56135", + ), + ( + &[ + 1, 4, 1, 29433, 35007, 34545, 16815, 37291, 11644, 53265, 56135, + ], + PrivateTrustCiPolicy, + "29433.35007.34545.16815.37291.11644.53265.56135", + ), + ]; + for (suffix, kind, id) in cases { + let oid = arcs(suffix); + let got = identity_from_eku(&[&oid]).unwrap().expect("identity"); + assert_eq!(got, (*kind, id.to_string())); + } + } + + #[test] + fn marker_and_unknown_subtypes_are_not_identities() { + let marker = arcs(&[1, 0]); + let unknown = arcs(&[1, 9, 7]); + assert_eq!(identity_from_eku(&[&marker, &unknown]).unwrap(), None); + } + + #[test] + fn rejects_truncated_and_non_canonical_oids() { + assert!(decode_eku_arcs(&[0x30, 0x03, 0x06, 0x01, 0x80]).is_err()); + assert!(decode_eku_arcs(&[0x30, 0x04, 0x06, 0x02, 0x80, 0x01]).is_err()); + } + + #[test] + fn extracts_publisher_id_from_real_signer_certificate() { + let id = artifact_signing_identity_from_cert_bytes(&fixture("devolutions-signer.pem")) + .expect("parse") + .expect("identity"); + assert_eq!(id.kind, ArtifactSigningCertificateKind::PublicTrust); + assert_eq!(id.publisher_id, "285218949.549339218.252840042.443064405"); + assert_eq!( + id.identity_eku, + "1.3.6.1.4.1.311.97.285218949.549339218.252840042.443064405" + ); + assert!(id.has_public_trust_marker); + assert!(id.has_code_signing_eku); + } + + #[test] + fn non_artifact_signing_certificate_has_no_identity() { + let root = MICROSOFT_IDENTITY_VERIFICATION_ROOT_2020.pem.as_bytes(); + assert_eq!( + artifact_signing_identity_from_cert_bytes(root).unwrap(), + None + ); + } + + fn real_pkcs7_and_digest() -> (Vec, Vec) { + let pkcs7 = fixture("psign-tool.pkcs7.der"); + let normalized = normalize_pkcs7_der_for_authenticode(&pkcs7); + let digest = authenticode::AuthenticodeSignature::from_bytes(normalized.as_ref()) + .expect("authenticode parse") + .digest() + .to_vec(); + (pkcs7, digest) + } + + fn verify_real( + digest: &[u8], + policy: ArtifactSigningPolicy, + opts: TrustVerifyPeOptions, + ) -> Result { + let (pkcs7, _) = real_pkcs7_and_digest(); + verify_artifact_signing_pkcs7( + &pkcs7, + 0, + digest, + Pkcs7DigestBinding::AuthenticodeIndirect, + &policy, + &opts, + ) + } + + fn pinned(ids: &[&str]) -> ArtifactSigningPolicy { + ArtifactSigningPolicy { + expected_publisher_ids: ids.iter().map(|s| s.to_string()).collect(), + ..Default::default() + } + } + + #[test] + fn verifies_real_public_trust_signature_at_timestamp() { + let (_, digest) = real_pkcs7_and_digest(); + let report = verify_real(&digest, pinned(&[]), Default::default()).expect("ok"); + assert!(report.timestamped); + assert_eq!( + report.identity.publisher_id, + "285218949.549339218.252840042.443064405" + ); + assert_eq!(report.root_sha1, "f40042e2e5f7e8ef8189fed15519aece42c3bfa2"); + } + + #[test] + fn rejects_unexpected_publisher_id() { + let (_, digest) = real_pkcs7_and_digest(); + let policy = pinned(&["990309390.766961637.194916062.941502583"]); + let err = verify_real(&digest, policy, Default::default()).unwrap_err(); + assert!( + format!("{err:#}").contains("not one of the expected"), + "{err:#}" + ); + } + + #[test] + fn accepts_expected_publisher_id() { + let (_, digest) = real_pkcs7_and_digest(); + let policy = pinned(&["1.2.3.4", "285218949.549339218.252840042.443064405"]); + verify_real(&digest, policy, Default::default()).expect("ok"); + } + + #[test] + fn rejects_signer_outside_72_hour_validity_without_timestamp_time() { + let (_, digest) = real_pkcs7_and_digest(); + let opts = TrustVerifyPeOptions { + verification_instant_override: Some( + crate::parse_verification_date_ymd("2026-11-01").unwrap(), + ), + ..Default::default() + }; + assert!(verify_real(&digest, pinned(&[]), opts).is_err()); + } + + #[test] + fn rejects_digest_mismatch() { + let (_, mut digest) = real_pkcs7_and_digest(); + digest[0] ^= 0xff; + assert!(verify_real(&digest, pinned(&[]), Default::default()).is_err()); + } + + #[test] + fn generic_anchors_cannot_satisfy_artifact_signing_policy() { + let pe_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../tests/fixtures/pe-authenticode-upstream/tiny64.signed.efi"); + let pe = std::fs::read(&pe_path).expect("fixture"); + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path().join("root.der"); + std::fs::write( + &root, + crate::pe_first_pkcs7_terminal_root(&pe) + .unwrap() + .to_der() + .unwrap(), + ) + .unwrap(); + let mut opts = TrustVerifyPeOptions { + trusted_ca_files: vec![root], + verification_instant_override: Some( + crate::parse_verification_date_ymd("2023-07-01").unwrap(), + ), + ..Default::default() + }; + crate::trust_verify_pe_bytes(&pe, &opts).expect("generic trust with explicit root"); + + opts.artifact_signing = Some(pinned(&[])); + let err = crate::trust_verify_pe_bytes(&pe, &opts).unwrap_err(); + assert!( + format!("{err:#}").contains("no Artifact Signing identity EKU"), + "{err:#}" + ); + } +} diff --git a/crates/psign-authenticode-trust/src/lib.rs b/crates/psign-authenticode-trust/src/lib.rs index 67734fc..9fe7e80 100644 --- a/crates/psign-authenticode-trust/src/lib.rs +++ b/crates/psign-authenticode-trust/src/lib.rs @@ -7,6 +7,7 @@ //! - **`picky` / `picky-asn1-x509`**: X.509 parsing, TBSCertificate signature verification, extension interpretation. pub mod anchor; +pub mod artifact_signing; pub mod authroot_cab; pub mod authroot_cache; pub mod authroot_ctl; @@ -20,12 +21,19 @@ pub mod trust_verify_cab; pub mod trust_verify_catalog; pub mod trust_verify_detached; pub mod trust_verify_esd; +pub mod trust_verify_file; pub mod trust_verify_msi; pub mod trust_verify_pe; pub mod trust_verify_script; pub mod trust_verify_zip; pub mod verification_instant; +pub use artifact_signing::{ + ArtifactSigningCertificateKind, ArtifactSigningIdentity, ArtifactSigningPolicy, + ArtifactSigningVerifiedSignature, artifact_signing_identities_from_pe, + artifact_signing_identity_from_cert_bytes, artifact_signing_identity_from_pkcs7_der, + verify_artifact_signing_pkcs7, +}; pub use inspect::{ InspectAuthenticodeDigest, InspectPeEntry, InspectPeFileReport, InspectPkcs7Report, InspectSigner, TimestampHint, inspect_authenticode_pkcs7_der, inspect_pe_authenticode, @@ -35,6 +43,7 @@ pub use trust_verify_cab::trust_verify_cab_bytes; pub use trust_verify_catalog::trust_verify_catalog_bytes; pub use trust_verify_detached::trust_verify_detached_bytes; pub use trust_verify_esd::trust_verify_wim_esd_path; +pub use trust_verify_file::trust_verify_path; pub use trust_verify_msi::trust_verify_msi_bytes; pub use trust_verify_pe::{ TrustVerifyPeOptions, TrustVerifyPeReport, load_trust_material, pe_first_pkcs7_terminal_root, diff --git a/crates/psign-authenticode-trust/src/online.rs b/crates/psign-authenticode-trust/src/online.rs index aab9a7f..25e28e4 100644 --- a/crates/psign-authenticode-trust/src/online.rs +++ b/crates/psign-authenticode-trust/src/online.rs @@ -9,7 +9,7 @@ use picky::x509::date::UtcDate; use rsa::pkcs1v15::{Signature as RsaPkcs1v15Signature, VerifyingKey}; use rsa::pkcs8::DecodePublicKey; use rsa::signature::Verifier; -use sha2::Sha256; +use sha2::{Sha256, Sha384, Sha512}; use std::io::{Read, Write}; use std::net::TcpStream; use x509_cert::Certificate; @@ -25,9 +25,40 @@ const OCSP_BASIC_OID_DER: &[u8] = &[ const SHA1_ALGORITHM_IDENTIFIER_DER: &[u8] = &[ 0x30, 0x09, 0x06, 0x05, 0x2b, 0x0e, 0x03, 0x02, 0x1a, 0x05, 0x00, ]; +#[cfg(test)] const SHA256_WITH_RSA_ENCRYPTION_DER: &[u8] = &[ 0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x0b, 0x05, 0x00, ]; +/// `1.2.840.113549.1.1` (PKCS #1) OID content octets without the final arc. +const PKCS1_OID_PREFIX: &[u8] = &[0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01]; + +/// RSASSA-PKCS1-v1_5 digests accepted for CRL and OCSP response signatures. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum RsaPkcs1v15Hash { + Sha256, + Sha384, + Sha512, +} + +impl RsaPkcs1v15Hash { + /// Map a DER `AlgorithmIdentifier` to `sha{256,384,512}WithRSAEncryption`. + /// + /// RFC 4055 §5 requires NULL parameters but also requires accepting them absent. + fn from_algorithm_identifier_tlv(tlv: &[u8]) -> Option { + let body = expect_tlv(tlv, 0x30).ok()?; + let mut pos = 0usize; + let oid = expect_tlv(read_tlv(body, &mut pos).ok()?, 0x06).ok()?; + if pos != body.len() && body[pos..] != [0x05, 0x00] { + return None; + } + match oid.strip_prefix(PKCS1_OID_PREFIX)? { + [0x0b] => Some(Self::Sha256), + [0x0c] => Some(Self::Sha384), + [0x0d] => Some(Self::Sha512), + _ => None, + } + } +} pub fn issuer_candidates_from_aia(cert: &Cert, options: &OnlineTrustOptions) -> Result> { if !options.enable_aia && options.aia_url_override.is_none() { @@ -152,7 +183,8 @@ fn check_one_cert_ocsp( .with_context(|| format!("POST OCSP request to {url}"))?; let parsed = parse_ocsp_response_der(&response) .with_context(|| format!("parse OCSP response from {url}"))?; - verify_rsa_sha256_signature( + verify_rsa_pkcs1v15_signature( + parsed.signature_hash, &parsed.tbs_response_data_tlv, &parsed.signature, issuer, @@ -237,6 +269,7 @@ pub fn ocsp_urls(cert: &Cert) -> Result> { struct ParsedOcspResponse { tbs_response_data_tlv: Vec, + signature_hash: RsaPkcs1v15Hash, signature: Vec, responses: Vec, } @@ -277,9 +310,8 @@ fn parse_basic_ocsp_response_der(input: &[u8]) -> Result { .context("BasicOCSPResponse.tbsResponseData")? .to_vec(); let sig_alg = read_tlv(outer, &mut pos).context("BasicOCSPResponse.signatureAlgorithm")?; - if sig_alg != SHA256_WITH_RSA_ENCRYPTION_DER { - return Err(anyhow!("unsupported OCSP signature algorithm")); - } + let signature_hash = RsaPkcs1v15Hash::from_algorithm_identifier_tlv(sig_alg) + .ok_or_else(|| anyhow!("unsupported OCSP signature algorithm"))?; let signature_body = expect_tlv( read_tlv(outer, &mut pos).context("BasicOCSPResponse.signature")?, 0x03, @@ -345,6 +377,7 @@ fn parse_basic_ocsp_response_der(input: &[u8]) -> Result { Ok(ParsedOcspResponse { tbs_response_data_tlv, + signature_hash, signature: signature_body[1..].to_vec(), responses: parsed_responses, }) @@ -352,7 +385,7 @@ fn parse_basic_ocsp_response_der(input: &[u8]) -> Result { struct ParsedCrl { tbs_tlv: Vec, - signature_algorithm_tlv: Vec, + signature_hash: RsaPkcs1v15Hash, signature: Vec, this_update: UtcDate, next_update: UtcDate, @@ -372,9 +405,8 @@ fn parse_certificate_list_der(input: &[u8]) -> Result { if pos != outer.len() { return Err(anyhow!("CertificateList has trailing fields")); } - if signature_algorithm_tlv != SHA256_WITH_RSA_ENCRYPTION_DER { - return Err(anyhow!("unsupported CRL signature algorithm")); - } + let signature_hash = RsaPkcs1v15Hash::from_algorithm_identifier_tlv(&signature_algorithm_tlv) + .ok_or_else(|| anyhow!("unsupported CRL signature algorithm"))?; let signature_body = expect_tlv(signature_tlv, 0x03).context("CRL signature BIT STRING")?; if signature_body.first().copied() != Some(0) { return Err(anyhow!("CRL signature BIT STRING has unused bits")); @@ -385,7 +417,13 @@ fn parse_certificate_list_der(input: &[u8]) -> Result { if tbs.get(tbs_pos).copied() == Some(0x02) { read_tlv(tbs, &mut tbs_pos).context("TBSCertList.version")?; } - read_tlv(tbs, &mut tbs_pos).context("TBSCertList.signature")?; + let tbs_signature_algorithm_tlv = + read_tlv(tbs, &mut tbs_pos).context("TBSCertList.signature")?; + if tbs_signature_algorithm_tlv != signature_algorithm_tlv.as_slice() { + return Err(anyhow!( + "CRL signatureAlgorithm does not match TBSCertList.signature" + )); + } read_tlv(tbs, &mut tbs_pos).context("TBSCertList.issuer")?; let this_update = parse_der_time_tlv(read_tlv(tbs, &mut tbs_pos).context("TBSCertList.thisUpdate")?) @@ -418,7 +456,7 @@ fn parse_certificate_list_der(input: &[u8]) -> Result { Ok(ParsedCrl { tbs_tlv, - signature_algorithm_tlv, + signature_hash, signature: signature_body[1..].to_vec(), this_update, next_update, @@ -427,13 +465,17 @@ fn parse_certificate_list_der(input: &[u8]) -> Result { } fn verify_crl_signature(crl: &ParsedCrl, issuer: &Cert) -> Result<()> { - if crl.signature_algorithm_tlv != SHA256_WITH_RSA_ENCRYPTION_DER { - return Err(anyhow!("unsupported CRL signature algorithm")); - } - verify_rsa_sha256_signature(&crl.tbs_tlv, &crl.signature, issuer, "CRL") + verify_rsa_pkcs1v15_signature( + crl.signature_hash, + &crl.tbs_tlv, + &crl.signature, + issuer, + "CRL", + ) } -fn verify_rsa_sha256_signature( +fn verify_rsa_pkcs1v15_signature( + hash: RsaPkcs1v15Hash, signed_tlv: &[u8], signature: &[u8], issuer: &Cert, @@ -453,9 +495,18 @@ fn verify_rsa_sha256_signature( .map_err(|e| anyhow!("{label} issuer RSA public key parse failed: {e}"))?; let signature = RsaPkcs1v15Signature::try_from(signature) .map_err(|e| anyhow!("{label} RSA signature parse failed: {e}"))?; - VerifyingKey::::new(public_key) - .verify(signed_tlv, &signature) - .map_err(|e| anyhow!("{label} signature verification failed: {e}")) + match hash { + RsaPkcs1v15Hash::Sha256 => { + VerifyingKey::::new(public_key).verify(signed_tlv, &signature) + } + RsaPkcs1v15Hash::Sha384 => { + VerifyingKey::::new(public_key).verify(signed_tlv, &signature) + } + RsaPkcs1v15Hash::Sha512 => { + VerifyingKey::::new(public_key).verify(signed_tlv, &signature) + } + } + .map_err(|e| anyhow!("{label} signature verification failed: {e}")) } fn cert_serial_tlv(cert: &Cert) -> Result> { @@ -872,12 +923,108 @@ mod tests { assert!(parsed.revoked_serial_tlvs.is_empty()); } + fn pkcs1_algorithm_identifier(last_arc: u8, params: &[u8]) -> Vec { + let mut body = Vec::new(); + let mut oid = PKCS1_OID_PREFIX.to_vec(); + oid.push(last_arc); + push_tlv(&mut body, 0x06, &oid); + body.extend_from_slice(params); + let mut out = Vec::new(); + push_tlv(&mut out, 0x30, &body); + out + } + + #[test] + fn maps_rsa_pkcs1v15_signature_algorithms() { + assert_eq!( + RsaPkcs1v15Hash::from_algorithm_identifier_tlv(SHA256_WITH_RSA_ENCRYPTION_DER), + Some(RsaPkcs1v15Hash::Sha256) + ); + for (arc, hash) in [ + (0x0b, RsaPkcs1v15Hash::Sha256), + (0x0c, RsaPkcs1v15Hash::Sha384), + (0x0d, RsaPkcs1v15Hash::Sha512), + ] { + let with_null = pkcs1_algorithm_identifier(arc, &[0x05, 0x00]); + let absent = pkcs1_algorithm_identifier(arc, &[]); + assert_eq!( + RsaPkcs1v15Hash::from_algorithm_identifier_tlv(&with_null), + Some(hash) + ); + assert_eq!( + RsaPkcs1v15Hash::from_algorithm_identifier_tlv(&absent), + Some(hash) + ); + } + } + + #[test] + fn rejects_unsupported_or_malformed_signature_algorithms() { + // sha1WithRSAEncryption, RSASSA-PSS, and non-NULL parameters. + for tlv in [ + pkcs1_algorithm_identifier(0x05, &[0x05, 0x00]), + pkcs1_algorithm_identifier(0x0a, &[0x30, 0x00]), + pkcs1_algorithm_identifier(0x0c, &[0x04, 0x00]), + ] { + assert_eq!(RsaPkcs1v15Hash::from_algorithm_identifier_tlv(&tlv), None); + } + } + + #[test] + fn rejects_crl_with_mismatched_inner_signature_algorithm() { + let sha384 = pkcs1_algorithm_identifier(0x0c, &[0x05, 0x00]); + let mut tbs = Vec::new(); + tbs.extend_from_slice(&[0x02, 0x01, 0x01]); + tbs.extend_from_slice(SHA256_WITH_RSA_ENCRYPTION_DER); + tbs.extend_from_slice(&[0x30, 0x00]); + push_tlv(&mut tbs, 0x17, b"240101000000Z"); + push_tlv(&mut tbs, 0x17, b"250101000000Z"); + let mut crl_body = Vec::new(); + push_tlv(&mut crl_body, 0x30, &tbs); + crl_body.extend_from_slice(&sha384); + push_tlv(&mut crl_body, 0x03, &[0x00, 0x11]); + let mut crl = Vec::new(); + push_tlv(&mut crl, 0x30, &crl_body); + + let err = parse_certificate_list_der(&crl) + .err() + .expect("mismatch rejected"); + assert!(err.to_string().contains("does not match"), "{err}"); + } + + fn artifact_signing_fixture(name: &str) -> Vec { + let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../tests/fixtures/artifact-signing") + .join(name); + std::fs::read(&p).unwrap_or_else(|e| panic!("read {}: {e}", p.display())) + } + + #[test] + fn verifies_real_sha384_rsa_crl_signature() { + let crl = parse_certificate_list_der(&artifact_signing_fixture( + "microsoft-public-rsa-timestamping-ca-2020.crl", + )) + .expect("parse Microsoft CRL"); + assert_eq!(crl.signature_hash, RsaPkcs1v15Hash::Sha384); + let issuer = parse_cert_bytes(&artifact_signing_fixture( + "microsoft-public-rsa-timestamping-ca-2020.pem", + )) + .expect("issuer"); + verify_crl_signature(&crl, &issuer).expect("SHA-384 CRL signature"); + + let mut tampered = crl; + let last = tampered.tbs_tlv.len() - 1; + tampered.tbs_tlv[last] ^= 0x01; + assert!(verify_crl_signature(&tampered, &issuer).is_err()); + } + #[test] fn ocsp_lookup_uses_status_from_matching_cert_id_only() { let d1 = UtcDate::ymd(2024, 1, 1).unwrap(); let d2 = UtcDate::ymd(2049, 1, 1).unwrap(); let parsed = ParsedOcspResponse { tbs_response_data_tlv: Vec::new(), + signature_hash: RsaPkcs1v15Hash::Sha256, signature: Vec::new(), responses: vec![ ParsedOcspSingleResponse { diff --git a/crates/psign-authenticode-trust/src/trust_pkcs7.rs b/crates/psign-authenticode-trust/src/trust_pkcs7.rs index d1c6d22..4e5cc5e 100644 --- a/crates/psign-authenticode-trust/src/trust_pkcs7.rs +++ b/crates/psign-authenticode-trust/src/trust_pkcs7.rs @@ -385,6 +385,46 @@ pub fn verify_pkcs9_message_digest_pkcs7_trust( ) } +/// How a PKCS#7 binds to the signed subject. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Pkcs7DigestBinding { + /// Authenticode `SpcIndirectDataContent` digest (PE, CAB, MSI, WIM/ESD, scripts, ZIP). + AuthenticodeIndirect, + /// PKCS#9 `messageDigest` over external content (catalogs, detached CMS). + Pkcs9MessageDigest, +} + +/// [`verify_authenticode_pkcs7_trust`] or [`verify_pkcs9_message_digest_pkcs7_trust`] by binding. +#[allow(clippy::too_many_arguments)] +pub fn verify_pkcs7_trust_with_binding( + binding: Pkcs7DigestBinding, + pkcs7_der: &[u8], + pkcs7_index: usize, + digest: &[u8], + anchors: &AnchorStore, + anchor_certs: &[Cert], + policy: &AuthenticodeTrustPolicy, + online: &OnlineTrustOptions, + verification_instant: &UtcDate, + verbose_chain: bool, +) -> Result<()> { + let verify = match binding { + Pkcs7DigestBinding::AuthenticodeIndirect => verify_authenticode_pkcs7_trust, + Pkcs7DigestBinding::Pkcs9MessageDigest => verify_pkcs9_message_digest_pkcs7_trust, + }; + verify( + pkcs7_der, + pkcs7_index, + digest, + anchors, + anchor_certs, + policy, + online, + verification_instant, + verbose_chain, + ) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/psign-authenticode-trust/src/trust_verify_cab.rs b/crates/psign-authenticode-trust/src/trust_verify_cab.rs index ab85974..ce561d1 100644 --- a/crates/psign-authenticode-trust/src/trust_verify_cab.rs +++ b/crates/psign-authenticode-trust/src/trust_verify_cab.rs @@ -1,8 +1,9 @@ //! CAB Authenticode trust (digest + PKCS#7 chain). -use crate::trust_pkcs7::verify_authenticode_pkcs7_trust; -use crate::trust_verify_pe::{TrustVerifyPeOptions, TrustVerifyPeReport, load_trust_material}; -use crate::verification_instant::resolve_verification_instant_for_pkcs7_with_trust; +use crate::trust_pkcs7::Pkcs7DigestBinding; +use crate::trust_verify_pe::{ + TrustVerifyPeOptions, TrustVerifyPeReport, trust_material_for, verify_pkcs7_entry, +}; use anyhow::{Result, anyhow}; use authenticode::AuthenticodeSignature; use psign_sip_digest::cab_digest::{ @@ -14,7 +15,7 @@ pub fn trust_verify_cab_bytes( data: &[u8], opts: &TrustVerifyPeOptions, ) -> Result { - let (anchors, anchor_certs) = load_trust_material(opts)?; + let (anchors, anchor_certs) = trust_material_for(opts)?; let ctx = parse_cab_context(data)?; let pkcs7 = cab_signature_pkcs7_der(data)?; @@ -28,29 +29,16 @@ pub fn trust_verify_cab_bytes( )); } - let verification_instant = resolve_verification_instant_for_pkcs7_with_trust( - pkcs7, - &opts.policy, - opts.verification_instant_override.as_ref(), - &anchors, - &anchor_certs, - &opts.online, - opts.verbose_chain, - )?; - verify_authenticode_pkcs7_trust( + let mut report = TrustVerifyPeReport::new(&anchors); + verify_pkcs7_entry( pkcs7, 0, cab_digest.as_slice(), + Pkcs7DigestBinding::AuthenticodeIndirect, &anchors, &anchor_certs, - &opts.policy, - &opts.online, - &verification_instant, - opts.verbose_chain, + opts, + &mut report, )?; - - Ok(TrustVerifyPeReport { - pkcs7_entries_verified: 1, - anchor_thumbprints: anchors.thumbprint_count(), - }) + Ok(report) } diff --git a/crates/psign-authenticode-trust/src/trust_verify_catalog.rs b/crates/psign-authenticode-trust/src/trust_verify_catalog.rs index 657dbff..716bf78 100644 --- a/crates/psign-authenticode-trust/src/trust_verify_catalog.rs +++ b/crates/psign-authenticode-trust/src/trust_verify_catalog.rs @@ -1,8 +1,9 @@ //! Catalog `.cat` Authenticode trust (CMS digest consistency + PKCS#7 chain). -use crate::trust_pkcs7::verify_pkcs9_message_digest_pkcs7_trust; -use crate::trust_verify_pe::{TrustVerifyPeOptions, TrustVerifyPeReport, load_trust_material}; -use crate::verification_instant::resolve_verification_instant_for_pkcs7_with_trust; +use crate::trust_pkcs7::Pkcs7DigestBinding; +use crate::trust_verify_pe::{ + TrustVerifyPeOptions, TrustVerifyPeReport, trust_material_for, verify_pkcs7_entry, +}; use anyhow::Result; use psign_sip_digest::catalog_digest; @@ -12,32 +13,19 @@ pub fn trust_verify_catalog_bytes( ) -> Result { catalog_digest::verify_catalog_digest_consistency_bytes(data)?; - let (anchors, anchor_certs) = load_trust_material(opts)?; + let (anchors, anchor_certs) = trust_material_for(opts)?; let digest = catalog_digest::catalog_econtent_digest(data)?; - let verification_instant = resolve_verification_instant_for_pkcs7_with_trust( - data, - &opts.policy, - opts.verification_instant_override.as_ref(), - &anchors, - &anchor_certs, - &opts.online, - opts.verbose_chain, - )?; - verify_pkcs9_message_digest_pkcs7_trust( + let mut report = TrustVerifyPeReport::new(&anchors); + verify_pkcs7_entry( data, 0, digest.as_slice(), + Pkcs7DigestBinding::Pkcs9MessageDigest, &anchors, &anchor_certs, - &opts.policy, - &opts.online, - &verification_instant, - opts.verbose_chain, + opts, + &mut report, )?; - - Ok(TrustVerifyPeReport { - pkcs7_entries_verified: 1, - anchor_thumbprints: anchors.thumbprint_count(), - }) + Ok(report) } diff --git a/crates/psign-authenticode-trust/src/trust_verify_detached.rs b/crates/psign-authenticode-trust/src/trust_verify_detached.rs index 55908c2..835be06 100644 --- a/crates/psign-authenticode-trust/src/trust_verify_detached.rs +++ b/crates/psign-authenticode-trust/src/trust_verify_detached.rs @@ -1,10 +1,9 @@ //! Detached PKCS#7 Authenticode trust (content bytes vs indirect digest). -use crate::trust_pkcs7::{ - verify_authenticode_pkcs7_trust, verify_pkcs9_message_digest_pkcs7_trust, +use crate::trust_pkcs7::Pkcs7DigestBinding; +use crate::trust_verify_pe::{ + TrustVerifyPeOptions, TrustVerifyPeReport, trust_material_for, verify_pkcs7_entry, }; -use crate::trust_verify_pe::{TrustVerifyPeOptions, TrustVerifyPeReport, load_trust_material}; -use crate::verification_instant::resolve_verification_instant_for_pkcs7_with_trust; use anyhow::{Result, anyhow}; use authenticode::AuthenticodeSignature; use digest::Digest; @@ -29,19 +28,10 @@ pub fn trust_verify_detached_bytes( pkcs7_der: &[u8], opts: &TrustVerifyPeOptions, ) -> Result { - let (anchors, anchor_certs) = load_trust_material(opts)?; + let (anchors, anchor_certs) = trust_material_for(opts)?; let normalized = normalize_pkcs7_der_for_authenticode(pkcs7_der); let slice = normalized.as_ref(); - - let verification_instant = resolve_verification_instant_for_pkcs7_with_trust( - slice, - &opts.policy, - opts.verification_instant_override.as_ref(), - &anchors, - &anchor_certs, - &opts.online, - opts.verbose_chain, - )?; + let mut report = TrustVerifyPeReport::new(&anchors); match AuthenticodeSignature::from_bytes(slice) { Ok(sig) => { @@ -52,16 +42,15 @@ pub fn trust_verify_detached_bytes( "detached content digest does not match PKCS#7 indirect digest (algorithm inferred from embedded digest length)" )); } - verify_authenticode_pkcs7_trust( + verify_pkcs7_entry( slice, 0, computed.as_slice(), + Pkcs7DigestBinding::AuthenticodeIndirect, &anchors, &anchor_certs, - &opts.policy, - &opts.online, - &verification_instant, - opts.verbose_chain, + opts, + &mut report, )?; } Err(authenticode_err) => { @@ -82,24 +71,20 @@ pub fn trust_verify_detached_bytes( "detached content digest does not match PKCS#9 messageDigest (algorithm inferred from embedded digest length)" )); } - verify_pkcs9_message_digest_pkcs7_trust( + verify_pkcs7_entry( slice, 0, computed.as_slice(), + Pkcs7DigestBinding::Pkcs9MessageDigest, &anchors, &anchor_certs, - &opts.policy, - &opts.online, - &verification_instant, - opts.verbose_chain, + opts, + &mut report, )?; } } - Ok(TrustVerifyPeReport { - pkcs7_entries_verified: 1, - anchor_thumbprints: anchors.thumbprint_count(), - }) + Ok(report) } #[cfg(test)] diff --git a/crates/psign-authenticode-trust/src/trust_verify_esd.rs b/crates/psign-authenticode-trust/src/trust_verify_esd.rs index ee742ca..4350a05 100644 --- a/crates/psign-authenticode-trust/src/trust_verify_esd.rs +++ b/crates/psign-authenticode-trust/src/trust_verify_esd.rs @@ -1,8 +1,9 @@ //! WIM/ESD Authenticode trust (ESD SIP digest + PKCS#7 chain). -use crate::trust_pkcs7::verify_authenticode_pkcs7_trust; -use crate::trust_verify_pe::{TrustVerifyPeOptions, TrustVerifyPeReport, load_trust_material}; -use crate::verification_instant::resolve_verification_instant_for_pkcs7_with_trust; +use crate::trust_pkcs7::Pkcs7DigestBinding; +use crate::trust_verify_pe::{ + TrustVerifyPeOptions, TrustVerifyPeReport, trust_material_for, verify_pkcs7_entry, +}; use anyhow::{Context, Result, anyhow}; use authenticode::AuthenticodeSignature; use psign_sip_digest::esd_digest::{ @@ -16,7 +17,7 @@ pub fn trust_verify_wim_esd_path( path: &Path, opts: &TrustVerifyPeOptions, ) -> Result { - let (anchors, anchor_certs) = load_trust_material(opts)?; + let (anchors, anchor_certs) = trust_material_for(opts)?; let mut header = [0u8; WIM_HEADER_PACKED_SIZE]; let mut f = std::fs::File::open(path).with_context(|| format!("open {}", path.display()))?; @@ -35,29 +36,16 @@ pub fn trust_verify_wim_esd_path( )); } - let verification_instant = resolve_verification_instant_for_pkcs7_with_trust( - &pkcs7, - &opts.policy, - opts.verification_instant_override.as_ref(), - &anchors, - &anchor_certs, - &opts.online, - opts.verbose_chain, - )?; - verify_authenticode_pkcs7_trust( + let mut report = TrustVerifyPeReport::new(&anchors); + verify_pkcs7_entry( &pkcs7, 0, computed.as_slice(), + Pkcs7DigestBinding::AuthenticodeIndirect, &anchors, &anchor_certs, - &opts.policy, - &opts.online, - &verification_instant, - opts.verbose_chain, + opts, + &mut report, )?; - - Ok(TrustVerifyPeReport { - pkcs7_entries_verified: 1, - anchor_thumbprints: anchors.thumbprint_count(), - }) + Ok(report) } diff --git a/crates/psign-authenticode-trust/src/trust_verify_file.rs b/crates/psign-authenticode-trust/src/trust_verify_file.rs new file mode 100644 index 0000000..a2358bc --- /dev/null +++ b/crates/psign-authenticode-trust/src/trust_verify_file.rs @@ -0,0 +1,41 @@ +//! Path-based format dispatch over the `trust_verify_*` entry points. +//! +//! Used where a caller has a file path rather than a known format (unified `psign-tool verify` +//! in both Windows and portable modes), so every caller applies the same per-format checks. + +use crate::trust_verify_pe::{TrustVerifyPeOptions, TrustVerifyPeReport}; +use crate::{ + trust_verify_cab_bytes, trust_verify_catalog_bytes, trust_verify_msi_bytes, + trust_verify_pe_bytes, trust_verify_script_bytes, trust_verify_wim_esd_path, + trust_verify_zip_bytes, +}; +use anyhow::{Context, Result, anyhow}; +use std::path::Path; + +/// Trust-verify `path`, choosing the format from its extension (same mapping as portable +/// `psign-tool verify`). +pub fn trust_verify_path(path: &Path, opts: &TrustVerifyPeOptions) -> Result { + let ext = path + .extension() + .and_then(|e| e.to_str()) + .map(str::to_ascii_lowercase) + .unwrap_or_default(); + if matches!(ext.as_str(), "wim" | "esd") { + return trust_verify_wim_esd_path(path, opts); + } + let read = || std::fs::read(path).with_context(|| format!("read {}", path.display())); + match ext.as_str() { + "exe" | "dll" | "sys" | "ocx" | "efi" | "winmd" => trust_verify_pe_bytes(&read()?, opts), + "cab" => trust_verify_cab_bytes(&read()?, opts), + "msi" | "msp" => trust_verify_msi_bytes(&read()?, opts), + "cat" => trust_verify_catalog_bytes(&read()?, opts), + "zip" => trust_verify_zip_bytes(&read()?, opts), + "ps1" | "psd1" | "psm1" | "ps1xml" | "psc1" | "cdxml" | "mof" => { + trust_verify_script_bytes(&read()?, &ext, opts) + } + _ => Err(anyhow!( + "portable trust verification does not support this file format: {}", + path.display() + )), + } +} diff --git a/crates/psign-authenticode-trust/src/trust_verify_msi.rs b/crates/psign-authenticode-trust/src/trust_verify_msi.rs index a3db09f..f0f18ad 100644 --- a/crates/psign-authenticode-trust/src/trust_verify_msi.rs +++ b/crates/psign-authenticode-trust/src/trust_verify_msi.rs @@ -1,8 +1,9 @@ //! Windows Installer Authenticode trust (MSI/MSP digest + PKCS#7 chain). -use crate::trust_pkcs7::verify_authenticode_pkcs7_trust; -use crate::trust_verify_pe::{TrustVerifyPeOptions, TrustVerifyPeReport, load_trust_material}; -use crate::verification_instant::resolve_verification_instant_for_pkcs7_with_trust; +use crate::trust_pkcs7::Pkcs7DigestBinding; +use crate::trust_verify_pe::{ + TrustVerifyPeOptions, TrustVerifyPeReport, trust_material_for, verify_pkcs7_entry, +}; use anyhow::{Result, anyhow}; use authenticode::AuthenticodeSignature; use psign_sip_digest::msi_digest::{ @@ -14,7 +15,7 @@ pub fn trust_verify_msi_bytes( data: &[u8], opts: &TrustVerifyPeOptions, ) -> Result { - let (anchors, anchor_certs) = load_trust_material(opts)?; + let (anchors, anchor_certs) = trust_material_for(opts)?; let pkcs7 = msi_digital_signature_pkcs7_der(data)?; let sig = AuthenticodeSignature::from_bytes(&pkcs7).map_err(|e| anyhow!("MSI PKCS#7: {e}"))?; @@ -27,29 +28,16 @@ pub fn trust_verify_msi_bytes( )); } - let verification_instant = resolve_verification_instant_for_pkcs7_with_trust( - &pkcs7, - &opts.policy, - opts.verification_instant_override.as_ref(), - &anchors, - &anchor_certs, - &opts.online, - opts.verbose_chain, - )?; - verify_authenticode_pkcs7_trust( + let mut report = TrustVerifyPeReport::new(&anchors); + verify_pkcs7_entry( &pkcs7, 0, msi_digest.as_slice(), + Pkcs7DigestBinding::AuthenticodeIndirect, &anchors, &anchor_certs, - &opts.policy, - &opts.online, - &verification_instant, - opts.verbose_chain, + opts, + &mut report, )?; - - Ok(TrustVerifyPeReport { - pkcs7_entries_verified: 1, - anchor_thumbprints: anchors.thumbprint_count(), - }) + Ok(report) } diff --git a/crates/psign-authenticode-trust/src/trust_verify_pe.rs b/crates/psign-authenticode-trust/src/trust_verify_pe.rs index 0be4e53..3319629 100644 --- a/crates/psign-authenticode-trust/src/trust_verify_pe.rs +++ b/crates/psign-authenticode-trust/src/trust_verify_pe.rs @@ -1,9 +1,12 @@ //! Portable PE Authenticode **trust** verification: picky CMS + explicit anchors. use crate::anchor::AnchorStore; +use crate::artifact_signing::{ + ArtifactSigningPolicy, ArtifactSigningVerifiedSignature, verify_artifact_signing_pkcs7, +}; use crate::chain::{issuer_chain_excluding_leaf, merge_unique_certs, terminal_root_cert}; use crate::policy::{AuthenticodeTrustPolicy, OnlineTrustOptions}; -use crate::trust_pkcs7::verify_authenticode_pkcs7_trust; +use crate::trust_pkcs7::{Pkcs7DigestBinding, verify_pkcs7_trust_with_binding}; use crate::verification_instant::resolve_verification_instant_for_pkcs7_with_trust; use anyhow::{Context, Result, anyhow}; use picky::x509::certificate::Cert; @@ -26,12 +29,84 @@ pub struct TrustVerifyPeOptions { pub verbose_chain: bool, pub online: OnlineTrustOptions, pub policy: AuthenticodeTrustPolicy, + /// Validate as Azure Artifact Signing: pinned Microsoft roots replace the anchors above. + pub artifact_signing: Option, } #[derive(Debug, Clone)] pub struct TrustVerifyPeReport { pub pkcs7_entries_verified: usize, pub anchor_thumbprints: usize, + /// One entry per verified PKCS#7 when [`TrustVerifyPeOptions::artifact_signing`] is set. + pub artifact_signing: Vec, +} + +impl TrustVerifyPeReport { + pub(crate) fn new(anchors: &AnchorStore) -> Self { + Self { + pkcs7_entries_verified: 0, + anchor_thumbprints: anchors.thumbprint_count(), + artifact_signing: Vec::new(), + } + } +} + +/// Anchors for `opts`: [`load_trust_material`], or the pinned Microsoft roots when +/// [`TrustVerifyPeOptions::artifact_signing`] is set (generic anchor inputs are then ignored). +pub(crate) fn trust_material_for(opts: &TrustVerifyPeOptions) -> Result<(AnchorStore, Vec)> { + if opts.artifact_signing.is_some() { + return crate::artifact_signing::public_trust_anchors(); + } + load_trust_material(opts) +} + +/// Trust-verify one PKCS#7 whose `digest` already matched the recomputed subject digest, applying +/// either the generic anchor policy or [`TrustVerifyPeOptions::artifact_signing`]. +#[allow(clippy::too_many_arguments)] +pub(crate) fn verify_pkcs7_entry( + pkcs7_der: &[u8], + pkcs7_index: usize, + digest: &[u8], + binding: Pkcs7DigestBinding, + anchors: &AnchorStore, + anchor_certs: &[Cert], + opts: &TrustVerifyPeOptions, + report: &mut TrustVerifyPeReport, +) -> Result<()> { + if let Some(policy) = &opts.artifact_signing { + report.artifact_signing.push(verify_artifact_signing_pkcs7( + pkcs7_der, + pkcs7_index, + digest, + binding, + policy, + opts, + )?); + } else { + let verification_instant = resolve_verification_instant_for_pkcs7_with_trust( + pkcs7_der, + &opts.policy, + opts.verification_instant_override.as_ref(), + anchors, + anchor_certs, + &opts.online, + opts.verbose_chain, + )?; + verify_pkcs7_trust_with_binding( + binding, + pkcs7_der, + pkcs7_index, + digest, + anchors, + anchor_certs, + &opts.policy, + &opts.online, + &verification_instant, + opts.verbose_chain, + )?; + } + report.pkcs7_entries_verified += 1; + Ok(()) } fn hex_lower(bytes: &[u8]) -> String { @@ -90,16 +165,22 @@ pub fn trust_verify_pe_bytes( pe_bytes: &[u8], opts: &TrustVerifyPeOptions, ) -> Result { - let (anchor_store, anchor_certs) = load_trust_material(opts)?; - - let pkcs7_verified = for_each_pe_pkcs7_signed_data(pe_bytes, |idx, pkcs7_der| { - verify_one_pkcs7(pe_bytes, idx, pkcs7_der, &anchor_store, &anchor_certs, opts) + let (anchor_store, anchor_certs) = trust_material_for(opts)?; + let mut report = TrustVerifyPeReport::new(&anchor_store); + + for_each_pe_pkcs7_signed_data(pe_bytes, |idx, pkcs7_der| { + verify_one_pkcs7( + pe_bytes, + idx, + pkcs7_der, + &anchor_store, + &anchor_certs, + opts, + &mut report, + ) })?; - Ok(TrustVerifyPeReport { - pkcs7_entries_verified: pkcs7_verified, - anchor_thumbprints: anchor_store.thumbprint_count(), - }) + Ok(report) } fn verify_one_pkcs7( @@ -109,6 +190,7 @@ fn verify_one_pkcs7( anchors: &AnchorStore, anchor_certs: &[Cert], opts: &TrustVerifyPeOptions, + report: &mut TrustVerifyPeReport, ) -> Result<()> { let normalized = normalize_pkcs7_der_for_authenticode(pkcs7_der); let sig_authenticode = authenticode::AuthenticodeSignature::from_bytes(normalized.as_ref()) @@ -122,25 +204,15 @@ fn verify_one_pkcs7( )); } - let verification_instant = resolve_verification_instant_for_pkcs7_with_trust( - pkcs7_der, - &opts.policy, - opts.verification_instant_override.as_ref(), - anchors, - anchor_certs, - &opts.online, - opts.verbose_chain, - )?; - verify_authenticode_pkcs7_trust( + verify_pkcs7_entry( pkcs7_der, pkcs7_index, pe_digest.as_slice(), + Pkcs7DigestBinding::AuthenticodeIndirect, anchors, anchor_certs, - &opts.policy, - &opts.online, - &verification_instant, - opts.verbose_chain, + opts, + report, ) } diff --git a/crates/psign-authenticode-trust/src/trust_verify_script.rs b/crates/psign-authenticode-trust/src/trust_verify_script.rs index 2dc97a1..426f16c 100644 --- a/crates/psign-authenticode-trust/src/trust_verify_script.rs +++ b/crates/psign-authenticode-trust/src/trust_verify_script.rs @@ -1,8 +1,9 @@ //! PowerShell script Authenticode trust (script SIP digest + PKCS#7 chain). -use crate::trust_pkcs7::verify_authenticode_pkcs7_trust; -use crate::trust_verify_pe::{TrustVerifyPeOptions, TrustVerifyPeReport, load_trust_material}; -use crate::verification_instant::resolve_verification_instant_for_pkcs7_with_trust; +use crate::trust_pkcs7::Pkcs7DigestBinding; +use crate::trust_verify_pe::{ + TrustVerifyPeOptions, TrustVerifyPeReport, trust_material_for, verify_pkcs7_entry, +}; use anyhow::Result; use psign_sip_digest::ps_script::powershell_class_digest_report; @@ -11,32 +12,19 @@ pub fn trust_verify_script_bytes( extension: &str, opts: &TrustVerifyPeOptions, ) -> Result { - let (anchors, anchor_certs) = load_trust_material(opts)?; + let (anchors, anchor_certs) = trust_material_for(opts)?; let script_report = powershell_class_digest_report(data, extension)?; - let verification_instant = resolve_verification_instant_for_pkcs7_with_trust( - &script_report.pkcs7_der, - &opts.policy, - opts.verification_instant_override.as_ref(), - &anchors, - &anchor_certs, - &opts.online, - opts.verbose_chain, - )?; - verify_authenticode_pkcs7_trust( + let mut report = TrustVerifyPeReport::new(&anchors); + verify_pkcs7_entry( &script_report.pkcs7_der, 0, &script_report.computed_digest, + Pkcs7DigestBinding::AuthenticodeIndirect, &anchors, &anchor_certs, - &opts.policy, - &opts.online, - &verification_instant, - opts.verbose_chain, + opts, + &mut report, )?; - - Ok(TrustVerifyPeReport { - pkcs7_entries_verified: 1, - anchor_thumbprints: anchors.thumbprint_count(), - }) + Ok(report) } diff --git a/crates/psign-authenticode-trust/src/trust_verify_zip.rs b/crates/psign-authenticode-trust/src/trust_verify_zip.rs index 8445feb..ca42b3f 100644 --- a/crates/psign-authenticode-trust/src/trust_verify_zip.rs +++ b/crates/psign-authenticode-trust/src/trust_verify_zip.rs @@ -1,8 +1,9 @@ //! Custom ZIP Authenticode trust (Devolutions ZIP comment convention). -use crate::trust_pkcs7::verify_authenticode_pkcs7_trust; -use crate::trust_verify_pe::{TrustVerifyPeOptions, TrustVerifyPeReport, load_trust_material}; -use crate::verification_instant::resolve_verification_instant_for_pkcs7_with_trust; +use crate::trust_pkcs7::Pkcs7DigestBinding; +use crate::trust_verify_pe::{ + TrustVerifyPeOptions, TrustVerifyPeReport, trust_material_for, verify_pkcs7_entry, +}; use anyhow::Result; use psign_sip_digest::ps_script::powershell_class_digest_report; use psign_sip_digest::zip_authenticode; @@ -11,34 +12,21 @@ pub fn trust_verify_zip_bytes( data: &[u8], opts: &TrustVerifyPeOptions, ) -> Result { - let (anchors, anchor_certs) = load_trust_material(opts)?; + let (anchors, anchor_certs) = trust_material_for(opts)?; let sig = zip_authenticode::verify_zip_digest_binding(data)?; let script = zip_authenticode::signature_script_from_parts(&sig.digest, &sig.pkcs7_base64); let script_report = powershell_class_digest_report(script.as_bytes(), "ps1")?; - let verification_instant = resolve_verification_instant_for_pkcs7_with_trust( - &script_report.pkcs7_der, - &opts.policy, - opts.verification_instant_override.as_ref(), - &anchors, - &anchor_certs, - &opts.online, - opts.verbose_chain, - )?; - verify_authenticode_pkcs7_trust( + let mut report = TrustVerifyPeReport::new(&anchors); + verify_pkcs7_entry( &script_report.pkcs7_der, 0, &script_report.computed_digest, + Pkcs7DigestBinding::AuthenticodeIndirect, &anchors, &anchor_certs, - &opts.policy, - &opts.online, - &verification_instant, - opts.verbose_chain, + opts, + &mut report, )?; - - Ok(TrustVerifyPeReport { - pkcs7_entries_verified: 1, - anchor_thumbprints: anchors.thumbprint_count(), - }) + Ok(report) } diff --git a/crates/psign-digest-cli/src/main.rs b/crates/psign-digest-cli/src/main.rs index b3236c3..508636f 100644 --- a/crates/psign-digest-cli/src/main.rs +++ b/crates/psign-digest-cli/src/main.rs @@ -8,7 +8,11 @@ use anyhow::{Context, Result, anyhow}; use base64::Engine as _; use clap::{Args, Parser, Subcommand, ValueEnum}; use psign_authenticode_trust::{ - AuthenticodeTrustPolicy, TrustVerifyPeOptions, TrustVerifyPeReport, + ArtifactSigningPolicy, AuthenticodeTrustPolicy, + artifact_signing::{ + artifact_signing_identities_from_pe, artifact_signing_identity_from_cert_bytes, + artifact_signing_identity_from_pkcs7_der, + }, TrustVerifyPeOptions, TrustVerifyPeReport, inspect_authenticode_pkcs7_der, inspect_pe_authenticode, parse_verification_date_ymd, policy::{OnlineTrustOptions, RevocationMode}, trust_verify_cab_bytes, trust_verify_catalog_bytes, trust_verify_detached_bytes, @@ -131,6 +135,56 @@ struct TrustVerifySharedArgs { /// Maximum bytes accepted for an online issuer certificate download. #[arg(long, default_value_t = 1024 * 1024)] online_max_download_bytes: usize, + /// Validate as **Azure Artifact Signing**: identity EKU, signer and timestamp chains pinned to + /// Microsoft Identity Verification Root CA 2020 (AuthRoot is not consulted), validity at the + /// trusted timestamp. + #[arg(long)] + artifact_signing: bool, + /// Require this Artifact Signing publisher ID (certificate profile EKU suffix); repeatable, + /// any match passes. Implies **`--artifact-signing`**. + #[arg(long, value_name = "ID", action = clap::ArgAction::Append)] + artifact_signing_publisher_id: Vec, + /// Subscriber root for Private Trust / Public Trust Test signatures (PEM or DER); repeatable. + /// Implies **`--artifact-signing`**. + #[arg(long, value_name = "PATH", action = clap::ArgAction::Append)] + artifact_signing_private_trust_root: Vec, + /// Accept Artifact Signing signatures without a timestamp (validated at the current time). + /// Implies **`--artifact-signing`**. + #[arg(long)] + artifact_signing_allow_missing_timestamp: bool, +} + +impl TrustVerifySharedArgs { + fn artifact_signing_policy(&self) -> Result> { + if !(self.artifact_signing + || !self.artifact_signing_publisher_id.is_empty() + || !self.artifact_signing_private_trust_root.is_empty() + || self.artifact_signing_allow_missing_timestamp) + { + return Ok(None); + } + let conflicts = [ + (self.anchor_dir.is_some(), "--anchor-dir"), + (!self.trusted_ca.is_empty(), "--trusted-ca"), + (!self.additional_trusted_ca.is_empty(), "--additional-trusted-ca"), + (self.authroot_cab.is_some(), "--authroot-cab"), + ( + self.expect_authroot_cab_sha256.is_some(), + "--expect-authroot-cab-sha256", + ), + (self.allow_loose_signing_cert, "--allow-loose-signing-cert"), + ]; + if let Some((_, flag)) = conflicts.iter().find(|(set, _)| *set) { + return Err(anyhow!( + "{flag} cannot be combined with --artifact-signing: Artifact Signing pins its own roots (use --artifact-signing-private-trust-root for Private Trust)" + )); + } + Ok(Some(ArtifactSigningPolicy { + expected_publisher_ids: self.artifact_signing_publisher_id.clone(), + private_trust_root_files: self.artifact_signing_private_trust_root.clone(), + require_timestamp: !self.artifact_signing_allow_missing_timestamp, + })) + } } #[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] @@ -159,7 +213,12 @@ fn trust_verify_options_from_shared(a: &TrustVerifySharedArgs) -> Result Some(parse_verification_date_ymd(s)?), None => None, }; - let authroot_cab = resolve_authroot_cab_for_shared(a)?; + let artifact_signing = a.artifact_signing_policy()?; + let authroot_cab = if artifact_signing.is_some() { + None + } else { + resolve_authroot_cab_for_shared(a)? + }; // When using authroot_cab, automatically enable AIA fetching so the chain builder // can download missing root certificates from intermediate cert AIA extensions. let effective_aia = a.online_aia || authroot_cab.is_some(); @@ -185,6 +244,7 @@ fn trust_verify_options_from_shared(a: &TrustVerifySharedArgs) -> Result Result<[u8; 32]> { } fn print_trust_ok(prefix: &str, report: &TrustVerifyPeReport) { + if !report.artifact_signing.is_empty() { + for sig in &report.artifact_signing { + println!("{prefix}: ok — Artifact Signing {sig}"); + } + return; + } println!( "{prefix}: ok — verified {} PKCS#7 entr(y/ies); {} anchor thumbprint(s)", report.pkcs7_entries_verified, report.anchor_thumbprints @@ -2027,6 +2093,15 @@ enum Command { #[command(flatten)] shared: TrustVerifySharedArgs, }, + /// Print the **Azure Artifact Signing** publisher ID (certificate profile identity EKU) as JSON. + /// + /// Reads the signer certificate(s) of a PE, a raw PKCS#7, or a single certificate (PEM/DER). No + /// trust checks; use **`trust-verify-pe --artifact-signing`** to validate the signature. + ArtifactSigningPublisherId { + path: PathBuf, + #[arg(long, value_enum, default_value_t = ArtifactSigningIdInput::Pe)] + input: ArtifactSigningIdInput, + }, /// Same trust pipeline as **`trust-verify-pe`** after CAB SIP digest consistency (**`verify-cab`**). TrustVerifyCab { path: PathBuf, @@ -2976,6 +3051,13 @@ enum InspectInputKind { Pkcs7, } +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +enum ArtifactSigningIdInput { + Pe, + Pkcs7, + Cert, +} + #[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] enum HashAlg { Sha1, @@ -4269,6 +4351,21 @@ where .with_context(|| format!("trust-verify-pe {}", path.display()))?; print_trust_ok("trust-verify-pe", &report); } + Command::ArtifactSigningPublisherId { path, input } => { + let bytes = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?; + let json = match input { + ArtifactSigningIdInput::Pe => { + serde_json::to_string_pretty(&artifact_signing_identities_from_pe(&bytes)?)? + } + ArtifactSigningIdInput::Pkcs7 => serde_json::to_string_pretty( + &artifact_signing_identity_from_pkcs7_der(&bytes)?, + )?, + ArtifactSigningIdInput::Cert => serde_json::to_string_pretty( + &artifact_signing_identity_from_cert_bytes(&bytes)?, + )?, + }; + println!("{json}"); + } Command::TrustVerifyCab { path, shared } => { let bytes = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?; let opts = trust_verify_options_from_shared(&shared)?; diff --git a/crates/psign-portable-core/src/lib.rs b/crates/psign-portable-core/src/lib.rs index 50ef2eb..6402740 100644 --- a/crates/psign-portable-core/src/lib.rs +++ b/crates/psign-portable-core/src/lib.rs @@ -16,8 +16,10 @@ use picky::x509::Cert as PickyCert; use picky::x509::date::UtcDate; use psign_authenticode_trust::policy::{OnlineTrustOptions, RevocationMode}; use psign_authenticode_trust::{ - AuthenticodeTrustPolicy, TrustVerifyPeOptions, inspect_authenticode_pkcs7_der, - inspect_pe_authenticode, trust_verify_cab_bytes, trust_verify_msi_bytes, trust_verify_pe_bytes, + ArtifactSigningIdentity, ArtifactSigningPolicy, ArtifactSigningVerifiedSignature, + AuthenticodeTrustPolicy, TrustVerifyPeOptions, artifact_signing_identity_from_cert_bytes, + inspect_authenticode_pkcs7_der, inspect_pe_authenticode, trust_verify_cab_bytes, + trust_verify_catalog_bytes, trust_verify_msi_bytes, trust_verify_pe_bytes, trust_verify_script_bytes, trust_verify_zip_bytes, }; use psign_sip_digest::pkcs7::AuthenticodeSigningDigest; @@ -156,6 +158,32 @@ pub struct PortableGetSignatureRequest { pub online_ocsp: bool, #[serde(default)] pub revocation_mode: PortableRevocationMode, + /// Validate as Azure Artifact Signing (pinned Microsoft roots) instead of the anchors above. + #[serde(default)] + pub artifact_signing: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct PortableArtifactSigningOptions { + /// When non-empty, the signer's publisher ID must equal one of these values. + #[serde(default)] + pub publisher_ids: Vec, + /// Subscriber roots for Private Trust / Public Trust Test signatures. + #[serde(default)] + pub private_trust_root_paths: Vec, + #[serde(default)] + pub allow_missing_timestamp: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PortableArtifactSigningIdentityRequest { + pub certificate_der_base64: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PortableArtifactSigningIdentityResponse { + pub schema_version: u32, + pub identity: Option, } impl PortableGetSignatureRequest { @@ -172,6 +200,7 @@ impl PortableGetSignatureRequest { online_aia: false, online_ocsp: false, revocation_mode: PortableRevocationMode::Off, + artifact_signing: None, } } } @@ -228,6 +257,7 @@ impl PortableTestFileCatalogRequest { online_aia: self.online_aia, online_ocsp: self.online_ocsp, revocation_mode: self.revocation_mode, + artifact_signing: None, } } } @@ -272,6 +302,7 @@ impl PortableValidatePowerShellRequest { online_aia: self.online_aia, online_ocsp: self.online_ocsp, revocation_mode: self.revocation_mode, + artifact_signing: None, } } } @@ -427,6 +458,12 @@ pub struct PortableSignatureResponse { pub pkcs7_der_base64: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub diagnostics: Vec, + /// Azure Artifact Signing identity of the signer certificate (decoded, not trust-verified). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub artifact_signing: Option, + /// Set when `artifact_signing` validation was requested and succeeded. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub artifact_signing_verification: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -641,11 +678,34 @@ pub fn portable_get_signature( )), }?; + response.artifact_signing = response + .signer_certificate_der_base64 + .as_deref() + .and_then(|der| base64::engine::general_purpose::STANDARD.decode(der).ok()) + .and_then(|der| { + artifact_signing_identity_from_cert_bytes(&der) + .ok() + .flatten() + }); + apply_trust_if_requested(&request, format, &data, &mut response)?; Ok(response) } +/// Decode the Azure Artifact Signing identity (publisher ID) of a DER certificate. +pub fn portable_get_artifact_signing_identity( + request: PortableArtifactSigningIdentityRequest, +) -> Result { + let der = base64::engine::general_purpose::STANDARD + .decode(request.certificate_der_base64.trim()) + .context("decode certificate_der_base64")?; + Ok(PortableArtifactSigningIdentityResponse { + schema_version: SCHEMA_VERSION, + identity: artifact_signing_identity_from_cert_bytes(&der)?, + }) +} + pub fn portable_clear_signature( request: PortableClearSignatureRequest, ) -> Result { @@ -2462,38 +2522,14 @@ fn apply_trust_if_requested( let (opts, temp_dir) = trust_options(request)?; let trust_result = match format { - PortableFileFormat::Pe => trust_verify_pe_bytes(data, &opts).map(|r| { - format!( - "explicit_trust=valid pkcs7_entries_verified={} anchors={}", - r.pkcs7_entries_verified, r.anchor_thumbprints - ) - }), - PortableFileFormat::Cab => trust_verify_cab_bytes(data, &opts).map(|r| { - format!( - "explicit_trust=valid pkcs7_entries_verified={} anchors={}", - r.pkcs7_entries_verified, r.anchor_thumbprints - ) - }), - PortableFileFormat::Msi => trust_verify_msi_bytes(data, &opts).map(|r| { - format!( - "explicit_trust=valid pkcs7_entries_verified={} anchors={}", - r.pkcs7_entries_verified, r.anchor_thumbprints - ) - }), - PortableFileFormat::Zip => trust_verify_zip_bytes(data, &opts).map(|r| { - format!( - "explicit_trust=valid pkcs7_entries_verified={} anchors={}", - r.pkcs7_entries_verified, r.anchor_thumbprints - ) - }), + PortableFileFormat::Pe => trust_verify_pe_bytes(data, &opts), + PortableFileFormat::Cab => trust_verify_cab_bytes(data, &opts), + PortableFileFormat::Msi => trust_verify_msi_bytes(data, &opts), + PortableFileFormat::Zip => trust_verify_zip_bytes(data, &opts), + PortableFileFormat::Catalog => trust_verify_catalog_bytes(data, &opts), PortableFileFormat::PowerShellScript => { let extension = script_extension_for(&request.path); - trust_verify_script_bytes(data, &extension, &opts).map(|r| { - format!( - "explicit_trust=valid pkcs7_entries_verified={} anchors={}", - r.pkcs7_entries_verified, r.anchor_thumbprints - ) - }) + trust_verify_script_bytes(data, &extension, &opts) } PortableFileFormat::NuGet | PortableFileFormat::AppInstaller @@ -2515,11 +2551,29 @@ fn apply_trust_if_requested( } match trust_result { - Ok(diagnostic) => { - response.status_message = - "Portable digest binding and explicit trust verification are valid.".to_string(); + Ok(report) => { response.trust_status = Some(PortableSignatureStatus::Valid); - response.diagnostics.push(diagnostic); + if let Some(sig) = report.artifact_signing.into_iter().next() { + response.status_message = + "Portable digest binding and Azure Artifact Signing verification are valid." + .to_string(); + response.diagnostics.push(format!( + "artifact_signing=valid publisher_id={} kind={} root_sha1={}", + sig.identity.publisher_id, + sig.identity.kind.as_str(), + sig.root_sha1 + )); + response.artifact_signing = Some(sig.identity.clone()); + response.artifact_signing_verification = Some(sig); + } else { + response.status_message = + "Portable digest binding and explicit trust verification are valid." + .to_string(); + response.diagnostics.push(format!( + "explicit_trust=valid pkcs7_entries_verified={} anchors={}", + report.pkcs7_entries_verified, report.anchor_thumbprints + )); + } } Err(error) => { response.status = PortableSignatureStatus::NotTrusted; @@ -2535,7 +2589,8 @@ fn apply_trust_if_requested( } fn trust_requested(request: &PortableGetSignatureRequest) -> bool { - !request.trusted_certificate_paths.is_empty() + request.artifact_signing.is_some() + || !request.trusted_certificate_paths.is_empty() || !request.trusted_certificates_der_base64.is_empty() || request.anchor_directory.is_some() || request.authroot_cab.is_some() @@ -2544,6 +2599,16 @@ fn trust_requested(request: &PortableGetSignatureRequest) -> bool { fn trust_options( request: &PortableGetSignatureRequest, ) -> Result<(TrustVerifyPeOptions, Option)> { + if request.artifact_signing.is_some() + && (!request.trusted_certificate_paths.is_empty() + || !request.trusted_certificates_der_base64.is_empty() + || request.anchor_directory.is_some() + || request.authroot_cab.is_some()) + { + bail!( + "trusted certificates, anchor directory, and AuthRoot CAB cannot be combined with Artifact Signing validation, which pins its own roots" + ); + } let mut trusted_ca_files = request.trusted_certificate_paths.clone(); let mut temp_dir = None; if !request.trusted_certificates_der_base64.is_empty() { @@ -2599,6 +2664,14 @@ fn trust_options( || request.require_valid_timestamp, require_valid_timestamp: request.require_valid_timestamp, }, + artifact_signing: request + .artifact_signing + .as_ref() + .map(|a| ArtifactSigningPolicy { + expected_publisher_ids: a.publisher_ids.clone(), + private_trust_root_files: a.private_trust_root_paths.clone(), + require_timestamp: !a.allow_missing_timestamp, + }), }, temp_dir, )) @@ -2668,6 +2741,8 @@ fn inspect_pe(path: &Path, data: &[u8]) -> Result { "matched_attribute_certificate_index={}", result.matched_attribute_certificate_index )], + artifact_signing: None, + artifact_signing_verification: None, }) } Err(error) => Ok(map_digest_error(path, PortableFileFormat::Pe, error)), @@ -2760,6 +2835,8 @@ fn inspect_zip(path: &Path, data: &[u8]) -> Result { timestamp_signing_time: summary.timestamp_signing_time, pkcs7_der_base64: summary.pkcs7_der_base64, diagnostics: Vec::new(), + artifact_signing: None, + artifact_signing_verification: None, }) } @@ -2797,6 +2874,8 @@ fn inspect_nuget(path: &Path, data: &[u8]) -> Result timestamp_signing_time: summary.timestamp_signing_time, pkcs7_der_base64: summary.pkcs7_der_base64, diagnostics: Vec::new(), + artifact_signing: None, + artifact_signing_verification: None, }) } @@ -2900,6 +2979,8 @@ fn inspect_appinstaller(path: &Path) -> Result { timestamp_signing_time: summary.timestamp_signing_time, pkcs7_der_base64: summary.pkcs7_der_base64, diagnostics: Vec::new(), + artifact_signing: None, + artifact_signing_verification: None, }) } @@ -2943,6 +3024,8 @@ fn inspect_script(path: &Path, data: &[u8]) -> Result timestamp_signing_time: summary.timestamp_signing_time, pkcs7_der_base64: summary.pkcs7_der_base64, diagnostics: Vec::new(), + artifact_signing: None, + artifact_signing_verification: None, }) } Err(error) => Ok(map_digest_error( @@ -2999,6 +3082,8 @@ fn inspect_pkcs7_file( timestamp_signing_time: summary.timestamp_signing_time, pkcs7_der_base64: summary.pkcs7_der_base64, diagnostics: Vec::new(), + artifact_signing: None, + artifact_signing_verification: None, }) } Err(error) => Ok(map_digest_error(path, format, error)), @@ -3097,6 +3182,8 @@ fn valid_response( timestamp_signing_time: summary.timestamp_signing_time, pkcs7_der_base64: summary.pkcs7_der_base64, diagnostics: Vec::new(), + artifact_signing: None, + artifact_signing_verification: None, } } @@ -3123,6 +3210,8 @@ fn base_response( timestamp_signing_time: None, pkcs7_der_base64: None, diagnostics: Vec::new(), + artifact_signing: None, + artifact_signing_verification: None, } } @@ -3158,6 +3247,8 @@ fn map_digest_error( timestamp_signing_time: None, pkcs7_der_base64: None, diagnostics: Vec::new(), + artifact_signing: None, + artifact_signing_verification: None, } } @@ -3510,6 +3601,38 @@ fn format_powershell_signature_block(pkcs7_der: &[u8], extension: &str) -> Strin mod tests { use super::*; + #[test] + fn decodes_artifact_signing_identity_from_certificate() { + let pem = std::fs::read( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../tests/fixtures/artifact-signing/devolutions-signer.pem"), + ) + .expect("fixture"); + let der = psign_authenticode_trust::anchor::parse_cert_bytes(&pem) + .expect("parse") + .to_der() + .expect("der"); + let response = + portable_get_artifact_signing_identity(PortableArtifactSigningIdentityRequest { + certificate_der_base64: base64::engine::general_purpose::STANDARD.encode(der), + }) + .expect("identity"); + let identity = response.identity.expect("artifact signing identity"); + assert_eq!( + identity.publisher_id, + "285218949.549339218.252840042.443064405" + ); + } + + #[test] + fn artifact_signing_rejects_generic_anchor_inputs() { + let mut request = PortableGetSignatureRequest::path_only(PathBuf::from("x.exe")); + request.artifact_signing = Some(PortableArtifactSigningOptions::default()); + request.trusted_certificate_paths = vec![PathBuf::from("root.cer")]; + let err = trust_options(&request).expect_err("conflict"); + assert!(err.to_string().contains("cannot be combined"), "{err}"); + } + #[test] fn infers_expected_formats() { assert_eq!(infer_format(Path::new("tool.exe")), PortableFileFormat::Pe); diff --git a/crates/psign-portable-ffi/src/lib.rs b/crates/psign-portable-ffi/src/lib.rs index bbe86ff..8196c3b 100644 --- a/crates/psign-portable-ffi/src/lib.rs +++ b/crates/psign-portable-ffi/src/lib.rs @@ -4,8 +4,8 @@ use std::panic::{AssertUnwindSafe, catch_unwind}; use psign_portable_core::{ PortableErrorCode, PortableErrorResponse, portable_clear_signature, portable_error_response, - portable_get_signature, portable_new_file_catalog, portable_sign, portable_test_file_catalog, - portable_validate_powershell_script, version, + portable_get_artifact_signing_identity, 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; @@ -102,6 +102,24 @@ pub unsafe extern "C" fn psign_core_test_file_catalog( ) } +/// Decode the Azure Artifact Signing identity (publisher ID) of a DER certificate. +/// +/// # 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_get_artifact_signing_identity( + request_json_ptr: *const u8, + request_json_len: usize, +) -> PsignFfiResult { + invoke_json( + request_json_ptr, + request_json_len, + portable_get_artifact_signing_identity, + ) +} + /// Validate a PowerShell script/module signature from in-memory content. /// /// # Safety diff --git a/docs/artifact-signing-validation.md b/docs/artifact-signing-validation.md new file mode 100644 index 0000000..3d83c24 --- /dev/null +++ b/docs/artifact-signing-validation.md @@ -0,0 +1,305 @@ +# Azure Artifact Signing validation + +This document explains how psign checks that an Authenticode signature was produced by **Azure Artifact Signing** (formerly *Trusted Signing* / *Azure Code Signing*). It can optionally pin a specific Artifact Signing certificate profile, using its **publisher ID**. It covers the identity mechanism, how the publisher ID is encoded, which Microsoft root CAs are pinned and why, and the exact validation steps. + +Implementation: [`crates/psign-authenticode-trust/src/artifact_signing.rs`](../crates/psign-authenticode-trust/src/artifact_signing.rs). Surfaces: `psign-tool verify --artifact-signing…` (Windows and portable modes), `psign-tool portable trust-verify-* --artifact-signing…`, `psign-tool portable artifact-signing-publisher-id`, and the PowerShell cmdlets `Test-PsignArtifactSigning`, `Get-PsignArtifactSigningPublisherId`, and `Get-PsignSignature -ArtifactSigning…`. + +## Why generic Authenticode trust is not enough + +A generic trust check (`trust-verify-pe`, or `WinVerifyTrust`) answers *"does this chain end at **any** trusted root?"*. For Artifact Signing that is the wrong question, for three reasons: + +1. **Certificates are short-lived.** Artifact Signing issues a new signer certificate every day, valid for **72 hours**. Thumbprints, serial numbers, and public keys change daily, so none of them can be pinned. +2. **The subject DN is not an identity.** It holds the validated legal name (`CN=Devolutions Inc, O=Devolutions Inc, …`). That name is not unique across CAs and can change over the life of an organization. +3. **An EKU is only an OID.** Microsoft encodes the durable profile identity as an extended key usage (EKU) OID (below). *Any* CA trusted by AuthRoot could technically issue a certificate that carries the same OID. The identity only means something if the chain is **pinned to the Microsoft root that operates Artifact Signing**. + +Microsoft has also announced that the Artifact Signing code-signing CAs are being removed from the Common CA Database (CCADB). They stay on the Windows platform trust list, so Windows keeps trusting them. Trust stores that follow CCADB (Mozilla-derived bundles on Linux) will not include them. Pinning the root explicitly makes validation independent of any platform trust store. + +## The durable identity: certificate profile EKU + +Each Artifact Signing **certificate profile** gets a custom EKU that every certificate issued from that profile carries, for the whole life of the profile. All Artifact Signing EKUs live under: + +```text +1.3.6.1.4.1.311.97 iso.org.dod.internet.private.enterprise.microsoft(311).97 +``` + +The arcs that follow identify the **profile type**, then the **profile-specific identity**: + +| Profile type | Identity EKU | Other EKUs present | Root | +|---|---|---|---| +| Public Trust | `1.3.6.1.4.1.311.97.` | `1.3.6.1.4.1.311.97.1.0` (Public Trust marker), `1.3.6.1.5.5.7.3.3` (code signing) | Microsoft Identity Verification Root CA 2020 | +| Public Trust Test | `1.3.6.1.4.1.311.97.1.2.` | code signing, `1.3.6.1.4.1.311.10.3.13` (lifetime signing) | Test root (not a production Microsoft root) | +| Private Trust | `1.3.6.1.4.1.311.97.1.3.1.` | code signing | Subscriber-specific root (`Get-AzCodeSigningRootCert` / `artifact-signing-root`) | +| Private Trust CI Policy | `1.3.6.1.4.1.311.97.1.4.1.` | **no** code signing EKU | Subscriber-specific root | + +The Public Trust Test prefix `…97.1.2.` is observed in real test-profile certificates (see the unit tests in [`trust_pkcs7.rs`](../crates/psign-authenticode-trust/src/trust_pkcs7.rs)). The other prefixes come from Microsoft's documentation. + +Rules psign applies when classifying an EKU under `1.3.6.1.4.1.311.97`: + +- `…97.1.0` exactly is the Public Trust **marker**, not an identity. +- `…97.1.2.*`, `…97.1.3.1.*`, `…97.1.4.1.*` map to the types above. +- Any other `…97.1.*` is an unknown sub-type and is ignored. +- Anything else under `…97.` (first arc ≠ `1`) is a **Public Trust identity**. +- A certificate with **two different** identity EKUs is rejected as ambiguous. + +Lifecycle notes from Microsoft: + +- The EKU belongs to the **certificate profile**, not to the subscriber or the identity validation. Two profiles of the same organization have **different** publisher IDs. +- **Deleting and recreating** a profile produces a **new** EKU. Anything pinned to the old value (a psign `--artifact-signing-publisher-id`, a WDAC policy, an ELAM resource) must be updated. +- For Private Trust, the CI Policy EKU uses the same suffix as the Private Trust code-signing EKU, under the `…97.1.4.1.` prefix. + +## The publisher ID + +psign calls the arcs **after the profile-type prefix** the **publisher ID**, rendered as a dotted decimal string: + +| Identity EKU | Kind | Publisher ID | +|---|---|---| +| `1.3.6.1.4.1.311.97.285218949.549339218.252840042.443064405` | `public_trust` | `285218949.549339218.252840042.443064405` | +| `1.3.6.1.4.1.311.97.1.3.1.29433.35007.34545.16815.37291.11644.53265.56135` | `private_trust` | `29433.35007.34545.16815.37291.11644.53265.56135` | + +Treat the publisher ID as an **opaque string** and compare it exactly. Microsoft documents only the prefix and that the suffix is unique per profile. Observed values have a regular shape: four arcs below 2³⁰ for Public Trust, eight arcs below 2¹⁶ in the Private Trust example. That suggests a 128-bit value split into groups, but the mapping to any Azure resource GUID is **not documented**, so psign does not try to convert it. + +### DER encoding walkthrough + +The EKU extension (`2.5.29.37`) of the Devolutions signer certificate in [`tests/fixtures/artifact-signing/`](../tests/fixtures/artifact-signing/) is: + +```text +30 33 SEQUENCE (51 bytes) + 06 0A 2B 06 01 04 01 82 37 61 01 00 OID 1.3.6.1.4.1.311.97.1.0 (Public Trust marker) + 06 08 2B 06 01 05 05 07 03 03 OID 1.3.6.1.5.5.7.3.3 (code signing) + 06 1B 2B 06 01 04 01 82 37 61 OID 1.3.6.1.4.1.311.97 … (identity, 27 bytes) + 81 88 80 B1 05 285218949 + 82 85 F9 80 52 549339218 + F8 C8 90 6A 252840042 + 81 D3 A2 C0 55 443064405 +``` + +Each arc is base-128, big-endian, with the high bit set on every byte except the last. Worked example for `81 88 80 B1 05`: + +```text +0x01·2²⁸ + 0x08·2²¹ + 0x00·2¹⁴ + 0x31·2⁷ + 0x05 += 268435456 + 16777216 + 0 + 6272 + 5 = 285218949 +``` + +Arcs ≥ 2²⁸ need **five** base-128 bytes. `const-oid` 0.9, which the `x509-cert` / `cms` stack uses, refuses arcs longer than four bytes, so `ExtendedKeyUsage` cannot be decoded with the usual types. psign therefore walks the EKU `SEQUENCE OF OBJECT IDENTIFIER` by hand. It rejects non-minimal encodings (a leading `0x80` byte), truncated OIDs, arcs wider than 64 bits, non-OID entries, and trailing data. + +### Extracting the publisher ID + +```bash +# Every embedded PE signature (no trust decision): +psign-tool portable artifact-signing-publisher-id ./MyApp.exe + +# A raw PKCS#7 / CMS blob, or a single certificate (PEM or DER): +psign-tool portable artifact-signing-publisher-id ./sig.p7s --input pkcs7 +psign-tool portable artifact-signing-publisher-id ./signer.cer --input cert +``` + +```json +[ + { + "pkcs7_index": 0, + "signer_subject": "C=CA,ST=Québec,L=Lavaltrie,O=Devolutions Inc,CN=Devolutions Inc", + "identity": { + "kind": "public_trust", + "publisher_id": "285218949.549339218.252840042.443064405", + "identity_eku": "1.3.6.1.4.1.311.97.285218949.549339218.252840042.443064405", + "has_public_trust_marker": true, + "has_code_signing_eku": true + } + } +] +``` + +`identity` is `null` when the signer has no Artifact Signing identity EKU. This command does **not** validate the signature: any certificate can carry these OIDs. Use it to *discover* the ID you want to pin, then pin it with `--artifact-signing-publisher-id` (or `-PublisherId` in PowerShell). + +On Windows the same value appears in the certificate's *Enhanced Key Usage* field as `Unknown Key Usage (1.3.6.1.4.1.311.97.…)`. + +## Root CAs and chain layout + +### Public Trust signer chain + +Observed on the Devolutions sample (October 2026): + +| Level | Subject | SHA-1 thumbprint | Validity | +|---|---|---|---| +| Leaf | `CN=Devolutions Inc, O=Devolutions Inc, …` | changes daily | 72 hours | +| Issuing CA | `CN=Microsoft ID Verified CS AOC CA 04` | `4c07413e5da89a259a127e1d76f792af239cd9d9` | 2026-03-26 → 2031-03-26 | +| Policy CA | `CN=Microsoft ID Verified Code Signing PCA 2021` | `8e750f459daf9a79d6370db747ad2226866ad818` | 2021-04-01 → 2036-04-01 | +| **Root (pinned)** | `CN=Microsoft Identity Verification Root Certificate Authority 2020` | **`f40042e2e5f7e8ef8189fed15519aece42c3bfa2`** | 2020-04-16 → 2045-04-16 | + +### Timestamp (TSA) chain + +Artifact Signing's TSA is `http://timestamp.acs.microsoft.com`: + +| Level | Subject | SHA-1 thumbprint | +|---|---|---| +| TSA | `CN=Microsoft Public RSA Time Stamping Authority, …` | rotates | +| Issuing CA | `CN=Microsoft Public RSA Timestamping CA 2020` | `27f0abac2877ba255f62b389b43ff539a0fb598e` | +| **Root (pinned)** | `CN=Microsoft Identity Verification Root Certificate Authority 2020` | **`f40042e2e5f7e8ef8189fed15519aece42c3bfa2`** | + +### What is pinned and why + +psign pins exactly **one** root for Public Trust: **Microsoft Identity Verification Root Certificate Authority 2020**. + +- SHA-1 `f40042e2e5f7e8ef8189fed15519aece42c3bfa2` (matches Microsoft's [PKI repository](https://www.microsoft.com/pkiops/docs/repository.htm)) +- SHA-256 `5367f20c7ade0e2bca790915056d086b720c33c1fa2a2661acf787e3292e1270` +- Embedded as PEM in [`crates/psign-authenticode-trust/certs/microsoft-identity-verification-root-ca-2020.pem`](../crates/psign-authenticode-trust/certs/microsoft-identity-verification-root-ca-2020.pem). It is re-hashed against **both** thumbprints every time it is loaded, so an edited PEM fails closed. + +Design choices: + +- **Pin the root, not the intermediates.** Issuing CAs rotate (the sample used *AOC CA 04*, issued March 2026). Pinning them would break validation on every rotation. The policy CA is long-lived, but the root is the stable anchor Microsoft publishes. +- **Use the same root for the TSA.** The timestamp decides *when* the signer certificate is checked for validity, and that is what keeps a 72-hour certificate's signature valid for years. A TSA from an arbitrary AuthRoot CA could back-date a signature into a revoked or expired certificate's validity window, so the TSA chain must end at the pinned root too. +- **Exclude everything else.** AuthRoot, `--anchor-dir`, the OS store, and other Microsoft roots (for example *Microsoft Root Certificate Authority 2010/2011*) are **not** consulted. The anchor set is fixed by the binary. +- **Private Trust / Public Trust Test** profiles chain to roots that are not production Microsoft roots. These signatures validate only when the caller supplies the root with `--artifact-signing-private-trust-root` (`-PrivateTrustRoot` in PowerShell). The pinned Microsoft root is **filtered out** of that list, so a Public Trust root can never anchor a non-Public-Trust identity. + +If Microsoft adds another Artifact Signing root, add a `PinnedRoot` entry to `PUBLIC_TRUST_ROOTS` with its PEM and both thumbprints. + +## Where the policy runs + +The policy lives in one place: `TrustVerifyPeOptions::artifact_signing` ([`trust_verify_pe.rs`](../crates/psign-authenticode-trust/src/trust_verify_pe.rs)). When it is set, every `trust_verify_*` entry point swaps its generic anchor handling for the Artifact Signing check. That covers PE/WinMD, CAB, MSI/MSP, WIM/ESD, catalog, ZIP Authenticode, PowerShell-class scripts, and detached PKCS#7. All callers reach it through the same functions: + +| Surface | How it reaches the policy | +|---|---| +| `psign-tool verify --artifact-signing…`, **Windows mode** | `WinVerifyTrust` first (forced to the Authenticode `/pa` policy unless `--policy` is given, because Artifact Signing certificates never satisfy the default driver policy), then [`artifact_signing_verify::verify_targets`](../src/artifact_signing_verify.rs) → `trust_verify_path`. | +| `psign-tool verify --artifact-signing…`, **portable mode** | The same `verify_targets` call, in place of the generic AuthRoot trust. `--policy pa` is accepted, so one command line works in both modes. | +| `psign-tool portable trust-verify-{pe,cab,msi,esd,catalog,zip,detached} --artifact-signing…` | The shared portable trust flags build the same `TrustVerifyPeOptions`. | +| PowerShell `Test-PsignArtifactSigning`, `Get-PsignSignature -ArtifactSigning…` | The `artifact_signing` object in the `psign_core_get_signature` request ([portable-core-ffi.md](portable-core-ffi.md)). | + +Windows mode can only be *stricter* than portable mode: a file must pass both WinVerifyTrust and the Artifact Signing check. For a genuine Artifact Signing signature both pass. The CLI tests in [`tests/artifact_signing_cli.rs`](../tests/artifact_signing_cli.rs) assert the same accept/reject outcome in both modes, using a real sample when `PSIGN_ARTIFACT_SIGNING_SAMPLE` is set. + +## Validation algorithm + +For **every** signature the format exposes (each PE certificate-table entry, or the single PKCS#7 of the other formats), all of these must pass: + +1. **Digest binding.** Recompute the subject digest with the format's SIP logic and require it to equal the PKCS#7 binding. That is the indirect `SpcIndirectDataContent` digest for Authenticode formats, and the PKCS#9 `messageDigest` for catalogs and CMS-detached signatures. +2. **Identity.** Take the certificate of the first `SignerInfo` and decode its Artifact Signing identity (see above). Fail if: + - it has no identity EKU; + - it is `public_trust` but lacks the `…97.1.0` marker; + - it is `private_trust_ci_policy` (WDAC policy signing, not code); + - it lacks the code signing EKU `1.3.6.1.5.5.7.3.3`. +3. **Publisher pin.** If one or more expected publisher IDs are given, the publisher ID must equal one of them (exact string match). +4. **Trusted timestamp.** Find the RFC 3161 countersignature (`1.3.6.1.4.1.311.3.3.1` or `1.2.840.113549.1.9.16.2.14`) on the primary signer and verify: + - its `MessageImprint` covers the primary `SignerInfo.signature`; + - its CMS signature verifies; + - the TSA signer certificate has the `timeStamping` EKU; + - the TSA chain ends at the **pinned Microsoft root** and is valid at `genTime`. + + `genTime` becomes the verification instant. With no timestamp, validation fails unless missing timestamps are explicitly allowed, in which case the current time is used. `--as-of YYYY-MM-DD` replaces the instant and skips this step. +5. **Anchor selection.** + - `public_trust`: the pinned Microsoft root(s) only. + - `public_trust_test` / `private_trust`: the supplied private trust roots, with any pinned Microsoft root removed. Fail if none remain. + + Generic anchor inputs (`--anchor-dir`, `--trusted-ca`, `--additional-trusted-ca`, `--authroot-cab`, and `-TrustedCertificate`/`-AnchorDirectory`/`-AuthRootCab` in PowerShell) are **rejected** with the Artifact Signing options, rather than silently ignored. AuthRoot is never downloaded. +6. **Signer chain and CMS.** Run the shared portable verifier ([`trust_pkcs7.rs`](../crates/psign-authenticode-trust/src/trust_pkcs7.rs)) with **strict** code-signing EKU policy: + - validate the authenticated attributes and the signature; + - build the issuer chain from the PKCS#7 certificate bag plus the anchors; + - require the terminal root's SHA-1 thumbprint to be in the selected anchor set; + - check every certificate signature and validity period at the instant from step 4. +7. **Revocation (opt-in).** With `--revocation-mode best-effort|require` (and optionally `--online-ocsp`), fetch the CRL from each certificate's distribution point and verify it with the issuing CA's key. This covers every non-root certificate in both the signer chain and the TSA chain. Any listed serial fails validation. `require` also fails when a CRL cannot be fetched, parsed, or verified, or is stale. On the sample, that is the CRLs of *ID Verified CS AOC CA 04* (≈22 KB), *Code Signing PCA 2021*, *Public RSA Timestamping CA 2020*, and the root (≈1 KB each), all well under the default 1 MiB download limit. + +The result reports, per signature: `publisher_id`, `kind`, `identity_eku`, the terminal root subject and SHA-1 (`root_sha1`), the verification instant, and whether it came from a trusted timestamp. + +## Usage + +### psign-tool + +```bash +# Accept any Artifact Signing Public Trust signer (Windows or portable mode, any supported format): +psign-tool verify --artifact-signing ./MyApp.exe + +# Accept only one certificate profile (repeat the flag to allow a set, e.g. across a profile rotation): +psign-tool verify --artifact-signing-publisher-id 285218949.549339218.252840042.443064405 ./MyApp.exe + +# Same check without WinVerifyTrust (Linux/macOS, or to skip the OS policy on Windows): +psign-tool --mode portable verify --artifact-signing-publisher-id 285218949.549339218.252840042.443064405 ./MyApp.exe + +# Private Trust: supply the subscriber root (see `artifact-signing-root` in migration-artifact-signing.md): +psign-tool verify --artifact-signing-private-trust-root ./profile-root.cer ./MyApp.exe + +# Fail closed on revoked or unverifiable revocation status (network required): +psign-tool verify --artifact-signing --revocation-mode require ./MyApp.msi + +# Low-level portable commands take the same flags: +psign-tool portable trust-verify-cab --artifact-signing-publisher-id 285218949.549339218.252840042.443064405 ./payload.cab + +# Discover the publisher ID to pin (no trust decision): +psign-tool portable artifact-signing-publisher-id ./MyApp.exe +``` + +| Flag (`verify` and `portable trust-verify-*`) | Effect | +|---|---| +| `--artifact-signing` | Enable the policy with defaults. | +| `--artifact-signing-publisher-id ID` | Repeatable; the signer's publisher ID must equal one of the values. Implies `--artifact-signing`. | +| `--artifact-signing-private-trust-root PATH` | Repeatable PEM/DER root for Private Trust / Public Trust Test; ignored for Public Trust. Implies `--artifact-signing`. | +| `--artifact-signing-allow-missing-timestamp` | Accept signatures without a timestamp and validate at the current time. They fail once the 72-hour certificate expires. Implies `--artifact-signing`. | +| `--as-of YYYY-MM-DD` | Fixed verification instant; skips timestamp verification. | +| `--revocation-mode off\|best-effort\|require`, `--online-ocsp`, `--online-timeout-secs N` | Online revocation, off by default. | +| `--verbose-chain` | Print the signer and TSA chains to stderr. | + +Sample output in Windows mode (portable mode prints only the Artifact Signing block): + +```text +Successfully verified: ./MyApp.exe +Artifact Signing verified: ./MyApp.exe + PKCS#7[0] C=CA,ST=Québec,L=Lavaltrie,O=Devolutions Inc,CN=Devolutions Inc publisher_id=285218949.549339218.252840042.443064405 kind=public_trust root=f40042e2e5f7e8ef8189fed15519aece42c3bfa2 time=2026-10-23T18:43:55Z (timestamp) +``` + +### PowerShell (`Devolutions.Psign`) + +```powershell +# Discover the publisher ID (no trust decision) from a file or a certificate: +Get-PsignArtifactSigningPublisherId .\MyApp.exe +Get-PsignArtifactSigningPublisherId -Certificate $cert + +# Boolean check with publisher pinning; -Detailed returns the signature object: +Test-PsignArtifactSigning .\MyApp.exe -PublisherId 285218949.549339218.252840042.443064405 +Test-PsignArtifactSigning .\MyApp.exe -PublisherId 285218949.549339218.252840042.443064405 -Detailed + +# Full signature object with the policy applied: +Get-PsignSignature .\MyApp.exe -ArtifactSigningPublisherId 285218949.549339218.252840042.443064405 +``` + +Every `Get-PsignSignature` result exposes `ArtifactSigning` (the decoded identity) and `ArtifactSigningPublisherId`, even without validation. With the policy applied, `ArtifactSigningVerification` holds the verified root, timestamp time, and signer. A failed policy check sets `Status`/`TrustStatus` to `NotTrusted`, and `StatusMessage` gives the reason. `Test-PsignArtifactSigning` also takes `-PrivateTrustRoot`, `-AllowMissingTimestamp`, `-AsOf`, `-RevocationMode`, and `-OnlineOcsp`. + +### Library API + +```rust +use psign_authenticode_trust::{ArtifactSigningPolicy, TrustVerifyPeOptions, trust_verify_path}; + +let report = trust_verify_path(path, &TrustVerifyPeOptions { + artifact_signing: Some(ArtifactSigningPolicy { + expected_publisher_ids: vec!["285218949.549339218.252840042.443064405".into()], + ..Default::default() // require_timestamp: true + }), + ..Default::default() +})?; +for sig in &report.artifact_signing { + println!("{sig}"); +} +``` + +Other entry points: + +- `trust_verify_{pe,cab,msi,catalog,zip,script,detached}_bytes` / `trust_verify_wim_esd_path` with the same options. +- `verify_artifact_signing_pkcs7(pkcs7_der, index, subject_digest, binding, &policy, &opts)` for a PKCS#7 whose subject digest you computed yourself. +- `artifact_signing_identity_from_certificate` / `_from_cert_bytes` / `_from_pkcs7_der` / `artifact_signing_identities_from_pe` for identity extraction only. + +## Other places the identity is used + +- **WDAC / App Control policies** can pin the identity EKU directly. That is the Windows-native equivalent of `--artifact-signing-publisher-id`. +- **ELAM anti-malware services**: Microsoft asks vendors to put the TBS hash of *Microsoft ID Verified Code Signing PCA 2021* in the ELAM resource `CertHash` and their identity EKU in its `EKU` field. +- **Signing-certificate selection**: `psign-tool sign --signing-cert-eku-prefix 1.3.6.1.4.1.311.97.` selects an Artifact Signing certificate from a store, the same way PowerShell OpenAuthenticode does. + +## Limitations + +- **Revocation is opt-in.** It is off by default because it needs network access. Use `--revocation-mode require` to fail closed. Microsoft revokes individual daily certificates on confirmed misuse, so require mode is recommended where network access is available. The Artifact Signing signer certificates publish a CRL distribution point but no OCSP URL, so CRLs do the work. Microsoft PKI CRLs are signed with `sha384WithRSAEncryption`, which the portable verifier supports along with SHA-256 and SHA-512. RSA-PSS and ECDSA-signed CRLs and OCSP responses are not supported. Revocation status is checked against the **current** CRL, not as of the timestamp time. +- **Formats.** PE/WinMD, CAB, MSI/MSP, WIM/ESD, catalogs, ZIP Authenticode, PowerShell-class scripts, and detached PKCS#7 are covered. MSIX/AppX, NuGet, VSIX, ClickOnce, and WSH scripts (`.js`, `.vbs`, `.wsf`) are rejected with the Artifact Signing options, because they have no portable trust verifier yet. In the PowerShell module, WIM/ESD and detached PKCS#7 also have no portable trust path yet. +- **Catalog database lookup.** `verify --catalog-search` / `--catalog-database-guid` cannot be combined with the policy. Pass the catalog explicitly with `--catalog`; its signature is then checked, and member hashes are verified as usual. +- **Nested signatures.** Only certificate-table entries are checked. Nested signatures (`1.3.6.1.4.1.311.2.4.1`) inside an entry are not evaluated as separate Artifact Signing signatures. +- **Root subtree.** The pinned root also issues non-Artifact-Signing CAs (for example the timestamping CA). Requiring the identity EKU, the Public Trust marker, and the code signing EKU on the signer is what limits acceptance to Artifact Signing. Intermediates are deliberately not pinned (see above). + +## References + +- Microsoft Learn: [Artifact Signing certificate management](https://learn.microsoft.com/azure/artifact-signing/concept-certificate-management), covering short-lived certificates, the certificate profile EKU, and the TSA endpoint. +- Microsoft Learn: [Artifact Signing FAQ](https://learn.microsoft.com/azure/artifact-signing/faq), covering ELAM `CertHash` / EKU guidance and Windows support. +- Microsoft: [PKI Services repository](https://www.microsoft.com/pkiops/docs/repository.htm), covering the root certificate download and thumbprint. +- Related psign docs: [migration-artifact-signing.md](migration-artifact-signing.md) (signing), [authenticode-trust-stack.md](authenticode-trust-stack.md) (portable trust internals), [psa-interoperability.md](psa-interoperability.md). diff --git a/docs/authenticode-trust-stack.md b/docs/authenticode-trust-stack.md index 5530fe9..d9fbda0 100644 --- a/docs/authenticode-trust-stack.md +++ b/docs/authenticode-trust-stack.md @@ -12,6 +12,7 @@ This describes how **`crates/psign-authenticode-trust`** composes crates for **L | Trust anchors | This crate (**`anchor`**, **`authroot_cache`**, **`authroot_cab`**, **`authroot_ctl`**) | Phase A: load **`*.crt`** / **`*.cer`** / **`*.pem`** from **`--anchor-dir`** or repeatable **`--trusted-ca`** files. Phase B: automatically cache Microsoft **`authrootstl.cab`** at **`~/.psign/authroot/`** when no replacing explicit anchors are supplied, then parse CAB **`*.stl`** → PKCS#7 **`SignedData`** **`eContent`** CTL **SHA-1 subject identifiers** plus PKCS#7-embedded certs. Repeatable **`--additional-trusted-ca`** files augment either selected trust set without suppressing automatic AuthRoot discovery. | | Policy knobs | **`policy::AuthenticodeTrustPolicy`** | Default **strict** code-signing EKU; CLI **`allow-loose-signing-cert`**, **`--prefer-timestamp-signing-time`** / **`--require-valid-timestamp`** (see [**Verification instant / timestamps**](#verification-instant--timestamps)), **`--as-of YYYY-MM-DD`** for **`exact_date`**. | | Portable CLI | **`psign-tool portable`** | **`trust-verify-pe`**, **`trust-verify-cab`**, **`trust-verify-catalog`**, **`trust-verify-detached`** share anchor, AuthRoot CAB/cache, AIA, OCSP, CRL revocation, timestamp-policy, and chain-diagnostic flags; detached uses [`pkcs7_wire::normalize_pkcs7_der_for_authenticode`](../crates/psign-sip-digest/src/pkcs7_wire.rs). **`inspect-authenticode`** emits JSON for PKCS#7 signers, timestamp-related OIDs, and nested signatures (**`1.3.6.1.4.1.311.2.4.1`**). Unified **`psign-tool --mode portable verify`** uses the portable trust commands by default for supported formats when automatic AuthRoot is enabled; **`PSIGN_NO_AUTO_TRUST=1`** restores digest-only routing unless explicit trust inputs are present. | +| Azure Artifact Signing policy | This crate **`artifact_signing`** | Decodes the certificate-profile identity EKU (**`1.3.6.1.4.1.311.97.*`**, hand-decoded because some arcs exceed **`const-oid`** limits) into a durable **publisher ID**, and verifies PKCS#7 with anchors pinned to the embedded **Microsoft Identity Verification Root CA 2020** (signer and TSA chains) or caller-supplied Private Trust roots. Validity is checked at the trusted timestamp. Enabled through **`TrustVerifyPeOptions::artifact_signing`** for every **`trust_verify_*`** format; CLI **`verify --artifact-signing…`** (both modes), **`trust-verify-* --artifact-signing…`**, **`artifact-signing-publisher-id`**. See [**artifact-signing-validation.md**](artifact-signing-validation.md). | | CMS inspection (no trust decision) | This crate **`inspect`** | Uses **`cms`** **`SignedData`** + **`authenticode`** digest probe; complements picky **`trust_*`** paths. See [**psa-interoperability.md**](psa-interoperability.md). | ## Verification order (per PKCS#7 blob) @@ -23,8 +24,8 @@ This describes how **`crates/psign-authenticode-trust`** composes crates for **L 4. Merge picky-decoded embedded certs with anchor / CAB-extracted certs; resolve the signing cert; walk **`issuer_chain_excluding_leaf`** to the terminal root (or the CMS fallback equivalent above). 5. If **`--online-aia`** is enabled and an issuer is missing, fetch **HTTP** AIA **`caIssuers`** certificates into memory only (or use **`--aia-url-override`** for deterministic local tests); no OS intermediate/root store is modified. 6. Require the terminal root’s **SHA-1 thumbprint** (full cert DER, Windows-style) to appear in the configured **`AnchorStore`**. -7. If **`--online-ocsp`** or **`--ocsp-url-override`** is set with revocation enabled, POST a bounded OCSP request, require a successful BasicOCSPResponse, verify its RSA/SHA-256 signature with the issuing CA, match the requested serial, and apply good/revoked/unknown status. -8. If OCSP is not configured or does not return a definitive good status, fetch the CRL over bounded HTTP from **`--crl-url-override`** or the first HTTP CRL Distribution Point URL, verify its RSA/SHA-256 signature with the issuing CA, and reject revoked leaf/intermediate serials. +7. If **`--online-ocsp`** or **`--ocsp-url-override`** is set with revocation enabled, POST a bounded OCSP request, require a successful BasicOCSPResponse, verify its RSA PKCS#1 v1.5 signature (SHA-256, SHA-384, or SHA-512) with the issuing CA, match the requested serial, and apply good/revoked/unknown status. +8. If OCSP is not configured or does not return a definitive good status, fetch the CRL over bounded HTTP from **`--crl-url-override`** or the first HTTP CRL Distribution Point URL, require its outer **`signatureAlgorithm`** to match **`TBSCertList.signature`**, verify its RSA PKCS#1 v1.5 signature (SHA-256, SHA-384, or SHA-512 — Microsoft PKI CRLs use SHA-384) with the issuing CA, and reject revoked leaf/intermediate serials. 9. **`leaf.verifier().chain(...).exact_date(...).verify()`** to validate signatures along the path. Normative background and CTL/bootstrap notes live in **[plan-linux-authenticode-trust-verify.md](plan-linux-authenticode-trust-verify.md)**. diff --git a/docs/migration-artifact-signing.md b/docs/migration-artifact-signing.md index 26cc9e3..afb4be0 100644 --- a/docs/migration-artifact-signing.md +++ b/docs/migration-artifact-signing.md @@ -236,6 +236,25 @@ psign-tool portable trust-verify-pe ./MyApp.exe \ --authroot-cab ./authroot.stl.cab ``` +### Artifact Signing-specific trust (pinned Microsoft root + publisher ID) + +**`trust-verify-pe`** accepts any root in AuthRoot. To require that a file was signed **by Azure Artifact Signing**, and optionally by one specific certificate profile, add the **`--artifact-signing…`** options. They behave the same in Windows and portable modes and across PE, CAB, MSI, WIM/ESD, catalog, ZIP, PowerShell script, and detached signatures: + +```bash +# Publisher ID (certificate profile identity EKU suffix) of each signer; no trust decision. +psign-tool portable artifact-signing-publisher-id ./MyApp.exe +psign-tool portable artifact-signing-publisher-id ./signer.cer --input cert + +# Full validation, pinned to Microsoft's Artifact Signing root and to one certificate profile. +psign-tool verify --artifact-signing-publisher-id 285218949.549339218.252840042.443064405 ./MyApp.exe +``` + +```powershell +Test-PsignArtifactSigning .\MyApp.exe -PublisherId 285218949.549339218.252840042.443064405 +``` + +This checks the signer's identity EKU, pins the signer **and** timestamp chains to **Microsoft Identity Verification Root Certificate Authority 2020** instead of AuthRoot, and evaluates the 72-hour certificate at the trusted timestamp time. **[artifact-signing-validation.md](artifact-signing-validation.md)** covers how the publisher ID is encoded, which roots are pinned and why, the full validation algorithm, Private Trust (`--artifact-signing-private-trust-root`), and known limitations. + ## MSIX / APPX MSIX uses the same **`SignerSignEx3`** SIP stack and the same decoupled **`--dlib` / `--dmdf`** bridge. **`--page-hashes`** for MSIX requires decoupled digest inputs. See also [`rust-sip-spec-refs.md`](rust-sip-spec-refs.md). diff --git a/docs/portable-core-ffi.md b/docs/portable-core-ffi.md index a35bffd..733fb52 100644 --- a/docs/portable-core-ffi.md +++ b/docs/portable-core-ffi.md @@ -6,6 +6,7 @@ The ABI is intentionally small and JSON-based: - `psign_core_version()` - `psign_core_get_signature(request_json_ptr, request_json_len)` +- `psign_core_get_artifact_signing_identity(request_json_ptr, request_json_len)` - `psign_core_validate_powershell_script(request_json_ptr, request_json_len)` - `psign_core_sign(request_json_ptr, request_json_len)` - `psign_core_free(buffer)` @@ -16,4 +17,6 @@ The first schema version supports portable digest/signature inspection and local `psign_core_get_signature` also accepts explicit trust material in the JSON request: trusted certificate paths, DER-encoded trusted certificates, anchor directory, AuthRoot CAB, `as_of`, timestamp-time policy booleans, online AIA/OCSP toggles, and revocation mode. The portable core never falls back to OS trust; when trust is requested, the response includes `trust_status` in addition to the digest/signature `status`. +Azure Artifact Signing validation is requested with an `artifact_signing` object (`publisher_ids`, `private_trust_root_paths`, `allow_missing_timestamp`) instead of trust anchors. Combining it with trusted certificates, an anchor directory, or an AuthRoot CAB is an error. Every response carries `artifact_signing` (the signer's decoded identity: `kind`, `publisher_id`, `identity_eku`, `has_public_trust_marker`, `has_code_signing_eku`) when present. A successful validation adds `artifact_signing_verification` (`root_sha1`, `root_subject`, `verification_time`, `timestamped`). `psign_core_get_artifact_signing_identity` takes `{ "certificate_der_base64": "…" }` and returns `{ "schema_version", "identity" }` (`identity` is `null` for certificates without an Artifact Signing EKU). See [artifact-signing-validation.md](artifact-signing-validation.md). + `psign_core_validate_powershell_script` is the embedding-oriented variant for patched PowerShell hosts. Its request contains `source_path_or_extension`, `content_base64`, and the same explicit trust fields as `psign_core_get_signature`. The function validates `.ps1`, `.psm1`, `.psd1`, `.ps1xml`, `.psc1`, `.cdxml`, and `.mof` content from memory so policy enforcement can avoid temporary files and shelling out. diff --git a/docs/portable-powershell-module.md b/docs/portable-powershell-module.md index 3edd910..7fa4e32 100644 --- a/docs/portable-powershell-module.md +++ b/docs/portable-powershell-module.md @@ -6,6 +6,8 @@ - `Set-PsignSignature` - `Get-PsignSignature` +- `Get-PsignArtifactSigningPublisherId` +- `Test-PsignArtifactSigning` Both cmdlets accept `-FilePath` and `-LiteralPath`. `-LiteralPath` accepts the same `PSPath` and `LP` aliases as the built-in Authenticode cmdlets. When the input is a directory, the module treats it as a PowerShell module tree and recursively processes PowerShell files plus newly supported portable signing neighbors such as `.dll`, `.exe`, `.nupkg`, `.snupkg`, `.vsix`, `.manifest`, `.application`, `.vsto`, and `.appinstaller`. @@ -46,6 +48,22 @@ Get-PsignSignature -LiteralPath .\tool.exe -AnchorDirectory .\anchors Get-PsignSignature -LiteralPath .\tool.exe -TrustedCertificate $rootCertificate -AsOf (Get-Date) -RevocationMode Off ``` +### Azure Artifact Signing + +Artifact Signing validation replaces anchors with the pinned Microsoft Identity Verification Root CA 2020 (signer and timestamp chains). It requires the certificate-profile identity EKU and checks certificate validity at the trusted timestamp. It is the same policy as `psign-tool verify --artifact-signing…`. Generic trust parameters (`-TrustedCertificate`, `-AnchorDirectory`, `-AuthRootCab`) cannot be combined with it, and AuthRoot is not downloaded. + +```powershell +# Publisher ID (certificate profile EKU suffix) of the signer; no trust decision: +Get-PsignArtifactSigningPublisherId .\tool.exe +Get-PsignArtifactSigningPublisherId -Certificate $signerCertificate + +# Azure Artifact Signing validation pinned to Microsoft's root and to one certificate profile: +Test-PsignArtifactSigning .\tool.exe -PublisherId 285218949.549339218.252840042.443064405 +Get-PsignSignature .\tool.exe -ArtifactSigningPublisherId 285218949.549339218.252840042.443064405 +``` + +Every result exposes `ArtifactSigning` (decoded identity) and `ArtifactSigningPublisherId`. Validated results also carry `ArtifactSigningVerification` (root thumbprint, timestamp time). See [artifact-signing-validation.md](artifact-signing-validation.md). + The output object is portable-specific, but its compatibility properties mirror the built-in Authenticode shape: `Status` is a `System.Management.Automation.SignatureStatus`, `SignatureType` is a `System.Management.Automation.SignatureType`, and `SignerCertificate`, `TimeStamperCertificate`, `StatusMessage`, `Path`, `IsOSBinary`, and `SubjectAlternativeName` are exposed by the same names. Portable-specific details remain available through `Format`, `PortableStatus`, `TrustStatus`, `PortableTrustStatus`, `TimestampKinds`, `TimestampSigningTime`, `SignatureCount`, and `PortableDiagnostics`. When trust is requested, the output object's `TrustStatus` is `Valid` or `NotTrusted`, while `Status` continues to report the overall portable signature result. Timestamped signatures expose `TimestampKinds`, `TimeStamperCertificate`, and `TimestampSigningTime` when the portable timestamp parser can extract the signing date. diff --git a/docs/psign-cli-matrix.json b/docs/psign-cli-matrix.json index 25ad8b6..f8afbee 100644 --- a/docs/psign-cli-matrix.json +++ b/docs/psign-cli-matrix.json @@ -123,6 +123,10 @@ {"native": "(detached sig file)", "rust": "--detached-pkcs7 (--p7s)", "tier": "P0", "status": "implemented", "notes": "Alias p7s for detached PKCS#7 path. In `--mode portable verify`, this routes to one portable CMS/chain validation via `trust-verify-detached` and requires exactly one verify target."}, {"native": "(allow test roots)", "rust": "--allow-test-root (--testroot)", "tier": "P1", "status": "implemented", "notes": "Windows argv /testroot supported"}, {"native": "(portable explicit root)", "rust": "--trusted-ca", "tier": "P1", "status": "implemented", "notes": "Portable trust only; repeatable PEM/DER root files, no OS trust-store writes. In `--mode portable verify`, supported formats route to portable trust by default when automatic AuthRoot is enabled; this flag supplies explicit roots and suppresses auto AuthRoot resolution, including detached PKCS#7 and explicit catalog routing."}, + {"native": "(Azure Artifact Signing)", "rust": "--artifact-signing", "tier": "P1", "status": "implemented", "notes": "Same policy in `--mode windows` (after WinVerifyTrust, forced to `/pa` unless `--policy` is given) and `--mode portable` (replaces AuthRoot trust): identity EKU `1.3.6.1.4.1.311.97.*`, signer and RFC3161 TSA chains pinned to Microsoft Identity Verification Root CA 2020, validity at the trusted timestamp. Rejects generic anchor flags, `--kernel-policy`, and catalog database search. See docs/artifact-signing-validation.md."}, + {"native": "(Azure Artifact Signing publisher pin)", "rust": "--artifact-signing-publisher-id", "tier": "P1", "status": "implemented", "notes": "Repeatable; signer certificate profile EKU suffix must equal one value. Implies `--artifact-signing`."}, + {"native": "(Azure Artifact Signing private trust root)", "rust": "--artifact-signing-private-trust-root", "tier": "P1", "status": "implemented", "notes": "Repeatable PEM/DER subscriber root for Private Trust / Public Trust Test signatures; Microsoft Public Trust roots are never accepted here. Implies `--artifact-signing`."}, + {"native": "(Azure Artifact Signing untimestamped)", "rust": "--artifact-signing-allow-missing-timestamp", "tier": "P2", "status": "implemented", "notes": "Accept Artifact Signing signatures without an RFC3161 countersignature (validated at the current time). Implies `--artifact-signing`."}, {"native": "(portable additive root)", "rust": "--additional-trusted-ca", "tier": "P1", "status": "implemented", "notes": "Portable trust only; repeatable PEM/DER root files that augment the selected trust set without suppressing automatic Microsoft AuthRoot discovery. Each supplied certificate is trusted as an anchor for that invocation."}, {"native": "(portable anchor directory)", "rust": "--anchor-dir", "tier": "P1", "status": "implemented", "notes": "Portable trust only; loads .crt/.cer/.pem files as anchors without elevation or persistent store changes."}, {"native": "(portable AIA)", "rust": "--online-aia", "tier": "P2", "status": "partial", "notes": "Portable trust only; explicit in-memory HTTP AIA caIssuers fetch for missing issuers. Revocation is available through OCSP/CRL HTTP overrides and CRL Distribution Points."}, @@ -251,7 +255,8 @@ "commands": [ {"name": "pe-digest", "maps_to_native_concept": "PE SIP image hash (algorithm-selectable)"}, {"name": "verify-pe", "maps_to_native_concept": "Consistency check after conceptual WinTrust success — similar to `psign-tool verify --rust-sip-pe-digest-check`"}, - {"name": "trust-verify-pe", "maps_to_native_concept": "Explicit-anchor PKCS#7 trust + picky chain + PE digest (not WinTrust); optional `--as-of YYYY-MM-DD` for expired fixtures"}, + {"name": "trust-verify-pe", "maps_to_native_concept": "Explicit-anchor PKCS#7 trust + picky chain + PE digest (not WinTrust); optional `--as-of YYYY-MM-DD` for expired fixtures; `--artifact-signing`, `--artifact-signing-publisher-id`, `--artifact-signing-private-trust-root`, `--artifact-signing-allow-missing-timestamp` (shared by every `trust-verify-*`) swap the anchors for the pinned Azure Artifact Signing policy"}, + {"name": "artifact-signing-publisher-id", "maps_to_native_concept": "Extract Azure Artifact Signing publisher ID (certificate profile identity EKU `1.3.6.1.4.1.311.97.*`) from PE / PKCS#7 / certificate as JSON (no trust decision)"}, {"name": "trust-verify-cab", "maps_to_native_concept": "`verify-cab` digest consistency then same trust stack as PE"}, {"name": "trust-verify-msi", "maps_to_native_concept": "`verify-msi` digest consistency then same trust stack as PE"}, {"name": "trust-verify-esd", "maps_to_native_concept": "`verify-esd` digest consistency then same trust stack as PE"}, diff --git a/docs/rust-sip-gaps.md b/docs/rust-sip-gaps.md index cfee8cd..66c7aaa 100644 --- a/docs/rust-sip-gaps.md +++ b/docs/rust-sip-gaps.md @@ -58,7 +58,7 @@ Use these labels in issues and release notes when scoping non-goals vs future wo | ID | Topic | |----|--------| -| **`linux_trust_crl_limited`** | CRL checks use explicit HTTP CDP URLs or URL overrides, verify issuing-CA RSA/SHA-256 signatures, reject revoked serials, and enforce `thisUpdate` / `nextUpdate` freshness. No indirect/delta CRLs or **`CERT_CHAIN_REVOCATION_CHECK_*`** parity yet. | +| **`linux_trust_crl_limited`** | CRL checks use explicit HTTP CDP URLs or URL overrides, verify issuing-CA RSA PKCS#1 v1.5 signatures (SHA-256/384/512; no RSA-PSS or ECDSA), reject revoked serials, and enforce `thisUpdate` / `nextUpdate` freshness. No indirect/delta CRLs or **`CERT_CHAIN_REVOCATION_CHECK_*`** parity yet. | | **`linux_trust_ocsp_limited`** | OCSP checks support issuing-CA-signed HTTP BasicOCSPResponse good/revoked/unknown status, exact CertID matching, and `thisUpdate` / `nextUpdate` freshness. No delegated responders, nonce policy, or broad error-status matrix yet. | | **`linux_trust_no_pinrules`** | No **`PinRulesSTL`** / auto-update pinning semantics. | | **`linux_trust_no_disallowed_stl`** | No **`disallowedcertstl.cab`** fail-closed thumbprint set (only explicit anchors today). | diff --git a/dotnet/Devolutions.Psign.PowerShell/Cmdlets/GetPsignArtifactSigningPublisherIdCommand.cs b/dotnet/Devolutions.Psign.PowerShell/Cmdlets/GetPsignArtifactSigningPublisherIdCommand.cs new file mode 100644 index 0000000..40da774 --- /dev/null +++ b/dotnet/Devolutions.Psign.PowerShell/Cmdlets/GetPsignArtifactSigningPublisherIdCommand.cs @@ -0,0 +1,111 @@ +using System.Management.Automation; +using System.Security.Cryptography.X509Certificates; +using Devolutions.Psign.PowerShell.Models; +using Devolutions.Psign.PowerShell.Native; +using Devolutions.Psign.PowerShell.Utilities; + +namespace Devolutions.Psign.PowerShell.Cmdlets; + +/// +/// Reads the Azure Artifact Signing publisher ID (certificate profile EKU) from a signed file's +/// signer certificate or from a certificate. This does not validate the signature; use +/// Test-PsignArtifactSigning or Get-PsignSignature -ArtifactSigningPublisherId for that. +/// +[Cmdlet(VerbsCommon.Get, "PsignArtifactSigningPublisherId", DefaultParameterSetName = FilePathParameterSet)] +[OutputType(typeof(PsignArtifactSigningIdentity))] +public sealed class GetPsignArtifactSigningPublisherIdCommand : PSCmdlet +{ + private const string FilePathParameterSet = "FilePath"; + private const string LiteralPathParameterSet = "LiteralPath"; + private const string CertificateParameterSet = "Certificate"; + + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true, ParameterSetName = FilePathParameterSet, HelpMessage = "Path(s) to signed files. Wildcards are supported.")] + [Alias("Path")] + public string[] FilePath { get; set; } = []; + + [Parameter(Mandatory = true, ValueFromPipelineByPropertyName = true, ParameterSetName = LiteralPathParameterSet, HelpMessage = "Literal path(s) to signed files. No wildcard expansion.")] + [Alias("PSPath", "LP")] + public string[] LiteralPath { get; set; } = []; + + [Parameter(Mandatory = true, ValueFromPipeline = true, ParameterSetName = CertificateParameterSet, HelpMessage = "Signer certificate(s) to decode.")] + public X509Certificate2[] Certificate { get; set; } = []; + + protected override void ProcessRecord() + { + if (ParameterSetName == CertificateParameterSet) + { + foreach (X509Certificate2 certificate in Certificate) + { + WriteCertificateIdentity(certificate); + } + return; + } + + bool literal = ParameterSetName == LiteralPathParameterSet; + foreach (string input in literal ? LiteralPath : FilePath) + { + foreach (string resolved in PathResolution.ResolveFilePaths(this, input, literal)) + { + WriteFileIdentity(resolved); + } + } + } + + private void WriteFileIdentity(string path) + { + try + { + PortableSignature signature = PsignNative.GetSignature(new PortableGetSignatureRequest { Path = path }); + if (signature.ArtifactSigning is null) + { + WriteNotArtifactSigningError(signature.SignerCertificate is null + ? $"'{path}' has no signer certificate ({signature.Status}: {signature.StatusMessage})." + : $"The signer of '{path}' has no Azure Artifact Signing identity EKU (1.3.6.1.4.1.311.97.*).", path); + return; + } + + signature.ArtifactSigning.Path = path; + signature.ArtifactSigning.Subject = signature.SignerCertificate?.Subject; + WriteObject(signature.ArtifactSigning); + } + catch (Exception ex) + { + WriteError(new ErrorRecord(ex, "GetPsignArtifactSigningPublisherIdFailed", ErrorCategory.NotSpecified, path)); + } + } + + private void WriteCertificateIdentity(X509Certificate2 certificate) + { + try + { + PortableArtifactSigningIdentityResponse response = PsignNative.GetArtifactSigningIdentity( + new PortableArtifactSigningIdentityRequest + { + CertificateDerBase64 = Convert.ToBase64String(certificate.Export(X509ContentType.Cert)), + }); + if (response.Identity is null) + { + WriteNotArtifactSigningError( + $"Certificate '{certificate.Subject}' has no Azure Artifact Signing identity EKU (1.3.6.1.4.1.311.97.*).", + certificate); + return; + } + + response.Identity.Subject = certificate.Subject; + WriteObject(response.Identity); + } + catch (Exception ex) + { + WriteError(new ErrorRecord(ex, "GetPsignArtifactSigningPublisherIdFailed", ErrorCategory.NotSpecified, certificate)); + } + } + + private void WriteNotArtifactSigningError(string message, object target) + { + WriteError(new ErrorRecord( + new InvalidOperationException(message), + "NotArtifactSigningCertificate", + ErrorCategory.InvalidData, + target)); + } +} diff --git a/dotnet/Devolutions.Psign.PowerShell/Cmdlets/GetPsignSignatureCommand.cs b/dotnet/Devolutions.Psign.PowerShell/Cmdlets/GetPsignSignatureCommand.cs index 18abd26..2974440 100644 --- a/dotnet/Devolutions.Psign.PowerShell/Cmdlets/GetPsignSignatureCommand.cs +++ b/dotnet/Devolutions.Psign.PowerShell/Cmdlets/GetPsignSignatureCommand.cs @@ -65,6 +65,19 @@ public sealed class GetPsignSignatureCommand : PSCmdlet [Parameter] public SwitchParameter SkipTrust { get; set; } + [Parameter(HelpMessage = "Validate as an Azure Artifact Signing signature: identity EKU, signer and timestamp chains pinned to Microsoft Identity Verification Root CA 2020, validity at the trusted timestamp.")] + public SwitchParameter ArtifactSigning { get; set; } + + [Parameter(HelpMessage = "Require one of these Azure Artifact Signing publisher IDs (certificate profile EKU suffix). Implies -ArtifactSigning.")] + [ValidateNotNullOrEmpty] + public string[] ArtifactSigningPublisherId { get; set; } = []; + + [Parameter(HelpMessage = "Subscriber root certificate for Private Trust / Public Trust Test signatures. Implies -ArtifactSigning.")] + public string[] ArtifactSigningPrivateTrustRoot { get; set; } = []; + + [Parameter(HelpMessage = "Accept Azure Artifact Signing signatures without a timestamp. Implies -ArtifactSigning.")] + public SwitchParameter ArtifactSigningAllowMissingTimestamp { get; set; } + protected override void ProcessRecord() { bool literal = ParameterSetName == LiteralPathParameterSet; @@ -136,8 +149,17 @@ private PortableGetSignatureRequest CreateRequest(string path) ? null : SessionState.Path.GetUnresolvedProviderPathFromPSPath(AuthRootCab); - // Auto-trust: if no explicit trust params and not opted out, use cached AuthRoot CAB + PortableArtifactSigningOptions? artifactSigning = ArtifactSigningOptions.Create( + this, + ArtifactSigning.IsPresent, + ArtifactSigningPublisherId, + ArtifactSigningPrivateTrustRoot, + ArtifactSigningAllowMissingTimestamp.IsPresent); + + // Auto-trust: if no explicit trust params and not opted out, use cached AuthRoot CAB. + // Artifact Signing pins its own roots, so it never needs AuthRoot. if (!SkipTrust.IsPresent + && artifactSigning is null && resolvedAuthRootCab is null && AnchorDirectory is null && TrustedCertificatePath.Length == 0 @@ -168,6 +190,7 @@ private PortableGetSignatureRequest CreateRequest(string path) OnlineAia = OnlineAia.IsPresent, OnlineOcsp = OnlineOcsp.IsPresent, RevocationMode = RevocationMode, + ArtifactSigning = artifactSigning, }; } diff --git a/dotnet/Devolutions.Psign.PowerShell/Cmdlets/TestPsignArtifactSigningCommand.cs b/dotnet/Devolutions.Psign.PowerShell/Cmdlets/TestPsignArtifactSigningCommand.cs new file mode 100644 index 0000000..f0e0ec4 --- /dev/null +++ b/dotnet/Devolutions.Psign.PowerShell/Cmdlets/TestPsignArtifactSigningCommand.cs @@ -0,0 +1,95 @@ +using System.Globalization; +using System.Management.Automation; +using Devolutions.Psign.PowerShell.Models; +using Devolutions.Psign.PowerShell.Native; +using Devolutions.Psign.PowerShell.Utilities; + +namespace Devolutions.Psign.PowerShell.Cmdlets; + +/// +/// Tests whether files carry a valid Azure Artifact Signing signature: identity EKU, signer and +/// timestamp chains pinned to Microsoft Identity Verification Root CA 2020, certificate validity at +/// the trusted timestamp, and optionally one of the expected publisher IDs. Same policy as +/// psign-tool verify --artifact-signing in both tool modes. +/// +[Cmdlet(VerbsDiagnostic.Test, "PsignArtifactSigning", DefaultParameterSetName = FilePathParameterSet)] +[OutputType(typeof(bool), typeof(PortableSignature))] +public sealed class TestPsignArtifactSigningCommand : PSCmdlet +{ + private const string FilePathParameterSet = "FilePath"; + private const string LiteralPathParameterSet = "LiteralPath"; + + [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true, ParameterSetName = FilePathParameterSet, HelpMessage = "Path(s) to signed files. Wildcards are supported.")] + [Alias("Path")] + public string[] FilePath { get; set; } = []; + + [Parameter(Mandatory = true, ValueFromPipelineByPropertyName = true, ParameterSetName = LiteralPathParameterSet, HelpMessage = "Literal path(s) to signed files. No wildcard expansion.")] + [Alias("PSPath", "LP")] + public string[] LiteralPath { get; set; } = []; + + [Parameter(Position = 1, HelpMessage = "Require one of these publisher IDs (certificate profile EKU suffix).")] + [ValidateNotNullOrEmpty] + public string[] PublisherId { get; set; } = []; + + [Parameter(HelpMessage = "Subscriber root certificate for Private Trust / Public Trust Test signatures.")] + public string[] PrivateTrustRoot { get; set; } = []; + + [Parameter(HelpMessage = "Accept signatures without a timestamp (validated at the current time).")] + public SwitchParameter AllowMissingTimestamp { get; set; } + + [Parameter(HelpMessage = "Validate certificates at this date instead of the timestamp time.")] + public DateTime? AsOf { get; set; } + + [Parameter] + public SwitchParameter OnlineOcsp { get; set; } + + [Parameter] + [ValidateSet("Off", "BestEffort", "Require")] + public string RevocationMode { get; set; } = "Off"; + + [Parameter(HelpMessage = "Return the full signature object instead of a Boolean.")] + public SwitchParameter Detailed { get; set; } + + protected override void ProcessRecord() + { + bool literal = ParameterSetName == LiteralPathParameterSet; + foreach (string input in literal ? LiteralPath : FilePath) + { + foreach (string resolved in PathResolution.ResolveFilePaths(this, input, literal)) + { + TestFile(resolved); + } + } + } + + private void TestFile(string path) + { + try + { + PortableSignature signature = PsignNative.GetSignature(new PortableGetSignatureRequest + { + Path = path, + AsOf = AsOf?.ToUniversalTime().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), + OnlineOcsp = OnlineOcsp.IsPresent, + RevocationMode = RevocationMode, + ArtifactSigning = ArtifactSigningOptions.Create( + this, + requested: true, + PublisherId, + PrivateTrustRoot, + AllowMissingTimestamp.IsPresent), + }); + bool valid = signature.Status == SignatureStatus.Valid + && signature.ArtifactSigningVerification is not null; + if (!valid) + { + WriteVerbose($"{path}: {signature.Status}: {signature.StatusMessage}"); + } + WriteObject(Detailed.IsPresent ? signature : valid); + } + catch (Exception ex) + { + WriteError(new ErrorRecord(ex, "TestPsignArtifactSigningFailed", ErrorCategory.NotSpecified, path)); + } + } +} diff --git a/dotnet/Devolutions.Psign.PowerShell/Models/ArtifactSigning.cs b/dotnet/Devolutions.Psign.PowerShell/Models/ArtifactSigning.cs new file mode 100644 index 0000000..2d5e25c --- /dev/null +++ b/dotnet/Devolutions.Psign.PowerShell/Models/ArtifactSigning.cs @@ -0,0 +1,90 @@ +using System.Text.Json.Serialization; + +namespace Devolutions.Psign.PowerShell.Models; + +/// +/// Azure Artifact Signing identity decoded from a signer certificate's certificate-profile EKU +/// (1.3.6.1.4.1.311.97.*). Decoding alone is not a trust decision. +/// +public sealed class PsignArtifactSigningIdentity +{ + /// public_trust, public_trust_test, private_trust, or private_trust_ci_policy. + [JsonPropertyName("kind")] + public string Kind { get; init; } = string.Empty; + + /// Dotted arcs after the profile-type prefix; stable across daily certificate renewals. + [JsonPropertyName("publisher_id")] + public string PublisherId { get; init; } = string.Empty; + + [JsonPropertyName("identity_eku")] + public string IdentityEku { get; init; } = string.Empty; + + [JsonPropertyName("has_public_trust_marker")] + public bool HasPublicTrustMarker { get; init; } + + [JsonPropertyName("has_code_signing_eku")] + public bool HasCodeSigningEku { get; init; } + + /// File the identity was read from, when known. + [JsonIgnore] + public string? Path { get; set; } + + /// Subject of the certificate carrying the identity, when known. + [JsonIgnore] + public string? Subject { get; set; } + + public override string ToString() => PublisherId; +} + +/// Result of a successful Azure Artifact Signing validation. +public sealed class PsignArtifactSigningVerification +{ + [JsonPropertyName("pkcs7_index")] + public int Pkcs7Index { get; init; } + + [JsonPropertyName("signer_subject")] + public string SignerSubject { get; init; } = string.Empty; + + [JsonPropertyName("identity")] + public PsignArtifactSigningIdentity? Identity { get; init; } + + [JsonPropertyName("root_subject")] + public string RootSubject { get; init; } = string.Empty; + + [JsonPropertyName("root_sha1")] + public string RootSha1 { get; init; } = string.Empty; + + /// true when certificate validity was checked at a trusted timestamp. + [JsonPropertyName("timestamped")] + public bool Timestamped { get; init; } + + [JsonPropertyName("verification_time")] + public DateTime VerificationTime { get; init; } +} + +internal sealed class PortableArtifactSigningOptions +{ + [JsonPropertyName("publisher_ids")] + public string[] PublisherIds { get; init; } = []; + + [JsonPropertyName("private_trust_root_paths")] + public string[] PrivateTrustRootPaths { get; init; } = []; + + [JsonPropertyName("allow_missing_timestamp")] + public bool AllowMissingTimestamp { get; init; } +} + +internal sealed class PortableArtifactSigningIdentityRequest +{ + [JsonPropertyName("certificate_der_base64")] + public required string CertificateDerBase64 { get; init; } +} + +internal sealed class PortableArtifactSigningIdentityResponse +{ + [JsonPropertyName("schema_version")] + public int SchemaVersion { get; init; } + + [JsonPropertyName("identity")] + public PsignArtifactSigningIdentity? Identity { get; init; } +} diff --git a/dotnet/Devolutions.Psign.PowerShell/Models/PortableRequests.cs b/dotnet/Devolutions.Psign.PowerShell/Models/PortableRequests.cs index 1d11ded..e9bcdb7 100644 --- a/dotnet/Devolutions.Psign.PowerShell/Models/PortableRequests.cs +++ b/dotnet/Devolutions.Psign.PowerShell/Models/PortableRequests.cs @@ -36,6 +36,9 @@ internal sealed class PortableGetSignatureRequest [JsonPropertyName("revocation_mode")] public string RevocationMode { get; init; } = "Off"; + + [JsonPropertyName("artifact_signing")] + public PortableArtifactSigningOptions? ArtifactSigning { get; init; } } internal sealed class PortableSignRequest diff --git a/dotnet/Devolutions.Psign.PowerShell/Models/PortableSignature.cs b/dotnet/Devolutions.Psign.PowerShell/Models/PortableSignature.cs index 55d9272..7c81fb1 100644 --- a/dotnet/Devolutions.Psign.PowerShell/Models/PortableSignature.cs +++ b/dotnet/Devolutions.Psign.PowerShell/Models/PortableSignature.cs @@ -64,6 +64,17 @@ public sealed class PortableSignature [JsonPropertyName("diagnostics")] public string[] PortableDiagnostics { get; init; } = []; + /// Azure Artifact Signing identity of the signer certificate (decoded, not trust-verified). + [JsonPropertyName("artifact_signing")] + public PsignArtifactSigningIdentity? ArtifactSigning { get; init; } + + /// Set when Azure Artifact Signing validation was requested and succeeded. + [JsonPropertyName("artifact_signing_verification")] + public PsignArtifactSigningVerification? ArtifactSigningVerification { get; init; } + + [JsonIgnore] + public string? ArtifactSigningPublisherId => ArtifactSigning?.PublisherId; + [JsonIgnore] public X509Certificate2? SignerCertificate { diff --git a/dotnet/Devolutions.Psign.PowerShell/Native/PsignNative.cs b/dotnet/Devolutions.Psign.PowerShell/Native/PsignNative.cs index 5b107df..f47d92a 100644 --- a/dotnet/Devolutions.Psign.PowerShell/Native/PsignNative.cs +++ b/dotnet/Devolutions.Psign.PowerShell/Native/PsignNative.cs @@ -20,6 +20,11 @@ internal static PortableSignature GetSignature(PortableGetSignatureRequest reque return Invoke(request, psign_core_get_signature); } + internal static PortableArtifactSigningIdentityResponse GetArtifactSigningIdentity(PortableArtifactSigningIdentityRequest request) + { + return Invoke(request, psign_core_get_artifact_signing_identity); + } + internal static PortableNewFileCatalogResponse NewFileCatalog(PortableNewFileCatalogRequest request) { return Invoke(request, psign_core_new_file_catalog); @@ -87,6 +92,9 @@ 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_get_artifact_signing_identity(IntPtr requestJsonPtr, UIntPtr requestJsonLen); + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)] private static extern PsignFfiResult psign_core_new_file_catalog(IntPtr requestJsonPtr, UIntPtr requestJsonLen); diff --git a/dotnet/Devolutions.Psign.PowerShell/Utilities/ArtifactSigningOptions.cs b/dotnet/Devolutions.Psign.PowerShell/Utilities/ArtifactSigningOptions.cs new file mode 100644 index 0000000..59d24ac --- /dev/null +++ b/dotnet/Devolutions.Psign.PowerShell/Utilities/ArtifactSigningOptions.cs @@ -0,0 +1,33 @@ +using System.Management.Automation; +using Devolutions.Psign.PowerShell.Models; + +namespace Devolutions.Psign.PowerShell.Utilities; + +internal static class ArtifactSigningOptions +{ + /// + /// Build the portable-core Artifact Signing options; null when validation is not requested. + /// Any publisher ID, private trust root, or missing-timestamp opt-in implies validation. + /// + internal static PortableArtifactSigningOptions? Create( + PSCmdlet cmdlet, + bool requested, + string[] publisherIds, + string[] privateTrustRootPaths, + bool allowMissingTimestamp) + { + if (!requested && publisherIds.Length == 0 && privateTrustRootPaths.Length == 0 && !allowMissingTimestamp) + { + return null; + } + + return new PortableArtifactSigningOptions + { + PublisherIds = publisherIds.Select(id => id.Trim()).ToArray(), + PrivateTrustRootPaths = privateTrustRootPaths + .Select(p => cmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath(p)) + .ToArray(), + AllowMissingTimestamp = allowMissingTimestamp, + }; + } +} diff --git a/src/artifact_signing_verify.rs b/src/artifact_signing_verify.rs new file mode 100644 index 0000000..2c58ddf --- /dev/null +++ b/src/artifact_signing_verify.rs @@ -0,0 +1,129 @@ +//! `psign-tool verify --artifact-signing…`: Azure Artifact Signing policy shared by both tool modes. +//! +//! Windows mode runs this after `WinVerifyTrust` succeeds; portable mode runs it instead of the +//! generic AuthRoot trust. Either way the same `psign-authenticode-trust` entry points evaluate +//! the signature, so both modes accept and reject exactly the same files. + +use crate::cli::{PortableRevocationMode, VerifyArgs}; +use anyhow::{Context, Result, anyhow}; +use psign_authenticode_trust::policy::{OnlineTrustOptions, RevocationMode}; +use psign_authenticode_trust::{ + ArtifactSigningPolicy, TrustVerifyPeOptions, TrustVerifyPeReport, parse_verification_date_ymd, + trust_verify_catalog_bytes, trust_verify_detached_bytes, trust_verify_path, +}; +use std::fmt::Write as _; +use std::path::Path; + +/// `true` when any `--artifact-signing*` option was given. +pub fn requested(args: &VerifyArgs) -> bool { + args.artifact_signing + || !args.artifact_signing_publisher_id.is_empty() + || !args.artifact_signing_private_trust_root.is_empty() + || args.artifact_signing_allow_missing_timestamp +} + +/// Reject options that would widen or contradict the Artifact Signing policy. +pub fn validate(args: &VerifyArgs) -> Result<()> { + let conflicts = [ + (args.anchor_dir.is_some(), "--anchor-dir"), + (!args.trusted_ca.is_empty(), "--trusted-ca"), + ( + !args.additional_trusted_ca.is_empty(), + "--additional-trusted-ca", + ), + (args.authroot_cab.is_some(), "--authroot-cab"), + ( + args.expect_authroot_cab_sha256.is_some(), + "--expect-authroot-cab-sha256", + ), + (args.allow_loose_signing_cert, "--allow-loose-signing-cert"), + (args.catalog_search.is_some(), "--catalog-search"), + ( + args.catalog_database_guid.is_some(), + "--catalog-database-guid", + ), + (args.kernel_policy, "--kernel-policy"), + ]; + if let Some((_, flag)) = conflicts.iter().find(|(set, _)| *set) { + return Err(anyhow!( + "{flag} cannot be combined with --artifact-signing: Artifact Signing pins its own roots \ + (use --artifact-signing-private-trust-root for Private Trust)" + )); + } + Ok(()) +} + +fn trust_options(args: &VerifyArgs) -> Result { + Ok(TrustVerifyPeOptions { + verification_instant_override: args + .as_of + .as_deref() + .map(parse_verification_date_ymd) + .transpose()?, + verbose_chain: args.verbose_chain, + online: OnlineTrustOptions { + enable_aia: args.online_aia, + aia_url_override: args.aia_url_override.clone(), + enable_ocsp: args.online_ocsp, + ocsp_url_override: args.ocsp_url_override.clone(), + revocation_mode: match args.revocation_mode { + None | Some(PortableRevocationMode::Off) => RevocationMode::Off, + Some(PortableRevocationMode::BestEffort) => RevocationMode::BestEffort, + Some(PortableRevocationMode::Require) => RevocationMode::Require, + }, + crl_url_override: args.crl_url_override.clone(), + timeout: std::time::Duration::from_secs(args.online_timeout_secs), + max_download_bytes: args.online_max_download_bytes, + }, + artifact_signing: Some(ArtifactSigningPolicy { + expected_publisher_ids: args.artifact_signing_publisher_id.clone(), + private_trust_root_files: args.artifact_signing_private_trust_root.clone(), + require_timestamp: !args.artifact_signing_allow_missing_timestamp, + }), + ..Default::default() + }) +} + +fn append_report(out: &mut String, path: &Path, report: &TrustVerifyPeReport) { + let _ = writeln!(out, "Artifact Signing verified: {}", path.display()); + for sig in &report.artifact_signing { + let _ = writeln!(out, " {sig}"); + } +} + +/// Verify every verify target as an Artifact Signing signature; returns report text. +/// +/// Detached (`--detached-pkcs7`) checks the PKCS#7 against its content; `--catalog` checks the +/// catalog signature (member hashes are left to the caller's normal catalog verification). +pub fn verify_targets(args: &VerifyArgs) -> Result { + validate(args)?; + let opts = trust_options(args)?; + let mut out = String::new(); + if let Some(signature) = &args.detached_pkcs7 { + let content_path = args + .detached_pkcs7_content + .as_ref() + .or(args.files.first()) + .ok_or_else(|| anyhow!("--detached-pkcs7 requires a content file"))?; + let content = std::fs::read(content_path) + .with_context(|| format!("read {}", content_path.display()))?; + let pkcs7 = + std::fs::read(signature).with_context(|| format!("read {}", signature.display()))?; + let report = trust_verify_detached_bytes(&content, &pkcs7, &opts) + .with_context(|| format!("Artifact Signing {}", signature.display()))?; + append_report(&mut out, signature, &report); + } else if let Some(catalog) = &args.catalog { + let bytes = + std::fs::read(catalog).with_context(|| format!("read {}", catalog.display()))?; + let report = trust_verify_catalog_bytes(&bytes, &opts) + .with_context(|| format!("Artifact Signing {}", catalog.display()))?; + append_report(&mut out, catalog, &report); + } else { + for path in &args.files { + let report = trust_verify_path(path, &opts) + .with_context(|| format!("Artifact Signing {}", path.display()))?; + append_report(&mut out, path, &report); + } + } + Ok(out) +} diff --git a/src/cli.rs b/src/cli.rs index 0defaa5..44b9b38 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -531,7 +531,7 @@ pub struct ArtifactSigningSubmitArgs { pub endpoint_base_url: Option, } -#[derive(Args, Debug)] +#[derive(Args, Debug, Clone)] pub struct VerifyArgs { /// Verification policy mode. #[arg(long, value_enum, default_value_t = VerifyPolicy::Default)] @@ -695,6 +695,24 @@ pub struct VerifyArgs { /// Portable trust: maximum bytes accepted for online issuer downloads. #[arg(long, default_value_t = 1024 * 1024)] pub online_max_download_bytes: usize, + /// Require an Azure Artifact Signing signature: identity EKU, signer and timestamp chains pinned + /// to Microsoft Identity Verification Root CA 2020, validity at the trusted timestamp. Same + /// check in Windows and portable modes (Windows mode runs it after WinVerifyTrust, using the + /// Authenticode `/pa` policy unless `--policy` says otherwise). + #[arg(long)] + pub artifact_signing: bool, + /// Require this Artifact Signing publisher ID (certificate profile EKU suffix); repeatable, any + /// match passes. Implies `--artifact-signing`. + #[arg(long, value_name = "ID", action = clap::ArgAction::Append)] + pub artifact_signing_publisher_id: Vec, + /// Subscriber root for Private Trust / Public Trust Test signatures (PEM or DER); repeatable. + /// Implies `--artifact-signing`. + #[arg(long, value_name = "PATH", action = clap::ArgAction::Append)] + pub artifact_signing_private_trust_root: Vec, + /// Accept Artifact Signing signatures without a timestamp (validated at the current time). + /// Implies `--artifact-signing`. + #[arg(long)] + pub artifact_signing_allow_missing_timestamp: bool, /// Files to verify (native ``; one or more trailing paths). #[arg(required = true)] pub files: Vec, diff --git a/src/lib.rs b/src/lib.rs index 9c6af7a..c7d100a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,7 @@ //! `response_argv`) and depend on **`psign-sip-digest`** for portable digest code. #![allow(clippy::not_unsafe_ptr_arg_deref)] +pub mod artifact_signing_verify; pub mod cert_store; pub mod cli; pub mod code; @@ -165,7 +166,11 @@ fn portable_command_for_path(path: &std::path::Path) -> anyhow::Result<&'static } fn portable_verify_unsupported(args: &crate::cli::VerifyArgs) -> bool { - args.policy != crate::cli::VerifyPolicy::Default + // Portable trust is Authenticode (`/pa`) policy; accept it explicitly with --artifact-signing so + // the same command line works in both modes. + let pa_with_artifact_signing = args.policy == crate::cli::VerifyPolicy::Pa + && crate::artifact_signing_verify::requested(args); + (args.policy != crate::cli::VerifyPolicy::Default && !pa_with_artifact_signing) || args.policy_guid.is_some() || args.revocation_check || args.catalog_search.is_some() @@ -317,6 +322,23 @@ fn execute_portable_verify(args: &crate::cli::VerifyArgs) -> anyhow::Result anyhow::Result { Command::Code(args) => crate::code::code_command(args), Command::CertStore(args) => crate::cert_store::cert_store_command(args), Command::Portable(args) => run_portable_args(&args.args), + Command::Verify(args) if crate::artifact_signing_verify::requested(args) => { + crate::artifact_signing_verify::validate(args)?; + // Artifact Signing certificates never satisfy the driver policy; use `/pa`. + let mut native = args.clone(); + if native.policy == crate::cli::VerifyPolicy::Default { + native.policy = crate::cli::VerifyPolicy::Pa; + } + let mut out = crate::win::verify::verify_file(&native, &cli.global)?; + out.stdout + .push_str(&crate::artifact_signing_verify::verify_targets(args)?); + Ok(out) + } Command::Verify(args) => crate::win::verify::verify_file(args, &cli.global), Command::Sign(args) => crate::win::sign::sign_file(args, &cli.global), Command::Timestamp(args) => crate::win::timestamp::timestamp_file(args, &cli.global), diff --git a/src/portable_sign.rs b/src/portable_sign.rs index 89df675..a0135c6 100644 --- a/src/portable_sign.rs +++ b/src/portable_sign.rs @@ -1715,6 +1715,8 @@ mod tests { timestamp_signing_time: None, pkcs7_der_base64: None, diagnostics: Vec::new(), + artifact_signing: None, + artifact_signing_verification: None, }, skipped: false, } diff --git a/tests/artifact_signing_cli.rs b/tests/artifact_signing_cli.rs new file mode 100644 index 0000000..1c89d48 --- /dev/null +++ b/tests/artifact_signing_cli.rs @@ -0,0 +1,153 @@ +//! Azure Artifact Signing CLI surface: publisher ID extraction and `verify --artifact-signing…` +//! parity between `--mode windows` and `--mode portable`. + +use assert_cmd::Command; +use predicates::prelude::*; +use std::path::PathBuf; + +const PUBLISHER_ID: &str = "285218949.549339218.252840042.443064405"; + +fn fixture(rel: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures") + .join(rel) +} + +fn psign() -> Command { + let mut cmd = Command::cargo_bin("psign-tool").expect("psign-tool binary"); + cmd.env("PSIGN_NO_AUTO_TRUST", "1"); + cmd +} + +/// Tool modes available on this host; Windows mode needs WinVerifyTrust. +fn modes() -> &'static [&'static str] { + if cfg!(windows) { + &["windows", "portable"] + } else { + &["portable"] + } +} + +#[test] +fn publisher_id_from_certificate_and_pkcs7() { + for (input, path) in [ + ("cert", "artifact-signing/devolutions-signer.pem"), + ("pkcs7", "artifact-signing/psign-tool.pkcs7.der"), + ] { + psign() + .args([ + "portable", + "artifact-signing-publisher-id", + "--input", + input, + ]) + .arg(fixture(path)) + .assert() + .success() + .stdout(predicate::str::contains(format!( + "\"publisher_id\": \"{PUBLISHER_ID}\"" + ))) + .stdout(predicate::str::contains("\"kind\": \"public_trust\"")); + } +} + +#[test] +fn publisher_id_is_null_for_non_artifact_signing_signer() { + psign() + .args(["portable", "artifact-signing-publisher-id"]) + .arg(fixture("pe-authenticode-upstream/tiny64.signed.efi")) + .assert() + .success() + .stdout(predicate::str::contains("\"identity\": null")); +} + +#[test] +fn verify_rejects_non_artifact_signing_signer_in_every_mode() { + for mode in modes() { + psign() + .args(["--mode", mode, "verify", "--artifact-signing"]) + .arg(fixture("pe-authenticode-upstream/tiny64.signed.efi")) + .assert() + .failure(); + } + psign() + .args(["--mode", "portable", "verify", "--artifact-signing"]) + .arg(fixture("pe-authenticode-upstream/tiny64.signed.efi")) + .assert() + .failure() + .stderr(predicate::str::contains("no Artifact Signing identity EKU")); +} + +#[test] +fn verify_rejects_generic_anchors_with_artifact_signing_in_every_mode() { + for mode in modes() { + psign() + .args(["--mode", mode, "verify", "--artifact-signing-publisher-id"]) + .arg(PUBLISHER_ID) + .arg("--trusted-ca") + .arg(fixture("artifact-signing/devolutions-signer.pem")) + .arg(fixture("pe-authenticode-upstream/tiny64.signed.efi")) + .assert() + .failure() + .stderr(predicate::str::contains( + "--trusted-ca cannot be combined with --artifact-signing", + )); + } +} + +#[test] +fn portable_trust_verify_rejects_generic_anchors_with_artifact_signing() { + psign() + .args([ + "portable", + "trust-verify-pe", + "--artifact-signing", + "--anchor-dir", + ]) + .arg(fixture("artifact-signing")) + .arg(fixture("pe-authenticode-upstream/tiny64.signed.efi")) + .assert() + .failure() + .stderr(predicate::str::contains( + "--anchor-dir cannot be combined with --artifact-signing", + )); +} + +/// Real Artifact Signing PE (too large to commit): set `PSIGN_ARTIFACT_SIGNING_SAMPLE` to a PE +/// signed with publisher ID [`PUBLISHER_ID`] (or override with +/// `PSIGN_ARTIFACT_SIGNING_SAMPLE_PUBLISHER_ID`). +#[test] +#[ignore = "requires PSIGN_ARTIFACT_SIGNING_SAMPLE"] +fn verify_real_sample_identically_in_every_mode() { + let sample = PathBuf::from( + std::env::var("PSIGN_ARTIFACT_SIGNING_SAMPLE").expect("PSIGN_ARTIFACT_SIGNING_SAMPLE"), + ); + let publisher_id = std::env::var("PSIGN_ARTIFACT_SIGNING_SAMPLE_PUBLISHER_ID") + .unwrap_or_else(|_| PUBLISHER_ID.to_string()); + for mode in modes() { + psign() + .args(["--mode", mode, "verify", "--artifact-signing-publisher-id"]) + .arg(&publisher_id) + .arg(&sample) + .assert() + .success() + .stdout(predicate::str::contains(format!( + "publisher_id={publisher_id}" + ))) + .stdout(predicate::str::contains( + "root=f40042e2e5f7e8ef8189fed15519aece42c3bfa2", + )); + psign() + .args([ + "--mode", + mode, + "verify", + "--artifact-signing-publisher-id", + "1.2.3.4", + ]) + .arg(&sample) + .assert() + .failure() + .stderr(predicate::str::contains("is not one of the expected IDs")); + } +} diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index 28e80a1..a89c760 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -52,6 +52,17 @@ committed signed vector, native-signing the unsigned source rows and verifying the fresh outputs with `psign`, and psign-signing the offline-supported subset from the current user's certificate store. +`artifact-signing/` holds public material from a Devolutions build signed by +Azure Artifact Signing (Public Trust): `psign-tool.pkcs7.der` is the PKCS#7 +extracted with `psign-tool portable extract-pe-pkcs7` (signer, intermediates, +pinned root, and RFC 3161 countersignature), and `devolutions-signer.pem` is the +72-hour signer certificate. The publisher ID is +`285218949.549339218.252840042.443064405`. +`microsoft-public-rsa-timestamping-ca-2020.{crl,pem}` are a real +`sha384WithRSAEncryption` CRL (downloaded from the CA's CRL distribution point +on 2026-10-26) and its issuer, used to test CRL signature verification. The +signature never expires, so the test does not depend on CRL freshness. + Vendored public test signing materials live in `tests/fixtures/devolutions-authenticode/`. They are sourced from `Devolutions/devolutions-authenticode` and are used by diff --git a/tests/fixtures/artifact-signing/devolutions-signer.pem b/tests/fixtures/artifact-signing/devolutions-signer.pem new file mode 100644 index 0000000..20f5530 --- /dev/null +++ b/tests/fixtures/artifact-signing/devolutions-signer.pem @@ -0,0 +1,38 @@ +-----BEGIN CERTIFICATE----- +MIIGqjCCBJKgAwIBAgITMwAJKeSQYjnNpMomBAAAAAkp5DANBgkqhkiG9w0BAQwF +ADBaMQswCQYDVQQGEwJVUzEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9u +MSswKQYDVQQDEyJNaWNyb3NvZnQgSUQgVmVyaWZpZWQgQ1MgQU9DIENBIDA0MB4X +DTI2MTAyMjE1Mzg1MFoXDTI2MTAyNTE1Mzg1MFowbDELMAkGA1UEBhMCQ0ExFTAT +BgNVBAgeDABRAHUA6QBiAGUAYzESMBAGA1UEBxMJTGF2YWx0cmllMRgwFgYDVQQK +Ew9EZXZvbHV0aW9ucyBJbmMxGDAWBgNVBAMTD0Rldm9sdXRpb25zIEluYzCCAaIw +DQYJKoZIhvcNAQEBBQADggGPADCCAYoCggGBALAw4AFXMlBun3NOZCM2zBqOSRxk +dwqHLdhWgl26814XOB8V37rZryu5PKRtXBoTB7EUwGXEcrDzFjVJgN60nQ7qBOR9 +RE7hw3BUY4JR6s0MB8YzQGcpw0bnyQru0S5T9dgj4GHnTPUtVrxc4Dj9PXSKmHsK +bQ/b8lJ0d06zPK5MKh/5MsGFi/k6237sv3lWqVqT02XrkPQwljH37FUSQLwDg3d/ +z6sn77KCIB/33FLfoA/6JvWwYTNuVPbnEiE490IMJv7WX13KDUOf/CmAAPjV9zJx +yPmIeiVopQmDCi8KoPorvIPOOY3ijRse/ae0yO2pR3rSjKDHLfrqssbYvAzTDjgS +14ZdJe1qzkD1UhQL626mNKZLOfDnQO5ninBpTeMOVVC69mjvDIFkz+jKRDb6IqZY +x5pUvGWHoRREgn0BriiNmF2rPKfY7XB6AWsGwCJvZSy2ZByZXERkT2QJwBNea3Ld +7EWB3IzHjmrnoGADuAD0ddVZ3nvpNJ+YjD1cnQIDAQABo4IB1TCCAdEwDAYDVR0T +AQH/BAIwADAOBgNVHQ8BAf8EBAMCB4AwPAYDVR0lBDUwMwYKKwYBBAGCN2EBAAYI +KwYBBQUHAwMGGysGAQQBgjdhgYiAsQWChfmAUvjIkGqB06LAVTAdBgNVHQ4EFgQU +p0uFcPM5j9ExNw9Pu01ae2BpxyEwHwYDVR0jBBgwFoAUayVB3vtrfP0YgAotf492 +XapzPbgwZwYDVR0fBGAwXjBcoFqgWIZWaHR0cDovL3d3dy5taWNyb3NvZnQuY29t +L3BraW9wcy9jcmwvTWljcm9zb2Z0JTIwSUQlMjBWZXJpZmllZCUyMENTJTIwQU9D +JTIwQ0ElMjAwNC5jcmwwdAYIKwYBBQUHAQEEaDBmMGQGCCsGAQUFBzAChlhodHRw +Oi8vd3d3Lm1pY3Jvc29mdC5jb20vcGtpb3BzL2NlcnRzL01pY3Jvc29mdCUyMElE +JTIwVmVyaWZpZWQlMjBDUyUyMEFPQyUyMENBJTIwMDQuY3J0MFQGA1UdIARNMEsw +SQYEVR0gADBBMD8GCCsGAQUFBwIBFjNodHRwOi8vd3d3Lm1pY3Jvc29mdC5jb20v +cGtpb3BzL0RvY3MvUmVwb3NpdG9yeS5odG0wDQYJKoZIhvcNAQEMBQADggIBAK8u +IqSxnkamZig4WfO5mbowMSrs5x/AuMMjqkHCvsUIdCMwx8WekdMukq3QMa1i7EEL +D+oq4yO5v3phXJFrRH6F16YsVGDH2WM59xRHd+gm2TLxdcX0TQtC1g52ufhzRmA/ +m7ouNPJwcOZhfmaLQwP0Zl6E1QICEBs4rVoV+60T2HuJ54vftUS4fGJcXfXNKDll +2jy5BvuJfmQH8fHopFxdVrfX7s8JtQFf+TtULoSj1H3rANqnS1IRJvQP1tphMErq +/0zGWWvpL2vBVnQn0nCbsY2/WgKGJlONNRxWwPcTPx6b6Qwp58n5EtzjkZz6ihhQ +10HlVISAALzzA84ksME+YYVLKiPc7Q78JfEC6KJKujlKJoJkv1F8JmhBIzdnr7Pq +EA0eWCNO+9zF0WTbAYAXMi5I4lBjuSdPxG5PnlfwcL7qcI8vN4UBIuh3QDm9OKMt +9vN/rob5sSXTU3nmOAm38kMbydavvD8os9EXTRrcwLf8qjP30rZ181omni1AZk+o +xl4CW2RiF+ZBAef0OrmBrDYM0Em4i0iDEi5BB8ROpmGnjc6o5Kf4l9qJwAJCoX02 +qf7keHEwakM2aZQ8ndQEz91S+ufJr3taR3Dw246DXhQ4vEbn3vzucJ9CYjgBur2y +VfSn9RB0UPisGzYEabAwpWHR3GR8kAIiQWv4aDFn +-----END CERTIFICATE----- diff --git a/tests/fixtures/artifact-signing/microsoft-public-rsa-timestamping-ca-2020.crl b/tests/fixtures/artifact-signing/microsoft-public-rsa-timestamping-ca-2020.crl new file mode 100644 index 0000000000000000000000000000000000000000..bf9d3e277902f13fea4807142d2772e0de7aa544 GIT binary patch literal 784 zcmXqLV&*Yu{K~}0Xu!+HsnzDu_MMlJk%yJRAkmQ9fRl|ml!Z;0DKywn&Oipl;Sv`0 z%}g%JFV0UZQE<*LD#$NNEXmBzGc+`Vq{(LjKWQ=5&Eg|W$8l#zv*iIKs88>B^qg@uWUu^8xP zxHJn3Khy?uLknY&4QM`QZUSoWo-j*`!&~@~_(shzaoCa}c;8AQq0Mw}u9n}=RT*24JYTZTyQJHoH?J(_f(H+qnOa)`%a#M2 z*-RCmmusz=Gx4X6kGl1lwYSrXuHXFEeouvi=R_!Pt;WS--xTwWmM^uByjZ+tb7tzX zFudIzbCIx+q?AH;g!n|zge(Btg#?rio?3Lf7jK5Hr6*v+5TL&;jz8s!$Q%mlT%av zL?lJd-^A8BzjBs+=}TvoZz9o;X1YIrGcV*-l+!$62c})I(`T`5ubmUE6}?9*NYz;; zsjoZY_1d~gm%Regc^)RK?GoDf-+$%2jW;iOg!b1MC0ttUq5glR*=NJ3oel+SnTk8( zjxY3Dm+T%{{Umi){r&7kJO>oLZ!9~red+A{zdC8xEp5J>ZMJ%%wt4*9{H)sMw${R#$?doI6AtczND!Ti_ih0b{s zkGz>Fu2*kZ^_Fk)hS{#y7k_2hzmp|BA>{IGD?^7U9TSGbF>2PY%{trlYtz{O<@NtK s`)cE~z9X$78f&=X+iU%pf`0|Ak_{0`p8VxMf3ES@|Cg6)#;{HT00qrIDF6Tf literal 0 HcmV?d00001 diff --git a/tests/fixtures/artifact-signing/microsoft-public-rsa-timestamping-ca-2020.pem b/tests/fixtures/artifact-signing/microsoft-public-rsa-timestamping-ca-2020.pem new file mode 100644 index 0000000..47913e9 --- /dev/null +++ b/tests/fixtures/artifact-signing/microsoft-public-rsa-timestamping-ca-2020.pem @@ -0,0 +1,43 @@ +-----BEGIN CERTIFICATE----- +MIIHgjCCBWqgAwIBAgITMwAAAAXlzw//Zi7JhwAAAAAABTANBgkqhkiG9w0BAQwF +ADB3MQswCQYDVQQGEwJVUzEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9u +MUgwRgYDVQQDEz9NaWNyb3NvZnQgSWRlbnRpdHkgVmVyaWZpY2F0aW9uIFJvb3Qg +Q2VydGlmaWNhdGUgQXV0aG9yaXR5IDIwMjAwHhcNMjAxMTE5MjAzMjMxWhcNMzUx +MTE5MjA0MjMxWjBhMQswCQYDVQQGEwJVUzEeMBwGA1UEChMVTWljcm9zb2Z0IENv +cnBvcmF0aW9uMTIwMAYDVQQDEylNaWNyb3NvZnQgUHVibGljIFJTQSBUaW1lc3Rh +bXBpbmcgQ0EgMjAyMDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAJ58 +51Jj/eDFnwV9Y7UGIqMcHtfnlzPREwW9ZUZHd5HBXXBvf7KrQ5cMSqFSHGqg2/qJ +hYqOQxwuEQXG8kB41wsDJP5d0zmLYKAY8Zxv3lYkuLDsfMuIEqvGYOPURAH+Ybl4 +SJEESnt0MbPEoKdNihwM5xGv0rGofJ1qOYSTNcc55EbBT7uq3wx3mXhtVmtcCEr5 +ZKTkKKE1CxZvNPWdGWJUPC6e4uRfWHIhZcgCsJ+sozf5EeH5KrlFnxpjKKTavwfF +P6XaGZGWUG8TZaiTogRoAlqcevbiqioUz1Yt4FRK53P6ovnUfANjIgM9JDdJ4e0q +iDRm5sOTiEQtBLGd9Vhd1MadxoGcHrRCsS5rO9yhv2fjJHrmlQ0EIXmp4DhDBieK +UGR+eZ4CNE3ctW4uvSDQVeSp9h1SaPV8UWEfyTxgGjOsRpeexIveR1MPTVf7gt8h +Y64XNPO6iyUGsEgt8c2PxF87E+CO7A28TpjNq5eLiiunhKbq0XbjkNoU5JhtYUrl +mAbpxRjb9tSreDdtACpm3rkpxp7AQndnI0Shu/fk1/rE3oWsDqMX3jjv40e8KN5Y +sJBnczyWB4JyeeFMW3JBfdeAKhzohFe8U5w9WuvcP1E8cIxLoKSDzCCBOu0hWdjz +KNu8Y5SwB1lt5dQhABYyzR3dxEO/T1K/BVF3rV69AgMBAAGjggIbMIICFzAOBgNV +HQ8BAf8EBAMCAYYwEAYJKwYBBAGCNxUBBAMCAQAwHQYDVR0OBBYEFGtpKDo1L0hj +QM972K9J6T7ZPdshMFQGA1UdIARNMEswSQYEVR0gADBBMD8GCCsGAQUFBwIBFjNo +dHRwOi8vd3d3Lm1pY3Jvc29mdC5jb20vcGtpb3BzL0RvY3MvUmVwb3NpdG9yeS5o +dG0wEwYDVR0lBAwwCgYIKwYBBQUHAwgwGQYJKwYBBAGCNxQCBAweCgBTAHUAYgBD +AEEwDwYDVR0TAQH/BAUwAwEB/zAfBgNVHSMEGDAWgBTIftJqhSobyhmYBAcnz1AQ +T2ioojCBhAYDVR0fBH0wezB5oHegdYZzaHR0cDovL3d3dy5taWNyb3NvZnQuY29t +L3BraW9wcy9jcmwvTWljcm9zb2Z0JTIwSWRlbnRpdHklMjBWZXJpZmljYXRpb24l +MjBSb290JTIwQ2VydGlmaWNhdGUlMjBBdXRob3JpdHklMjAyMDIwLmNybDCBlAYI +KwYBBQUHAQEEgYcwgYQwgYEGCCsGAQUFBzAChnVodHRwOi8vd3d3Lm1pY3Jvc29m +dC5jb20vcGtpb3BzL2NlcnRzL01pY3Jvc29mdCUyMElkZW50aXR5JTIwVmVyaWZp +Y2F0aW9uJTIwUm9vdCUyMENlcnRpZmljYXRlJTIwQXV0aG9yaXR5JTIwMjAyMC5j +cnQwDQYJKoZIhvcNAQEMBQADggIBAF+Idsd+bbVaFXXnTHho+k7h2ESZJRWluLE0 +Oa/pO+4ge/XEizXvhs0Y7+KVYyb4nHlugBesnFqBGEdC2IWmtKMyS1OWIviwpnK3 +aL5JedwzbeBF7POyg6IGG/XhhJ3UqWeWTO+Czb1c2NP5zyEh89F72u9UIw+IfvM9 +lzDmc2O2END7MPnrcjWdQnrLn1Ntday7JSyrDvBdmgbNnCKNZPmhzoa8PccOiQlj +jTW6GePe5sGFuRHzdFt8y+bN2neF7Zu8hTO1I64XNGqst8S+w+RUdie8fXC1jKu3 +m9KGIqF4aldrYBamyh3g4nJPj/LR2CBaLyD+2BuGZCVmoNR/dSpRCxlot0i79dKO +ChmoONqbMI8m04uLaEHAv4qwKHQ1vBzbV/nG89LDKbRSSvijmwJwxRxLLpMQ/u4x +XxFfR4f/gksSkbJp7oqLwliDm/h+w0aJ/U5ccnYhYb7vPKNMN+SZDWycU5ODIRfy +oGl59BsXR/HpRGtiJquOYGmvA/pk5vC1lcnbeMrcWD/26ozePQ/TWfNXKBOmkFpv +PE8CH+EeGGWzqTCjdAsno2jzTeNSxlx3glDGJgcdz5D/AAxw9Sdgq/+rY7jjgs7X +6fqPTXPmaCAJKVHAP19oEjJIBwD1LyHbaEgBxFCogYSOiUIr0Xqcr1nJfiWG2GwY +e6ZoAF1b +-----END CERTIFICATE----- diff --git a/tests/fixtures/artifact-signing/psign-tool.pkcs7.der b/tests/fixtures/artifact-signing/psign-tool.pkcs7.der new file mode 100644 index 0000000000000000000000000000000000000000..18a54911f186f02ce95970272283e7679abfae79 GIT binary patch literal 14000 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 literal 0 HcmV?d00001 From 91b2df06fdf8bad2b65d72ba9ea5e1a1a9d0dd94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Mon, 26 Oct 2026 13:51:24 -0400 Subject: [PATCH 2/6] Verify Artifact Signing for WSH scripts, MSIX/AppX, NuGet, and VSIX Extend portable trust verification, and with it the Azure Artifact Signing policy, to the formats that previously had no trust check. The policy runs the same way in psign-tool Windows and portable modes and in the PowerShell module. - WSH (.js/.jse/.vbs/.vbe/.wsf): wsh_digest_report feeds the shared script trust path. - MSIX/AppX and bundles: msix_verified_signature returns the PKCS#7 and the verified APPX blob; AXCI presence must now match on both sides. - NuGet: package hash over the exact unsigned bytes (the last-entry .signature.p7s is cut out byte for byte, as NuGet does), replacing the ZIP rewrite that never matched dotnet nuget sign packages. Embedded signature content is required to match; psign's detached CMS is still accepted. - VSIX: new standards-based OPC XMLDSig verifier (quick-xml DOM with inclusive/exclusive C14N): SignedInfo SignatureValue, #id object and Manifest part digests (raw, C14N, RelationshipTransform), full part coverage, [Content_Types].xml content types, and the dotnet/sign RFC 3161 timestamp over the SignatureValue octets. - Artifact Signing policy split into identity, timestamp-instant, and anchor helpers with a certificate-based entry point for XMLDSig; standalone RFC 3161 token verification factored out of the CMS path. - psign-tool: portable trust-verify-{script,msix,nupkg,vsix}; verify routes explicit trust and --artifact-signing for these formats; Windows mode skips WinVerifyTrust only for NuGet/VSIX (no Windows SIP). - Portable core / PowerShell: trust and Artifact Signing for these formats, VSIX inspection with the standard verifier (legacy psign layout still reported), NuGet hash binding in inspection, WSH signer extraction. - Synthetic Private Trust fixtures (script-generated, keys discarded) and Rust, CLI, and Pester tests for both modes; docs updated. Co-Authored-By: Claude Opus 5.5 --- .gitattributes | 2 + Cargo.lock | 3 + .../PsignArtifactSigning.Cmdlets.Tests.ps1 | 42 ++ crates/psign-authenticode-trust/Cargo.toml | 3 + .../src/artifact_signing.rs | 247 +++++--- crates/psign-authenticode-trust/src/chain.rs | 44 ++ crates/psign-authenticode-trust/src/lib.rs | 7 + .../src/rfc3161_extract.rs | 72 ++- .../src/trust_pkcs7.rs | 2 +- .../src/trust_verify_file.rs | 11 +- .../src/trust_verify_msix.rs | 33 + .../src/trust_verify_nuget.rs | 109 ++++ .../src/trust_verify_script.rs | 7 +- .../src/trust_verify_vsix.rs | 579 ++++++++++++++++++ .../psign-authenticode-trust/src/xml_c14n.rs | 429 +++++++++++++ .../tests/format_trust.rs | 186 ++++++ crates/psign-digest-cli/src/main.rs | 58 ++ crates/psign-opc-sign/src/nuget.rs | 122 +++- crates/psign-portable-core/src/lib.rs | 116 ++-- crates/psign-sip-digest/src/lib.rs | 13 + crates/psign-sip-digest/src/msix_digest.rs | 30 +- crates/psign-sip-digest/src/wsh_script.rs | 15 +- docs/artifact-signing-validation.md | 28 +- docs/authenticode-trust-stack.md | 2 +- docs/gap-analysis-signing-platforms.md | 6 +- docs/migration-artifact-signing.md | 2 +- docs/psign-cli-matrix.json | 4 + .../Models/ArtifactSigning.cs | 5 +- ...rtifact-signing-private-trust-fixtures.ps1 | 137 +++++ src/artifact_signing_verify.rs | 8 + src/lib.rs | 32 +- tests/artifact_signing_cli.rs | 121 ++++ tests/cli_pe_digest.rs | 4 +- tests/fixtures/README.md | 7 + .../artifact-signing/private-trust/root.cer | Bin 0 -> 828 bytes .../artifact-signing/private-trust/sample.js | 52 ++ .../private-trust/sample.msix | Bin 0 -> 5771 bytes .../private-trust/sample.nupkg | Bin 0 -> 3445 bytes .../private-trust/sample.vsix | Bin 0 -> 7212 bytes 39 files changed, 2375 insertions(+), 163 deletions(-) create mode 100644 crates/psign-authenticode-trust/src/trust_verify_msix.rs create mode 100644 crates/psign-authenticode-trust/src/trust_verify_nuget.rs create mode 100644 crates/psign-authenticode-trust/src/trust_verify_vsix.rs create mode 100644 crates/psign-authenticode-trust/src/xml_c14n.rs create mode 100644 crates/psign-authenticode-trust/tests/format_trust.rs create mode 100644 scripts/ci/build-artifact-signing-private-trust-fixtures.ps1 create mode 100644 tests/fixtures/artifact-signing/private-trust/root.cer create mode 100644 tests/fixtures/artifact-signing/private-trust/sample.js create mode 100644 tests/fixtures/artifact-signing/private-trust/sample.msix create mode 100644 tests/fixtures/artifact-signing/private-trust/sample.nupkg create mode 100644 tests/fixtures/artifact-signing/private-trust/sample.vsix diff --git a/.gitattributes b/.gitattributes index 5858eb0..ec16a98 100644 --- a/.gitattributes +++ b/.gitattributes @@ -9,6 +9,8 @@ tests/fixtures/*.wsf -text tests/fixtures/generated-unsigned/** -text tests/fixtures/generated-signed/** -text tests/fixtures/rdp/** -text -diff +# Signed Artifact Signing fixtures (scripts included): byte-exact. +tests/fixtures/artifact-signing/** -text # Binary Authenticode fixtures. tests/fixtures/**/*.appx binary diff --git a/Cargo.lock b/Cargo.lock index 2be19a5..58c9467 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2058,7 +2058,9 @@ dependencies = [ "digest 0.10.7", "picky", "picky-asn1-x509", + "psign-opc-sign", "psign-sip-digest", + "quick-xml", "rcgen", "rsa 0.9.10", "serde", @@ -2068,6 +2070,7 @@ dependencies = [ "tempfile", "time", "x509-cert", + "zip", ] [[package]] diff --git a/PowerShell/tests/PsignArtifactSigning.Cmdlets.Tests.ps1 b/PowerShell/tests/PsignArtifactSigning.Cmdlets.Tests.ps1 index 9c1ea33..38e2181 100644 --- a/PowerShell/tests/PsignArtifactSigning.Cmdlets.Tests.ps1 +++ b/PowerShell/tests/PsignArtifactSigning.Cmdlets.Tests.ps1 @@ -136,3 +136,45 @@ Describe 'Get-PsignSignature Artifact Signing parameters' { $err[0].Exception.Message | Should -BeLike '*cannot be combined with Artifact Signing*' } } + +Describe 'Artifact Signing across script, MSIX, NuGet, and VSIX formats' { + BeforeAll { + $script:PrivateTrustDir = Join-Path $script:FixtureDir 'private-trust' + $script:PrivateTrustRoot = Join-Path $script:PrivateTrustDir 'root.cer' + $script:PrivateTrustId = '11.22.33.44.55.66.77.88' + } + + It 'decodes the publisher ID from ' -TestCases @( + @{ File = 'sample.js' }, @{ File = 'sample.msix' }, @{ File = 'sample.nupkg' }, @{ File = 'sample.vsix' } + ) { + $id = Get-PsignArtifactSigningPublisherId -LiteralPath (Join-Path $script:PrivateTrustDir $File) + $id.PublisherId | Should -Be $script:PrivateTrustId + $id.Kind | Should -Be 'private_trust' + } + + It 'validates against the private trust root with publisher pinning' -TestCases @( + @{ File = 'sample.js' }, @{ File = 'sample.msix' }, @{ File = 'sample.nupkg' }, @{ File = 'sample.vsix' } + ) { + $path = Join-Path $script:PrivateTrustDir $File + $common = @{ LiteralPath = $path; PrivateTrustRoot = $script:PrivateTrustRoot; AllowMissingTimestamp = $true } + Test-PsignArtifactSigning @common -PublisherId $script:PrivateTrustId | Should -BeTrue + Test-PsignArtifactSigning @common -PublisherId '1.2.3.4' | Should -BeFalse + Test-PsignArtifactSigning -LiteralPath $path -AllowMissingTimestamp | Should -BeFalse + + $detail = Test-PsignArtifactSigning @common -PublisherId $script:PrivateTrustId -Detailed + $detail.ArtifactSigningVerification.Identity.Kind | Should -Be 'private_trust' + } + + It 'trust-verifies test-CA signed with explicit anchors' -TestCases @( + @{ File = 'generated-signed\scripts\wsh\js\utf8-crlf.js' }, + @{ File = 'generated-signed\msix\sample.msix' }, + @{ File = 'package-signing\signed\sample.signed.nupkg' }, + @{ File = 'package-signing\signed\sample.signed.vsix' } + ) { + $signature = Get-PsignSignature -LiteralPath (Join-Path $repoRoot "tests\fixtures\$File") ` + -TrustedCertificatePath (Join-Path $repoRoot 'tests\fixtures\devolutions-authenticode\authenticode-test-ca.crt') ` + -AsOf ([datetime]'2026-06-01') + $signature.Status | Should -Be 'Valid' + $signature.TrustStatus | Should -Be 'Valid' + } +} diff --git a/crates/psign-authenticode-trust/Cargo.toml b/crates/psign-authenticode-trust/Cargo.toml index 2291886..aa683aa 100644 --- a/crates/psign-authenticode-trust/Cargo.toml +++ b/crates/psign-authenticode-trust/Cargo.toml @@ -19,6 +19,9 @@ sha2 = "0.10" rsa = { version = "0.9.10", features = ["sha2"] } authenticode = { version = "0.5.0", features = ["std", "object"] } psign-sip-digest = { path = "../psign-sip-digest" } +psign-opc-sign = { path = "../psign-opc-sign" } +quick-xml = "0.38" +zip = { version = "0.6.6", default-features = false, features = ["deflate"] } picky = { version = "7.0.0-rc.23", features = ["pkcs7", "time_conversion"] } picky-asn1-x509 = "0.15.4" time = { version = "0.3", features = ["formatting", "parsing"] } diff --git a/crates/psign-authenticode-trust/src/artifact_signing.rs b/crates/psign-authenticode-trust/src/artifact_signing.rs index 8060f54..fd6117d 100644 --- a/crates/psign-authenticode-trust/src/artifact_signing.rs +++ b/crates/psign-authenticode-trust/src/artifact_signing.rs @@ -21,9 +21,12 @@ //! - use crate::anchor::{AnchorStore, cert_sha1_thumbprint, parse_cert_bytes}; +use crate::chain::verify_certificate_chain_to_anchors; use crate::chain::{issuer_chain_excluding_leaf, merge_unique_certs, terminal_root_cert}; use crate::policy::AuthenticodeTrustPolicy; -use crate::rfc3161_extract::trusted_utc_date_from_authenticode_timestamp_token; +use crate::rfc3161_extract::{ + trusted_utc_date_from_authenticode_timestamp_token, trusted_utc_date_from_timestamp_token, +}; use crate::trust_pkcs7::{Pkcs7DigestBinding, verify_pkcs7_trust_with_binding}; use crate::trust_verify_pe::TrustVerifyPeOptions; use anyhow::{Context, Result, anyhow}; @@ -395,7 +398,8 @@ impl Default for ArtifactSigningPolicy { /// One verified Artifact Signing signature. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ArtifactSigningVerifiedSignature { - pub pkcs7_index: usize, + /// PE certificate-table entry index; `0` for single-signature formats. + pub signature_index: usize, pub signer_subject: String, pub identity: ArtifactSigningIdentity, /// Subject of the trust anchor that terminates the signer chain. @@ -412,8 +416,8 @@ impl std::fmt::Display for ArtifactSigningVerifiedSignature { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( f, - "PKCS#7[{}] {} publisher_id={} kind={} root={} time={}{}", - self.pkcs7_index, + "signature[{}] {} publisher_id={} kind={} root={} time={}{}", + self.signature_index, self.signer_subject, self.identity.publisher_id, self.identity.kind.as_str(), @@ -447,27 +451,15 @@ pub fn public_trust_anchors() -> Result<(AnchorStore, Vec)> { anchors_from_certs(public_trust_anchor_certs()?) } -/// Verify one PKCS#7 as an Artifact Signing signature whose digest binding equals `subject_digest`. -/// -/// Checks, in order: the signer carries an Artifact Signing identity EKU (and the Public Trust -/// marker for Public Trust profiles), the publisher ID matches -/// [`ArtifactSigningPolicy::expected_publisher_ids`], the timestamp countersignature chains to a -/// pinned Microsoft root, and the signer chain terminates at a pinned Microsoft root (Public -/// Trust) or a supplied subscriber root (Private Trust / Public Trust Test) with the CMS -/// signature and code-signing EKU valid at the timestamp. -pub fn verify_artifact_signing_pkcs7( - pkcs7_der: &[u8], - pkcs7_index: usize, - subject_digest: &[u8], - binding: Pkcs7DigestBinding, +/// Identity of `signer`, checked against the Artifact Signing rules and the publisher pin. +fn checked_identity( + signer: &Certificate, policy: &ArtifactSigningPolicy, - opts: &TrustVerifyPeOptions, -) -> Result { +) -> Result<(String, ArtifactSigningIdentity)> { use ArtifactSigningCertificateKind::*; - let (signer, identity) = pkcs7_signer_identity(pkcs7_der)?; - let signer_subject = subject_display(&signer); - let identity = identity.ok_or_else(|| { + let signer_subject = subject_display(signer); + let identity = artifact_signing_identity_from_certificate(signer)?.ok_or_else(|| { anyhow!( "signer {signer_subject:?} has no Artifact Signing identity EKU ({ARTIFACT_SIGNING_EKU_PREFIX}…)" ) @@ -501,57 +493,114 @@ pub fn verify_artifact_signing_pkcs7( policy.expected_publisher_ids.join(", ") )); } + Ok((signer_subject, identity)) +} +/// Verification instant: the override, else a timestamp verified against [`PUBLIC_TRUST_ROOTS`]. +/// +/// `trusted_timestamp` verifies the format's timestamp with the given anchors; `Ok(None)` means +/// the signature carries no timestamp. +fn artifact_signing_instant( + policy: &ArtifactSigningPolicy, + opts: &TrustVerifyPeOptions, + trusted_timestamp: impl FnOnce(&AnchorStore, &[Cert]) -> Result>, +) -> Result<(bool, UtcDate)> { + if let Some(instant) = &opts.verification_instant_override { + return Ok((false, instant.clone())); + } let (ms_anchors, ms_anchor_certs) = public_trust_anchors()?; - let (timestamped, verification_instant) = match &opts.verification_instant_override { - Some(instant) => (false, instant.clone()), - None => match trusted_utc_date_from_authenticode_timestamp_token( - pkcs7_der, - &ms_anchors, - &ms_anchor_certs, - &opts.online, - opts.verbose_chain, - ) + match trusted_timestamp(&ms_anchors, &ms_anchor_certs) .context("Artifact Signing timestamp countersignature")? - { - Some(instant) => (true, instant), - None if policy.require_timestamp => { - return Err(anyhow!( - "Artifact Signing signature has no RFC 3161 timestamp countersignature" - )); - } - None => (false, UtcDate::now()), - }, - }; + { + Some(instant) => Ok((true, instant)), + None if policy.require_timestamp => Err(anyhow!( + "Artifact Signing signature has no RFC 3161 timestamp countersignature" + )), + None => Ok((false, UtcDate::now())), + } +} - let (anchors, anchor_certs) = match identity.kind { - PublicTrust => (ms_anchors, ms_anchor_certs), - _ => { - if policy.private_trust_root_files.is_empty() { - return Err(anyhow!( - "{} signature requires the subscriber root certificate (private trust root)", - identity.kind.as_str() - )); - } - let (_, certs) = AnchorStore::load_files(&policy.private_trust_root_files)?; - // Microsoft production roots must never anchor a non-Public-Trust profile. - let certs: Vec = certs - .into_iter() - .filter(|c| { - cert_sha1_thumbprint(c) - .map(|t| !PUBLIC_TRUST_ROOTS.iter().any(|r| r.sha1 == t)) - .unwrap_or(false) - }) - .collect(); - if certs.is_empty() { - return Err(anyhow!( - "no usable private trust roots (Microsoft Public Trust roots are not accepted for {})", - identity.kind.as_str() - )); - } - anchors_from_certs(certs)? - } - }; +/// Anchors for an identity kind: [`PUBLIC_TRUST_ROOTS`] for Public Trust, else the subscriber roots. +fn artifact_signing_anchors( + kind: ArtifactSigningCertificateKind, + policy: &ArtifactSigningPolicy, +) -> Result<(AnchorStore, Vec)> { + if kind == ArtifactSigningCertificateKind::PublicTrust { + return public_trust_anchors(); + } + if policy.private_trust_root_files.is_empty() { + return Err(anyhow!( + "{} signature requires the subscriber root certificate (private trust root)", + kind.as_str() + )); + } + let (_, certs) = AnchorStore::load_files(&policy.private_trust_root_files)?; + // Microsoft production roots must never anchor a non-Public-Trust profile. + let certs: Vec = certs + .into_iter() + .filter(|c| { + cert_sha1_thumbprint(c) + .map(|t| !PUBLIC_TRUST_ROOTS.iter().any(|r| r.sha1 == t)) + .unwrap_or(false) + }) + .collect(); + if certs.is_empty() { + return Err(anyhow!( + "no usable private trust roots (Microsoft Public Trust roots are not accepted for {})", + kind.as_str() + )); + } + anchors_from_certs(certs) +} + +fn verified_signature( + signature_index: usize, + signer_subject: String, + identity: ArtifactSigningIdentity, + root: &Cert, + timestamped: bool, + verification_instant: &UtcDate, +) -> Result { + Ok(ArtifactSigningVerifiedSignature { + signature_index, + signer_subject, + identity, + root_subject: root.subject_name().to_string(), + root_sha1: hex_lower(&cert_sha1_thumbprint(root)?), + timestamped, + verification_time: utc_date_rfc3339(verification_instant), + }) +} + +/// Verify one PKCS#7 as an Artifact Signing signature whose digest binding equals `subject_digest`. +/// +/// Checks, in order: the signer carries an Artifact Signing identity EKU (and the Public Trust +/// marker for Public Trust profiles), the publisher ID matches +/// [`ArtifactSigningPolicy::expected_publisher_ids`], the timestamp countersignature chains to a +/// pinned Microsoft root, and the signer chain terminates at a pinned Microsoft root (Public +/// Trust) or a supplied subscriber root (Private Trust / Public Trust Test) with the CMS +/// signature and code-signing EKU valid at the timestamp. +pub fn verify_artifact_signing_pkcs7( + pkcs7_der: &[u8], + pkcs7_index: usize, + subject_digest: &[u8], + binding: Pkcs7DigestBinding, + policy: &ArtifactSigningPolicy, + opts: &TrustVerifyPeOptions, +) -> Result { + let (signer, _) = pkcs7_signer_identity(pkcs7_der)?; + let (signer_subject, identity) = checked_identity(&signer, policy)?; + let (timestamped, verification_instant) = + artifact_signing_instant(policy, opts, |anchors, certs| { + trusted_utc_date_from_authenticode_timestamp_token( + pkcs7_der, + anchors, + certs, + &opts.online, + opts.verbose_chain, + ) + })?; + let (anchors, anchor_certs) = artifact_signing_anchors(identity.kind, policy)?; let trust_policy = AuthenticodeTrustPolicy { strict_code_signing_eku: true, @@ -572,15 +621,65 @@ pub fn verify_artifact_signing_pkcs7( )?; let root = signer_terminal_root(pkcs7_der, &anchors, &anchor_certs)?; - Ok(ArtifactSigningVerifiedSignature { + verified_signature( pkcs7_index, signer_subject, identity, - root_subject: root.subject_name().to_string(), - root_sha1: hex_lower(&cert_sha1_thumbprint(&root)?), + &root, timestamped, - verification_time: utc_date_rfc3339(&verification_instant), - }) + &verification_instant, + ) +} + +/// Artifact Signing policy for signatures that are not CMS (VSIX XMLDSig): the caller has already +/// verified the signature value with `signer`'s key, so this checks the identity, the timestamp +/// token (`timestamp` = token DER and the bytes it covers), and `signer`'s chain to the anchors. +pub fn verify_artifact_signing_certificate( + signer: &Certificate, + extra_certs: Vec, + timestamp: Option<(&[u8], &[u8])>, + signature_index: usize, + policy: &ArtifactSigningPolicy, + opts: &TrustVerifyPeOptions, +) -> Result { + let (signer_subject, identity) = checked_identity(signer, policy)?; + let (timestamped, verification_instant) = + artifact_signing_instant(policy, opts, |anchors, certs| { + timestamp + .map(|(token, covered)| { + trusted_utc_date_from_timestamp_token( + token, + covered, + anchors, + certs, + &opts.online, + opts.verbose_chain, + ) + }) + .transpose() + })?; + let (anchors, anchor_certs) = artifact_signing_anchors(identity.kind, policy)?; + + let leaf_der = + der::Encode::to_der(signer).map_err(|e| anyhow!("encode signer certificate: {e}"))?; + let leaf = Cert::from_der(&leaf_der).map_err(|e| anyhow!("picky signer certificate: {e}"))?; + let root = verify_certificate_chain_to_anchors( + &leaf, + extra_certs, + &anchors, + &anchor_certs, + &opts.online, + &verification_instant, + opts.verbose_chain, + )?; + verified_signature( + signature_index, + signer_subject, + identity, + &root, + timestamped, + &verification_instant, + ) } /// Re-walk the (already verified) signer chain to report which anchor terminated it. diff --git a/crates/psign-authenticode-trust/src/chain.rs b/crates/psign-authenticode-trust/src/chain.rs index e2f86d2..65c81dc 100644 --- a/crates/psign-authenticode-trust/src/chain.rs +++ b/crates/psign-authenticode-trust/src/chain.rs @@ -140,6 +140,50 @@ pub fn issuer_chain_excluding_leaf_online( Ok(out) } +/// Build `leaf`'s chain from `pool` plus the anchor certificates (fetching issuers through AIA when +/// enabled), require it to end in `anchors`, apply the revocation policy, and verify every +/// certificate signature and validity period at `instant`. Returns the terminal root. +/// +/// For signatures that are not CMS (VSIX XMLDSig), where picky's Authenticode verifier does not +/// apply. +pub fn verify_certificate_chain_to_anchors( + leaf: &Cert, + pool: Vec, + anchors: &AnchorStore, + anchor_certs: &[Cert], + online: &crate::policy::OnlineTrustOptions, + instant: &picky::x509::date::UtcDate, + verbose_chain: bool, +) -> Result { + let mut merged = merge_unique_certs(anchor_certs.to_vec(), pool)?; + let chain = issuer_chain_excluding_leaf_online(leaf, &mut merged, online, Some(anchors))?; + let root = terminal_root_cert_owned(leaf, &chain).clone(); + let root_thumb = cert_sha1_thumbprint(&root)?; + if verbose_chain { + eprintln!("trust-verify: leaf subject: {}", leaf.subject_name()); + for (i, c) in chain.iter().enumerate() { + eprintln!("trust-verify: chain[{i}] subject: {}", c.subject_name()); + } + } + if !anchors.contains_thumbprint(&root_thumb) { + return Err(anyhow!( + "terminal root certificate is not in the anchor store (SHA-1 thumbprint {:02x}{:02x}…)", + root_thumb[0], + root_thumb[1] + )); + } + crate::online::check_revocation_chain(leaf, &chain, online)?; + if leaf.subject_name() == leaf.issuer_name() && chain.is_empty() { + return Ok(root); + } + leaf.verifier() + .chain(chain.iter()) + .exact_date(instant) + .verify() + .map_err(|e| anyhow!("certificate chain verification: {e}"))?; + Ok(root) +} + pub fn terminal_root_cert<'a>(leaf: &'a Cert, chain: &'a [&'a Cert]) -> &'a Cert { if leaf.subject_name() == leaf.issuer_name() { leaf diff --git a/crates/psign-authenticode-trust/src/lib.rs b/crates/psign-authenticode-trust/src/lib.rs index 9fe7e80..3cd8960 100644 --- a/crates/psign-authenticode-trust/src/lib.rs +++ b/crates/psign-authenticode-trust/src/lib.rs @@ -23,10 +23,14 @@ pub mod trust_verify_detached; pub mod trust_verify_esd; pub mod trust_verify_file; pub mod trust_verify_msi; +pub mod trust_verify_msix; +pub mod trust_verify_nuget; pub mod trust_verify_pe; pub mod trust_verify_script; +pub mod trust_verify_vsix; pub mod trust_verify_zip; pub mod verification_instant; +mod xml_c14n; pub use artifact_signing::{ ArtifactSigningCertificateKind, ArtifactSigningIdentity, ArtifactSigningPolicy, @@ -45,10 +49,13 @@ pub use trust_verify_detached::trust_verify_detached_bytes; pub use trust_verify_esd::trust_verify_wim_esd_path; pub use trust_verify_file::trust_verify_path; pub use trust_verify_msi::trust_verify_msi_bytes; +pub use trust_verify_msix::trust_verify_msix_bytes; +pub use trust_verify_nuget::{nuget_verified_signature_content, trust_verify_nuget_bytes}; pub use trust_verify_pe::{ TrustVerifyPeOptions, TrustVerifyPeReport, load_trust_material, pe_first_pkcs7_terminal_root, pkcs7_signed_data_der_terminal_root, trust_verify_pe_bytes, }; pub use trust_verify_script::trust_verify_script_bytes; +pub use trust_verify_vsix::{VsixSignature, trust_verify_vsix_bytes, verify_vsix_signature_bytes}; pub use trust_verify_zip::trust_verify_zip_bytes; pub use verification_instant::parse_verification_date_ymd; diff --git a/crates/psign-authenticode-trust/src/rfc3161_extract.rs b/crates/psign-authenticode-trust/src/rfc3161_extract.rs index bd8f7a1..7f01180 100644 --- a/crates/psign-authenticode-trust/src/rfc3161_extract.rs +++ b/crates/psign-authenticode-trust/src/rfc3161_extract.rs @@ -84,13 +84,77 @@ pub fn trusted_utc_date_from_authenticode_timestamp_token( else { return Ok(None); }; + verify_timestamp_token( + &token, + &tstinfo_der, + gen_date, + primary_signature, + anchors, + anchor_certs, + online, + verbose_chain, + ) + .map(Some) +} - let ts_sd = signed_data_from_content_info(&token).ok_or_else(|| { +/// Verify a standalone RFC 3161 timestamp token (DER `ContentInfo`) whose `MessageImprint` covers +/// `timestamped_bytes`, with the TSA chain terminating in `anchors`; returns `TSTInfo.genTime`. +/// +/// Used for timestamps carried outside CMS unsigned attributes, such as the OPC +/// `` element of VSIX signatures (imprint over the `SignatureValue` octets). +pub fn trusted_utc_date_from_timestamp_token( + token_der: &[u8], + timestamped_bytes: &[u8], + anchors: &AnchorStore, + anchor_certs: &[Cert], + online: &OnlineTrustOptions, + verbose_chain: bool, +) -> Result { + let token = decode_content_info_loose(token_der) + .ok_or_else(|| anyhow!("timestamp token is not a CMS ContentInfo"))?; + let sd = signed_data_from_content_info(&token) + .ok_or_else(|| anyhow!("timestamp token SignedData decode failed"))?; + if sd.encap_content_info.econtent_type != ID_CT_TSTINFO { + return Err(anyhow!("timestamp token does not encapsulate TSTInfo")); + } + let tstinfo_der = sd + .encap_content_info + .econtent + .as_ref() + .and_then(tstinfo_bytes_from_encapsulated_econtent) + .ok_or_else(|| anyhow!("timestamp token TSTInfo is missing"))? + .to_vec(); + let gen_date = + tstinfo_gen_time(&tstinfo_der).ok_or_else(|| anyhow!("timestamp token genTime missing"))?; + verify_timestamp_token( + &token, + &tstinfo_der, + gen_date, + timestamped_bytes, + anchors, + anchor_certs, + online, + verbose_chain, + ) +} + +#[allow(clippy::too_many_arguments)] +fn verify_timestamp_token( + token: &ContentInfo, + tstinfo_der: &[u8], + gen_date: UtcDate, + primary_signature: &[u8], + anchors: &AnchorStore, + anchor_certs: &[Cert], + online: &OnlineTrustOptions, + verbose_chain: bool, +) -> Result { + let ts_sd = signed_data_from_content_info(token).ok_or_else(|| { anyhow!( "timestamp token SignedData decode failed (after stripping unsupported cert choices)" ) })?; - let parsed = psign_sip_digest::timestamp::parse_tst_info_der(&tstinfo_der) + let parsed = psign_sip_digest::timestamp::parse_tst_info_der(tstinfo_der) .ok_or_else(|| anyhow!("timestamp token TSTInfo parse failed"))?; let expected_imprint = digest_for_oid(&parsed.message_imprint_digest_alg_oid, primary_signature)?; @@ -99,7 +163,7 @@ pub fn trusted_utc_date_from_authenticode_timestamp_token( "timestamp token MessageImprint does not match primary signer signature" )); } - let tst_digest = sha2::Sha256::digest(&tstinfo_der); + let tst_digest = sha2::Sha256::digest(tstinfo_der); verify_signed_data_pkcs9_message_digest_and_rsa_sha256_pkcs1v15_signature( &ts_sd, 0, @@ -149,7 +213,7 @@ pub fn trusted_utc_date_from_authenticode_timestamp_token( .exact_date(&gen_date) .verify() .map_err(|e| anyhow!("timestamp token TSA chain verification: {e}"))?; - Ok(Some(gen_date)) + Ok(gen_date) } fn signed_data_from_pkcs7(pkcs7_der: &[u8]) -> Option { diff --git a/crates/psign-authenticode-trust/src/trust_pkcs7.rs b/crates/psign-authenticode-trust/src/trust_pkcs7.rs index 4e5cc5e..b694486 100644 --- a/crates/psign-authenticode-trust/src/trust_pkcs7.rs +++ b/crates/psign-authenticode-trust/src/trust_pkcs7.rs @@ -88,7 +88,7 @@ fn extended_key_usage_contains(der: &[u8], expected: ObjectIdentifier) -> Result Ok(found) } -fn x509_cert_has_code_signing_eku(cert: &Certificate) -> Result { +pub(crate) fn x509_cert_has_code_signing_eku(cert: &Certificate) -> Result { let Some(exts) = &cert.tbs_certificate.extensions else { return Ok(false); }; diff --git a/crates/psign-authenticode-trust/src/trust_verify_file.rs b/crates/psign-authenticode-trust/src/trust_verify_file.rs index a2358bc..d89b4a5 100644 --- a/crates/psign-authenticode-trust/src/trust_verify_file.rs +++ b/crates/psign-authenticode-trust/src/trust_verify_file.rs @@ -6,7 +6,8 @@ use crate::trust_verify_pe::{TrustVerifyPeOptions, TrustVerifyPeReport}; use crate::{ trust_verify_cab_bytes, trust_verify_catalog_bytes, trust_verify_msi_bytes, - trust_verify_pe_bytes, trust_verify_script_bytes, trust_verify_wim_esd_path, + trust_verify_msix_bytes, trust_verify_nuget_bytes, trust_verify_pe_bytes, + trust_verify_script_bytes, trust_verify_vsix_bytes, trust_verify_wim_esd_path, trust_verify_zip_bytes, }; use anyhow::{Context, Result, anyhow}; @@ -30,9 +31,13 @@ pub fn trust_verify_path(path: &Path, opts: &TrustVerifyPeOptions) -> Result trust_verify_msi_bytes(&read()?, opts), "cat" => trust_verify_catalog_bytes(&read()?, opts), "zip" => trust_verify_zip_bytes(&read()?, opts), - "ps1" | "psd1" | "psm1" | "ps1xml" | "psc1" | "cdxml" | "mof" => { - trust_verify_script_bytes(&read()?, &ext, opts) + "ps1" | "psd1" | "psm1" | "ps1xml" | "psc1" | "cdxml" | "mof" | "js" | "jse" | "vbs" + | "vbe" | "wsf" => trust_verify_script_bytes(&read()?, &ext, opts), + "msix" | "appx" | "msixbundle" | "appxbundle" => { + trust_verify_msix_bytes(&read()?, &ext, opts) } + "nupkg" | "snupkg" => trust_verify_nuget_bytes(&read()?, opts), + "vsix" => trust_verify_vsix_bytes(&read()?, opts), _ => Err(anyhow!( "portable trust verification does not support this file format: {}", path.display() diff --git a/crates/psign-authenticode-trust/src/trust_verify_msix.rs b/crates/psign-authenticode-trust/src/trust_verify_msix.rs new file mode 100644 index 0000000..3be22ba --- /dev/null +++ b/crates/psign-authenticode-trust/src/trust_verify_msix.rs @@ -0,0 +1,33 @@ +//! MSIX / AppX (and bundle) Authenticode trust: APPX SIP digest + PKCS#7 chain. + +use crate::trust_pkcs7::Pkcs7DigestBinding; +use crate::trust_verify_pe::{ + TrustVerifyPeOptions, TrustVerifyPeReport, trust_material_for, verify_pkcs7_entry, +}; +use anyhow::Result; +use psign_sip_digest::msix_digest::msix_verified_signature; + +/// Trust-verify a cleartext `.msix` / `.appx` / `.msixbundle` / `.appxbundle` package. +/// +/// `extension` is the package extension without the dot. The APPX `messageDigest` blob +/// (AXPC/AXCD/AXCT/AXBM[/AXCI]) is recomputed and compared before the PKCS#7 trust step. +pub fn trust_verify_msix_bytes( + data: &[u8], + extension: &str, + opts: &TrustVerifyPeOptions, +) -> Result { + let (anchors, anchor_certs) = trust_material_for(opts)?; + let (pkcs7, digest) = msix_verified_signature(data, extension)?; + let mut report = TrustVerifyPeReport::new(&anchors); + verify_pkcs7_entry( + &pkcs7, + 0, + &digest, + Pkcs7DigestBinding::AuthenticodeIndirect, + &anchors, + &anchor_certs, + opts, + &mut report, + )?; + Ok(report) +} diff --git a/crates/psign-authenticode-trust/src/trust_verify_nuget.rs b/crates/psign-authenticode-trust/src/trust_verify_nuget.rs new file mode 100644 index 0000000..18a3e78 --- /dev/null +++ b/crates/psign-authenticode-trust/src/trust_verify_nuget.rs @@ -0,0 +1,109 @@ +//! NuGet package author-signature trust (`.nupkg` / `.snupkg`). +//! +//! The `.signature.p7s` CMS encapsulates the signature content +//! (`Version:1` + `-Hash:`). The package hash is recomputed over the exact +//! unsigned package bytes (see [`psign_opc_sign::nuget::signed_package_unsigned_bytes`]), then the +//! CMS is trust-verified with its PKCS#9 `messageDigest` bound to that content. + +use crate::trust_pkcs7::Pkcs7DigestBinding; +use crate::trust_verify_pe::{ + TrustVerifyPeOptions, TrustVerifyPeReport, trust_material_for, verify_pkcs7_entry, +}; +use anyhow::{Context, Result, anyhow}; +use der::asn1::OctetString; +use psign_opc_sign::nuget; +use psign_sip_digest::pkcs7::{ + parse_pkcs7_signed_data_der, signer_info_pkcs9_message_digest_octets, +}; +use sha2::Digest; + +fn digest_for_length(len: usize, data: &[u8]) -> Result> { + Ok(match len { + 32 => sha2::Sha256::digest(data).to_vec(), + 48 => sha2::Sha384::digest(data).to_vec(), + 64 => sha2::Sha512::digest(data).to_vec(), + n => return Err(anyhow!("unsupported NuGet signature digest length {n}")), + }) +} + +/// The verified NuGet signature content of `package` and its PKCS#9 `messageDigest`. +/// +/// Embedded content (as `dotnet nuget sign` writes it) must record the package hash of the exact +/// unsigned bytes. A detached CMS (written by earlier psign releases) is accepted when the +/// signature content rebuilt from that package hash matches the signed `messageDigest`. +pub fn nuget_verified_signature_content( + package: &[u8], + signature: &[u8], +) -> Result<(Vec, Vec)> { + let sd = parse_pkcs7_signed_data_der(signature).context("NuGet .signature.p7s CMS")?; + let signer = sd + .signer_infos + .0 + .as_slice() + .first() + .ok_or_else(|| anyhow!("NuGet signature has no SignerInfo"))?; + let message_digest = signer_info_pkcs9_message_digest_octets(signer) + .map_err(|e| anyhow!("NuGet signature messageDigest: {e}"))?; + let unsigned = nuget::signed_package_unsigned_bytes(package)?; + + let content = match sd.encap_content_info.econtent.as_ref() { + Some(econtent) => { + let content = econtent + .decode_as::() + .map_err(|e| anyhow!("NuGet signature content is not an OCTET STRING: {e}"))? + .as_bytes() + .to_vec(); + let parsed = nuget::parse_signature_content(&content)?; + if parsed.hash_algorithm.hash(&unsigned) != parsed.package_hash { + return Err(anyhow!( + "NuGet package hash mismatch: the package content changed after signing" + )); + } + content + } + None => [ + nuget::NuGetHashAlgorithm::Sha256, + nuget::NuGetHashAlgorithm::Sha384, + nuget::NuGetHashAlgorithm::Sha512, + ] + .into_iter() + .map(|alg| nuget::signature_content_bytes(alg, &alg.hash(&unsigned))) + .find(|content| { + digest_for_length(message_digest.len(), content) + .ok() + .as_ref() + == Some(&message_digest) + }) + .ok_or_else(|| { + anyhow!("NuGet package hash mismatch: the package content changed after signing") + })?, + }; + if digest_for_length(message_digest.len(), &content)? != message_digest { + return Err(anyhow!( + "NuGet signature messageDigest mismatch: the signed content differs from the embedded content" + )); + } + Ok((content, message_digest)) +} + +pub fn trust_verify_nuget_bytes( + data: &[u8], + opts: &TrustVerifyPeOptions, +) -> Result { + let (anchors, anchor_certs) = trust_material_for(opts)?; + let signature = nuget::extract_signature(std::io::Cursor::new(data))?; + let (_, content_digest) = nuget_verified_signature_content(data, &signature)?; + + let mut report = TrustVerifyPeReport::new(&anchors); + verify_pkcs7_entry( + &signature, + 0, + &content_digest, + Pkcs7DigestBinding::Pkcs9MessageDigest, + &anchors, + &anchor_certs, + opts, + &mut report, + )?; + Ok(report) +} diff --git a/crates/psign-authenticode-trust/src/trust_verify_script.rs b/crates/psign-authenticode-trust/src/trust_verify_script.rs index 426f16c..00a8d92 100644 --- a/crates/psign-authenticode-trust/src/trust_verify_script.rs +++ b/crates/psign-authenticode-trust/src/trust_verify_script.rs @@ -1,11 +1,12 @@ -//! PowerShell script Authenticode trust (script SIP digest + PKCS#7 chain). +//! Script Authenticode trust: PowerShell-class (`pwrshsip`) and WSH (`wshext`: `.js`, `.jse`, +//! `.vbs`, `.vbe`, `.wsf`) SIP digest + PKCS#7 chain. use crate::trust_pkcs7::Pkcs7DigestBinding; use crate::trust_verify_pe::{ TrustVerifyPeOptions, TrustVerifyPeReport, trust_material_for, verify_pkcs7_entry, }; use anyhow::Result; -use psign_sip_digest::ps_script::powershell_class_digest_report; +use psign_sip_digest::script_digest_report; pub fn trust_verify_script_bytes( data: &[u8], @@ -13,7 +14,7 @@ pub fn trust_verify_script_bytes( opts: &TrustVerifyPeOptions, ) -> Result { let (anchors, anchor_certs) = trust_material_for(opts)?; - let script_report = powershell_class_digest_report(data, extension)?; + let script_report = script_digest_report(data, extension)?; let mut report = TrustVerifyPeReport::new(&anchors); verify_pkcs7_entry( diff --git a/crates/psign-authenticode-trust/src/trust_verify_vsix.rs b/crates/psign-authenticode-trust/src/trust_verify_vsix.rs new file mode 100644 index 0000000..9295a9d --- /dev/null +++ b/crates/psign-authenticode-trust/src/trust_verify_vsix.rs @@ -0,0 +1,579 @@ +//! VSIX (OPC XML digital signature) trust verification. +//! +//! Verifies the package signature the way Visual Studio / `System.IO.Packaging` and dotnet/sign +//! produce it: +//! +//! 1. Exactly one XML signature part (`/package/services/digital-signature/xml-signature/*`). +//! 2. `SignedInfo` is canonicalized (C14N 1.0 or exclusive C14N) and its `SignatureValue` is an +//! RSA PKCS#1 v1.5 signature (SHA-256/384/512) by a certificate from `KeyInfo`. +//! 3. Every `SignedInfo` reference (`#id` same-document objects) and every `Manifest` reference +//! (package parts, optionally with C14N / OPC RelationshipTransform) digest matches. +//! 4. The `Manifest` is inside an `Object` covered by `SignedInfo`; every package part except +//! `[Content_Types].xml` and the signature part is referenced, and each reference's +//! `ContentType` matches `[Content_Types].xml`. +//! 5. Trust: the signer chain ends at the configured anchors (or the Artifact Signing policy), at +//! the RFC 3161 time from `` when present (imprint over the +//! `SignatureValue` octets). + +use crate::anchor::parse_cert_bytes; +use crate::artifact_signing::verify_artifact_signing_certificate; +use crate::chain::verify_certificate_chain_to_anchors; +use crate::rfc3161_extract::trusted_utc_date_from_timestamp_token; +use crate::trust_pkcs7::x509_cert_has_code_signing_eku; +use crate::trust_verify_pe::{TrustVerifyPeOptions, TrustVerifyPeReport, trust_material_for}; +use crate::xml_c14n::{C14nMode, Element, canonicalize, parse_document}; +use anyhow::{Context, Result, anyhow}; +use base64::Engine as _; +use der::{Decode, Encode}; +use picky::x509::certificate::Cert; +use picky::x509::date::UtcDate; +use rsa::pkcs8::DecodePublicKey; +use rsa::signature::Verifier; +use sha2::Digest; +use std::collections::{BTreeMap, BTreeSet}; +use std::io::{Cursor, Read}; +use x509_cert::Certificate; + +const DSIG: &str = "http://www.w3.org/2000/09/xmldsig#"; +const OPC_DSIG: &str = "http://schemas.openxmlformats.org/package/2006/digital-signature"; +const OPC_RELS: &str = "http://schemas.openxmlformats.org/package/2006/relationships"; +const OPC_CONTENT_TYPES: &str = "http://schemas.openxmlformats.org/package/2006/content-types"; +const RELATIONSHIP_TRANSFORM: &str = + "http://schemas.openxmlformats.org/package/2006/RelationshipTransform"; +const SIGNATURE_PART_PREFIX: &str = "package/services/digital-signature/xml-signature/"; +const CONTENT_TYPES_PART: &str = "[Content_Types].xml"; + +#[derive(Clone, Copy)] +enum Hash { + Sha256, + Sha384, + Sha512, +} + +impl Hash { + fn from_digest_uri(uri: &str) -> Result { + match uri { + "http://www.w3.org/2001/04/xmlenc#sha256" => Ok(Self::Sha256), + "http://www.w3.org/2001/04/xmldsig-more#sha384" => Ok(Self::Sha384), + "http://www.w3.org/2001/04/xmlenc#sha512" => Ok(Self::Sha512), + other => Err(anyhow!("unsupported XMLDSig DigestMethod {other}")), + } + } + + fn from_signature_uri(uri: &str) -> Result { + match uri { + "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256" => Ok(Self::Sha256), + "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384" => Ok(Self::Sha384), + "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512" => Ok(Self::Sha512), + other => Err(anyhow!("unsupported XMLDSig SignatureMethod {other}")), + } + } + + fn digest(self, data: &[u8]) -> Vec { + match self { + Self::Sha256 => sha2::Sha256::digest(data).to_vec(), + Self::Sha384 => sha2::Sha384::digest(data).to_vec(), + Self::Sha512 => sha2::Sha512::digest(data).to_vec(), + } + } + + fn verify_rsa(self, cert: &Certificate, data: &[u8], signature: &[u8]) -> Result<()> { + let spki = cert + .tbs_certificate + .subject_public_key_info + .to_der() + .map_err(|e| anyhow!("encode SubjectPublicKeyInfo: {e}"))?; + let key = rsa::RsaPublicKey::from_public_key_der(&spki) + .map_err(|e| anyhow!("signer public key is not RSA: {e}"))?; + let signature = rsa::pkcs1v15::Signature::try_from(signature) + .map_err(|e| anyhow!("SignatureValue octets: {e}"))?; + let result = match self { + Self::Sha256 => { + rsa::pkcs1v15::VerifyingKey::::new(key).verify(data, &signature) + } + Self::Sha384 => { + rsa::pkcs1v15::VerifyingKey::::new(key).verify(data, &signature) + } + Self::Sha512 => { + rsa::pkcs1v15::VerifyingKey::::new(key).verify(data, &signature) + } + }; + result.map_err(|e| anyhow!("SignatureValue verification: {e}")) + } +} + +fn decode_base64(text: &str, what: &str) -> Result> { + let compact: String = text.chars().filter(|c| !c.is_whitespace()).collect(); + base64::engine::general_purpose::STANDARD + .decode(compact) + .with_context(|| format!("{what} is not valid base64")) +} + +fn algorithm<'a>(e: &'a Element, child: &str) -> Result<&'a str> { + e.child(DSIG, child) + .and_then(|c| c.attr("Algorithm")) + .ok_or_else(|| anyhow!("XMLDSig {} is missing {child}", e.local_name())) +} + +/// Percent-decoded, slash-trimmed OPC part name for ZIP lookup and comparison (case-insensitive). +fn part_key(name: &str) -> String { + let bytes = name.trim_start_matches('/').as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' + && i + 2 < bytes.len() + && let Ok(b) = u8::from_str_radix(&String::from_utf8_lossy(&bytes[i + 1..i + 3]), 16) + { + out.push(b); + i += 3; + continue; + } + out.push(bytes[i]); + i += 1; + } + String::from_utf8_lossy(&out).to_ascii_lowercase() +} + +struct Package { + parts: BTreeMap>, + /// Part key → content type from `[Content_Types].xml`. + content_types: BTreeMap, + signature_part: String, +} + +fn read_package(data: &[u8]) -> Result { + let mut archive = zip::ZipArchive::new(Cursor::new(data)).context("open VSIX ZIP")?; + let mut parts = BTreeMap::new(); + let mut raw_content_types = None; + for i in 0..archive.len() { + let mut file = archive.by_index(i).context("read VSIX ZIP entry")?; + if file.is_dir() { + continue; + } + let name = file.name().to_string(); + let mut bytes = Vec::with_capacity(file.size() as usize); + file.read_to_end(&mut bytes) + .with_context(|| format!("read VSIX part {name}"))?; + if name == CONTENT_TYPES_PART { + raw_content_types = Some(bytes); + continue; + } + if parts.insert(part_key(&name), bytes).is_some() { + return Err(anyhow!("VSIX contains duplicate part name {name}")); + } + } + + let mut signatures = parts + .keys() + .filter(|k| k.starts_with(SIGNATURE_PART_PREFIX)) + .cloned(); + let signature_part = signatures + .next() + .ok_or_else(|| anyhow!("VSIX package has no OPC XML signature part"))?; + if signatures.next().is_some() { + return Err(anyhow!( + "VSIX package has more than one OPC XML signature part" + )); + } + + let ct_doc = parse_document( + &raw_content_types.ok_or_else(|| anyhow!("VSIX package has no [Content_Types].xml"))?, + ) + .context("[Content_Types].xml")?; + let mut defaults = BTreeMap::new(); + let mut overrides = BTreeMap::new(); + for e in ct_doc.child_elements() { + match (e.namespace(), e.local_name()) { + (OPC_CONTENT_TYPES, "Default") => { + if let (Some(ext), Some(ct)) = (e.attr("Extension"), e.attr("ContentType")) { + defaults.insert(ext.to_ascii_lowercase(), ct.to_string()); + } + } + (OPC_CONTENT_TYPES, "Override") => { + if let (Some(p), Some(ct)) = (e.attr("PartName"), e.attr("ContentType")) { + overrides.insert(part_key(p), ct.to_string()); + } + } + _ => {} + } + } + let content_types = parts + .keys() + .filter_map(|k| { + let ct = overrides.get(k).cloned().or_else(|| { + let ext = k.rsplit_once('.').map(|(_, e)| e).unwrap_or(""); + defaults.get(ext).cloned() + })?; + Some((k.clone(), ct)) + }) + .collect(); + + Ok(Package { + parts, + content_types, + signature_part, + }) +} + +/// OPC RelationshipTransform (ECMA-376 Part 2): keep the selected relationships sorted by `Id`, +/// with `TargetMode` defaulted, as a new `Relationships` element (then canonicalized). +fn relationship_transform(transform: &Element, rels_xml: &[u8]) -> Result { + let source_ids: BTreeSet<&str> = transform + .children_named(OPC_DSIG, "RelationshipReference") + .filter_map(|e| e.attr("SourceId")) + .collect(); + let source_types: BTreeSet<&str> = transform + .children_named(OPC_DSIG, "RelationshipsGroupReference") + .filter_map(|e| e.attr("SourceType")) + .collect(); + let rels = parse_document(rels_xml).context("relationships part")?; + if !rels.is(OPC_RELS, "Relationships") { + return Err(anyhow!( + "RelationshipTransform input is not a Relationships part" + )); + } + let mut selected: Vec = rels + .children_named(OPC_RELS, "Relationship") + .filter(|r| { + r.attr("Id").is_some_and(|id| source_ids.contains(id)) + || r.attr("Type").is_some_and(|t| source_types.contains(t)) + }) + .map(|r| { + let mut attrs = Vec::new(); + for name in ["Id", "Target", "TargetMode", "Type"] { + match r.attr(name) { + Some(v) => attrs.push((name.to_string(), v.to_string())), + None if name == "TargetMode" => { + attrs.push((name.to_string(), "Internal".to_string())) + } + None => {} + } + } + Element { + qname: "Relationship".to_string(), + attrs, + children: Vec::new(), + scope: rels.scope.clone(), + } + }) + .collect(); + selected.sort_by(|a, b| a.attr("Id").cmp(&b.attr("Id"))); + Ok(Element { + qname: "Relationships".to_string(), + attrs: vec![("xmlns".to_string(), OPC_RELS.to_string())], + children: selected + .into_iter() + .map(crate::xml_c14n::Node::Element) + .collect(), + scope: [(String::new(), OPC_RELS.to_string())] + .into_iter() + .collect(), + }) +} + +/// Octets a package-part reference digests, after its transforms. +fn part_reference_octets(reference: &Element, part: &[u8]) -> Result> { + let Some(transforms) = reference.child(DSIG, "Transforms") else { + return Ok(part.to_vec()); + }; + let mut current: Option = None; + let mut octets = part.to_vec(); + for transform in transforms.children_named(DSIG, "Transform") { + let uri = transform.attr("Algorithm").unwrap_or_default(); + if uri == RELATIONSHIP_TRANSFORM { + current = Some(relationship_transform(transform, &octets)?); + } else if let Some(mode) = C14nMode::from_uri(uri) { + let element = match current.take() { + Some(e) => e, + None => parse_document(&octets)?, + }; + octets = canonicalize(&element, mode); + } else { + return Err(anyhow!("unsupported XMLDSig part transform {uri}")); + } + } + if let Some(element) = current { + octets = canonicalize(&element, C14nMode::Inclusive); + } + Ok(octets) +} + +/// Octets a same-document (`#id`) reference digests. +fn object_reference_octets(reference: &Element, target: &Element) -> Result> { + let mut mode = C14nMode::Inclusive; + if let Some(transforms) = reference.child(DSIG, "Transforms") { + for transform in transforms.children_named(DSIG, "Transform") { + let uri = transform.attr("Algorithm").unwrap_or_default(); + mode = C14nMode::from_uri(uri) + .ok_or_else(|| anyhow!("unsupported XMLDSig object transform {uri}"))?; + } + } + Ok(canonicalize(target, mode)) +} + +fn check_digest(reference: &Element, octets: &[u8], what: &str) -> Result<()> { + let hash = Hash::from_digest_uri(algorithm(reference, "DigestMethod")?)?; + let expected = decode_base64( + &reference + .child(DSIG, "DigestValue") + .ok_or_else(|| anyhow!("XMLDSig reference {what} has no DigestValue"))? + .text(), + "DigestValue", + )?; + if hash.digest(octets) != expected { + return Err(anyhow!("VSIX signature digest mismatch for {what}")); + } + Ok(()) +} + +/// Verified signature material handed to the trust step. +struct VerifiedXmlSignature { + signer: Certificate, + certificates: Vec, + signature_value: Vec, + timestamp_token: Option>, +} + +fn verify_xml_signature(package: &Package) -> Result { + let doc = + parse_document(&package.parts[&package.signature_part]).context("VSIX signature XML")?; + if !doc.is(DSIG, "Signature") { + return Err(anyhow!("VSIX signature part is not an XMLDSig Signature")); + } + let signed_info = doc + .child(DSIG, "SignedInfo") + .ok_or_else(|| anyhow!("XMLDSig Signature has no SignedInfo"))?; + let c14n_uri = algorithm(signed_info, "CanonicalizationMethod")?; + let c14n = C14nMode::from_uri(c14n_uri) + .ok_or_else(|| anyhow!("unsupported XMLDSig CanonicalizationMethod {c14n_uri}"))?; + let sig_hash = Hash::from_signature_uri(algorithm(signed_info, "SignatureMethod")?)?; + let signature_value = decode_base64( + &doc.child(DSIG, "SignatureValue") + .ok_or_else(|| anyhow!("XMLDSig Signature has no SignatureValue"))? + .text(), + "SignatureValue", + )?; + + // KeyInfo certificates; the signer is the one whose key verifies SignedInfo. + let mut x509 = Vec::new(); + if let Some(key_info) = doc.child(DSIG, "KeyInfo") { + for data in key_info.children_named(DSIG, "X509Data") { + for cert in data.children_named(DSIG, "X509Certificate") { + x509.push(decode_base64(&cert.text(), "X509Certificate")?); + } + } + } + let canonical_signed_info = canonicalize(signed_info, c14n); + let signer = x509 + .iter() + .filter_map(|der| Certificate::from_der(der).ok()) + .find(|cert| { + sig_hash + .verify_rsa(cert, &canonical_signed_info, &signature_value) + .is_ok() + }) + .ok_or_else(|| { + anyhow!("VSIX SignatureValue does not verify with any KeyInfo X509Certificate") + })?; + let certificates = x509 + .iter() + .map(|der| parse_cert_bytes(der)) + .collect::>>()?; + + // SignedInfo references: same-document objects. + let mut covered_objects: Vec<&Element> = Vec::new(); + for reference in signed_info.children_named(DSIG, "Reference") { + let uri = reference.attr("URI").unwrap_or_default(); + let id = uri + .strip_prefix('#') + .ok_or_else(|| anyhow!("unsupported SignedInfo reference URI {uri:?}"))?; + let mut targets = Vec::new(); + doc.find_all(&|e| e.attr("Id") == Some(id), &mut targets); + let [target] = targets[..] else { + return Err(anyhow!( + "SignedInfo reference #{id} must match exactly one element" + )); + }; + check_digest(reference, &object_reference_octets(reference, target)?, uri)?; + covered_objects.push(target); + } + + // The package manifest must sit inside a covered object. + let mut manifests = Vec::new(); + for object in &covered_objects { + object.find_all(&|e| e.is(DSIG, "Manifest"), &mut manifests); + } + if manifests.is_empty() { + return Err(anyhow!("VSIX signature does not sign a package Manifest")); + } + let mut referenced = BTreeSet::new(); + for manifest in manifests { + for reference in manifest.children_named(DSIG, "Reference") { + let uri = reference.attr("URI").unwrap_or_default(); + let (path, query) = uri.split_once('?').unwrap_or((uri, "")); + if !path.starts_with('/') { + return Err(anyhow!("unsupported Manifest reference URI {uri:?}")); + } + let key = part_key(path); + let part = package + .parts + .get(&key) + .ok_or_else(|| anyhow!("VSIX signature references missing part {path}"))?; + if let Some(ct) = query.strip_prefix("ContentType=") + && package.content_types.get(&key).map(String::as_str) != Some(ct) + { + return Err(anyhow!( + "VSIX part {path} content type differs from the signed {ct}" + )); + } + check_digest(reference, &part_reference_octets(reference, part)?, path)?; + referenced.insert(key); + } + } + if let Some(unsigned) = package + .parts + .keys() + .find(|k| **k != package.signature_part && !referenced.contains(*k)) + { + return Err(anyhow!( + "VSIX part /{unsigned} is not covered by the signature" + )); + } + + let mut stamps = Vec::new(); + doc.find_all(&|e| e.is(OPC_DSIG, "EncodedTime"), &mut stamps); + let timestamp_token = match stamps[..] { + [] => None, + [stamp] => Some(decode_base64(&stamp.text(), "EncodedTime")?), + _ => return Err(anyhow!("VSIX signature has more than one timestamp")), + }; + + Ok(VerifiedXmlSignature { + signer, + certificates, + signature_value, + timestamp_token, + }) +} + +/// A VSIX signature whose XMLDSig structure, part digests, and `SignatureValue` verified. +#[derive(Debug, Clone)] +pub struct VsixSignature { + /// DER of the `KeyInfo` certificate whose key produced the `SignatureValue`. + pub signer_certificate_der: Vec, + /// RFC 3161 token from `` (not yet trust-verified). + pub timestamp_token: Option>, +} + +/// Verify the VSIX signature binding (steps 1-4 of the module docs) without any trust decision. +pub fn verify_vsix_signature_bytes(data: &[u8]) -> Result { + let sig = verify_xml_signature(&read_package(data)?)?; + Ok(VsixSignature { + signer_certificate_der: sig + .signer + .to_der() + .map_err(|e| anyhow!("encode signer certificate: {e}"))?, + timestamp_token: sig.timestamp_token, + }) +} + +/// Trust-verify a signed VSIX package; see the module documentation for the checks. +pub fn trust_verify_vsix_bytes( + data: &[u8], + opts: &TrustVerifyPeOptions, +) -> Result { + let (anchors, anchor_certs) = trust_material_for(opts)?; + let package = read_package(data)?; + let sig = verify_xml_signature(&package)?; + let mut report = TrustVerifyPeReport::new(&anchors); + + if let Some(policy) = &opts.artifact_signing { + report + .artifact_signing + .push(verify_artifact_signing_certificate( + &sig.signer, + sig.certificates, + sig.timestamp_token + .as_deref() + .map(|t| (t, sig.signature_value.as_slice())), + 0, + policy, + opts, + )?); + } else { + if opts.policy.strict_code_signing_eku && !x509_cert_has_code_signing_eku(&sig.signer)? { + return Err(anyhow!( + "VSIX signer certificate lacks the code signing EKU" + )); + } + let instant = match &opts.verification_instant_override { + Some(instant) => instant.clone(), + None if opts.policy.prefer_timestamp_signing_time => match &sig.timestamp_token { + Some(token) => trusted_utc_date_from_timestamp_token( + token, + &sig.signature_value, + &anchors, + &anchor_certs, + &opts.online, + opts.verbose_chain, + ) + .context("VSIX timestamp")?, + None if opts.policy.require_valid_timestamp => { + return Err(anyhow!("VSIX signature has no RFC 3161 timestamp")); + } + None => UtcDate::now(), + }, + None => UtcDate::now(), + }; + let leaf_der = sig + .signer + .to_der() + .map_err(|e| anyhow!("encode signer certificate: {e}"))?; + let leaf = parse_cert_bytes(&leaf_der)?; + verify_certificate_chain_to_anchors( + &leaf, + sig.certificates, + &anchors, + &anchor_certs, + &opts.online, + &instant, + opts.verbose_chain, + )?; + } + report.pkcs7_entries_verified = 1; + Ok(report) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn part_keys_are_decoded_and_case_insensitive() { + assert_eq!(part_key("/_rels/.rels"), "_rels/.rels"); + assert_eq!(part_key("/A%20b.TXT"), "a b.txt"); + } + + #[test] + fn relationship_transform_selects_and_sorts() { + let transform = parse_document( + br#""#, + ) + .unwrap(); + let rels = br#""#; + let out = canonicalize( + &relationship_transform(&transform, rels).unwrap(), + C14nMode::Inclusive, + ); + assert_eq!( + String::from_utf8(out).unwrap(), + concat!( + r#""#, + r#""#, + r#""#, + "" + ) + ); + } +} diff --git a/crates/psign-authenticode-trust/src/xml_c14n.rs b/crates/psign-authenticode-trust/src/xml_c14n.rs new file mode 100644 index 0000000..25c85e3 --- /dev/null +++ b/crates/psign-authenticode-trust/src/xml_c14n.rs @@ -0,0 +1,429 @@ +//! Minimal XML DOM + Canonical XML 1.0 (inclusive and exclusive, without comments) for XMLDSig. +//! +//! Scope: the signature parts and OPC relationship parts of signed packages (VSIX). DOCTYPE and +//! processing instructions inside the document element are rejected, so no DTD-defined defaults or +//! entities can influence canonicalization. Line endings and attribute values are normalized as an +//! XML 1.0 processor would before canonicalization. + +use anyhow::{Context, Result, anyhow}; +use quick_xml::Reader; +use quick_xml::events::{BytesStart, Event}; +use std::collections::BTreeMap; + +const XML_NS: &str = "http://www.w3.org/XML/1998/namespace"; + +/// Namespace prefix → URI (`""` is the default namespace). +pub(crate) type NsScope = BTreeMap; + +#[derive(Debug, Clone)] +pub(crate) struct Element { + /// Qualified name as written (`prefix:local` or `local`). + pub qname: String, + /// Attributes as written, including `xmlns` declarations; values are normalized + unescaped. + pub attrs: Vec<(String, String)>, + pub children: Vec, + /// Namespaces in scope at this element (inherited + declared here). + pub scope: NsScope, +} + +#[derive(Debug, Clone)] +pub(crate) enum Node { + Element(Element), + Text(String), +} + +fn split_qname(qname: &str) -> (&str, &str) { + qname.split_once(':').unwrap_or(("", qname)) +} + +impl Element { + pub fn local_name(&self) -> &str { + split_qname(&self.qname).1 + } + + pub fn namespace(&self) -> &str { + let prefix = split_qname(&self.qname).0; + self.scope.get(prefix).map(String::as_str).unwrap_or("") + } + + pub fn is(&self, ns: &str, local: &str) -> bool { + self.local_name() == local && self.namespace() == ns + } + + /// Unprefixed attribute value. + pub fn attr(&self, name: &str) -> Option<&str> { + self.attrs + .iter() + .find(|(k, _)| k == name) + .map(|(_, v)| v.as_str()) + } + + pub fn child_elements(&self) -> impl Iterator { + self.children.iter().filter_map(|n| match n { + Node::Element(e) => Some(e), + Node::Text(_) => None, + }) + } + + pub fn child(&self, ns: &str, local: &str) -> Option<&Element> { + self.child_elements().find(|e| e.is(ns, local)) + } + + pub fn children_named<'a>( + &'a self, + ns: &'a str, + local: &'a str, + ) -> impl Iterator + 'a { + self.child_elements().filter(move |e| e.is(ns, local)) + } + + /// Concatenated text content of direct text children. + pub fn text(&self) -> String { + self.children + .iter() + .filter_map(|n| match n { + Node::Text(t) => Some(t.as_str()), + Node::Element(_) => None, + }) + .collect() + } + + /// Depth-first search including `self`. + pub fn find_all<'a>(&'a self, pred: &dyn Fn(&Element) -> bool, out: &mut Vec<&'a Element>) { + if pred(self) { + out.push(self); + } + for child in self.child_elements() { + child.find_all(pred, out); + } + } +} + +fn normalize_newlines(s: &str) -> String { + s.replace("\r\n", "\n").replace('\r', "\n") +} + +fn start_element(e: &BytesStart<'_>, parent_scope: &NsScope) -> Result { + let qname = std::str::from_utf8(e.name().as_ref()) + .context("XML element name is not UTF-8")? + .to_string(); + let mut attrs = Vec::new(); + let mut scope = parent_scope.clone(); + for attr in e.attributes() { + let attr = attr.map_err(|e| anyhow!("XML attribute: {e}"))?; + let key = std::str::from_utf8(attr.key.as_ref()) + .context("XML attribute name is not UTF-8")? + .to_string(); + let raw = std::str::from_utf8(&attr.value).context("XML attribute value is not UTF-8")?; + // Attribute-value normalization: literal whitespace characters become spaces. + let normalized = normalize_newlines(raw).replace(['\t', '\n'], " "); + let value = quick_xml::escape::unescape(&normalized) + .map_err(|e| anyhow!("XML attribute {key} value: {e}"))? + .into_owned(); + if key == "xmlns" { + scope.insert(String::new(), value.clone()); + } else if let Some(prefix) = key.strip_prefix("xmlns:") { + scope.insert(prefix.to_string(), value.clone()); + } + attrs.push((key, value)); + } + scope.insert("xml".to_string(), XML_NS.to_string()); + Ok(Element { + qname, + attrs, + children: Vec::new(), + scope, + }) +} + +fn push_text(stack: &mut [Element], text: &str) -> Result<()> { + let Some(top) = stack.last_mut() else { + if text.trim().is_empty() { + return Ok(()); + } + return Err(anyhow!("XML text outside the document element")); + }; + if let Some(Node::Text(last)) = top.children.last_mut() { + last.push_str(text); + } else { + top.children.push(Node::Text(text.to_string())); + } + Ok(()) +} + +/// Parse a UTF-8 XML document (optional BOM) into its document element. +pub(crate) fn parse_document(xml: &[u8]) -> Result { + let xml = xml.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(xml); + let text = std::str::from_utf8(xml).context("XML document is not UTF-8")?; + let mut reader = Reader::from_str(text); + reader.config_mut().expand_empty_elements = true; + let mut stack: Vec = Vec::new(); + let mut root = None; + loop { + match reader + .read_event() + .map_err(|e| anyhow!("XML parse error at {}: {e}", reader.buffer_position()))? + { + Event::Start(e) => { + if root.is_some() { + return Err(anyhow!("XML has more than one document element")); + } + let scope = stack.last().map(|p| p.scope.clone()).unwrap_or_default(); + stack.push(start_element(&e, &scope)?); + } + Event::End(_) => { + let done = stack + .pop() + .ok_or_else(|| anyhow!("unbalanced XML end tag"))?; + match stack.last_mut() { + Some(parent) => parent.children.push(Node::Element(done)), + None => root = Some(done), + } + } + Event::Text(t) => { + let raw = t.decode().map_err(|e| anyhow!("XML text: {e}"))?; + push_text(&mut stack, &normalize_newlines(&raw))?; + } + Event::CData(c) => { + let raw = c.decode().map_err(|e| anyhow!("XML CDATA: {e}"))?; + push_text(&mut stack, &normalize_newlines(&raw))?; + } + Event::GeneralRef(r) => { + let resolved = if r.is_char_ref() { + r.resolve_char_ref() + .map_err(|e| anyhow!("XML character reference: {e}"))? + .ok_or_else(|| anyhow!("invalid XML character reference"))? + .to_string() + } else { + match r.decode().map_err(|e| anyhow!("XML entity: {e}"))?.as_ref() { + "amp" => "&".into(), + "lt" => "<".into(), + "gt" => ">".into(), + "quot" => "\"".into(), + "apos" => "'".into(), + other => return Err(anyhow!("undefined XML entity &{other};")), + } + }; + push_text(&mut stack, &resolved)?; + } + Event::Comment(_) | Event::Decl(_) => {} + Event::PI(_) if stack.is_empty() => {} + Event::PI(_) => return Err(anyhow!("XML processing instructions are not supported")), + Event::DocType(_) => return Err(anyhow!("XML DOCTYPE is not allowed")), + Event::Empty(_) => unreachable!("expand_empty_elements is enabled"), + Event::Eof => break, + } + } + if !stack.is_empty() { + return Err(anyhow!("XML document is truncated")); + } + root.ok_or_else(|| anyhow!("XML document has no document element")) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum C14nMode { + /// `http://www.w3.org/TR/2001/REC-xml-c14n-20010315` + Inclusive, + /// `http://www.w3.org/2001/10/xml-exc-c14n#` (no `InclusiveNamespaces` prefix list). + Exclusive, +} + +impl C14nMode { + pub fn from_uri(uri: &str) -> Option { + match uri { + "http://www.w3.org/TR/2001/REC-xml-c14n-20010315" => Some(Self::Inclusive), + "http://www.w3.org/2001/10/xml-exc-c14n#" => Some(Self::Exclusive), + _ => None, + } + } +} + +fn escape_text(s: &str, out: &mut String) { + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '\r' => out.push_str(" "), + c => out.push(c), + } + } +} + +fn escape_attr(s: &str, out: &mut String) { + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '"' => out.push_str("""), + '\t' => out.push_str(" "), + '\n' => out.push_str(" "), + '\r' => out.push_str(" "), + c => out.push(c), + } + } +} + +/// Canonicalize `element` and its subtree as an XMLDSig node-set (the element is the apex; its +/// ancestors' namespaces are in `element.scope`). +pub(crate) fn canonicalize(element: &Element, mode: C14nMode) -> Vec { + let mut out = String::new(); + write_element(element, mode, &NsScope::new(), &mut out); + out.into_bytes() +} + +fn write_element(e: &Element, mode: C14nMode, rendered: &NsScope, out: &mut String) { + let lookup = |scope: &NsScope, prefix: &str| -> String { + scope.get(prefix).cloned().unwrap_or_default() + }; + + // Namespace declarations to emit. + let mut emit: Vec<(String, String)> = Vec::new(); + match mode { + C14nMode::Inclusive => { + for (prefix, uri) in &e.scope { + if prefix == "xml" { + continue; + } + if lookup(rendered, prefix) != *uri { + emit.push((prefix.clone(), uri.clone())); + } + } + } + C14nMode::Exclusive => { + let mut used: Vec<&str> = vec![split_qname(&e.qname).0]; + for (k, _) in &e.attrs { + if k == "xmlns" || k.starts_with("xmlns:") { + continue; + } + let (prefix, _) = split_qname(k); + if !prefix.is_empty() { + used.push(prefix); + } + } + used.sort_unstable(); + used.dedup(); + for prefix in used { + if prefix == "xml" { + continue; + } + let uri = lookup(&e.scope, prefix); + if lookup(rendered, prefix) != uri { + emit.push((prefix.to_string(), uri)); + } + } + } + } + // `xmlns=""` only undoes a non-empty default rendered on an output ancestor. + emit.retain(|(p, u)| !(p.is_empty() && u.is_empty() && lookup(rendered, "").is_empty())); + emit.sort_by(|a, b| a.0.cmp(&b.0)); + + let mut attrs: Vec<(String, String, &str, &str)> = e + .attrs + .iter() + .filter(|(k, _)| k != "xmlns" && !k.starts_with("xmlns:")) + .map(|(k, v)| { + let (prefix, local) = split_qname(k); + let ns = if prefix.is_empty() { + String::new() + } else { + lookup(&e.scope, prefix) + }; + (ns, local.to_string(), k.as_str(), v.as_str()) + }) + .collect(); + attrs.sort_by(|a, b| (&a.0, &a.1).cmp(&(&b.0, &b.1))); + + out.push('<'); + out.push_str(&e.qname); + for (prefix, uri) in &emit { + out.push_str(if prefix.is_empty() { + " xmlns" + } else { + " xmlns:" + }); + out.push_str(prefix); + out.push_str("=\""); + escape_attr(uri, out); + out.push('"'); + } + for (_, _, name, value) in &attrs { + out.push(' '); + out.push_str(name); + out.push_str("=\""); + escape_attr(value, out); + out.push('"'); + } + out.push('>'); + + let mut child_rendered = rendered.clone(); + for (prefix, uri) in emit { + child_rendered.insert(prefix, uri); + } + for child in &e.children { + match child { + Node::Text(t) => escape_text(t, out), + Node::Element(c) => write_element(c, mode, &child_rendered, out), + } + } + out.push_str("'); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn c14n(xml: &str, mode: C14nMode) -> String { + String::from_utf8(canonicalize(&parse_document(xml.as_bytes()).unwrap(), mode)).unwrap() + } + + #[test] + fn inclusive_renders_inherited_namespaces_on_apex() { + let doc = parse_document( + br#""#, + ) + .unwrap(); + let child = doc.child_elements().next().unwrap(); + assert_eq!( + String::from_utf8(canonicalize(child, C14nMode::Inclusive)).unwrap(), + r#""# + ); + assert_eq!( + String::from_utf8(canonicalize(child, C14nMode::Exclusive)).unwrap(), + r#""# + ); + } + + #[test] + fn exclusive_omits_unused_namespaces() { + assert_eq!( + c14n( + r#""#, + C14nMode::Exclusive + ), + r#""# + ); + assert_eq!( + c14n( + r#""#, + C14nMode::Inclusive + ), + r#""# + ); + } + + #[test] + fn text_and_line_endings_are_normalized() { + assert_eq!( + c14n("a\r\nb < > ", C14nMode::Inclusive), + "a\nb < > " + ); + } + + #[test] + fn rejects_doctype() { + assert!(parse_document(b"]>&e;").is_err()); + } +} diff --git a/crates/psign-authenticode-trust/tests/format_trust.rs b/crates/psign-authenticode-trust/tests/format_trust.rs new file mode 100644 index 0000000..eeb2146 --- /dev/null +++ b/crates/psign-authenticode-trust/tests/format_trust.rs @@ -0,0 +1,186 @@ +//! Generic (explicit-anchor) trust for script, MSIX, NuGet, and VSIX signatures, using fixtures +//! signed by the Devolutions test CA with native tooling. + +use psign_authenticode_trust::{ + TrustVerifyPeOptions, parse_verification_date_ymd, trust_verify_path, +}; +use std::path::{Path, PathBuf}; + +fn repo(rel: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join(rel) +} + +fn test_ca_opts() -> TrustVerifyPeOptions { + TrustVerifyPeOptions { + trusted_ca_files: vec![repo( + "tests/fixtures/devolutions-authenticode/authenticode-test-ca.crt", + )], + // Inside the test certificate's validity window (2022-03-22 .. 2027-03-22). + verification_instant_override: Some(parse_verification_date_ymd("2026-06-01").unwrap()), + ..Default::default() + } +} + +#[test] +fn wsh_scripts_trust_verify() { + for rel in [ + "tests/fixtures/generated-signed/scripts/wsh/js/utf8-crlf.js", + "tests/fixtures/generated-signed/scripts/wsh/js/utf16le-bom-lf.js", + "tests/fixtures/generated-signed/scripts/wsh/vbs/utf8-bom-crlf.vbs", + "tests/fixtures/generated-signed/scripts/wsh/wsf/utf8-crlf.wsf", + "tests/fixtures/generated-signed/scripts/wsh-probe/jse/utf8-crlf.jse", + "tests/fixtures/generated-signed/scripts/wsh-probe/vbe/utf8-crlf.vbe", + ] { + let report = trust_verify_path(&repo(rel), &test_ca_opts()) + .unwrap_or_else(|e| panic!("{rel}: {e:#}")); + assert_eq!(report.pkcs7_entries_verified, 1, "{rel}"); + } +} + +#[test] +fn msix_packages_trust_verify() { + for rel in [ + "tests/fixtures/generated-signed/msix/sample.msix", + "tests/fixtures/generated-signed/msix/sample.appx", + "tests/fixtures/generated-signed/msix/sample.msixbundle", + "tests/fixtures/generated-signed/msix/sample.appxbundle", + ] { + trust_verify_path(&repo(rel), &test_ca_opts()).unwrap_or_else(|e| panic!("{rel}: {e:#}")); + } +} + +#[test] +fn nuget_packages_trust_verify() { + for rel in [ + "tests/fixtures/package-signing/signed/sample.signed.nupkg", + "tests/fixtures/package-signing/signed/sample.signed.snupkg", + "tests/fixtures/package-signing/signed/with-pe.signed.nupkg", + ] { + trust_verify_path(&repo(rel), &test_ca_opts()).unwrap_or_else(|e| panic!("{rel}: {e:#}")); + } +} + +#[test] +fn nuget_package_tamper_is_rejected() { + let mut bytes = std::fs::read(repo( + "tests/fixtures/package-signing/signed/sample.signed.nupkg", + )) + .unwrap(); + // Flip a byte inside the first local file entry's data (well before the signature entry). + bytes[80] ^= 0x01; + let err = + psign_authenticode_trust::trust_verify_nuget_bytes(&bytes, &test_ca_opts()).unwrap_err(); + assert!( + format!("{err:#}").to_lowercase().contains("mismatch") + || format!("{err:#}").contains("ZIP"), + "{err:#}" + ); +} + +#[test] +fn vsix_packages_trust_verify() { + for rel in [ + "tests/fixtures/package-signing/signed/sample.signed.vsix", + "tests/fixtures/package-signing/signed/nested.signed.vsix", + "tests/fixtures/package-signing/signed/deep-nested.signed.vsix", + ] { + trust_verify_path(&repo(rel), &test_ca_opts()).unwrap_or_else(|e| panic!("{rel}: {e:#}")); + } +} + +/// Rewrite a ZIP, letting `edit` change or add entries (name, bytes). +fn rewrite_zip( + src: &[u8], + edit: impl Fn(&str, Vec) -> Vec, + extra: Option<(&str, &[u8])>, +) -> Vec { + use std::io::{Read, Write}; + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(src)).unwrap(); + let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for i in 0..archive.len() { + let mut file = archive.by_index(i).unwrap(); + let name = file.name().to_string(); + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes).unwrap(); + out.start_file(name.clone(), zip::write::FileOptions::default()) + .unwrap(); + out.write_all(&edit(&name, bytes)).unwrap(); + } + if let Some((name, bytes)) = extra { + out.start_file(name, zip::write::FileOptions::default()) + .unwrap(); + out.write_all(bytes).unwrap(); + } + out.finish().unwrap().into_inner() +} + +fn vsix_err(bytes: &[u8]) -> String { + let err = + psign_authenticode_trust::trust_verify_vsix_bytes(bytes, &test_ca_opts()).unwrap_err(); + format!("{err:#}") +} + +#[test] +fn vsix_tampering_is_rejected() { + let signed = std::fs::read(repo( + "tests/fixtures/package-signing/signed/sample.signed.vsix", + )) + .unwrap(); + // Unmodified round trip through the rewriter still verifies. + let same = rewrite_zip(&signed, |_, b| b, None); + psign_authenticode_trust::trust_verify_vsix_bytes(&same, &test_ca_opts()) + .expect("rewritten zip"); + + let tampered = rewrite_zip( + &signed, + |n, mut b| { + if n == "payload.txt" { + b[0] ^= 1; + } + b + }, + None, + ); + assert!(vsix_err(&tampered).contains("digest mismatch for /payload.txt")); + + let injected = rewrite_zip(&signed, |_, b| b, Some(("evil.dll", b"MZ"))); + assert!(vsix_err(&injected).contains("not covered by the signature")); + + let retyped = rewrite_zip( + &signed, + |n, b| { + if n == "[Content_Types].xml" { + String::from_utf8(b) + .unwrap() + .replace("text/plain", "application/octet-stream") + .into_bytes() + } else { + b + } + }, + None, + ); + assert!(vsix_err(&retyped).contains("content type")); + + let resigned = rewrite_zip( + &signed, + |n, b| { + if n.starts_with("package/services/digital-signature/xml-signature/") { + let t = String::from_utf8(b).unwrap(); + // Change a signed byte inside SignedInfo (a DigestValue character). + t.replacen("z", "Z", 1) + .into_bytes() + } else { + b + } + }, + None, + ); + assert!( + vsix_err(&resigned).contains("SignatureValue does not verify"), + "{}", + vsix_err(&resigned) + ); +} diff --git a/crates/psign-digest-cli/src/main.rs b/crates/psign-digest-cli/src/main.rs index 508636f..413b80c 100644 --- a/crates/psign-digest-cli/src/main.rs +++ b/crates/psign-digest-cli/src/main.rs @@ -2139,6 +2139,30 @@ enum Command { #[command(flatten)] shared: TrustVerifySharedArgs, }, + /// PowerShell-class (`.ps1`, `.psm1`, …) or WSH (`.js`, `.jse`, `.vbs`, `.vbe`, `.wsf`) script SIP digest plus PKCS#7 chain to anchors. + TrustVerifyScript { + path: PathBuf, + #[command(flatten)] + shared: TrustVerifySharedArgs, + }, + /// Cleartext MSIX / AppX (and bundle) APPX SIP digest (**`verify-msix`**) plus PKCS#7 chain to anchors. + TrustVerifyMsix { + path: PathBuf, + #[command(flatten)] + shared: TrustVerifySharedArgs, + }, + /// NuGet `.nupkg` / `.snupkg` author signature: package hash over the exact unsigned bytes plus `.signature.p7s` chain to anchors. + TrustVerifyNupkg { + path: PathBuf, + #[command(flatten)] + shared: TrustVerifySharedArgs, + }, + /// VSIX OPC XML signature: C14N `SignedInfo`, part digests and coverage, `SignatureValue`, `` RFC 3161 timestamp, plus signer chain to anchors. + TrustVerifyVsix { + path: PathBuf, + #[command(flatten)] + shared: TrustVerifySharedArgs, + }, /// Print whether embedded PKCS#7 bytes contain **SPC_PE_IMAGE_PAGE_HASHES** attribute OIDs (V1/V2 DER scan). /// /// Outputs `yes` or `no` (does **not** validate page segments vs file bytes — use **`verify-pe-page-hashes`** for the experimental Rust check). @@ -4366,6 +4390,40 @@ where }; println!("{json}"); } + Command::TrustVerifyScript { path, shared } => { + let bytes = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?; + let opts = trust_verify_options_from_shared(&shared)?; + let ext = script_ext_from_path(&path)?; + let report = psign_authenticode_trust::trust_verify_script_bytes(&bytes, ext, &opts) + .with_context(|| format!("trust-verify-script {}", path.display()))?; + print_trust_ok("trust-verify-script", &report); + } + Command::TrustVerifyMsix { path, shared } => { + let bytes = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?; + let opts = trust_verify_options_from_shared(&shared)?; + let ext = path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + let report = psign_authenticode_trust::trust_verify_msix_bytes(&bytes, &ext, &opts) + .with_context(|| format!("trust-verify-msix {}", path.display()))?; + print_trust_ok("trust-verify-msix", &report); + } + Command::TrustVerifyNupkg { path, shared } => { + let bytes = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?; + let opts = trust_verify_options_from_shared(&shared)?; + let report = psign_authenticode_trust::trust_verify_nuget_bytes(&bytes, &opts) + .with_context(|| format!("trust-verify-nupkg {}", path.display()))?; + print_trust_ok("trust-verify-nupkg", &report); + } + Command::TrustVerifyVsix { path, shared } => { + let bytes = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?; + let opts = trust_verify_options_from_shared(&shared)?; + let report = psign_authenticode_trust::trust_verify_vsix_bytes(&bytes, &opts) + .with_context(|| format!("trust-verify-vsix {}", path.display()))?; + print_trust_ok("trust-verify-vsix", &report); + } Command::TrustVerifyCab { path, shared } => { let bytes = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?; let opts = trust_verify_options_from_shared(&shared)?; diff --git a/crates/psign-opc-sign/src/nuget.rs b/crates/psign-opc-sign/src/nuget.rs index b894ee8..72577a0 100644 --- a/crates/psign-opc-sign/src/nuget.rs +++ b/crates/psign-opc-sign/src/nuget.rs @@ -173,11 +173,114 @@ where } pub fn signed_package_unsigned_bytes_path(path: &Path) -> Result> { - let reader = File::open(path).with_context(|| format!("open {}", path.display()))?; - let mut out = std::io::Cursor::new(Vec::new()); - write_package_without_signature_impl(reader, &mut out, true) - .with_context(|| format!("remove NuGet signature from {}", path.display()))?; - Ok(out.into_inner()) + let bytes = std::fs::read(path).with_context(|| format!("read {}", path.display()))?; + signed_package_unsigned_bytes(&bytes) + .with_context(|| format!("remove NuGet signature from {}", path.display())) +} + +fn le16(bytes: &[u8], at: usize) -> Result { + bytes + .get(at..at + 2) + .map(|b| u16::from_le_bytes([b[0], b[1]]) as usize) + .ok_or_else(|| anyhow!("truncated ZIP structure")) +} + +fn le32(bytes: &[u8], at: usize) -> Result { + bytes + .get(at..at + 4) + .map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as usize) + .ok_or_else(|| anyhow!("truncated ZIP structure")) +} + +/// Reconstruct the exact unsigned package bytes a NuGet author signature covers. +/// +/// NuGet appends `.signature.p7s` as the **last** ZIP entry (local header, data, and central +/// directory record), so the signed package hash is computed over the archive with that entry's +/// bytes removed and the end-of-central-directory record adjusted. This is a byte-level cut, not +/// a ZIP rewrite, so it reproduces packages signed by `dotnet nuget sign` / `NuGet.exe sign`. +pub fn signed_package_unsigned_bytes(bytes: &[u8]) -> Result> { + const EOCD_SIG: [u8; 4] = [0x50, 0x4b, 0x05, 0x06]; + const CDFH_SIG: [u8; 4] = [0x50, 0x4b, 0x01, 0x02]; + let search_from = bytes.len().saturating_sub(22 + u16::MAX as usize); + let eocd = bytes[search_from..] + .windows(4) + .rposition(|w| w == EOCD_SIG) + .map(|p| search_from + p) + .ok_or_else(|| anyhow!("ZIP end of central directory not found"))?; + let comment_len = le16(bytes, eocd + 20)?; + if eocd + 22 + comment_len != bytes.len() { + return Err(anyhow!("ZIP end of central directory has trailing data")); + } + let disk_entries = le16(bytes, eocd + 8)?; + let total_entries = le16(bytes, eocd + 10)?; + let cd_size = le32(bytes, eocd + 12)?; + let cd_offset = le32(bytes, eocd + 16)?; + if disk_entries != total_entries || total_entries == 0xffff || cd_offset == 0xffff_ffff { + return Err(anyhow!( + "multi-disk or ZIP64 NuGet packages are not supported for signature verification" + )); + } + if cd_offset.checked_add(cd_size) != Some(eocd) { + return Err(anyhow!( + "ZIP central directory does not end at the EOCD record" + )); + } + + // Walk central directory records, remembering where the signature record sits. + let mut records = Vec::with_capacity(total_entries); + let mut pos = cd_offset; + for _ in 0..total_entries { + if bytes.get(pos..pos + 4) != Some(&CDFH_SIG[..]) { + return Err(anyhow!("invalid ZIP central directory record")); + } + let name_len = le16(bytes, pos + 28)?; + let len = 46 + name_len + le16(bytes, pos + 30)? + le16(bytes, pos + 32)?; + let name = bytes + .get(pos + 46..pos + 46 + name_len) + .ok_or_else(|| anyhow!("truncated ZIP central directory record"))?; + records.push(( + pos, + len, + name == PACKAGE_SIGNATURE_FILE_NAME.as_bytes(), + le32(bytes, pos + 42)?, + )); + pos += len; + } + if pos != eocd { + return Err(anyhow!("ZIP central directory size mismatch")); + } + + let signature_index = records + .iter() + .position(|r| r.2) + .ok_or_else(|| anyhow!("package does not contain {PACKAGE_SIGNATURE_FILE_NAME}"))?; + if records.iter().filter(|r| r.2).count() != 1 || signature_index + 1 != records.len() { + return Err(anyhow!( + "{PACKAGE_SIGNATURE_FILE_NAME} must be the single, last central directory record" + )); + } + let signature_local_offset = records[signature_index].3; + if records[..signature_index] + .iter() + .any(|r| r.3 >= signature_local_offset) + { + return Err(anyhow!( + "{PACKAGE_SIGNATURE_FILE_NAME} must be the last local file entry" + )); + } + + let kept_cd_size = records[signature_index].0 - cd_offset; + let mut out = Vec::with_capacity(signature_local_offset + kept_cd_size + 22 + comment_len); + out.extend_from_slice(&bytes[..signature_local_offset]); + out.extend_from_slice(&bytes[cd_offset..cd_offset + kept_cd_size]); + let mut end = bytes[eocd..].to_vec(); + let entries = (total_entries - 1) as u16; + end[8..10].copy_from_slice(&entries.to_le_bytes()); + end[10..12].copy_from_slice(&entries.to_le_bytes()); + end[12..16].copy_from_slice(&(kept_cd_size as u32).to_le_bytes()); + end[16..20].copy_from_slice(&(signature_local_offset as u32).to_le_bytes()); + out.extend_from_slice(&end); + Ok(out) } pub fn signed_package_signature_content_path( @@ -455,6 +558,15 @@ where #[cfg(test)] mod tests { use super::*; + + #[test] + fn signed_package_unsigned_bytes_reproduces_dotnet_signed_fixture() { + let root = + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/package-signing"); + let signed = std::fs::read(root.join("signed/sample.signed.nupkg")).unwrap(); + let unsigned = std::fs::read(root.join("unsigned/sample.nupkg")).unwrap(); + assert_eq!(signed_package_unsigned_bytes(&signed).unwrap(), unsigned); + } use std::io::{Cursor, Write}; use zip::write::FileOptions; diff --git a/crates/psign-portable-core/src/lib.rs b/crates/psign-portable-core/src/lib.rs index 6402740..b6c678d 100644 --- a/crates/psign-portable-core/src/lib.rs +++ b/crates/psign-portable-core/src/lib.rs @@ -18,9 +18,11 @@ use psign_authenticode_trust::policy::{OnlineTrustOptions, RevocationMode}; use psign_authenticode_trust::{ ArtifactSigningIdentity, ArtifactSigningPolicy, ArtifactSigningVerifiedSignature, AuthenticodeTrustPolicy, TrustVerifyPeOptions, artifact_signing_identity_from_cert_bytes, - inspect_authenticode_pkcs7_der, inspect_pe_authenticode, trust_verify_cab_bytes, - trust_verify_catalog_bytes, trust_verify_msi_bytes, trust_verify_pe_bytes, - trust_verify_script_bytes, trust_verify_zip_bytes, + inspect_authenticode_pkcs7_der, inspect_pe_authenticode, nuget_verified_signature_content, + trust_verify_cab_bytes, trust_verify_catalog_bytes, trust_verify_msi_bytes, + trust_verify_msix_bytes, trust_verify_nuget_bytes, trust_verify_pe_bytes, + trust_verify_script_bytes, trust_verify_vsix_bytes, trust_verify_zip_bytes, + verify_vsix_signature_bytes, }; use psign_sip_digest::pkcs7::AuthenticodeSigningDigest; use psign_sip_digest::verify_pe::{ @@ -926,7 +928,7 @@ pub fn infer_format(path: &Path) -> PortableFileFormat { "ps1" | "psm1" | "psd1" | "ps1xml" | "psc1" | "cdxml" | "mof" => { PortableFileFormat::PowerShellScript } - "vbs" | "js" | "wsf" => PortableFileFormat::WshScript, + "vbs" | "vbe" | "js" | "jse" | "wsf" => PortableFileFormat::WshScript, _ => PortableFileFormat::Unknown, } } @@ -2527,14 +2529,16 @@ fn apply_trust_if_requested( PortableFileFormat::Msi => trust_verify_msi_bytes(data, &opts), PortableFileFormat::Zip => trust_verify_zip_bytes(data, &opts), PortableFileFormat::Catalog => trust_verify_catalog_bytes(data, &opts), - PortableFileFormat::PowerShellScript => { + PortableFileFormat::PowerShellScript | PortableFileFormat::WshScript => { let extension = script_extension_for(&request.path); trust_verify_script_bytes(data, &extension, &opts) } - PortableFileFormat::NuGet - | PortableFileFormat::AppInstaller - | PortableFileFormat::Vsix - | PortableFileFormat::Msix + PortableFileFormat::Msix => { + trust_verify_msix_bytes(data, &script_extension_for(&request.path), &opts) + } + PortableFileFormat::NuGet => trust_verify_nuget_bytes(data, &opts), + PortableFileFormat::Vsix => trust_verify_vsix_bytes(data, &opts), + PortableFileFormat::AppInstaller | PortableFileFormat::MsixUpload | PortableFileFormat::MsixEncrypted | PortableFileFormat::ClickOnceManifest => Err(anyhow::anyhow!( @@ -2854,6 +2858,9 @@ fn inspect_nuget(path: &Path, data: &[u8]) -> Result } // Extract signature and parse CMS let sig_bytes = extract_nuget_signature_p7s(data)?; + if let Err(error) = nuget_verified_signature_content(data, &sig_bytes) { + return Ok(map_digest_error(path, PortableFileFormat::NuGet, error)); + } let summary = summarize_pkcs7_der(&sig_bytes).unwrap_or_default(); Ok(PortableSignatureResponse { schema_version: SCHEMA_VERSION, @@ -2861,7 +2868,7 @@ fn inspect_nuget(path: &Path, data: &[u8]) -> Result format: PortableFileFormat::NuGet, status: PortableSignatureStatus::Valid, status_message: - "NuGet package signature (.signature.p7s) is present; trust was not evaluated." + "NuGet package hash matches the signed .signature.p7s content; trust was not evaluated." .to_string(), trust_status: None, signature_count: 1, @@ -2891,37 +2898,56 @@ fn inspect_vsix_opc(path: &Path, data: &[u8]) -> Result { + let mut response = base_response( + path.to_path_buf(), + PortableFileFormat::Vsix, + PortableSignatureStatus::Valid, + "VSIX OPC XMLDSig signature, part digests, and SignatureValue are valid; trust was not evaluated.", + ); + response.signer_certificate_der_base64 = + Some(base64::engine::general_purpose::STANDARD.encode(sig.signer_certificate_der)); + if sig.timestamp_token.is_some() { + response + .timestamp_kinds + .push("opc_encoded_time".to_string()); + } + Ok(response) + } + Err(standard_error) => { + // Signatures written by earlier psign releases reference parts directly and are not + // verifiable by Visual Studio; keep reporting their part digests for compatibility. + let sig_xml = + psign_opc_sign::vsix::extract_signature_xml_path(path).unwrap_or_default(); + let legacy_ok = !sig_xml.is_empty() + && psign_opc_sign::vsix::verify_signature_reference_xml( + Cursor::new(data), + &sig_xml, + psign_opc_sign::vsix::VsixHashAlgorithm::Sha256, + ) + .is_ok(); + let mut response = if legacy_ok { + base_response( + path.to_path_buf(), + PortableFileFormat::Vsix, + PortableSignatureStatus::Valid, + "Legacy psign VSIX signature references are valid; the signature is not a standard OPC XMLDSig signature and cannot be trust-verified.", + ) + } else { + base_response( + path.to_path_buf(), + PortableFileFormat::Vsix, + PortableSignatureStatus::HashMismatch, + format!("VSIX OPC XMLDSig signature is invalid: {standard_error:#}"), + ) + }; + response + .diagnostics + .push(format!("standard_xmldsig=failed: {standard_error:#}")); + Ok(response) + } } - // Verify reference digests - let vsix_alg = psign_opc_sign::vsix::VsixHashAlgorithm::Sha256; - let refs_ok = - psign_opc_sign::vsix::verify_signature_reference_xml(Cursor::new(data), &sig_xml, vsix_alg) - .is_ok(); - let status = if refs_ok { - PortableSignatureStatus::Valid - } else { - PortableSignatureStatus::HashMismatch - }; - let message = if refs_ok { - "VSIX OPC XMLDSig signature references are valid; trust was not evaluated." - } else { - "VSIX OPC XMLDSig signature reference digests do not match package content." - }; - Ok(base_response( - path.to_path_buf(), - PortableFileFormat::Vsix, - status, - message, - )) } fn inspect_clickonce_manifest(path: &Path, data: &[u8]) -> Result { @@ -2998,13 +3024,9 @@ fn inspect_script(path: &Path, data: &[u8]) -> Result let ext = script_extension_for(path); match verify_script_digest_consistency(data, &ext) { Ok(()) => { - let report = if ps_script::is_wsh_extension(&ext.to_ascii_lowercase()) { - None - } else { - ps_script::powershell_class_digest_report(data, &ext) - .ok() - .and_then(|r| summarize_pkcs7_der(&r.pkcs7_der)) - }; + let report = psign_sip_digest::script_digest_report(data, &ext) + .ok() + .and_then(|r| summarize_pkcs7_der(&r.pkcs7_der)); let summary = report.unwrap_or_default(); Ok(PortableSignatureResponse { schema_version: SCHEMA_VERSION, diff --git a/crates/psign-sip-digest/src/lib.rs b/crates/psign-sip-digest/src/lib.rs index ce743ad..4bd2bcd 100644 --- a/crates/psign-sip-digest/src/lib.rs +++ b/crates/psign-sip-digest/src/lib.rs @@ -24,6 +24,19 @@ pub mod zip_authenticode; use anyhow::Result; /// PowerShell-class (`pwrshsip.dll`) or WSH (`wshext.dll`) script digest vs PKCS#7 indirect data. +/// Verified script digest binding (PKCS#7 plus digests) for PowerShell-class or WSH scripts. +pub fn script_digest_report( + raw: &[u8], + ext: &str, +) -> Result { + let ext_l = ext.to_ascii_lowercase(); + if ps_script::is_wsh_extension(&ext_l) { + wsh_script::wsh_digest_report(raw, &ext_l) + } else { + ps_script::powershell_class_digest_report(raw, &ext_l) + } +} + pub fn verify_script_digest_consistency(raw: &[u8], ext: &str) -> Result<()> { let ext_l = ext.to_ascii_lowercase(); if ps_script::is_wsh_extension(&ext_l) { diff --git a/crates/psign-sip-digest/src/msix_digest.rs b/crates/psign-sip-digest/src/msix_digest.rs index 5a3aa36..4dde6c0 100644 --- a/crates/psign-sip-digest/src/msix_digest.rs +++ b/crates/psign-sip-digest/src/msix_digest.rs @@ -1884,7 +1884,21 @@ pub fn verify_msix_digest_consistency(path: &Path) -> Result<()> { } let buf = std::fs::read(path)?; - verify_msix_digest_consistency_bytes(&buf, &ext) + verify_msix_digest_consistency_bytes(&buf, &ext).map(|_| ()) +} + +/// Verify the APPX digest binding of a cleartext package and return its PKCS#7 (without the +/// `PKCX` prefix) and the verified APPX `messageDigest` blob, for trust verification. +/// +/// `ext` is the lowercase extension without the dot (`msix`, `appx`, `msixbundle`, `appxbundle`). +pub fn msix_verified_signature(buf: &[u8], ext: &str) -> Result<(Vec, Vec)> { + let ext = ext.to_ascii_lowercase(); + if !matches!(ext.as_str(), "msix" | "appx" | "msixbundle" | "appxbundle") { + return Err(anyhow!( + "Rust MSIX SIP digest check applies only to cleartext `.msix` / `.appx` / `.msixbundle` / `.appxbundle` files" + )); + } + verify_msix_digest_consistency_bytes(buf, &ext) } /// Fast `--skip-signed` preflight: does this OPC/ZIP package already carry an `AppxSignature.p7x` part? @@ -1970,7 +1984,7 @@ pub fn msix_authenticode_digest_blob( Ok((kind, blob)) } -fn verify_msix_digest_consistency_bytes(buf: &[u8], ext: &str) -> Result<()> { +fn verify_msix_digest_consistency_bytes(buf: &[u8], ext: &str) -> Result<(Vec, Vec)> { let tail = parse_zip_tail(buf)?; let mut layout_archive = ZipArchive::new(Cursor::new(buf))?; validate_zip_file_layout(buf, &mut layout_archive, &tail)?; @@ -2040,15 +2054,21 @@ fn verify_msix_digest_consistency_bytes(buf: &[u8], ext: &str) -> Result<()> { check("AXCD (central directory)", &cd_hash, &parts.axcd)?; check("AXCT ([Content_Types].xml)", &ct_hash, &parts.axct)?; check("AXBM (AppxBlockMap.xml)", &bm_hash, &parts.axbm)?; - if let (Some(ch), Some(sig_ci)) = (&ci_hash, &parts.axci) { - check("AXCI (CodeIntegrity.cat)", ch, sig_ci)?; + match (&ci_hash, &parts.axci) { + (Some(ch), Some(sig_ci)) => check("AXCI (CodeIntegrity.cat)", ch, sig_ci)?, + (None, None) => {} + _ => { + return Err(anyhow!( + "MSIX Authenticode AXCI presence mismatch (CodeIntegrity.cat vs PKCS#7 APPX blob)" + )); + } } validate_block_map_file_hashes(buf, kind, &block_map_files)?; if matches!(ext, "msixbundle" | "appxbundle") { verify_bundle_child_packages(buf)?; } - Ok(()) + Ok((pkcs7.to_vec(), embedded.to_vec())) } #[cfg(test)] diff --git a/crates/psign-sip-digest/src/wsh_script.rs b/crates/psign-sip-digest/src/wsh_script.rs index adab342..6b0c6d3 100644 --- a/crates/psign-sip-digest/src/wsh_script.rs +++ b/crates/psign-sip-digest/src/wsh_script.rs @@ -11,7 +11,9 @@ //! ANSI-only or exotic encodings. use crate::pe_digest::PeAuthenticodeHashKind; -use crate::ps_script::{file_utf16_units, hash_payload, utf16le_bytes}; +use crate::ps_script::{ + ScriptAuthenticodeDigestReport, file_utf16_units, hash_payload, utf16le_bytes, +}; use anyhow::{Result, anyhow}; use authenticode::AuthenticodeSignature; use base64::Engine; @@ -189,6 +191,11 @@ fn extract_pkcs7_der(units: &[u16], kind: WshKind) -> Result> { } pub fn verify_wsh_digest_consistency(raw: &[u8], ext: &str) -> Result<()> { + wsh_digest_report(raw, ext).map(|_| ()) +} + +/// Verify the WSH digest binding and return the PKCS#7 with embedded and recomputed digests. +pub fn wsh_digest_report(raw: &[u8], ext: &str) -> Result { let kind = wsh_kind_from_ext(ext).ok_or_else(|| anyhow!("not a WSH script extension .{ext}"))?; let mut units = file_utf16_units(raw); @@ -209,7 +216,11 @@ pub fn verify_wsh_digest_consistency(raw: &[u8], ext: &str) -> Result<()> { "WSH Authenticode digest mismatch (wshext strip + offset dword vs PKCS#7)" )); } - Ok(()) + Ok(ScriptAuthenticodeDigestReport { + embedded_digest: embedded.to_vec(), + computed_digest: computed.to_vec(), + pkcs7_der: pkcs7, + }) } #[cfg(test)] diff --git a/docs/artifact-signing-validation.md b/docs/artifact-signing-validation.md index 3d83c24..1d7743d 100644 --- a/docs/artifact-signing-validation.md +++ b/docs/artifact-signing-validation.md @@ -155,20 +155,20 @@ If Microsoft adds another Artifact Signing root, add a `PinnedRoot` entry to `PU ## Where the policy runs -The policy lives in one place: `TrustVerifyPeOptions::artifact_signing` ([`trust_verify_pe.rs`](../crates/psign-authenticode-trust/src/trust_verify_pe.rs)). When it is set, every `trust_verify_*` entry point swaps its generic anchor handling for the Artifact Signing check. That covers PE/WinMD, CAB, MSI/MSP, WIM/ESD, catalog, ZIP Authenticode, PowerShell-class scripts, and detached PKCS#7. All callers reach it through the same functions: +The policy lives in one place: `TrustVerifyPeOptions::artifact_signing` ([`trust_verify_pe.rs`](../crates/psign-authenticode-trust/src/trust_verify_pe.rs)). When it is set, every `trust_verify_*` entry point swaps its generic anchor handling for the Artifact Signing check. That covers PE/WinMD, CAB, MSI/MSP, WIM/ESD, catalogs, ZIP Authenticode, PowerShell and WSH scripts, cleartext MSIX/AppX packages and bundles, NuGet packages, VSIX packages, and detached PKCS#7 (see [Format notes](#format-notes)). All callers reach it through the same functions: | Surface | How it reaches the policy | |---|---| -| `psign-tool verify --artifact-signing…`, **Windows mode** | `WinVerifyTrust` first (forced to the Authenticode `/pa` policy unless `--policy` is given, because Artifact Signing certificates never satisfy the default driver policy), then [`artifact_signing_verify::verify_targets`](../src/artifact_signing_verify.rs) → `trust_verify_path`. | +| `psign-tool verify --artifact-signing…`, **Windows mode** | `WinVerifyTrust` first (forced to the Authenticode `/pa` policy unless `--policy` is given, because Artifact Signing certificates never satisfy the default driver policy), then [`artifact_signing_verify::verify_targets`](../src/artifact_signing_verify.rs) → `trust_verify_path`. NuGet and VSIX have no Windows SIP, so they skip `WinVerifyTrust`. | | `psign-tool verify --artifact-signing…`, **portable mode** | The same `verify_targets` call, in place of the generic AuthRoot trust. `--policy pa` is accepted, so one command line works in both modes. | -| `psign-tool portable trust-verify-{pe,cab,msi,esd,catalog,zip,detached} --artifact-signing…` | The shared portable trust flags build the same `TrustVerifyPeOptions`. | +| `psign-tool portable trust-verify-{pe,cab,msi,esd,catalog,zip,detached,script,msix,nupkg,vsix} --artifact-signing…` | The shared portable trust flags build the same `TrustVerifyPeOptions`. | | PowerShell `Test-PsignArtifactSigning`, `Get-PsignSignature -ArtifactSigning…` | The `artifact_signing` object in the `psign_core_get_signature` request ([portable-core-ffi.md](portable-core-ffi.md)). | Windows mode can only be *stricter* than portable mode: a file must pass both WinVerifyTrust and the Artifact Signing check. For a genuine Artifact Signing signature both pass. The CLI tests in [`tests/artifact_signing_cli.rs`](../tests/artifact_signing_cli.rs) assert the same accept/reject outcome in both modes, using a real sample when `PSIGN_ARTIFACT_SIGNING_SAMPLE` is set. ## Validation algorithm -For **every** signature the format exposes (each PE certificate-table entry, or the single PKCS#7 of the other formats), all of these must pass: +For **every** signature the format exposes (each PE certificate-table entry, or the single signature of the other formats), all of these must pass. Step 1 and the chain/timestamp sources differ per format; see [Format notes](#format-notes). 1. **Digest binding.** Recompute the subject digest with the format's SIP logic and require it to equal the PKCS#7 binding. That is the indirect `SpcIndirectDataContent` digest for Authenticode formats, and the PKCS#9 `messageDigest` for catalogs and CMS-detached signatures. 2. **Identity.** Take the certificate of the first `SignerInfo` and decode its Artifact Signing identity (see above). Fail if: @@ -240,7 +240,7 @@ Sample output in Windows mode (portable mode prints only the Artifact Signing bl ```text Successfully verified: ./MyApp.exe Artifact Signing verified: ./MyApp.exe - PKCS#7[0] C=CA,ST=Québec,L=Lavaltrie,O=Devolutions Inc,CN=Devolutions Inc publisher_id=285218949.549339218.252840042.443064405 kind=public_trust root=f40042e2e5f7e8ef8189fed15519aece42c3bfa2 time=2026-10-23T18:43:55Z (timestamp) + signature[0] C=CA,ST=Québec,L=Lavaltrie,O=Devolutions Inc,CN=Devolutions Inc publisher_id=285218949.549339218.252840042.443064405 kind=public_trust root=f40042e2e5f7e8ef8189fed15519aece42c3bfa2 time=2026-10-23T18:43:55Z (timestamp) ``` ### PowerShell (`Devolutions.Psign`) @@ -283,6 +283,21 @@ Other entry points: - `verify_artifact_signing_pkcs7(pkcs7_der, index, subject_digest, binding, &policy, &opts)` for a PKCS#7 whose subject digest you computed yourself. - `artifact_signing_identity_from_certificate` / `_from_cert_bytes` / `_from_pkcs7_der` / `artifact_signing_identities_from_pe` for identity extraction only. +## Format notes + +| Format | Signature binding checked before the policy | Signer chain / timestamp source | +|---|---|---| +| PE/WinMD, CAB, MSI/MSP, WIM/ESD, ZIP | Format SIP digest vs `SpcIndirectDataContent` | PKCS#7 bag; RFC 3161 unsigned attribute | +| PowerShell scripts, WSH scripts (`.js`, `.jse`, `.vbs`, `.vbe`, `.wsf`) | `pwrshsip` / `wshext` digest of the stripped script | PKCS#7 bag; RFC 3161 unsigned attribute | +| MSIX/AppX and bundles | APPX blob: AXPC/AXCD/AXCT/AXBM (+ AXCI when `CodeIntegrity.cat` exists, on both sides), block map file hashes, bundle children | `AppxSignature.p7x` bag; RFC 3161 unsigned attribute | +| Catalogs, detached PKCS#7 | PKCS#9 `messageDigest` over the content | PKCS#7 bag; RFC 3161 unsigned attribute | +| NuGet (`.nupkg`, `.snupkg`) | Package hash over the **exact** unsigned bytes: the `.signature.p7s` entry must be the last ZIP entry, and is cut out byte for byte, as NuGet does. The signature content (`Version:1`, `-Hash:`) must be embedded in the CMS and match, with PKCS#9 `messageDigest` over it | `.signature.p7s` bag; RFC 3161 unsigned attribute | +| VSIX | OPC XMLDSig: C14N 1.0 / exclusive C14N `SignedInfo` and `SignatureValue` (RSA SHA-256/384/512); `#idPackageObject` digest; every `Manifest` part digest (raw, C14N, or OPC RelationshipTransform). Every package part except `[Content_Types].xml` and the signature part must be signed, and each reference's `ContentType` must match `[Content_Types].xml` | `KeyInfo` certificates; RFC 3161 token in `` (dotnet/sign), imprint over the raw `SignatureValue` octets | + +VSIX signatures from dotnet/sign carry only the leaf certificate in `KeyInfo`, so building the Public Trust chain needs the issuing CAs from their AIA URLs. Pass `--online-aia` (fetched certificates are still validated against the pinned root). NuGet, MSIX and the PKCS#7 formats embed the chain. + +Windows mode runs `WinVerifyTrust` for every format that has a Windows SIP, including MSIX/AppX and WSH. NuGet and VSIX have no SIP, so Windows mode applies only the shared check to them. + ## Other places the identity is used - **WDAC / App Control policies** can pin the identity EKU directly. That is the Windows-native equivalent of `--artifact-signing-publisher-id`. @@ -292,7 +307,8 @@ Other entry points: ## Limitations - **Revocation is opt-in.** It is off by default because it needs network access. Use `--revocation-mode require` to fail closed. Microsoft revokes individual daily certificates on confirmed misuse, so require mode is recommended where network access is available. The Artifact Signing signer certificates publish a CRL distribution point but no OCSP URL, so CRLs do the work. Microsoft PKI CRLs are signed with `sha384WithRSAEncryption`, which the portable verifier supports along with SHA-256 and SHA-512. RSA-PSS and ECDSA-signed CRLs and OCSP responses are not supported. Revocation status is checked against the **current** CRL, not as of the timestamp time. -- **Formats.** PE/WinMD, CAB, MSI/MSP, WIM/ESD, catalogs, ZIP Authenticode, PowerShell-class scripts, and detached PKCS#7 are covered. MSIX/AppX, NuGet, VSIX, ClickOnce, and WSH scripts (`.js`, `.vbs`, `.wsf`) are rejected with the Artifact Signing options, because they have no portable trust verifier yet. In the PowerShell module, WIM/ESD and detached PKCS#7 also have no portable trust path yet. +- **Formats.** Everything in [Format notes](#format-notes) is covered. ClickOnce manifests, App Installer descriptors, encrypted MSIX, and MSIX/AppX upload containers are rejected with the Artifact Signing options. In the PowerShell module, WIM/ESD and detached PKCS#7 have no portable trust path. +- **psign-written legacy signatures.** VSIX signatures written by psign's own VSIX signer reference parts directly, without the OPC `Object`/`Manifest`, and are not valid OPC XMLDSig. Inspection reports them, but trust and Artifact Signing verification reject them. NuGet signatures written by `Set-PsignSignature` use a detached CMS; they are accepted when the rebuilt signature content matches the signed digest, but NuGet clients expect the content embedded. - **Catalog database lookup.** `verify --catalog-search` / `--catalog-database-guid` cannot be combined with the policy. Pass the catalog explicitly with `--catalog`; its signature is then checked, and member hashes are verified as usual. - **Nested signatures.** Only certificate-table entries are checked. Nested signatures (`1.3.6.1.4.1.311.2.4.1`) inside an entry are not evaluated as separate Artifact Signing signatures. - **Root subtree.** The pinned root also issues non-Artifact-Signing CAs (for example the timestamping CA). Requiring the identity EKU, the Public Trust marker, and the code signing EKU on the signer is what limits acceptance to Artifact Signing. Intermediates are deliberately not pinned (see above). diff --git a/docs/authenticode-trust-stack.md b/docs/authenticode-trust-stack.md index d9fbda0..467ee08 100644 --- a/docs/authenticode-trust-stack.md +++ b/docs/authenticode-trust-stack.md @@ -11,7 +11,7 @@ This describes how **`crates/psign-authenticode-trust`** composes crates for **L | CMS Authenticode rules + X.509 chain verification | **`picky`** (`AuthenticodeSignature`, `authenticode_verifier`, `Cert::verifier`) | Validate **`messageDigest`** vs provided digest, signature over authenticated attributes, TBSCertificate signatures along **`issuer_chain`**, Basic Constraints / dates / EKU policy hooks. | | Trust anchors | This crate (**`anchor`**, **`authroot_cache`**, **`authroot_cab`**, **`authroot_ctl`**) | Phase A: load **`*.crt`** / **`*.cer`** / **`*.pem`** from **`--anchor-dir`** or repeatable **`--trusted-ca`** files. Phase B: automatically cache Microsoft **`authrootstl.cab`** at **`~/.psign/authroot/`** when no replacing explicit anchors are supplied, then parse CAB **`*.stl`** → PKCS#7 **`SignedData`** **`eContent`** CTL **SHA-1 subject identifiers** plus PKCS#7-embedded certs. Repeatable **`--additional-trusted-ca`** files augment either selected trust set without suppressing automatic AuthRoot discovery. | | Policy knobs | **`policy::AuthenticodeTrustPolicy`** | Default **strict** code-signing EKU; CLI **`allow-loose-signing-cert`**, **`--prefer-timestamp-signing-time`** / **`--require-valid-timestamp`** (see [**Verification instant / timestamps**](#verification-instant--timestamps)), **`--as-of YYYY-MM-DD`** for **`exact_date`**. | -| Portable CLI | **`psign-tool portable`** | **`trust-verify-pe`**, **`trust-verify-cab`**, **`trust-verify-catalog`**, **`trust-verify-detached`** share anchor, AuthRoot CAB/cache, AIA, OCSP, CRL revocation, timestamp-policy, and chain-diagnostic flags; detached uses [`pkcs7_wire::normalize_pkcs7_der_for_authenticode`](../crates/psign-sip-digest/src/pkcs7_wire.rs). **`inspect-authenticode`** emits JSON for PKCS#7 signers, timestamp-related OIDs, and nested signatures (**`1.3.6.1.4.1.311.2.4.1`**). Unified **`psign-tool --mode portable verify`** uses the portable trust commands by default for supported formats when automatic AuthRoot is enabled; **`PSIGN_NO_AUTO_TRUST=1`** restores digest-only routing unless explicit trust inputs are present. | +| Portable CLI | **`psign-tool portable`** | **`trust-verify-script`** (PowerShell and WSH), **`trust-verify-msix`**, **`trust-verify-nupkg`**, **`trust-verify-vsix`** (OPC XMLDSig with C14N, manifest coverage, `` timestamps), **`trust-verify-pe`**, **`trust-verify-cab`**, **`trust-verify-catalog`**, **`trust-verify-detached`** share anchor, AuthRoot CAB/cache, AIA, OCSP, CRL revocation, timestamp-policy, and chain-diagnostic flags; detached uses [`pkcs7_wire::normalize_pkcs7_der_for_authenticode`](../crates/psign-sip-digest/src/pkcs7_wire.rs). **`inspect-authenticode`** emits JSON for PKCS#7 signers, timestamp-related OIDs, and nested signatures (**`1.3.6.1.4.1.311.2.4.1`**). Unified **`psign-tool --mode portable verify`** uses the portable trust commands by default for supported formats when automatic AuthRoot is enabled; **`PSIGN_NO_AUTO_TRUST=1`** restores digest-only routing unless explicit trust inputs are present. | | Azure Artifact Signing policy | This crate **`artifact_signing`** | Decodes the certificate-profile identity EKU (**`1.3.6.1.4.1.311.97.*`**, hand-decoded because some arcs exceed **`const-oid`** limits) into a durable **publisher ID**, and verifies PKCS#7 with anchors pinned to the embedded **Microsoft Identity Verification Root CA 2020** (signer and TSA chains) or caller-supplied Private Trust roots. Validity is checked at the trusted timestamp. Enabled through **`TrustVerifyPeOptions::artifact_signing`** for every **`trust_verify_*`** format; CLI **`verify --artifact-signing…`** (both modes), **`trust-verify-* --artifact-signing…`**, **`artifact-signing-publisher-id`**. See [**artifact-signing-validation.md**](artifact-signing-validation.md). | | CMS inspection (no trust decision) | This crate **`inspect`** | Uses **`cms`** **`SignedData`** + **`authenticode`** digest probe; complements picky **`trust_*`** paths. See [**psa-interoperability.md**](psa-interoperability.md). | diff --git a/docs/gap-analysis-signing-platforms.md b/docs/gap-analysis-signing-platforms.md index 07ad17f..8028c22 100644 --- a/docs/gap-analysis-signing-platforms.md +++ b/docs/gap-analysis-signing-platforms.md @@ -47,7 +47,7 @@ This inventory starts from the in-tree supported formats, then expands to inbox | **AppX extension SIP chain** | Delegates to installed `ExtensionsSip*` providers. | No bundled/provider-specific parity coverage; behavior depends on optional third-party SIP DLLs. | Not implemented. | No extension-provider discovery, DLL contract, or portable provider model. | | **Standalone P7X / PKCX** (`.p7x`) | OS `P7xSip*` can participate when registered; real package signatures are produced as `AppxSignature.p7x` inside signed AppX/MSIX packages. | Direct standalone `.p7x` signing is rejected by current SignTool. | `inspect-pkcs7` accepts raw PKCS#7, bare `SignedData`, and PKCX-wrapped `AppxSignature.p7x`; `extract-pkcx-pkcs7` strips the PKCX wrapper; detached trust remains available through `trust-verify-detached` when caller supplies the detached content. | No standalone `.p7x` signing/export flow mapped to native `/p7*` switches. | | **PowerShell-class scripts** (`.ps1`, `.psm1`, `.psd1`, `.ps1xml`, `.psc1`, `.cdxml`, `.mof`) | Sign/verify through `pwrshsip.dll`; parity fixtures cover `.ps1`, `.psm1`, `.psd1`. | Need parity fixtures and format detection for `.ps1xml`, `.psc1`, `.cdxml`, `.mof`. | `verify-script` digest consistency for PowerShell-style markers. | No signing/embed; digest remains heuristic for every malformed block and encoding edge case. | -| **WSH scripts** (`.js`, `.jse`, `.vbs`, `.vbe`, `.wsf`) | Sign/verify through `wshext.dll`; parity fixtures cover `.js`, `.vbs`, `.wsf`. | Need `.jse` and `.vbe` parity coverage. | `verify-script` digest consistency for WSH markers. | No signing/embed; native COM text conversion and unusual encodings may diverge. | +| **WSH scripts** (`.js`, `.jse`, `.vbs`, `.vbe`, `.wsf`) | Sign/verify through `wshext.dll`; parity fixtures cover `.js`, `.vbs`, `.wsf`. | Need `.jse` and `.vbe` parity coverage. | `verify-script` digest consistency for WSH markers; `trust-verify-script` PKCS#7 trust (including Azure Artifact Signing). | No signing/embed; native COM text conversion and unusual encodings may diverge. | | **Office / VBA macro projects** | Delegates to installed `mso.dll` / `VBE7.DLL` SIP when present. | No direct Office/VBA CLI affordance or parity fixture set; depends on installed Office components. | Not implemented. | No VBA project graph hashing; likely needs VBE7/Office FFI or permanent OS delegation. | ### Adjacent Windows code-signing ecosystems, not normal Authenticode SIP parity @@ -56,8 +56,8 @@ This inventory starts from the in-tree supported formats, then expands to inbox |---------|-----------------------|-------------------|------------------------|--------------------| | **RDP files** (`.rdp`) | Implemented `rdp` path using Windows certificate stores. | Mostly fixture breadth and native `rdpsign.exe` output-shape parity. | Implemented `portable rdp` with local cert/key or external detached PKCS#7. | No Windows store selection or native `rdpsign.exe` integration by design. | | **App Installer descriptors** (`.appinstaller`) | Direct embedded signing is rejected by current SignTool; descriptor signing can be represented as unsigned XML plus a PKCS#7 companion artifact generated with SignTool `/p7`. | No full XML+companion signing/verification UX or native parity wrapper. | `appinstaller-info` inspects descriptor metadata and companion `.p7`; `appinstaller-set-publisher` updates namespace-aware MainPackage/MainBundle publisher metadata; `appinstaller-sign-companion` creates a local RSA/SHA-2 detached PKCS#7 companion with optional RFC3161 timestamping; `appinstaller-sign-companion-prehash` + `appinstaller-sign-companion-from-signature` assemble companion CMS from external RSA signatures; detached trust primitives verify the XML plus companion signature; `psign-tool code` creates top-level and nested companion signatures with local cert/key, PFX, portable cert-store SHA-1, Azure Key Vault, or Artifact Signing identity, and nested ZIP orchestration writes descriptor-local `.p7` companions. | No App Installer-specific policy checks. | -| **NuGet packages** (`.nupkg`, `.snupkg`) | Not a `signtool`/WinTrust SIP target in this repo. | No `nuget sign`-compatible author/repository signing workflow in Windows mode. | `psign-opc-sign` groundwork: marker inspection, unsigned package digest, NuGet v1 signature-content generation/verification, local RSA/SHA-2 CMS generation for signature content with RFC3161 timestamp tokens, external-signer CMS assembly via `nupkg-signature-pkcs7-prehash` + `nupkg-signature-pkcs7-from-signature`, `.signature.p7s` embed/overwrite, one-step local `nupkg-sign`, embedded `.signature.p7s` package hash + explicit-anchor CMS verification, top-level `psign-tool code` execution from local cert/key, PFX, portable cert-store SHA-1, Azure Key Vault, or Artifact Signing identity, package-native nested execution when embedded in VSIX/ZIP containers, nested PE/WinMD signing before package signatures, nested exclude filters, `--skip-signed`, and `--overwrite`. | No repository signatures, full NuGet policy verification, or non-PE Authenticode nested payload signing. | -| **VSIX packages** (`.vsix`) | Not a first-class Windows-mode signing surface here. | No VSIX package signing/verification workflow. | Signature marker inspection, signature XML embed primitive with OPC signature content-type and relationship metadata, deterministic XMLDSig Reference/DigestValue generation/verification for package parts, local RSA/SHA-2 XMLDSig `SignatureValue` generation/verification, external-signer XMLDSig assembly via `vsix-signature-xml-prehash` + `vsix-signature-xml-from-signature`, one-step local `vsix-sign`, embedded OPC XMLDSig verification with optional explicit-anchor signer chain validation, top-level `psign-tool code` execution from local cert/key, PFX, portable cert-store SHA-1, Azure Key Vault, or Artifact Signing identity, package-native nested VSIX/ZIP -> NuGet/VSIX -> PE/WinMD execution, nested exclude filters, `--skip-signed`, and `--overwrite`. | No timestamping or non-PE Authenticode nested payload signing. | +| **NuGet packages** (`.nupkg`, `.snupkg`) | Not a `signtool`/WinTrust SIP target in this repo. | No `nuget sign`-compatible author/repository signing workflow in Windows mode. | `psign-opc-sign` groundwork: marker inspection, unsigned package digest, NuGet v1 signature-content generation/verification, local RSA/SHA-2 CMS generation for signature content with RFC3161 timestamp tokens, external-signer CMS assembly via `nupkg-signature-pkcs7-prehash` + `nupkg-signature-pkcs7-from-signature`, `.signature.p7s` embed/overwrite, one-step local `nupkg-sign`, embedded `.signature.p7s` package hash over the exact unsigned bytes (matching `dotnet nuget sign`) + explicit-anchor or Azure Artifact Signing CMS verification (`trust-verify-nupkg`), top-level `psign-tool code` execution from local cert/key, PFX, portable cert-store SHA-1, Azure Key Vault, or Artifact Signing identity, package-native nested execution when embedded in VSIX/ZIP containers, nested PE/WinMD signing before package signatures, nested exclude filters, `--skip-signed`, and `--overwrite`. | No repository signatures, full NuGet policy verification, or non-PE Authenticode nested payload signing. | +| **VSIX packages** (`.vsix`) | Not a first-class Windows-mode signing surface here. | No VSIX package signing/verification workflow. | Signature marker inspection, signature XML embed primitive with OPC signature content-type and relationship metadata, deterministic XMLDSig Reference/DigestValue generation/verification for package parts, local RSA/SHA-2 XMLDSig `SignatureValue` generation/verification, external-signer XMLDSig assembly via `vsix-signature-xml-prehash` + `vsix-signature-xml-from-signature`, one-step local `vsix-sign`, standards-based OPC XMLDSig trust verification (`trust-verify-vsix`: C14N `SignedInfo`, `Object`/`Manifest` part digests with RelationshipTransform, part coverage and content types, `` RFC3161 timestamps, explicit-anchor or Azure Artifact Signing signer chains), top-level `psign-tool code` execution from local cert/key, PFX, portable cert-store SHA-1, Azure Key Vault, or Artifact Signing identity, package-native nested VSIX/ZIP -> NuGet/VSIX -> PE/WinMD execution, nested exclude filters, `--skip-signed`, and `--overwrite`. | No timestamping or non-PE Authenticode nested payload signing; psign's own VSIX signer still writes a legacy non-OPC signature layout. | | **ClickOnce / VSTO manifests** (`.manifest`, `.application`, `.vsto`, `.deploy` workflows) | Not implemented. | No `mage.exe`/manifest XMLDSig workflow, certificate embedding, or timestamping. | `psign-tool code --dry-run` classifies ClickOnce/VSTO workflow nodes; portable helpers inspect/copy `.deploy` payloads; guarded `psign-tool code` execution signs PE-like `.deploy` payloads and `.manifest` / `.application` / `.vsto` XMLDSig manifests with local cert/key; portable helpers update and verify manifest file size/digest references; `clickonce-sign-manifest` / `clickonce-sign-manifest-prehash` / `clickonce-sign-manifest-from-signature` / `clickonce-verify-manifest-signature` provide deterministic portable structural local/external XMLDSig signing with embedded signer certificate. | Full Mage-compatible canonicalization/policy, timestamping, full deployment graph orchestration, and ClickOnce/VSTO policy checks remain. | | **Business Central `.app`** | Format-specific behavior is not implemented. | No confirmed NAVX signing/verification workflow. | `business-central-app-info` detects NAVX headers, `psign-tool code --dry-run` classifies NAVX `.app` files, and signing execution now reports a Business Central-specific unsupported diagnostic instead of silently treating them as generic files. | Actual package signing and verification policy remain pending format confirmation. | | **File catalog authoring** | Can sign/verify an existing `.cat` at the Authenticode layer. | No catalog creation from arbitrary file sets or INF/driver package metadata. | `sign-catalog` authors generic CTL catalogs; catalog PKCS#7 consistency/trust and explicit `verify-catalog-member` cover committed MakeCat-style and psign-authored generic catalogs. | Driver/INF policy, OS catalog database search, and MakeCat byte-for-byte output remain out of scope. | diff --git a/docs/migration-artifact-signing.md b/docs/migration-artifact-signing.md index afb4be0..d64ed33 100644 --- a/docs/migration-artifact-signing.md +++ b/docs/migration-artifact-signing.md @@ -238,7 +238,7 @@ psign-tool portable trust-verify-pe ./MyApp.exe \ ### Artifact Signing-specific trust (pinned Microsoft root + publisher ID) -**`trust-verify-pe`** accepts any root in AuthRoot. To require that a file was signed **by Azure Artifact Signing**, and optionally by one specific certificate profile, add the **`--artifact-signing…`** options. They behave the same in Windows and portable modes and across PE, CAB, MSI, WIM/ESD, catalog, ZIP, PowerShell script, and detached signatures: +**`trust-verify-pe`** accepts any root in AuthRoot. To require that a file was signed **by Azure Artifact Signing**, and optionally by one specific certificate profile, add the **`--artifact-signing…`** options. They behave the same in Windows and portable modes and across PE, CAB, MSI, WIM/ESD, catalog, ZIP, PowerShell and WSH script, MSIX/AppX, NuGet, VSIX, and detached signatures: ```bash # Publisher ID (certificate profile identity EKU suffix) of each signer; no trust decision. diff --git a/docs/psign-cli-matrix.json b/docs/psign-cli-matrix.json index f8afbee..3df7b8d 100644 --- a/docs/psign-cli-matrix.json +++ b/docs/psign-cli-matrix.json @@ -261,6 +261,10 @@ {"name": "trust-verify-msi", "maps_to_native_concept": "`verify-msi` digest consistency then same trust stack as PE"}, {"name": "trust-verify-esd", "maps_to_native_concept": "`verify-esd` digest consistency then same trust stack as PE"}, {"name": "trust-verify-catalog", "maps_to_native_concept": "`verify-catalog` CMS digest consistency then picky trust when Authenticode-wrapped"}, + {"name": "trust-verify-script", "maps_to_native_concept": "PowerShell-class or WSH (`.js`, `.jse`, `.vbs`, `.vbe`, `.wsf`) script SIP digest then same trust stack as PE"}, + {"name": "trust-verify-msix", "maps_to_native_concept": "`verify-msix` APPX digest (cleartext `.msix`/`.appx`/bundles) then same trust stack as PE"}, + {"name": "trust-verify-nupkg", "maps_to_native_concept": "NuGet author signature: package hash over the exact unsigned package bytes, then `.signature.p7s` CMS chain to anchors"}, + {"name": "trust-verify-vsix", "maps_to_native_concept": "VSIX OPC XMLDSig: C14N SignedInfo, Manifest part digests/coverage/content types, SignatureValue, `` RFC3161 timestamp, signer chain to anchors"}, {"name": "trust-verify-detached", "maps_to_native_concept": "Detached PKCS#7 vs raw content bytes (digest algo inferred from PKCS#7); blob normalization like Win32 detached helpers"}, {"name": "trust-verify-zip", "maps_to_native_concept": "Custom ZIP Authenticode comment signature digest binding plus same explicit-anchor trust stack as PE"}, {"name": "pe-has-page-hashes", "maps_to_native_concept": "Subset of `verify /ph` — CMS signed-attribute probe plus raw OID TLV fallback"}, diff --git a/dotnet/Devolutions.Psign.PowerShell/Models/ArtifactSigning.cs b/dotnet/Devolutions.Psign.PowerShell/Models/ArtifactSigning.cs index 2d5e25c..5482bd3 100644 --- a/dotnet/Devolutions.Psign.PowerShell/Models/ArtifactSigning.cs +++ b/dotnet/Devolutions.Psign.PowerShell/Models/ArtifactSigning.cs @@ -39,8 +39,9 @@ public sealed class PsignArtifactSigningIdentity /// Result of a successful Azure Artifact Signing validation. public sealed class PsignArtifactSigningVerification { - [JsonPropertyName("pkcs7_index")] - public int Pkcs7Index { get; init; } + /// PE certificate-table entry index; 0 for single-signature formats. + [JsonPropertyName("signature_index")] + public int SignatureIndex { get; init; } [JsonPropertyName("signer_subject")] public string SignerSubject { get; init; } = string.Empty; diff --git a/scripts/ci/build-artifact-signing-private-trust-fixtures.ps1 b/scripts/ci/build-artifact-signing-private-trust-fixtures.ps1 new file mode 100644 index 0000000..928c8d8 --- /dev/null +++ b/scripts/ci/build-artifact-signing-private-trust-fixtures.ps1 @@ -0,0 +1,137 @@ +#Requires -Version 7.0 +# Build synthetic Azure Artifact Signing *Private Trust* fixtures for the non-PE formats. +# +# A throwaway root CA and a signer certificate carrying the Private Trust identity EKU +# (1.3.6.1.4.1.311.97.1.3.1.) plus code signing are created in memory with +# CertificateRequest (no certificate store is touched), then used to sign: +# - sample.js (signtool, WSH SIP) +# - sample.msix (signtool, AppX SIP; the publisher must equal the signer subject) +# - sample.nupkg (psign-tool portable nupkg-sign; `dotnet nuget sign` refuses untrusted roots) +# - sample.vsix (System.IO.Packaging.PackageDigitalSignatureManager via Windows PowerShell) +# No timestamps are applied, so verification uses --artifact-signing-allow-missing-timestamp. +# +# Requires Windows, the Windows SDK signtool.exe, Windows PowerShell 5.1, and a built psign-tool: +# cargo build +# pwsh -NoProfile -File .\scripts\ci\build-artifact-signing-private-trust-fixtures.ps1 +param( + [string] $WorkspaceRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path, + [string] $PublisherId = '11.22.33.44.55.66.77.88', + [string] $SignTool, + [string] $PsignTool +) + +$ErrorActionPreference = 'Stop' +$X509 = 'System.Security.Cryptography.X509Certificates' + +$fixtures = Join-Path $WorkspaceRoot 'tests\fixtures' +$outDir = Join-Path $fixtures 'artifact-signing\private-trust' +New-Item -ItemType Directory -Force -Path $outDir | Out-Null +$work = Join-Path ([IO.Path]::GetTempPath()) ("psign-aas-pt-" + [Guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Force -Path $work | Out-Null + +if (-not $SignTool) { + $SignTool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin\*\x64\signtool.exe" | + Sort-Object FullName | Select-Object -Last 1 -ExpandProperty FullName +} +if (-not $SignTool) { throw 'signtool.exe not found; pass -SignTool' } +if (-not $PsignTool) { $PsignTool = Join-Path $WorkspaceRoot 'target\debug\psign-tool.exe' } +if (-not (Test-Path $PsignTool)) { throw "psign-tool not found at $PsignTool; run cargo build or pass -PsignTool" } + +$sha256 = [System.Security.Cryptography.HashAlgorithmName]::SHA256 +$pkcs1 = [System.Security.Cryptography.RSASignaturePadding]::Pkcs1 +$notBefore = [DateTimeOffset]::new(2026, 1, 1, 0, 0, 0, [TimeSpan]::Zero) + +# Root CA +$rootKey = [System.Security.Cryptography.RSA]::Create(2048) +$rootReq = New-Object "$X509.CertificateRequest" 'CN=Psign Artifact Signing Private Trust Test Root', $rootKey, $sha256, $pkcs1 +$rootReq.CertificateExtensions.Add((New-Object "$X509.X509BasicConstraintsExtension" $true, $false, 0, $true)) +$rootReq.CertificateExtensions.Add((New-Object "$X509.X509KeyUsageExtension" ([System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]'KeyCertSign, CrlSign'), $true)) +$rootReq.CertificateExtensions.Add((New-Object "$X509.X509SubjectKeyIdentifierExtension" $rootReq.PublicKey, $false)) +$root = $rootReq.CreateSelfSigned($notBefore, $notBefore.AddYears(25)) + +# Signer: the subject must equal the MSIX fixture's Publisher. +$leafKey = [System.Security.Cryptography.RSA]::Create(2048) +$leafReq = New-Object "$X509.CertificateRequest" 'CN=Test Code Signing Certificate', $leafKey, $sha256, $pkcs1 +$leafReq.CertificateExtensions.Add((New-Object "$X509.X509BasicConstraintsExtension" $false, $false, 0, $true)) +$leafReq.CertificateExtensions.Add((New-Object "$X509.X509KeyUsageExtension" ([System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::DigitalSignature), $true)) +$ekus = [System.Security.Cryptography.OidCollection]::new() +$null = $ekus.Add([System.Security.Cryptography.Oid]::new("1.3.6.1.4.1.311.97.1.3.1.$PublisherId")) +$null = $ekus.Add([System.Security.Cryptography.Oid]::new('1.3.6.1.5.5.7.3.3')) +$leafReq.CertificateExtensions.Add((New-Object "$X509.X509EnhancedKeyUsageExtension" $ekus, $false)) +$leafReq.CertificateExtensions.Add((New-Object "$X509.X509SubjectKeyIdentifierExtension" $leafReq.PublicKey, $false)) +$serial = [byte[]](1..16 | ForEach-Object { Get-Random -Maximum 256 }) +$serial[0] = $serial[0] -band 0x7f +$leafPublic = $leafReq.Create($root, $notBefore, $notBefore.AddYears(20), $serial) +$leaf = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::CopyWithPrivateKey($leafPublic, $leafKey) + +$password = [Guid]::NewGuid().ToString('N') +$pfxPath = Join-Path $work 'signer.pfx' +$chain = New-Object "$X509.X509Certificate2Collection" +$null = $chain.Add($leaf) +$null = $chain.Add((New-Object "$X509.X509Certificate2" (, $root.RawData))) +[IO.File]::WriteAllBytes($pfxPath, $chain.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Pfx, $password)) +$certPath = Join-Path $work 'signer.cer' +$keyPath = Join-Path $work 'signer.key' +[IO.File]::WriteAllBytes($certPath, $leafPublic.RawData) +[IO.File]::WriteAllText($keyPath, [System.Security.Cryptography.PemEncoding]::WriteString('PRIVATE KEY', $leafKey.ExportPkcs8PrivateKey())) +[IO.File]::WriteAllBytes((Join-Path $outDir 'root.cer'), $root.RawData) +$rootCertPath = Join-Path $work 'root.cer' +[IO.File]::WriteAllBytes($rootCertPath, $root.RawData) + +function Invoke-SignTool([string] $Path) { + $output = & $SignTool sign /f $pfxPath /p $password /fd SHA256 $Path 2>&1 + if ($LASTEXITCODE -ne 0) { throw "signtool failed for $Path`n$($output -join "`n")" } +} + +# WSH script +$js = Join-Path $outDir 'sample.js' +Copy-Item -LiteralPath (Join-Path $fixtures 'unsigned-sample.js') -Destination $js -Force +Invoke-SignTool $js + +# MSIX +$msix = Join-Path $outDir 'sample.msix' +Copy-Item -LiteralPath (Join-Path $fixtures 'generated-unsigned\msix\sample.msix') -Destination $msix -Force +Invoke-SignTool $msix + +# NuGet +$nupkg = Join-Path $outDir 'sample.nupkg' +$output = & $PsignTool portable nupkg-sign (Join-Path $fixtures 'package-signing\unsigned\sample.nupkg') ` + --cert $certPath --key $keyPath --chain-cert $rootCertPath --output $nupkg --overwrite 2>&1 +if ($LASTEXITCODE -ne 0) { throw "psign-tool nupkg-sign failed`n$($output -join "`n")" } + +# VSIX: PackageDigitalSignatureManager exists only on .NET Framework. +$vsix = Join-Path $outDir 'sample.vsix' +Copy-Item -LiteralPath (Join-Path $fixtures 'package-signing\unsigned\sample.vsix') -Destination $vsix -Force +$env:PSIGN_FIXTURE_VSIX = $vsix +$env:PSIGN_FIXTURE_PFX = $pfxPath +$env:PSIGN_FIXTURE_PFX_PASSWORD = $password +try { + $output = & powershell.exe -NoProfile -NonInteractive -Command { + $ErrorActionPreference = 'Stop' + Add-Type -AssemblyName WindowsBase + $cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($env:PSIGN_FIXTURE_PFX, $env:PSIGN_FIXTURE_PFX_PASSWORD) + $package = [System.IO.Packaging.Package]::Open($env:PSIGN_FIXTURE_VSIX, [IO.FileMode]::Open, [IO.FileAccess]::ReadWrite) + try { + $manager = [System.IO.Packaging.PackageDigitalSignatureManager]::new($package) + $manager.CertificateOption = [System.IO.Packaging.CertificateEmbeddingOption]::InSignaturePart + $manager.HashAlgorithm = 'http://www.w3.org/2001/04/xmlenc#sha256' + $parts = [System.Collections.Generic.List[Uri]]::new() + foreach ($part in $package.GetParts()) { $parts.Add($part.Uri) } + $parts.Add([System.IO.Packaging.PackUriHelper]::GetRelationshipPartUri($manager.SignatureOrigin)) + $parts.Add($manager.SignatureOrigin) + $parts.Add([System.IO.Packaging.PackUriHelper]::GetRelationshipPartUri([Uri]::new('/', [UriKind]::RelativeOrAbsolute))) + $null = $manager.Sign($parts, $cert) + } + finally { + $package.Close() + } + } 2>&1 + if ($LASTEXITCODE -ne 0) { throw "VSIX signing failed`n$($output -join "`n")" } +} +finally { + Remove-Item Env:\PSIGN_FIXTURE_VSIX, Env:\PSIGN_FIXTURE_PFX, Env:\PSIGN_FIXTURE_PFX_PASSWORD -ErrorAction SilentlyContinue +} + +Remove-Item -LiteralPath $work -Recurse -Force +Get-ChildItem $outDir | Select-Object Name, Length +"publisher_id=$PublisherId" diff --git a/src/artifact_signing_verify.rs b/src/artifact_signing_verify.rs index 2c58ddf..e0a814e 100644 --- a/src/artifact_signing_verify.rs +++ b/src/artifact_signing_verify.rs @@ -14,6 +14,14 @@ use psign_authenticode_trust::{ use std::fmt::Write as _; use std::path::Path; +/// NuGet / VSIX packages: signed with CMS or XMLDSig inside a ZIP, with no Windows SIP, so +/// `WinVerifyTrust` cannot evaluate them and Windows mode relies on the shared check alone. +pub fn is_package_without_windows_sip(path: &Path) -> bool { + path.extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "nupkg" | "snupkg" | "vsix")) +} + /// `true` when any `--artifact-signing*` option was given. pub fn requested(args: &VerifyArgs) -> bool { args.artifact_signing diff --git a/src/lib.rs b/src/lib.rs index c7d100a..797c12d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -155,9 +155,11 @@ fn portable_command_for_path(path: &std::path::Path) -> anyhow::Result<&'static )), "zip" => Ok("verify-zip"), "cat" => Ok("verify-catalog"), - "ps1" | "psd1" | "psm1" | "ps1xml" | "psc1" | "cdxml" | "mof" | "js" | "vbs" | "wsf" => { - Ok("verify-script") - } + "ps1" | "psd1" | "psm1" | "ps1xml" | "psc1" | "cdxml" | "mof" | "js" | "jse" | "vbs" + | "vbe" | "wsf" => Ok("verify-script"), + // Package signatures have no digest-only check; they always need trust inputs. + "nupkg" | "snupkg" => Ok("trust-verify-nupkg"), + "vsix" => Ok("trust-verify-vsix"), _ => Err(anyhow::anyhow!( "portable verify cannot infer a supported SIP format for {}", path.display() @@ -303,6 +305,18 @@ fn portable_auto_trust_enabled() -> bool { !psign_authenticode_trust::authroot_cache::is_auto_trust_disabled() } +/// Trust command used when explicit trust inputs are given; also covers formats whose automatic +/// (AuthRoot) routing stays digest-only. +fn portable_explicit_trust_command(command: &str) -> Option<&'static str> { + portable_trust_command_for_verify_command(command).or(match command { + "verify-script" => Some("trust-verify-script"), + "verify-msix" => Some("trust-verify-msix"), + "trust-verify-nupkg" => Some("trust-verify-nupkg"), + "trust-verify-vsix" => Some("trust-verify-vsix"), + _ => None, + }) +} + fn portable_trust_command_for_verify_command(command: &str) -> Option<&'static str> { match command { "verify-pe" => Some("trust-verify-pe"), @@ -385,7 +399,7 @@ fn execute_portable_verify(args: &crate::cli::VerifyArgs) -> anyhow::Result anyhow::Result { if native.policy == crate::cli::VerifyPolicy::Default { native.policy = crate::cli::VerifyPolicy::Pa; } - let mut out = crate::win::verify::verify_file(&native, &cli.global)?; + // NuGet and VSIX have no Windows SIP; only the shared Rust check applies to them. + native + .files + .retain(|p| !crate::artifact_signing_verify::is_package_without_windows_sip(p)); + let mut out = if native.files.is_empty() { + CommandOutput::ok(String::new()) + } else { + crate::win::verify::verify_file(&native, &cli.global)? + }; out.stdout .push_str(&crate::artifact_signing_verify::verify_targets(args)?); Ok(out) diff --git a/tests/artifact_signing_cli.rs b/tests/artifact_signing_cli.rs index 1c89d48..4cabda7 100644 --- a/tests/artifact_signing_cli.rs +++ b/tests/artifact_signing_cli.rs @@ -151,3 +151,124 @@ fn verify_real_sample_identically_in_every_mode() { .stderr(predicate::str::contains("is not one of the expected IDs")); } } + +const PRIVATE_TRUST_ID: &str = "11.22.33.44.55.66.77.88"; +const PRIVATE_TRUST_FILES: [&str; 4] = ["sample.js", "sample.msix", "sample.nupkg", "sample.vsix"]; + +fn private_trust_args(cmd: &mut Command, publisher_id: &str) { + cmd.args(["--artifact-signing-publisher-id", publisher_id]) + .arg("--artifact-signing-private-trust-root") + .arg(fixture("artifact-signing/private-trust/root.cer")) + .arg("--artifact-signing-allow-missing-timestamp"); +} + +/// Synthetic Private Trust signer (see scripts/ci/build-artifact-signing-private-trust-fixtures.ps1). +#[test] +fn private_trust_script_msix_nuget_vsix_verify_in_portable_mode() { + for file in PRIVATE_TRUST_FILES { + let mut cmd = psign(); + cmd.args(["--mode", "portable", "verify"]); + private_trust_args(&mut cmd, PRIVATE_TRUST_ID); + cmd.arg(fixture(&format!("artifact-signing/private-trust/{file}"))) + .assert() + .success() + .stdout(predicate::str::contains(format!( + "publisher_id={PRIVATE_TRUST_ID} kind=private_trust" + ))); + + let mut cmd = psign(); + cmd.args(["--mode", "portable", "verify"]); + private_trust_args(&mut cmd, "1.2.3.4"); + cmd.arg(fixture(&format!("artifact-signing/private-trust/{file}"))) + .assert() + .failure() + .stderr(predicate::str::contains("is not one of the expected IDs")); + } +} + +#[test] +fn private_trust_requires_the_subscriber_root() { + psign() + .args([ + "--mode", + "portable", + "verify", + "--artifact-signing-allow-missing-timestamp", + ]) + .arg(fixture("artifact-signing/private-trust/sample.vsix")) + .assert() + .failure() + .stderr(predicate::str::contains("requires the subscriber root")); +} + +/// NuGet / VSIX have no Windows SIP, so Windows mode applies the shared check alone. +#[cfg(windows)] +#[test] +fn package_formats_verify_in_windows_mode() { + for file in ["sample.nupkg", "sample.vsix"] { + let mut cmd = psign(); + cmd.args(["--mode", "windows", "verify"]); + private_trust_args(&mut cmd, PRIVATE_TRUST_ID); + cmd.arg(fixture(&format!("artifact-signing/private-trust/{file}"))) + .assert() + .success() + .stdout(predicate::str::contains("kind=private_trust")); + } +} + +const TEST_CA: &str = "devolutions-authenticode/authenticode-test-ca.crt"; + +#[test] +fn portable_trust_verify_commands_cover_scripts_msix_nuget_vsix() { + for (command, file) in [ + ( + "trust-verify-script", + "generated-signed/scripts/wsh/js/utf8-crlf.js", + ), + ( + "trust-verify-script", + "generated-signed/scripts/wsh/vbs/utf8-crlf.vbs", + ), + ("trust-verify-msix", "generated-signed/msix/sample.msix"), + ( + "trust-verify-nupkg", + "package-signing/signed/sample.signed.nupkg", + ), + ( + "trust-verify-vsix", + "package-signing/signed/sample.signed.vsix", + ), + ] { + psign() + .args(["portable", command, "--as-of", "2026-06-01", "--trusted-ca"]) + .arg(fixture(TEST_CA)) + .arg(fixture(file)) + .assert() + .success() + .stdout(predicate::str::contains(format!("{command}: ok"))); + } +} + +#[test] +fn portable_verify_routes_explicit_trust_for_new_formats() { + for file in [ + "generated-signed/scripts/wsh/wsf/utf8-crlf.wsf", + "generated-signed/msix/sample.appx", + "package-signing/signed/sample.signed.snupkg", + "package-signing/signed/nested.signed.vsix", + ] { + psign() + .args([ + "--mode", + "portable", + "verify", + "--as-of", + "2026-06-01", + "--trusted-ca", + ]) + .arg(fixture(TEST_CA)) + .arg(fixture(file)) + .assert() + .success(); + } +} diff --git a/tests/cli_pe_digest.rs b/tests/cli_pe_digest.rs index 0e4006e..f0148d2 100644 --- a/tests/cli_pe_digest.rs +++ b/tests/cli_pe_digest.rs @@ -619,6 +619,8 @@ fn nupkg_verify_signature_rejects_tampered_signed_package() { sign.assert().success(); append_zip_entry(&signed, &tampered, "tampered.txt", b"changed"); + // NuGet requires `.signature.p7s` to stay the last ZIP entry, so an appended entry is + // rejected before the package hash is even recomputed. let mut verify = portable_cmd(); verify .arg("nupkg-verify-signature") @@ -627,7 +629,7 @@ fn nupkg_verify_signature_rejects_tampered_signed_package() { .arg(&cert) .arg("--allow-loose-signing-cert"); verify.assert().failure().stderr(predicate::str::contains( - "detached content digest does not match", + ".signature.p7s must be the single, last central directory record", )); } diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index a89c760..003ed1f 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -52,6 +52,13 @@ committed signed vector, native-signing the unsigned source rows and verifying the fresh outputs with `psign`, and psign-signing the offline-supported subset from the current user's certificate store. +`artifact-signing/private-trust/` holds a synthetic Artifact Signing *Private +Trust* root (`root.cer`) and `sample.js`, `sample.msix`, `sample.nupkg`, and +`sample.vsix` signed by a certificate with the identity EKU +`1.3.6.1.4.1.311.97.1.3.1.11.22.33.44.55.66.77.88` (no timestamps). Regenerate +with `pwsh -File scripts/ci/build-artifact-signing-private-trust-fixtures.ps1` +after `cargo build`; keys are created in memory and discarded. + `artifact-signing/` holds public material from a Devolutions build signed by Azure Artifact Signing (Public Trust): `psign-tool.pkcs7.der` is the PKCS#7 extracted with `psign-tool portable extract-pe-pkcs7` (signer, intermediates, diff --git a/tests/fixtures/artifact-signing/private-trust/root.cer b/tests/fixtures/artifact-signing/private-trust/root.cer new file mode 100644 index 0000000000000000000000000000000000000000..0a570795fa6005889b4ce4f8f96c781951ef74a8 GIT binary patch literal 828 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 Zn-$)4d@o13%eA-jk2!XJ@~HmP{{nX%v8n(7 literal 0 HcmV?d00001 From 7dd9f0fecda5835811d74be0a7b4c9555822f5dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Mon, 26 Oct 2026 14:15:20 -0400 Subject: [PATCH 3/6] Make psign's VSIX and NuGet signers standards-compliant VSIX: replace the legacy layout (direct part references in SignedInfo, SignedInfo signed without its inherited namespace, _rels/.rels unsigned) with the OPC XMLDSig layout that System.IO.Packaging / PackageDigitalSignatureManager and dotnet/sign produce: an idPackageObject Manifest referencing every part (as it will exist after embedding, with ContentType from [Content_Types].xml) plus SignatureTime, a SignedInfo reference to it, and inclusive C14N. The template is written directly in canonical form, so all signing providers and the split prehash/from-signature flow keep signing VsixSignatureTemplate's signed_info bytes. Output is verified Success by .NET VerifySignatures. - RFC3161 timestamps (dotnet/sign format) in vsix-sign (--timestamp-url/--timestamp-digest), psign-tool code, and Set-PsignSignature -TimestampServer. - Fix expand_self_closing_xml_root, which turned the first self-closing child of [Content_Types].xml into a bogus and corrupted every VSIX psign signed; add a rels content-type default for the origin relationships part. - vsix-verify-signature(-xml) use the standard OPC verifier; the old reference checker is kept as verify_legacy_signature_reference_xml for inspecting packages signed by earlier releases. NuGet: Set-PsignSignature now embeds the signature content in the CMS (it was detached) and, for local keys, adds the NuGet author attributes, so `dotnet nuget verify` classifies it as an Author signature. Co-Authored-By: Claude Opus 5.5 --- .../PortableSignature.PackageNative.Tests.ps1 | 20 + .../src/trust_verify_vsix.rs | 5 + crates/psign-digest-cli/src/main.rs | 168 ++++-- crates/psign-opc-sign/src/vsix.rs | 566 +++++++++++++++--- crates/psign-portable-core/src/lib.rs | 135 ++++- docs/artifact-signing-validation.md | 2 +- docs/gap-analysis-signing-platforms.md | 4 +- docs/psign-cli-matrix.json | 14 +- src/code.rs | 73 ++- tests/cli_pe_digest.rs | 16 +- tests/code_command.rs | 11 +- 11 files changed, 834 insertions(+), 180 deletions(-) diff --git a/PowerShell/tests/PortableSignature.PackageNative.Tests.ps1 b/PowerShell/tests/PortableSignature.PackageNative.Tests.ps1 index 20a586d..e747dca 100644 --- a/PowerShell/tests/PortableSignature.PackageNative.Tests.ps1 +++ b/PowerShell/tests/PortableSignature.PackageNative.Tests.ps1 @@ -185,6 +185,23 @@ Describe 'Portable PowerShell package-native coverage' { $inspected = Get-PsignSignature -LiteralPath $work $inspected.Format | Should -Be 'NuGet' $inspected.Status | Should -Be 'Valid' + + # NuGet clients require the signature content embedded in the CMS (not detached). + Add-Type -AssemblyName System.IO.Compression.FileSystem + $zip = [System.IO.Compression.ZipFile]::OpenRead($work) + try { + $stream = $zip.GetEntry('.signature.p7s').Open() + $buffer = [System.IO.MemoryStream]::new() + $stream.CopyTo($buffer) + $stream.Dispose() + } + finally { + $zip.Dispose() + } + $cms = [System.Security.Cryptography.Pkcs.SignedCms]::new() + $cms.Decode($buffer.ToArray()) + $cms.Detached | Should -BeFalse + [System.Text.Encoding]::UTF8.GetString($cms.ContentInfo.Content) | Should -BeLike 'Version:1*-Hash:*' } It 'signs and inspects symbol NuGet packages' { @@ -209,6 +226,9 @@ Describe 'Portable PowerShell package-native coverage' { $inspected = Get-PsignSignature -LiteralPath $work $inspected.Format | Should -Be 'Vsix' $inspected.Status | Should -Be 'Valid' + # Standard OPC XMLDSig, not the legacy psign layout. + $inspected.StatusMessage | Should -BeLike 'VSIX OPC XMLDSig signature*' + $inspected.SignerCertificate | Should -Not -BeNullOrEmpty } It 'signs and inspects ClickOnce XML manifests for .manifest, .application, and .vsto' -TestCases @( diff --git a/crates/psign-authenticode-trust/src/trust_verify_vsix.rs b/crates/psign-authenticode-trust/src/trust_verify_vsix.rs index 9295a9d..d268cad 100644 --- a/crates/psign-authenticode-trust/src/trust_verify_vsix.rs +++ b/crates/psign-authenticode-trust/src/trust_verify_vsix.rs @@ -333,6 +333,7 @@ struct VerifiedXmlSignature { certificates: Vec, signature_value: Vec, timestamp_token: Option>, + reference_count: usize, } fn verify_xml_signature(package: &Package) -> Result { @@ -454,6 +455,7 @@ fn verify_xml_signature(package: &Package) -> Result { certificates, signature_value, timestamp_token, + reference_count: referenced.len(), }) } @@ -464,6 +466,8 @@ pub struct VsixSignature { pub signer_certificate_der: Vec, /// RFC 3161 token from `` (not yet trust-verified). pub timestamp_token: Option>, + /// Number of package parts covered by the signed manifest. + pub reference_count: usize, } /// Verify the VSIX signature binding (steps 1-4 of the module docs) without any trust decision. @@ -475,6 +479,7 @@ pub fn verify_vsix_signature_bytes(data: &[u8]) -> Result { .to_der() .map_err(|e| anyhow!("encode signer certificate: {e}"))?, timestamp_token: sig.timestamp_token, + reference_count: sig.reference_count, }) } diff --git a/crates/psign-digest-cli/src/main.rs b/crates/psign-digest-cli/src/main.rs index 413b80c..911380e 100644 --- a/crates/psign-digest-cli/src/main.rs +++ b/crates/psign-digest-cli/src/main.rs @@ -2887,7 +2887,7 @@ enum Command { #[arg(long, default_value_t = false)] overwrite: bool, }, - /// Write deterministic VSIX XMLDSig Reference/DigestValue XML for package parts. + /// Write the unsigned OPC signature skeleton (package manifest + SignedInfo) for review or `vsix-verify-signature-reference-xml`. VsixSignatureReferenceXml { path: PathBuf, #[arg(long, value_enum, default_value_t = VsixHashAlg::Sha256)] @@ -2895,7 +2895,7 @@ enum Command { #[arg(long, value_name = "PATH")] output: Option, }, - /// Create deterministic VSIX XMLDSig XML with local RSA/SHA-2 SignatureValue. + /// Create OPC XMLDSig XML (System.IO.Packaging layout, package-derived SignatureTime) with a local RSA/SHA-2 SignatureValue. VsixSignatureXml { path: PathBuf, #[arg(long, value_enum, default_value_t = VsixHashAlg::Sha256)] @@ -2936,7 +2936,7 @@ enum Command { #[arg(long, value_name = "PATH")] output: Option, }, - /// Create and embed deterministic VSIX XMLDSig XML with local RSA/SHA-2 SignatureValue. + /// Sign a VSIX with a local RSA/SHA-2 key: OPC XMLDSig verifiable by System.IO.Packaging / Visual Studio, optionally RFC3161-timestamped. VsixSign { path: PathBuf, #[arg(long, value_enum, default_value_t = VsixHashAlg::Sha256)] @@ -2951,6 +2951,12 @@ enum Command { output: PathBuf, #[arg(long, default_value_t = false)] overwrite: bool, + /// RFC3161 timestamp URL; the token covers the `SignatureValue` and is stored as the OPC `` element. + #[arg(long = "timestamp-url", visible_alias = "tr")] + timestamp_url: Option, + /// RFC3161 timestamp digest algorithm. + #[arg(long = "timestamp-digest", visible_alias = "td", value_enum)] + timestamp_digest: Option, }, /// Verify VSIX XMLDSig Reference/DigestValue XML against package parts. VsixVerifySignatureReferenceXml { @@ -3247,6 +3253,77 @@ fn xml_signed_info_remote_prehash( } } +/// Standard OPC XMLDSig verification of a signed VSIX (part digests, coverage, `SignatureValue`), +/// optionally requiring a specific signer certificate and applying the shared trust options. +/// Returns the referenced part count and, when trust ran, the anchor count. +fn verify_vsix_package_signature( + package: &[u8], + expected_cert: Option<&[u8]>, + shared: &TrustVerifySharedArgs, +) -> Result<(usize, Option)> { + let signature = psign_authenticode_trust::verify_vsix_signature_bytes(package)?; + if let Some(expected) = expected_cert { + let expected = psign_authenticode_trust::anchor::parse_cert_bytes(expected) + .context("parse expected VSIX signer certificate")? + .to_der() + .map_err(|e| anyhow!("encode expected VSIX signer certificate: {e}"))?; + if expected != signature.signer_certificate_der { + return Err(anyhow!( + "VSIX SignatureValue was not produced by the expected signer certificate" + )); + } + } + let trust = if trust_verify_args_present(shared) { + let opts = trust_verify_options_from_shared(shared)?; + let report = psign_authenticode_trust::trust_verify_vsix_bytes(package, &opts)?; + Some(report.anchor_thumbprints) + } else { + None + }; + Ok((signature.reference_count, trust)) +} + +/// Add an RFC3161 `` timestamp over the signature's `SignatureValue` when requested. +fn vsix_timestamp_if_requested( + signature_xml: Vec, + timestamp_url: Option, + timestamp_digest: Option, +) -> Result> { + match (timestamp_url, timestamp_digest) { + (Some(url), Some(timestamp_digest)) => { + #[cfg(feature = "timestamp-http")] + { + let signature_value = vsix::signature_value_from_signature_xml(&signature_xml)?; + let imprint = digest_bytes_for_hash_alg(timestamp_digest, &signature_value); + let response = post_rfc3161_timestamp_request(&url, timestamp_digest, &imprint) + .context("vsix-sign: RFC3161 timestamp request")?; + let parsed = parse_time_stamp_resp_der(&response) + .ok_or_else(|| anyhow!("could not parse TimeStampResp DER from TSA response"))?; + if !parsed.pki_status.granted() { + return Err(anyhow!( + "TimeStampResp status is not granted (status={})", + parsed.pki_status.as_raw_integer() + )); + } + let token = parsed + .time_stamp_token + .ok_or_else(|| anyhow!("TimeStampResp has no timeStampToken"))?; + vsix::add_signature_timestamp(&signature_xml, token) + } + #[cfg(not(feature = "timestamp-http"))] + { + let _ = (url, timestamp_digest); + Err(anyhow!( + "vsix-sign RFC3161 timestamping requires the timestamp-http feature" + )) + } + } + (Some(_), None) => Err(anyhow!("vsix-sign requires --timestamp-digest with --timestamp-url")), + (None, Some(_)) => Err(anyhow!("vsix-sign requires --timestamp-url with --timestamp-digest")), + (None, None) => Ok(signature_xml), + } +} + fn verify_xml_signed_info( algorithm: vsix::VsixHashAlgorithm, signer_cert: &x509_cert::Certificate, @@ -6002,10 +6079,15 @@ where std::fs::read(&key).with_context(|| format!("read {}", key.display()))?; let private_key = rdp::parse_rsa_private_key(&key_bytes) .with_context(|| format!("parse RSA private key {}", key.display()))?; - let signed_info = vsix::signed_info_xml_path(&path, algorithm) + let template = vsix::signature_template_path( + &path, + algorithm, + vsix::VsixSigningTime::FromPackage, + ) .with_context(|| format!("vsix-signature-xml {}", path.display()))?; - let signature = sign_xml_signed_info(algorithm, private_key, &signed_info)?; - let xml = vsix::signature_xml_from_signed_info(&signed_info, &signature, Some(&cert_bytes)) + let signature = sign_xml_signed_info(algorithm, private_key, &template.signed_info)?; + let xml = template + .signature_xml(&signature, Some(&cert_bytes)) .into_bytes(); match output { Some(path) => { @@ -6043,12 +6125,10 @@ where .with_context(|| format!("parse signer certificate {}", cert.display()))?; let signature_bytes = std::fs::read(&signature) .with_context(|| format!("read {}", signature.display()))?; - let signed_info = vsix::signed_info_xml_path(&path, algorithm).with_context(|| { - format!("vsix-signature-xml-from-signature {}", path.display()) - })?; - let xml = - vsix::signature_xml_from_signed_info(&signed_info, &signature_bytes, Some(&cert_bytes)) - .into_bytes(); + let xml = vsix::signature_xml_path(&path, algorithm, &signature_bytes, Some(&cert_bytes)) + .with_context(|| { + format!("vsix-signature-xml-from-signature {}", path.display()) + })?; match output { Some(path) => { std::fs::write(&path, &xml).with_context(|| format!("write {}", path.display()))? @@ -6065,6 +6145,8 @@ where key, output, overwrite, + timestamp_url, + timestamp_digest, } => { let algorithm = vsix::VsixHashAlgorithm::from(algorithm); let cert_bytes = std::fs::read(&cert).with_context(|| format!("read {}", cert.display()))?; @@ -6074,17 +6156,20 @@ where std::fs::read(&key).with_context(|| format!("read {}", key.display()))?; let private_key = rdp::parse_rsa_private_key(&key_bytes) .with_context(|| format!("parse RSA private key {}", key.display()))?; - let signed_info = vsix::signed_info_xml_path(&path, algorithm) - .with_context(|| format!("vsix-sign {}", path.display()))?; - let signature = sign_xml_signed_info(algorithm, private_key, &signed_info)?; - let xml = - vsix::signature_xml_from_signed_info(&signed_info, &signature, Some(&cert_bytes)) - .into_bytes(); + let template = + vsix::signature_template_path(&path, algorithm, vsix::VsixSigningTime::Now) + .with_context(|| format!("vsix-sign {}", path.display()))?; + let signature = sign_xml_signed_info(algorithm, private_key, &template.signed_info)?; + let xml = template + .signature_xml(&signature, Some(&cert_bytes)) + .into_bytes(); + let xml = vsix_timestamp_if_requested(xml, timestamp_url, timestamp_digest)?; vsix::embed_signature_xml_path(&path, &output, &xml, overwrite) .with_context(|| format!("vsix-sign embed signature XML into {}", output.display()))?; println!("output={}", output.display()); println!("signature_xml_part={}", vsix::DEFAULT_VSIX_SIGNATURE_PART); println!("reference_digest_algorithm={}", vsix_hash_alg_label(algorithm)); + println!("reference_count={}", template.reference_count()); println!("signature_xml_len={}", xml.len()); } Command::VsixVerifySignatureReferenceXml { @@ -6116,19 +6201,15 @@ where let algorithm = vsix::VsixHashAlgorithm::from(algorithm); let xml = std::fs::read(&signature_xml) .with_context(|| format!("read {}", signature_xml.display()))?; - let references = vsix::verify_signature_reference_xml_path(&path, &xml, algorithm) + let package = + std::fs::read(&path).with_context(|| format!("read {}", path.display()))?; + let mut embedded = std::io::Cursor::new(Vec::new()); + vsix::embed_signature_xml(std::io::Cursor::new(package), &mut embedded, &xml, true) .with_context(|| format!("vsix-verify-signature-xml {}", path.display()))?; let cert_bytes = std::fs::read(&cert).with_context(|| format!("read {}", cert.display()))?; - let signer_cert = rdp::parse_certificate(&cert_bytes) - .with_context(|| format!("parse signer certificate {}", cert.display()))?; - let signed_info = vsix::signed_info_xml_from_signature_xml(&xml)?; - let signature = vsix::signature_value_from_signature_xml(&xml)?; - verify_xml_signed_info(algorithm, &signer_cert, &signed_info, &signature)?; - let trust_anchor_count = if trust_verify_args_present(&shared) { - Some(verify_xml_signer_certificate_trust(&cert_bytes, &shared)?) - } else { - None - }; + let (references, trust_anchor_count) = + verify_vsix_package_signature(&embedded.into_inner(), Some(&cert_bytes), &shared) + .with_context(|| format!("vsix-verify-signature-xml {}", path.display()))?; println!("reference_digest_algorithm={}", vsix_hash_alg_label(algorithm)); println!("reference_count={references}"); println!("reference_digest_match=yes"); @@ -6145,26 +6226,19 @@ where shared, } => { let algorithm = vsix::VsixHashAlgorithm::from(algorithm); - let xml = vsix::extract_signature_xml_path(&path) - .with_context(|| format!("vsix-verify-signature {}", path.display()))?; - let references = vsix::verify_signature_reference_xml_path(&path, &xml, algorithm) + let package = + std::fs::read(&path).with_context(|| format!("read {}", path.display()))?; + vsix::extract_signature_xml(std::io::Cursor::new(&package)) .with_context(|| format!("vsix-verify-signature {}", path.display()))?; - let cert_bytes = match cert { - Some(cert) => std::fs::read(&cert) - .with_context(|| format!("read {}", cert.display()))?, - None => vsix::signer_certificate_from_signature_xml(&xml) - .context("read embedded VSIX signer certificate")?, - }; - let signer_cert = - rdp::parse_certificate(&cert_bytes).context("parse VSIX signer certificate")?; - let signed_info = vsix::signed_info_xml_from_signature_xml(&xml)?; - let signature = vsix::signature_value_from_signature_xml(&xml)?; - verify_xml_signed_info(algorithm, &signer_cert, &signed_info, &signature)?; - let trust_anchor_count = if trust_verify_args_present(&shared) { - Some(verify_xml_signer_certificate_trust(&cert_bytes, &shared)?) - } else { - None + let expected_cert = match cert { + Some(cert) => Some( + std::fs::read(&cert).with_context(|| format!("read {}", cert.display()))?, + ), + None => None, }; + let (references, trust_anchor_count) = + verify_vsix_package_signature(&package, expected_cert.as_deref(), &shared) + .with_context(|| format!("vsix-verify-signature {}", path.display()))?; println!("vsix-verify-signature: ok"); println!("signature_xml_present=yes"); println!("signature_xml_part={}", vsix::DEFAULT_VSIX_SIGNATURE_PART); diff --git a/crates/psign-opc-sign/src/vsix.rs b/crates/psign-opc-sign/src/vsix.rs index 1186562..9ef1222 100644 --- a/crates/psign-opc-sign/src/vsix.rs +++ b/crates/psign-opc-sign/src/vsix.rs @@ -79,81 +79,370 @@ pub fn signature_reference_xml_path(path: &Path, algorithm: VsixHashAlgorithm) - .with_context(|| format!("create VSIX signature reference XML for {}", path.display())) } +/// Unsigned signature skeleton (SignedInfo + package `Object`, empty `SignatureValue`) for review +/// or for [`verify_signature_reference_xml`]. pub fn signature_reference_xml(reader: R, algorithm: VsixHashAlgorithm) -> Result> where R: Read + Seek, { - let signed_info = signed_info_xml(reader, algorithm)?; - Ok(signature_xml_from_signed_info(&signed_info, &[], None).into_bytes()) + Ok( + signature_template(reader, algorithm, VsixSigningTime::FromPackage)? + .signature_xml(&[], None) + .into_bytes(), + ) } +/// Canonical `SignedInfo` octets to sign (see [`signature_template`]; package-derived signing time). pub fn signed_info_xml_path(path: &Path, algorithm: VsixHashAlgorithm) -> Result> { let reader = File::open(path).with_context(|| format!("open {}", path.display()))?; signed_info_xml(reader, algorithm) .with_context(|| format!("create VSIX SignedInfo XML for {}", path.display())) } +/// Canonical `SignedInfo` octets to sign (see [`signature_template`]; package-derived signing time). pub fn signed_info_xml(reader: R, algorithm: VsixHashAlgorithm) -> Result> where R: Read + Seek, { - let references = reference_digests(reader, algorithm)?; - if references.is_empty() { - return Err(anyhow!( - "VSIX package has no non-signature parts to reference" - )); + Ok(signature_template(reader, algorithm, VsixSigningTime::FromPackage)?.signed_info) +} + +/// Complete signature XML for `path` with a package-derived signing time, so split +/// prehash / from-signature steps produce the same `SignedInfo`. +pub fn signature_xml_path( + path: &Path, + algorithm: VsixHashAlgorithm, + signature_value: &[u8], + signer_cert_der: Option<&[u8]>, +) -> Result> { + Ok( + signature_template_path(path, algorithm, VsixSigningTime::FromPackage)? + .signature_xml(signature_value, signer_cert_der) + .into_bytes(), + ) +} + +const XMLDSIG_NS: &str = "http://www.w3.org/2000/09/xmldsig#"; +const OPC_DIGITAL_SIGNATURE_NS: &str = + "http://schemas.openxmlformats.org/package/2006/digital-signature"; +const C14N_URI: &str = "http://www.w3.org/TR/2001/REC-xml-c14n-20010315"; +const XMLDSIG_OBJECT_TYPE: &str = "http://www.w3.org/2000/09/xmldsig#Object"; +const RELATIONSHIPS_CONTENT_TYPE: &str = "application/vnd.openxmlformats-package.relationships+xml"; +/// OPC `SignatureTime/Format` value (ECMA-376 Part 2); `Value` uses `yyyy-MM-ddTHH:mm:ss.f+00:00`. +pub const VSIX_SIGNATURE_TIME_FORMAT: &str = "YYYY-MM-DDThh:mm:ss.sTZD"; + +/// OPC package signature ready to be signed, in the layout `System.IO.Packaging` / +/// `PackageDigitalSignatureManager` and dotnet/sign produce: +/// +/// - `` holds a `Manifest` with one `Reference` per package part +/// (everything except `[Content_Types].xml` and the signature part itself, including +/// `_rels/.rels` and the signature origin parts as they will exist after embedding), each +/// `/part?ContentType=` with a raw-octet digest, plus `SignatureProperties/SignatureTime`. +/// - `SignedInfo` (inclusive C14N 1.0, RSA PKCS#1 v1.5) references `#idPackageObject`. +/// +/// Both elements are written directly in canonical form, so [`Self::signed_info`] is exactly the +/// octet string the RSA signature covers. +#[derive(Clone, Debug)] +pub struct VsixSignatureTemplate { + /// Canonical `SignedInfo` octets to sign. + pub signed_info: Vec, + signed_info_in_document: String, + object_in_document: String, + references: usize, +} + +impl VsixSignatureTemplate { + /// Number of package parts referenced by the manifest. + pub fn reference_count(&self) -> usize { + self.references } - let mut xml = String::new(); - xml.push_str(""); - xml.push_str( - r#""#, - ); - xml.push_str(&format!( - r#""#, - algorithm.signature_uri() - )); - for (name, digest) in references { + /// Signature part XML carrying `signature_value` (RSA over [`Self::signed_info`]). + pub fn signature_xml(&self, signature_value: &[u8], signer_cert_der: Option<&[u8]>) -> String { + let mut xml = String::new(); + xml.push_str(r#""#); xml.push_str(&format!( - r#"{}"#, - xml_escape_attr(&name), - algorithm.digest_uri(), - BASE64_STANDARD.encode(digest) + r#""# )); + xml.push_str(&self.signed_info_in_document); + xml.push_str(""); + xml.push_str(&BASE64_STANDARD.encode(signature_value)); + xml.push_str(""); + if let Some(cert) = signer_cert_der { + xml.push_str(""); + xml.push_str(&BASE64_STANDARD.encode(cert)); + xml.push_str(""); + } + xml.push_str(&self.object_in_document); + xml.push_str(""); + xml } - xml.push_str(""); - Ok(xml.into_bytes()) } -pub fn signature_xml_path( +fn c14n_attr(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + for c in value.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '"' => out.push_str("""), + '\t' => out.push_str(" "), + '\n' => out.push_str(" "), + '\r' => out.push_str(" "), + c => out.push(c), + } + } + out +} + +/// Document form of canonical XML: empty `Algorithm` elements written self-closing, as +/// `XmlWriter` does (`System.IO.Packaging` rejects explicit end tags there). Canonicalization maps +/// both spellings to the same octets. +fn document_form(canonical: &str) -> String { + let mut out = canonical.to_string(); + for name in ["CanonicalizationMethod", "SignatureMethod", "DigestMethod"] { + out = out.replace(&format!(">"), " />"); + } + out +} + +fn c14n_text(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + for c in value.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '\r' => out.push_str(" "), + c => out.push(c), + } + } + out +} + +/// `SignatureTime/Value` text for `time` (UTC). +fn signature_time_value(time: time::OffsetDateTime) -> String { + let t = time.to_offset(time::UtcOffset::UTC); + format!( + "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{}+00:00", + t.year(), + u8::from(t.month()), + t.day(), + t.hour(), + t.minute(), + t.second(), + t.millisecond() / 100 + ) +} + +/// [`VsixSigningTime::FromPackage`]: newest ZIP entry modification time (DOS times carry no +/// zone; read as UTC). +fn package_signature_time_value(archive: &mut zip::ZipArchive) -> String { + let mut newest: Option<(u16, u8, u8, u8, u8, u8)> = None; + for i in 0..archive.len() { + if let Ok(file) = archive.by_index_raw(i) { + let d = file.last_modified(); + let key = ( + d.year(), + d.month(), + d.day(), + d.hour(), + d.minute(), + d.second(), + ); + if newest.is_none_or(|n| key > n) { + newest = Some(key); + } + } + } + let (y, mo, d, h, mi, s) = newest.unwrap_or((1980, 1, 1, 0, 0, 0)); + format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}.0+00:00") +} + +struct ContentTypes { + defaults: BTreeMap, + overrides: BTreeMap, +} + +impl ContentTypes { + fn parse(xml: &[u8]) -> Result { + let text = std::str::from_utf8(xml).context("OPC [Content_Types].xml is not UTF-8")?; + let mut defaults = BTreeMap::new(); + let mut overrides = BTreeMap::new(); + for (tag, map, key_attr) in [ + ("') + .map(|e| start + e) + .ok_or_else(|| anyhow!("unterminated {tag} in [Content_Types].xml"))?; + let element = &text[start..=end]; + if let (Some(key), Some(ct)) = ( + xml_attr(element, key_attr), + xml_attr(element, "ContentType"), + ) { + let key = key.trim_start_matches('/').to_ascii_lowercase(); + map.insert(key, ct); + } + cursor = end + 1; + } + } + Ok(Self { + defaults, + overrides, + }) + } + + fn for_part(&self, part: &str) -> Option<&str> { + let key = part.to_ascii_lowercase(); + self.overrides + .get(&key) + .or_else(|| { + let ext = key.rsplit_once('.').map(|(_, e)| e).unwrap_or(""); + self.defaults.get(ext) + }) + .map(String::as_str) + } +} + +/// `SignatureTime` recorded in the signed package object. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum VsixSigningTime { + /// Current UTC time, for one-shot signing. + Now, + /// Newest ZIP entry modification time, so independent split-signing steps (prehash, then + /// from-signature) produce identical `SignedInfo` without passing extra inputs. + FromPackage, +} + +/// Build the OPC signature for `reader` (an unsigned or re-signed VSIX); see +/// [`VsixSignatureTemplate`]. +pub fn signature_template( + reader: R, + algorithm: VsixHashAlgorithm, + signing_time: VsixSigningTime, +) -> Result +where + R: Read + Seek, +{ + let mut prepared = std::io::Cursor::new(Vec::new()); + write_signed_package(reader, &mut prepared, None, true)?; + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(prepared.into_inner())) + .context("open prepared VSIX ZIP")?; + let time_value = match signing_time { + VsixSigningTime::Now => signature_time_value(time::OffsetDateTime::now_utc()), + VsixSigningTime::FromPackage => package_signature_time_value(&mut archive), + }; + template_from_prepared(&mut archive, algorithm, &time_value) +} + +pub fn signature_template_path( path: &Path, algorithm: VsixHashAlgorithm, - signature_value: &[u8], - signer_cert_der: Option<&[u8]>, -) -> Result> { - let signed_info = signed_info_xml_path(path, algorithm)?; - Ok(signature_xml_from_signed_info(&signed_info, signature_value, signer_cert_der).into_bytes()) + signing_time: VsixSigningTime, +) -> Result { + let reader = File::open(path).with_context(|| format!("open {}", path.display()))?; + signature_template(reader, algorithm, signing_time) + .with_context(|| format!("create VSIX signature for {}", path.display())) } -pub fn signature_xml_from_signed_info( - signed_info_xml: &[u8], - signature_value: &[u8], - signer_cert_der: Option<&[u8]>, -) -> String { - let signed_info = String::from_utf8_lossy(signed_info_xml); - let mut xml = String::new(); - xml.push_str(r#""#); - xml.push_str(&signed_info); - xml.push_str(""); - xml.push_str(&BASE64_STANDARD.encode(signature_value)); - xml.push_str(""); - if let Some(cert) = signer_cert_der { - xml.push_str(""); - xml.push_str(&BASE64_STANDARD.encode(cert)); - xml.push_str(""); - } - xml.push_str(""); - xml +fn template_from_prepared( + archive: &mut zip::ZipArchive>>, + algorithm: VsixHashAlgorithm, + time_value: &str, +) -> Result { + let mut content_types_xml = Vec::new(); + archive + .by_name(CONTENT_TYPES_PART) + .context("prepared VSIX has no [Content_Types].xml")? + .read_to_end(&mut content_types_xml)?; + let content_types = ContentTypes::parse(&content_types_xml)?; + + let mut manifest = String::from(""); + let mut references = 0usize; + for i in 0..archive.len() { + let mut file = archive.by_index(i).context("read VSIX ZIP entry")?; + let name = normalize_zip_part_name(file.name())?; + if file.is_dir() || name == CONTENT_TYPES_PART || name == DEFAULT_VSIX_SIGNATURE_PART { + continue; + } + let content_type = content_types + .for_part(&name) + .ok_or_else(|| anyhow!("VSIX part /{name} has no content type in [Content_Types].xml"))? + .to_string(); + let mut bytes = Vec::with_capacity(file.size() as usize); + file.read_to_end(&mut bytes)?; + manifest.push_str(&format!( + r#"{}"#, + c14n_attr(&format!("/{name}?ContentType={content_type}")), + algorithm.digest_uri(), + BASE64_STANDARD.encode(algorithm.hash(&bytes)) + )); + references += 1; + } + if references == 0 { + return Err(anyhow!("VSIX package has no parts to sign")); + } + manifest.push_str(""); + + let object_body = format!( + concat!( + "{manifest}", + r##""##, + r#"{format}{value}"#, + "" + ), + manifest = manifest, + opc = OPC_DIGITAL_SIGNATURE_NS, + format = VSIX_SIGNATURE_TIME_FORMAT, + value = c14n_text(time_value), + ); + let object_canonical = + format!(r#"{object_body}"#); + let object_in_document = + document_form(&format!(r#"{object_body}"#)); + + let signed_info_body = format!( + concat!( + r#""#, + r#""#, + r##""##, + r#"{value}"#, + "" + ), + c14n = C14N_URI, + sig = algorithm.signature_uri(), + object_type = XMLDSIG_OBJECT_TYPE, + digest = algorithm.digest_uri(), + value = BASE64_STANDARD.encode(algorithm.hash(object_canonical.as_bytes())), + ); + Ok(VsixSignatureTemplate { + signed_info: format!(r#"{signed_info_body}"#).into_bytes(), + signed_info_in_document: document_form(&format!("{signed_info_body}")), + object_in_document, + references, + }) +} + +/// Insert an RFC 3161 timestamp token (DER `ContentInfo` over the `SignatureValue` octets) as the +/// dotnet/sign `` element. +pub fn add_signature_timestamp(signature_xml: &[u8], token_der: &[u8]) -> Result> { + let text = std::str::from_utf8(signature_xml).context("VSIX signature XML is not UTF-8")?; + if text.contains("") { + return Err(anyhow!("VSIX signature XML is already timestamped")); + } + let end = text + .rfind("") + .ok_or_else(|| anyhow!("VSIX signature XML is missing "))?; + let stamp = format!( + r#"Timestamp got from the time stamp server{}"#, + BASE64_STANDARD.encode(token_der) + ); + Ok(format!("{}{stamp}{}", &text[..end], &text[end..]).into_bytes()) } pub fn verify_signature_reference_xml_path( @@ -166,6 +455,9 @@ pub fn verify_signature_reference_xml_path( .with_context(|| format!("verify VSIX signature references for {}", path.display())) } +/// Check that `signature_xml` (from [`VsixSignatureTemplate::signature_xml`]) carries the +/// `SignedInfo` and package `Object` this package produces with the same signing time, which +/// covers every part digest. Returns the number of referenced parts. pub fn verify_signature_reference_xml( reader: R, signature_xml: &[u8], @@ -174,8 +466,41 @@ pub fn verify_signature_reference_xml( where R: Read + Seek, { - let expected = reference_digests(reader, algorithm)?; - let actual = parse_reference_digests(signature_xml, algorithm)?; + let text = std::str::from_utf8(signature_xml).context("VSIX signature XML is not UTF-8")?; + let time_start = text + .find("( + reader: R, + signature_xml: &[u8], + algorithm: VsixHashAlgorithm, +) -> Result +where + R: Read + Seek, +{ + let expected = legacy_reference_digests(reader, algorithm)?; + let actual = parse_legacy_reference_digests(signature_xml, algorithm)?; if actual.len() != expected.len() { return Err(anyhow!( "VSIX signature reference count mismatch: expected {}, found {}", @@ -235,7 +560,7 @@ where Ok(xml) } -fn reference_digests( +fn legacy_reference_digests( reader: R, algorithm: VsixHashAlgorithm, ) -> Result>> @@ -261,7 +586,7 @@ where Ok(references) } -fn parse_reference_digests( +fn parse_legacy_reference_digests( signature_xml: &[u8], algorithm: VsixHashAlgorithm, ) -> Result>> { @@ -355,18 +680,14 @@ fn element_text<'a>(text: &'a str, name: &str) -> Option<&'a str> { } fn xml_attr(tag: &str, name: &str) -> Option { - let needle = format!("{name}=\""); - let start = tag.find(&needle)? + needle.len(); - let end = tag[start..].find('"')? + start; - Some(tag[start..end].to_owned()) -} - -fn xml_escape_attr(value: &str) -> String { - value - .replace('&', "&") - .replace('"', """) - .replace('<', "<") - .replace('>', ">") + for quote in ['"', '\''] { + let needle = format!(" {name}={quote}"); + if let Some(start) = tag.find(&needle).map(|s| s + needle.len()) { + let end = tag[start..].find(quote)? + start; + return Some(tag[start..end].to_owned()); + } + } + None } fn is_opc_signature_entry(name: &str) -> bool { @@ -385,6 +706,16 @@ fn ensure_signature_content_types(bytes: &[u8]) -> Result> { }; let mut text = ensure_content_type_default(&text, "psdsor", OPC_SIGNATURE_ORIGIN_CONTENT_TYPE)?; text = ensure_content_type_default(&text, "psdsxs", OPC_SIGNATURE_XML_CONTENT_TYPE)?; + // The signature origin relationships part needs a content type to be signable. + if !text.to_ascii_lowercase().contains(r#"extension="rels""#) { + let insert_at = text + .rfind("") + .ok_or_else(|| anyhow!("OPC [Content_Types].xml is missing "))?; + text.insert_str( + insert_at, + &format!(r#""#), + ); + } Ok(text.into_bytes()) } @@ -451,14 +782,20 @@ fn ensure_relationship( Ok(out.into_bytes()) } +/// Turn a self-closing document element (``) into an open/close pair so children can be +/// appended; a document element that already has a separate end tag is left untouched. fn expand_self_closing_xml_root(text: &str, root: &str) -> String { let Some(start) = text.find(&format!("<{root}")) else { return text.to_string(); }; - let Some(end) = text[start..].find("/>") else { + // End of the document element's start tag (attribute values cannot contain a raw `>`). + let Some(tag_end) = text[start..].find('>').map(|e| start + e) else { return text.to_string(); }; - let end = start + end; + if !text[..tag_end].ends_with('/') { + return text.to_string(); + } + let end = tag_end - 1; let mut out = String::with_capacity(text.len() + root.len() + 3); out.push_str(&text[..end]); out.push('>'); @@ -502,6 +839,8 @@ pub fn embed_signature_xml_path( .with_context(|| format!("embed VSIX signature XML into {}", output.display())) } +/// Embed `signature_xml` into the package as the OPC signature part, adding the signature origin +/// and relationships exactly as [`signature_template`] assumed when digesting the parts. pub fn embed_signature_xml( reader: R, writer: W, @@ -515,6 +854,21 @@ where if signature_xml.is_empty() { return Err(anyhow!("VSIX signature XML payload is empty")); } + write_signed_package(reader, writer, Some(signature_xml), overwrite) +} + +/// Copy the package with the OPC signature infrastructure (content types, root origin +/// relationship, origin part and its relationships) and, when given, the signature part. +fn write_signed_package( + reader: R, + writer: W, + signature_xml: Option<&[u8]>, + overwrite: bool, +) -> Result<()> +where + R: Read + std::io::Seek, + W: Write + std::io::Seek, +{ let mut input = zip::ZipArchive::new(reader).context("open VSIX ZIP")?; let mut output = zip::ZipWriter::new(writer); let mut had_signature = false; @@ -566,8 +920,10 @@ where output.write_all(&[])?; output.start_file(OPC_SIGNATURE_ORIGIN_RELS_PART, stored)?; output.write_all(&signature_origin_relationships_xml())?; - output.start_file(DEFAULT_VSIX_SIGNATURE_PART, stored)?; - output.write_all(signature_xml)?; + if let Some(signature_xml) = signature_xml { + output.start_file(DEFAULT_VSIX_SIGNATURE_PART, stored)?; + output.write_all(signature_xml)?; + } } output.finish()?; Ok(()) @@ -628,6 +984,25 @@ mod tests { assert!(origin_rels.contains(OPC_SIGNATURE_REL_TYPE)); } + #[test] + fn signature_infrastructure_keeps_existing_content_types_well_formed() { + let types = br#""#; + let out = String::from_utf8(ensure_signature_content_types(types).unwrap()).unwrap(); + assert!( + out.contains(r#""#), + "{out}" + ); + assert_eq!(out.matches("").count(), 1, "{out}"); + assert!(out.contains(r#"Extension="psdsor""#) && out.contains(r#"Extension="rels""#)); + + let empty = + String::from_utf8(ensure_signature_content_types(b"").unwrap()).unwrap(); + assert!( + empty.starts_with("") && empty.ends_with(""), + "{empty}" + ); + } + #[test] fn embed_signature_xml_rejects_existing_signature_without_overwrite() { let zip = zip_with(&[(DEFAULT_VSIX_SIGNATURE_PART, b"")]); @@ -637,10 +1012,12 @@ mod tests { assert!(err.to_string().contains("already contains OPC signature")); } + const TYPES: &[u8] = br#""#; + #[test] - fn signature_reference_xml_covers_non_signature_parts() { + fn signature_reference_xml_covers_every_part_but_the_signature() { let zip = zip_with(&[ - ("[Content_Types].xml", b""), + ("[Content_Types].xml", TYPES), ("extension.vsixmanifest", b"manifest"), (DEFAULT_VSIX_SIGNATURE_PART, b""), ]); @@ -648,31 +1025,39 @@ mod tests { let xml = signature_reference_xml(Cursor::new(zip), VsixHashAlgorithm::Sha256).unwrap(); let text = String::from_utf8(xml.clone()).unwrap(); - assert!(text.contains(r#""#)); - assert!(!text.contains(r#""#)); - assert!(!text.contains(r#""#)); + assert!(text.contains(r#""#)); + assert!(text.contains(r#""#)); + assert!(text.contains(&format!(r#""##)); assert_eq!( verify_signature_reference_xml( Cursor::new(zip_with(&[ - ("[Content_Types].xml", b""), + ("[Content_Types].xml", TYPES), ("extension.vsixmanifest", b"manifest"), ])), &xml, VsixHashAlgorithm::Sha256 ) .unwrap(), - 1 + 4 ); } #[test] fn signature_reference_xml_detects_tampered_part() { - let zip = zip_with(&[("extension.vsixmanifest", b"manifest")]); + let zip = zip_with(&[ + ("[Content_Types].xml", TYPES), + ("extension.vsixmanifest", b"manifest"), + ]); let xml = signature_reference_xml(Cursor::new(zip), VsixHashAlgorithm::Sha256).unwrap(); let err = verify_signature_reference_xml( - Cursor::new(zip_with(&[("extension.vsixmanifest", b"changed")])), + Cursor::new(zip_with(&[ + ("[Content_Types].xml", TYPES), + ("extension.vsixmanifest", b"changed"), + ])), &xml, VsixHashAlgorithm::Sha256, ) @@ -681,6 +1066,33 @@ mod tests { assert!(err.to_string().contains("digest mismatch")); } + #[test] + fn split_signing_steps_produce_identical_signed_info() { + let zip = zip_with(&[ + ("[Content_Types].xml", TYPES), + ("extension.vsixmanifest", b"m"), + ]); + let first = signed_info_xml(Cursor::new(zip.clone()), VsixHashAlgorithm::Sha256).unwrap(); + let second = signed_info_xml(Cursor::new(zip), VsixHashAlgorithm::Sha256).unwrap(); + assert_eq!(first, second); + assert!(first.starts_with(br#""#)); + } + + #[test] + fn timestamp_is_appended_as_encoded_time_object() { + let xml = add_signature_timestamp( + b"AA==", + b"\x30\x00", + ) + .unwrap(); + let text = String::from_utf8(xml.clone()).unwrap(); + assert!( + text.ends_with(r#"MAA="#), + "{text}" + ); + assert!(add_signature_timestamp(&xml, b"\x30\x00").is_err()); + } + #[test] fn extract_signature_xml_returns_embedded_signature_part() { let zip = zip_with(&[ diff --git a/crates/psign-portable-core/src/lib.rs b/crates/psign-portable-core/src/lib.rs index b6c678d..703bd96 100644 --- a/crates/psign-portable-core/src/lib.rs +++ b/crates/psign-portable-core/src/lib.rs @@ -1319,6 +1319,54 @@ impl SigningProvider { } } + /// NuGet author signature: id-data CMS that embeds the signature content (as NuGet clients + /// require). Local keys add the NuGet author attributes (signing time, commitment type, and + /// signing-certificate-v2); remote signers only learn their certificate when signing, so they + /// keep the basic attribute set. + fn create_nuget_author_signature( + &self, + content: &[u8], + digest_algorithm: AuthenticodeSigningDigest, + ) -> Result> { + let econtent_der = der_encode_octet_string(content)?; + let id_data = der::asn1::ObjectIdentifier::new_unwrap(pkcs7::PKCS7_ID_DATA_OID); + if let SigningProvider::Local(local) = self { + let signed_attrs = pkcs7::pkcs7_signed_attrs( + id_data, + &econtent_der, + digest_algorithm, + pkcs7::Pkcs7SignedAttributeProfile::NuGetAuthor, + Some(&local.signer_cert), + )?; + let attrs_der = signed_attrs + .to_der() + .context("encode NuGet signed attributes")?; + let signature = sign_xml_signed_info_rsa( + vsix_algorithm_from_authenticode_digest(digest_algorithm), + &attrs_der, + &local.private_key, + )?; + return pkcs7::create_pkcs7_signed_data_der_with_signed_attrs_and_rsa_signature( + pkcs7::Pkcs7SignedDataDerInput { + econtent_type: id_data, + econtent_der: &econtent_der, + digest_algorithm, + signer_cert: local.signer_cert.clone(), + chain_certs: local.chain.clone(), + encrypted_digest: &signature, + content_mode: pkcs7::Pkcs7ContentMode::Attached, + signed_attrs, + }, + ); + } + self.create_pkcs7_signed_data( + id_data, + &econtent_der, + digest_algorithm, + pkcs7::Pkcs7ContentMode::Attached, + ) + } + fn create_pkcs7_signed_data( &self, econtent_type: der::asn1::ObjectIdentifier, @@ -2007,18 +2055,10 @@ fn sign_nuget(request: &PortableSignRequest, output_path: &Path) -> Result<()> { })?; let content = psign_opc_sign::nuget::signature_content_bytes(nuget_alg, &nuget_alg.hash(&unsigned)); - // Create a CMS SignedData with id-data content type, then detach eContent - let econtent_der = der_encode_octet_string(&content)?; - let id_data = der::asn1::ObjectIdentifier::new_unwrap(pkcs7::PKCS7_ID_DATA_OID); - let pkcs7_detached = provider - .create_pkcs7_signed_data( - id_data, - &econtent_der, - request.hash_algorithm.into(), - pkcs7::Pkcs7ContentMode::Detached, - ) + let pkcs7 = provider + .create_nuget_author_signature(&content, request.hash_algorithm.into()) .with_context(|| format!("create NuGet CMS signature for {}", request.path.display()))?; - let pkcs7_final = maybe_timestamp_pkcs7(request, pkcs7_detached) + let pkcs7_final = maybe_timestamp_pkcs7(request, pkcs7) .with_context(|| format!("timestamp {}", request.path.display()))?; let mut out = Cursor::new(Vec::new()); psign_opc_sign::nuget::embed_signature(Cursor::new(unsigned), &mut out, &pkcs7_final, false) @@ -2036,15 +2076,29 @@ fn sign_vsix(request: &PortableSignRequest, output_path: &Path) -> Result<()> { PortableDigestAlgorithm::Sha384 => psign_opc_sign::vsix::VsixHashAlgorithm::Sha384, PortableDigestAlgorithm::Sha512 => psign_opc_sign::vsix::VsixHashAlgorithm::Sha512, }; - let signed_info = psign_opc_sign::vsix::signed_info_xml(Cursor::new(data.clone()), vsix_alg) - .with_context(|| format!("create VSIX SignedInfo XML for {}", request.path.display()))?; - let (signature, cert_der) = provider.sign_xml_signed_info(vsix_alg, &signed_info)?; - let xml = psign_opc_sign::vsix::signature_xml_from_signed_info( - &signed_info, - &signature, - Some(&cert_der), + let template = psign_opc_sign::vsix::signature_template( + Cursor::new(data.clone()), + vsix_alg, + psign_opc_sign::vsix::VsixSigningTime::Now, ) - .into_bytes(); + .with_context(|| format!("create VSIX signature for {}", request.path.display()))?; + let (signature, cert_der) = provider.sign_xml_signed_info(vsix_alg, &template.signed_info)?; + let xml = template + .signature_xml(&signature, Some(&cert_der)) + .into_bytes(); + let xml = match request.timestamp_server.as_deref() { + Some(server) => { + let alg = request.timestamp_hash_algorithm.unwrap_or_default(); + let signature_value = psign_opc_sign::vsix::signature_value_from_signature_xml(&xml)?; + let token = request_rfc3161_token(server, alg, &signature_value) + .with_context(|| format!("timestamp {}", request.path.display()))?; + psign_opc_sign::vsix::add_signature_timestamp(&xml, &token)? + } + None if request.timestamp_hash_algorithm.is_some() => { + bail!("timestamp_hash_algorithm requires timestamp_server") + } + None => xml, + }; let mut out = Cursor::new(Vec::new()); psign_opc_sign::vsix::embed_signature_xml(Cursor::new(data), &mut out, &xml, false) .with_context(|| format!("embed VSIX signature XML into {}", request.path.display()))?; @@ -2119,6 +2173,16 @@ fn sign_appinstaller(request: &PortableSignRequest, output_path: &Path) -> Resul .with_context(|| format!("write companion {}", companion_path.display())) } +fn vsix_algorithm_from_authenticode_digest( + digest: AuthenticodeSigningDigest, +) -> psign_opc_sign::vsix::VsixHashAlgorithm { + match digest { + AuthenticodeSigningDigest::Sha384 => psign_opc_sign::vsix::VsixHashAlgorithm::Sha384, + AuthenticodeSigningDigest::Sha512 => psign_opc_sign::vsix::VsixHashAlgorithm::Sha512, + AuthenticodeSigningDigest::Sha256 => psign_opc_sign::vsix::VsixHashAlgorithm::Sha256, + } +} + fn sign_xml_signed_info_rsa( algorithm: psign_opc_sign::vsix::VsixHashAlgorithm, signed_info: &[u8], @@ -2457,7 +2521,23 @@ fn timestamp_pkcs7_der_rfc3161( .as_slice() .first() .ok_or_else(|| anyhow::anyhow!("PKCS#7 SignedData has no SignerInfo to timestamp"))?; - let imprint = digest_bytes_for_timestamp_alg(timestamp_digest, signer.signature.as_bytes()); + let token = request_rfc3161_token( + timestamp_server, + timestamp_digest, + signer.signature.as_bytes(), + )?; + let stamped = pkcs7::signed_data_add_rfc3161_timestamp_token(&sd, 0, &token) + .context("attach RFC3161 timestamp token")?; + pkcs7::encode_pkcs7_content_info_signed_data_der(&stamped) +} + +/// Request an RFC3161 timestamp token (DER `ContentInfo`) whose imprint covers `stamped`. +fn request_rfc3161_token( + timestamp_server: &str, + timestamp_digest: PortableTimestampDigestAlgorithm, + stamped: &[u8], +) -> Result> { + let imprint = digest_bytes_for_timestamp_alg(timestamp_digest, stamped); let request = timestamp::build_timestamp_request_bytes( ×tamp::Rfc3161TimestampRequestPlan { digest_alg_oid: timestamp_digest_oid(timestamp_digest), @@ -2486,12 +2566,10 @@ fn timestamp_pkcs7_der_rfc3161( parsed.pki_status.as_raw_integer() ); } - let token = parsed + parsed .time_stamp_token - .ok_or_else(|| anyhow::anyhow!("TimeStampResp has no timeStampToken"))?; - let stamped = pkcs7::signed_data_add_rfc3161_timestamp_token(&sd, 0, token) - .context("attach RFC3161 timestamp token")?; - pkcs7::encode_pkcs7_content_info_signed_data_der(&stamped) + .map(<[u8]>::to_vec) + .ok_or_else(|| anyhow::anyhow!("TimeStampResp has no timeStampToken")) } fn timestamp_digest_oid(alg: PortableTimestampDigestAlgorithm) -> &'static str { @@ -2916,12 +2994,13 @@ fn inspect_vsix_opc(path: &Path, data: &[u8]) -> Result { - // Signatures written by earlier psign releases reference parts directly and are not - // verifiable by Visual Studio; keep reporting their part digests for compatibility. + // Legacy only: signatures written by psign releases before the OPC layout referenced + // parts directly and are not verifiable by Visual Studio; keep reporting their part + // digests so existing packages still inspect. let sig_xml = psign_opc_sign::vsix::extract_signature_xml_path(path).unwrap_or_default(); let legacy_ok = !sig_xml.is_empty() - && psign_opc_sign::vsix::verify_signature_reference_xml( + && psign_opc_sign::vsix::verify_legacy_signature_reference_xml( Cursor::new(data), &sig_xml, psign_opc_sign::vsix::VsixHashAlgorithm::Sha256, diff --git a/docs/artifact-signing-validation.md b/docs/artifact-signing-validation.md index 1d7743d..312d5a0 100644 --- a/docs/artifact-signing-validation.md +++ b/docs/artifact-signing-validation.md @@ -308,7 +308,7 @@ Windows mode runs `WinVerifyTrust` for every format that has a Windows SIP, incl - **Revocation is opt-in.** It is off by default because it needs network access. Use `--revocation-mode require` to fail closed. Microsoft revokes individual daily certificates on confirmed misuse, so require mode is recommended where network access is available. The Artifact Signing signer certificates publish a CRL distribution point but no OCSP URL, so CRLs do the work. Microsoft PKI CRLs are signed with `sha384WithRSAEncryption`, which the portable verifier supports along with SHA-256 and SHA-512. RSA-PSS and ECDSA-signed CRLs and OCSP responses are not supported. Revocation status is checked against the **current** CRL, not as of the timestamp time. - **Formats.** Everything in [Format notes](#format-notes) is covered. ClickOnce manifests, App Installer descriptors, encrypted MSIX, and MSIX/AppX upload containers are rejected with the Artifact Signing options. In the PowerShell module, WIM/ESD and detached PKCS#7 have no portable trust path. -- **psign-written legacy signatures.** VSIX signatures written by psign's own VSIX signer reference parts directly, without the OPC `Object`/`Manifest`, and are not valid OPC XMLDSig. Inspection reports them, but trust and Artifact Signing verification reject them. NuGet signatures written by `Set-PsignSignature` use a detached CMS; they are accepted when the rebuilt signature content matches the signed digest, but NuGet clients expect the content embedded. +- **Legacy psign signatures.** psign now writes standard VSIX signatures (verified by .NET `PackageDigitalSignatureManager`) and embedded NuGet signature content. Packages signed by earlier psign releases are handled for compatibility only. Old VSIX signatures referenced parts directly, without the OPC `Object`/`Manifest`. Inspection still reports them, but trust and Artifact Signing verification reject them; re-sign such packages. Old `Set-PsignSignature` NuGet signatures used a detached CMS. They are still accepted when the rebuilt signature content matches the signed digest. - **Catalog database lookup.** `verify --catalog-search` / `--catalog-database-guid` cannot be combined with the policy. Pass the catalog explicitly with `--catalog`; its signature is then checked, and member hashes are verified as usual. - **Nested signatures.** Only certificate-table entries are checked. Nested signatures (`1.3.6.1.4.1.311.2.4.1`) inside an entry are not evaluated as separate Artifact Signing signatures. - **Root subtree.** The pinned root also issues non-Artifact-Signing CAs (for example the timestamping CA). Requiring the identity EKU, the Public Trust marker, and the code signing EKU on the signer is what limits acceptance to Artifact Signing. Intermediates are deliberately not pinned (see above). diff --git a/docs/gap-analysis-signing-platforms.md b/docs/gap-analysis-signing-platforms.md index 8028c22..929fb5b 100644 --- a/docs/gap-analysis-signing-platforms.md +++ b/docs/gap-analysis-signing-platforms.md @@ -57,7 +57,7 @@ This inventory starts from the in-tree supported formats, then expands to inbox | **RDP files** (`.rdp`) | Implemented `rdp` path using Windows certificate stores. | Mostly fixture breadth and native `rdpsign.exe` output-shape parity. | Implemented `portable rdp` with local cert/key or external detached PKCS#7. | No Windows store selection or native `rdpsign.exe` integration by design. | | **App Installer descriptors** (`.appinstaller`) | Direct embedded signing is rejected by current SignTool; descriptor signing can be represented as unsigned XML plus a PKCS#7 companion artifact generated with SignTool `/p7`. | No full XML+companion signing/verification UX or native parity wrapper. | `appinstaller-info` inspects descriptor metadata and companion `.p7`; `appinstaller-set-publisher` updates namespace-aware MainPackage/MainBundle publisher metadata; `appinstaller-sign-companion` creates a local RSA/SHA-2 detached PKCS#7 companion with optional RFC3161 timestamping; `appinstaller-sign-companion-prehash` + `appinstaller-sign-companion-from-signature` assemble companion CMS from external RSA signatures; detached trust primitives verify the XML plus companion signature; `psign-tool code` creates top-level and nested companion signatures with local cert/key, PFX, portable cert-store SHA-1, Azure Key Vault, or Artifact Signing identity, and nested ZIP orchestration writes descriptor-local `.p7` companions. | No App Installer-specific policy checks. | | **NuGet packages** (`.nupkg`, `.snupkg`) | Not a `signtool`/WinTrust SIP target in this repo. | No `nuget sign`-compatible author/repository signing workflow in Windows mode. | `psign-opc-sign` groundwork: marker inspection, unsigned package digest, NuGet v1 signature-content generation/verification, local RSA/SHA-2 CMS generation for signature content with RFC3161 timestamp tokens, external-signer CMS assembly via `nupkg-signature-pkcs7-prehash` + `nupkg-signature-pkcs7-from-signature`, `.signature.p7s` embed/overwrite, one-step local `nupkg-sign`, embedded `.signature.p7s` package hash over the exact unsigned bytes (matching `dotnet nuget sign`) + explicit-anchor or Azure Artifact Signing CMS verification (`trust-verify-nupkg`), top-level `psign-tool code` execution from local cert/key, PFX, portable cert-store SHA-1, Azure Key Vault, or Artifact Signing identity, package-native nested execution when embedded in VSIX/ZIP containers, nested PE/WinMD signing before package signatures, nested exclude filters, `--skip-signed`, and `--overwrite`. | No repository signatures, full NuGet policy verification, or non-PE Authenticode nested payload signing. | -| **VSIX packages** (`.vsix`) | Not a first-class Windows-mode signing surface here. | No VSIX package signing/verification workflow. | Signature marker inspection, signature XML embed primitive with OPC signature content-type and relationship metadata, deterministic XMLDSig Reference/DigestValue generation/verification for package parts, local RSA/SHA-2 XMLDSig `SignatureValue` generation/verification, external-signer XMLDSig assembly via `vsix-signature-xml-prehash` + `vsix-signature-xml-from-signature`, one-step local `vsix-sign`, standards-based OPC XMLDSig trust verification (`trust-verify-vsix`: C14N `SignedInfo`, `Object`/`Manifest` part digests with RelationshipTransform, part coverage and content types, `` RFC3161 timestamps, explicit-anchor or Azure Artifact Signing signer chains), top-level `psign-tool code` execution from local cert/key, PFX, portable cert-store SHA-1, Azure Key Vault, or Artifact Signing identity, package-native nested VSIX/ZIP -> NuGet/VSIX -> PE/WinMD execution, nested exclude filters, `--skip-signed`, and `--overwrite`. | No timestamping or non-PE Authenticode nested payload signing; psign's own VSIX signer still writes a legacy non-OPC signature layout. | +| **VSIX packages** (`.vsix`) | Not a first-class Windows-mode signing surface here. | No VSIX package signing/verification workflow. | Signature marker inspection, signature XML embed primitive with OPC signature content-type and relationship metadata, OPC XMLDSig signing in the `System.IO.Packaging` / dotnet/sign layout (`Object`/`Manifest` over every part, `SignatureTime`, inclusive C14N; verified by .NET `PackageDigitalSignatureManager`), RFC3161 `` timestamping in `vsix-sign`, `psign-tool code`, and `Set-PsignSignature`, external-signer XMLDSig assembly via `vsix-signature-xml-prehash` + `vsix-signature-xml-from-signature`, one-step local `vsix-sign`, standards-based OPC XMLDSig trust verification (`trust-verify-vsix`: C14N `SignedInfo`, `Object`/`Manifest` part digests with RelationshipTransform, part coverage and content types, `` RFC3161 timestamps, explicit-anchor or Azure Artifact Signing signer chains), top-level `psign-tool code` execution from local cert/key, PFX, portable cert-store SHA-1, Azure Key Vault, or Artifact Signing identity, package-native nested VSIX/ZIP -> NuGet/VSIX -> PE/WinMD execution, nested exclude filters, `--skip-signed`, and `--overwrite`. | No non-PE Authenticode nested payload signing; VSIX signatures carry only the signer certificate in `KeyInfo`. | | **ClickOnce / VSTO manifests** (`.manifest`, `.application`, `.vsto`, `.deploy` workflows) | Not implemented. | No `mage.exe`/manifest XMLDSig workflow, certificate embedding, or timestamping. | `psign-tool code --dry-run` classifies ClickOnce/VSTO workflow nodes; portable helpers inspect/copy `.deploy` payloads; guarded `psign-tool code` execution signs PE-like `.deploy` payloads and `.manifest` / `.application` / `.vsto` XMLDSig manifests with local cert/key; portable helpers update and verify manifest file size/digest references; `clickonce-sign-manifest` / `clickonce-sign-manifest-prehash` / `clickonce-sign-manifest-from-signature` / `clickonce-verify-manifest-signature` provide deterministic portable structural local/external XMLDSig signing with embedded signer certificate. | Full Mage-compatible canonicalization/policy, timestamping, full deployment graph orchestration, and ClickOnce/VSTO policy checks remain. | | **Business Central `.app`** | Format-specific behavior is not implemented. | No confirmed NAVX signing/verification workflow. | `business-central-app-info` detects NAVX headers, `psign-tool code --dry-run` classifies NAVX `.app` files, and signing execution now reports a Business Central-specific unsupported diagnostic instead of silently treating them as generic files. | Actual package signing and verification policy remain pending format confirmation. | | **File catalog authoring** | Can sign/verify an existing `.cat` at the Authenticode layer. | No catalog creation from arbitrary file sets or INF/driver package metadata. | `sign-catalog` authors generic CTL catalogs; catalog PKCS#7 consistency/trust and explicit `verify-catalog-member` cover committed MakeCat-style and psign-authored generic catalogs. | Driver/INF policy, OS catalog database search, and MakeCat byte-for-byte output remain out of scope. | @@ -120,7 +120,7 @@ The next tier is still worth tracking because each item either unlocks a recogni | 7 | `portable-trust-policy-hardening` | Current portable trust is intentionally anchor-directory based; production verification often also needs disallowed CTLs/STLs, EKU/application-policy rules, revocation depth, OCSP/CRL edge cases, and TrustedPublisher semantics. | Add policy switches and diagnostics without pretending to be the OS trust store, prioritizing disallowed roots/intermediates and AuthRoot-derived pin/rule fixtures. | | 8 | `clickonce-mage-compatible-signing` | ClickOnce deployments are still common in enterprise Windows estates and have their own manifest canonicalization, timestamping, and policy expectations beyond generic XML/package handling. | Implement detect/inspect/verify parity first, then add Mage-compatible signing and timestamping only after native-vs-portable fixture parity is clear. | | 9 | `nuget-repository-signature-policy` | NuGet packages have author signatures, repository signatures, countersignatures, timestamp policy, and package-source trust decisions that go beyond the existing package digest primitives. | Extend inspection to distinguish author vs repository signatures and add policy diagnostics before attempting repository-signature authoring. | -| 10 | `vsix-timestamping-and-policy` | VSIX signing support without timestamp/policy parity leaves long-lived extension distribution workflows incomplete. | Add timestamp mutation and verify diagnostics for existing signed VSIX packages, with tamper/expiration fixtures. | +| 10 | `vsix-timestamping-and-policy` | VSIX signing and verification now cover RFC3161 `` timestamps; re-timestamping an existing signature and Visual Studio-specific policy diagnostics are still missing. | Add timestamp mutation for already-signed VSIX packages and expiration fixtures. | | 11 | `appinstaller-policy-verify` | App Installer files are small but security-sensitive orchestration manifests; portable hashing alone does not answer whether an update/feed policy is safe or acceptable. | Add policy-focused inspection and verify diagnostics for signed `.appinstaller` manifests, separate from MSIX package signing. | | 12 | `encrypted-msix-delegation-fixtures` | Encrypted MSIX/AppX packages should probably remain a Windows/decryption-bound path, but explicit fixture-backed detection prevents confusing portable failures and regression drift. | Add corpus coverage and diagnostics that distinguish encrypted-package rejection from malformed-package failures, then route Windows-mode operations to the OS where possible. | | 13 | `cab-replacement-multivolume-mutation` | CAB support covers important single-volume signing paths, but setup media and legacy installers can require replacement/multivolume behavior and removal parity. | Add negative/diagnostic coverage for unsupported CAB layouts first, then implement replacement and remove flows for the safest subset. | diff --git a/docs/psign-cli-matrix.json b/docs/psign-cli-matrix.json index 3df7b8d..f91d4c8 100644 --- a/docs/psign-cli-matrix.json +++ b/docs/psign-cli-matrix.json @@ -326,14 +326,14 @@ {"name": "nupkg-verify-signature", "maps_to_native_concept": "NuGet embedded `.signature.p7s` verification against reconstructed package hash content plus explicit-anchor CMS trust"}, {"name": "nupkg-embed-signature", "maps_to_native_concept": "NuGet split-signing primitive: add or overwrite root `.signature.p7s` as a stored ZIP entry"}, {"name": "vsix-signature-info", "maps_to_native_concept": "VSIX OPC XMLDSig marker inspection under package digital-signature service parts (not SIP; groundwork for portable VSIX XMLDSig signing)"}, - {"name": "vsix-signature-reference-xml", "maps_to_native_concept": "Deterministic VSIX XMLDSig Reference/DigestValue XML over package parts"}, - {"name": "vsix-signature-xml", "maps_to_native_concept": "Deterministic VSIX XMLDSig XML with local RSA/SHA-2 SignatureValue and embedded X.509 certificate"}, - {"name": "vsix-signature-xml-prehash", "maps_to_native_concept": "VSIX remote-signing primitive: compute the SignedInfo digest that an external RSA signer signs"}, + {"name": "vsix-signature-reference-xml", "maps_to_native_concept": "Unsigned OPC signature skeleton: package `Object`/`Manifest` (every part except `[Content_Types].xml` and the signature part, as it will be after embedding) plus the `SignedInfo` that references it"}, + {"name": "vsix-signature-xml", "maps_to_native_concept": "OPC XMLDSig (System.IO.Packaging / dotnet/sign layout) with local RSA/SHA-2 SignatureValue and embedded X.509 certificate; package-derived SignatureTime"}, + {"name": "vsix-signature-xml-prehash", "maps_to_native_concept": "VSIX remote-signing primitive: digest of the canonical SignedInfo an external RSA signer signs (package-derived SignatureTime keeps it stable for `vsix-signature-xml-from-signature`)"}, {"name": "vsix-signature-xml-from-signature", "maps_to_native_concept": "VSIX remote-signing primitive: assemble XMLDSig from signer certificate plus external RSA signature bytes"}, - {"name": "vsix-sign", "maps_to_native_concept": "VSIX local signing primitive: generate deterministic XMLDSig XML and embed the OPC signature parts"}, - {"name": "vsix-verify-signature-reference-xml", "maps_to_native_concept": "Verify VSIX package part digests against Reference/DigestValue XML"}, - {"name": "vsix-verify-signature-xml", "maps_to_native_concept": "Verify VSIX package part digests and local RSA/SHA-2 SignatureValue against an explicit signer certificate, with optional explicit-anchor signer chain validation"}, - {"name": "vsix-verify-signature", "maps_to_native_concept": "Verify embedded VSIX OPC XMLDSig package part digests and local RSA/SHA-2 SignatureValue against the embedded or explicit signer certificate, with optional explicit-anchor signer chain validation"}, + {"name": "vsix-sign", "maps_to_native_concept": "VSIX local signing: OPC XMLDSig verifiable by System.IO.Packaging / Visual Studio, embedded with the signature origin parts; `--timestamp-url` / `--timestamp-digest` add the dotnet/sign `` RFC3161 token over the SignatureValue"}, + {"name": "vsix-verify-signature-reference-xml", "maps_to_native_concept": "Verify that signature XML carries the SignedInfo and package manifest this package produces (all part digests)"}, + {"name": "vsix-verify-signature-xml", "maps_to_native_concept": "Embed external signature XML in memory and run the standard OPC XMLDSig verifier against an explicit signer certificate, with optional explicit-anchor signer chain validation"}, + {"name": "vsix-verify-signature", "maps_to_native_concept": "Standard OPC XMLDSig verification of the embedded signature (C14N SignedInfo, manifest part digests and coverage, content types, SignatureValue), optionally pinned to an explicit signer certificate, with optional explicit-anchor signer chain validation"}, {"name": "vsix-embed-signature-xml", "maps_to_native_concept": "VSIX OPC split-signing primitive: add signature origin marker and XML signature part"}, {"name": "appinstaller-info", "maps_to_native_concept": "App Installer descriptor and optional detached `.p7` companion metadata inspection"}, {"name": "appinstaller-verify-companion", "maps_to_native_concept": "App Installer XML descriptor plus detached PKCS#7 companion signature verification with explicit anchors"}, diff --git a/src/code.rs b/src/code.rs index 4e6e2db..d5bd6b7 100644 --- a/src/code.rs +++ b/src/code.rs @@ -665,11 +665,6 @@ fn sign_vsix_bytes( timestamp_url: Option<&str>, timestamp_digest: Option, ) -> Result> { - if timestamp_url.is_some() || timestamp_digest.is_some() { - return Err(anyhow!( - "VSIX XMLDSig timestamping is not implemented in `psign-tool code` yet" - )); - } if skip_signed && package_has_signature(input_bytes, &CodeFormat::Vsix)? { return Ok(input_bytes.to_vec()); } @@ -688,11 +683,19 @@ fn sign_vsix_bytes( timestamp_url, timestamp_digest, )?; - let signed_info = vsix::signed_info_xml(Cursor::new(updated.clone()), vsix_digest) - .with_context(|| format!("create VSIX SignedInfo for {label}"))?; - let (signature, cert_bytes) = signer.sign_xml_signed_info(vsix_digest, &signed_info)?; - let xml = vsix::signature_xml_from_signed_info(&signed_info, &signature, Some(&cert_bytes)) + let template = vsix::signature_template( + Cursor::new(updated.clone()), + vsix_digest, + vsix::VsixSigningTime::Now, + ) + .with_context(|| format!("create VSIX signature for {label}"))?; + let (signature, cert_bytes) = + signer.sign_xml_signed_info(vsix_digest, &template.signed_info)?; + let xml = template + .signature_xml(&signature, Some(&cert_bytes)) .into_bytes(); + let xml = timestamp_vsix_signature_if_requested(xml, timestamp_url, timestamp_digest) + .with_context(|| format!("timestamp VSIX signature for {label}"))?; let mut out = Cursor::new(Vec::new()); vsix::embed_signature_xml(Cursor::new(updated), &mut out, &xml, overwrite) .with_context(|| format!("embed VSIX signature XML into {label}"))?; @@ -1801,6 +1804,58 @@ fn timestamp_pkcs7_der_rfc3161( pkcs7::encode_pkcs7_content_info_signed_data_der(&stamped) } +/// Add the dotnet/sign `` RFC3161 timestamp over the VSIX `SignatureValue`. +fn timestamp_vsix_signature_if_requested( + signature_xml: Vec, + timestamp_url: Option<&str>, + timestamp_digest: Option, +) -> Result> { + match (timestamp_url, timestamp_digest) { + (Some(url), Some(digest)) => timestamp_vsix_signature_rfc3161(&signature_xml, url, digest), + (Some(_), None) => Err(anyhow!( + "`psign-tool code` requires --timestamp-digest with --timestamp-url" + )), + (None, Some(_)) => Err(anyhow!( + "`psign-tool code` requires --timestamp-url with --timestamp-digest" + )), + (None, None) => Ok(signature_xml), + } +} + +#[cfg(feature = "timestamp-http")] +fn timestamp_vsix_signature_rfc3161( + signature_xml: &[u8], + timestamp_url: &str, + timestamp_digest: DigestAlgorithm, +) -> Result> { + let signature_value = vsix::signature_value_from_signature_xml(signature_xml)?; + let imprint = timestamp_digest_bytes(timestamp_digest, &signature_value)?; + let response = post_rfc3161_timestamp_request(timestamp_url, timestamp_digest, &imprint)?; + let parsed = parse_time_stamp_resp_der(&response) + .ok_or_else(|| anyhow!("could not parse TimeStampResp DER from TSA response"))?; + if !parsed.pki_status.granted() { + return Err(anyhow!( + "TimeStampResp status is not granted (status={})", + parsed.pki_status.as_raw_integer() + )); + } + let token = parsed + .time_stamp_token + .ok_or_else(|| anyhow!("TimeStampResp has no timeStampToken"))?; + vsix::add_signature_timestamp(signature_xml, token) +} + +#[cfg(not(feature = "timestamp-http"))] +fn timestamp_vsix_signature_rfc3161( + _signature_xml: &[u8], + _timestamp_url: &str, + _timestamp_digest: DigestAlgorithm, +) -> Result> { + Err(anyhow!( + "`psign-tool code` RFC3161 timestamping requires the timestamp-http feature" + )) +} + #[cfg(not(feature = "timestamp-http"))] fn timestamp_pkcs7_der_rfc3161( _pkcs7_der: &[u8], diff --git a/tests/cli_pe_digest.rs b/tests/cli_pe_digest.rs index f0148d2..d4dcf8d 100644 --- a/tests/cli_pe_digest.rs +++ b/tests/cli_pe_digest.rs @@ -847,7 +847,8 @@ fn vsix_signature_xml_signs_verifies_and_embeds() { .arg("--cert") .arg(&cert) .arg("--trusted-ca") - .arg(&cert); + .arg(&cert) + .arg("--allow-loose-signing-cert"); verify .assert() .success() @@ -926,7 +927,8 @@ fn vsix_signature_xml_from_external_signature_signs_verifies_and_embeds() { .arg("--cert") .arg(&cert) .arg("--trusted-ca") - .arg(&cert); + .arg(&cert) + .arg("--allow-loose-signing-cert"); verify .assert() .success() @@ -949,7 +951,8 @@ fn vsix_signature_xml_from_external_signature_signs_verifies_and_embeds() { .arg("vsix-verify-signature") .arg(&output) .arg("--trusted-ca") - .arg(&cert); + .arg(&cert) + .arg("--allow-loose-signing-cert"); embedded_verify .assert() .success() @@ -1013,7 +1016,8 @@ fn vsix_sign_creates_verifiable_signed_package() { .arg("vsix-verify-signature") .arg(&output) .arg("--trusted-ca") - .arg(&cert); + .arg(&cert) + .arg("--allow-loose-signing-cert"); embedded_verify .assert() .success() @@ -1049,7 +1053,7 @@ fn vsix_verify_signature_rejects_tampered_signed_package() { let mut verify = portable_cmd(); verify.arg("vsix-verify-signature").arg(&tampered); verify.assert().failure().stderr(predicate::str::contains( - "signature reference count mismatch", + "VSIX part /tampered.txt is not covered by the signature", )); } @@ -1086,7 +1090,7 @@ fn vsix_signature_reference_xml_rejects_different_package() { .arg("--signature-xml") .arg(&xml); verify.assert().failure().stderr(predicate::str::contains( - "signature reference count mismatch", + "VSIX signature manifest digest mismatch", )); } diff --git a/tests/code_command.rs b/tests/code_command.rs index 7185f32..df7254a 100644 --- a/tests/code_command.rs +++ b/tests/code_command.rs @@ -1161,7 +1161,7 @@ fn code_rejects_clickonce_manifest_timestamping_explicitly() { } #[test] -fn code_rejects_vsix_timestamping_explicitly() { +fn code_timestamps_vsix_signature_through_configured_tsa() { let temp = tempfile::tempdir().unwrap(); let base = temp.path(); let input = base.join("sample.vsix"); @@ -1191,8 +1191,9 @@ fn code_rejects_vsix_timestamping_explicitly() { ]) .arg(&output) .arg("sample.vsix"); + // The VSIX `SignatureValue` is sent to the TSA (nothing listens on port 9). cmd.assert().failure().stderr(predicate::str::contains( - "VSIX XMLDSig timestamping is not implemented", + "timestamp VSIX signature for sample.vsix", )); } @@ -1718,7 +1719,11 @@ fn code_signs_nested_nupkg_before_outer_vsix() { &input, &[ ("extension.vsixmanifest", b"".as_slice()), - ("[Content_Types].xml", b"".as_slice()), + ( + "[Content_Types].xml", + br#""# + .as_slice(), + ), ( "packages/sample.nupkg", &std::fs::read( From 6be830530126a232c28ac8b6c3ad21fbdb91ceb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Mon, 26 Oct 2026 14:28:29 -0400 Subject: [PATCH 4/6] Add --format id to artifact-signing-publisher-id Print only the Azure Artifact Signing publisher ID (one line per signature) so it can be pinned without JSON parsing; fails when the signer has no identity EKU. Co-Authored-By: Claude Opus 5.5 --- crates/psign-digest-cli/src/main.rs | 47 +++++++++++++++++++++++++++-- docs/artifact-signing-validation.md | 13 ++++++++ docs/psign-cli-matrix.json | 2 +- tests/artifact_signing_cli.rs | 35 +++++++++++++++++++++ 4 files changed, 94 insertions(+), 3 deletions(-) diff --git a/crates/psign-digest-cli/src/main.rs b/crates/psign-digest-cli/src/main.rs index 911380e..d2f58d2 100644 --- a/crates/psign-digest-cli/src/main.rs +++ b/crates/psign-digest-cli/src/main.rs @@ -2093,14 +2093,18 @@ enum Command { #[command(flatten)] shared: TrustVerifySharedArgs, }, - /// Print the **Azure Artifact Signing** publisher ID (certificate profile identity EKU) as JSON. + /// Print the **Azure Artifact Signing** publisher ID (certificate profile identity EKU). /// /// Reads the signer certificate(s) of a PE, a raw PKCS#7, or a single certificate (PEM/DER). No /// trust checks; use **`trust-verify-pe --artifact-signing`** to validate the signature. + /// **`--format id`** prints only the publisher ID (one line per signature) and fails when the + /// signer has none. ArtifactSigningPublisherId { path: PathBuf, #[arg(long, value_enum, default_value_t = ArtifactSigningIdInput::Pe)] input: ArtifactSigningIdInput, + #[arg(long, value_enum, default_value_t = ArtifactSigningIdFormat::Json)] + format: ArtifactSigningIdFormat, }, /// Same trust pipeline as **`trust-verify-pe`** after CAB SIP digest consistency (**`verify-cab`**). TrustVerifyCab { @@ -3088,6 +3092,14 @@ enum ArtifactSigningIdInput { Cert, } +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +enum ArtifactSigningIdFormat { + /// Identity details as JSON. + Json, + /// Only the publisher ID (for pinning), one line per signature. + Id, +} + #[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] enum HashAlg { Sha1, @@ -4452,7 +4464,38 @@ where .with_context(|| format!("trust-verify-pe {}", path.display()))?; print_trust_ok("trust-verify-pe", &report); } - Command::ArtifactSigningPublisherId { path, input } => { + Command::ArtifactSigningPublisherId { + path, + input, + format: ArtifactSigningIdFormat::Id, + } => { + let bytes = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?; + let ids: Vec = match input { + ArtifactSigningIdInput::Pe => artifact_signing_identities_from_pe(&bytes)? + .into_iter() + .filter_map(|signer| signer.identity) + .map(|identity| identity.publisher_id) + .collect(), + ArtifactSigningIdInput::Pkcs7 => artifact_signing_identity_from_pkcs7_der(&bytes)? + .map(|identity| identity.publisher_id) + .into_iter() + .collect(), + ArtifactSigningIdInput::Cert => artifact_signing_identity_from_cert_bytes(&bytes)? + .map(|identity| identity.publisher_id) + .into_iter() + .collect(), + }; + if ids.is_empty() { + return Err(anyhow!( + "{}: no Azure Artifact Signing identity EKU (1.3.6.1.4.1.311.97.*) on the signer", + path.display() + )); + } + for id in ids { + println!("{id}"); + } + } + Command::ArtifactSigningPublisherId { path, input, .. } => { let bytes = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?; let json = match input { ArtifactSigningIdInput::Pe => { diff --git a/docs/artifact-signing-validation.md b/docs/artifact-signing-validation.md index 312d5a0..8371ed3 100644 --- a/docs/artifact-signing-validation.md +++ b/docs/artifact-signing-validation.md @@ -111,6 +111,19 @@ psign-tool portable artifact-signing-publisher-id ./signer.cer --input cert `identity` is `null` when the signer has no Artifact Signing identity EKU. This command does **not** validate the signature: any certificate can carry these OIDs. Use it to *discover* the ID you want to pin, then pin it with `--artifact-signing-publisher-id` (or `-PublisherId` in PowerShell). +To get only the publisher ID for pinning, without JSON parsing: + +```bash +psign-tool portable artifact-signing-publisher-id --format id ./MyApp.exe +# 285218949.549339218.252840042.443064405 +``` + +```powershell +(Get-PsignArtifactSigningPublisherId .\MyApp.exe).PublisherId +``` + +`--format id` prints one line per signature that carries an identity, and exits non-zero when none does. + On Windows the same value appears in the certificate's *Enhanced Key Usage* field as `Unknown Key Usage (1.3.6.1.4.1.311.97.…)`. ## Root CAs and chain layout diff --git a/docs/psign-cli-matrix.json b/docs/psign-cli-matrix.json index f91d4c8..7279f24 100644 --- a/docs/psign-cli-matrix.json +++ b/docs/psign-cli-matrix.json @@ -256,7 +256,7 @@ {"name": "pe-digest", "maps_to_native_concept": "PE SIP image hash (algorithm-selectable)"}, {"name": "verify-pe", "maps_to_native_concept": "Consistency check after conceptual WinTrust success — similar to `psign-tool verify --rust-sip-pe-digest-check`"}, {"name": "trust-verify-pe", "maps_to_native_concept": "Explicit-anchor PKCS#7 trust + picky chain + PE digest (not WinTrust); optional `--as-of YYYY-MM-DD` for expired fixtures; `--artifact-signing`, `--artifact-signing-publisher-id`, `--artifact-signing-private-trust-root`, `--artifact-signing-allow-missing-timestamp` (shared by every `trust-verify-*`) swap the anchors for the pinned Azure Artifact Signing policy"}, - {"name": "artifact-signing-publisher-id", "maps_to_native_concept": "Extract Azure Artifact Signing publisher ID (certificate profile identity EKU `1.3.6.1.4.1.311.97.*`) from PE / PKCS#7 / certificate as JSON (no trust decision)"}, + {"name": "artifact-signing-publisher-id", "maps_to_native_concept": "Extract Azure Artifact Signing publisher ID (certificate profile identity EKU `1.3.6.1.4.1.311.97.*`) from PE / PKCS#7 / certificate as JSON, or only the ID with `--format id` (no trust decision)"}, {"name": "trust-verify-cab", "maps_to_native_concept": "`verify-cab` digest consistency then same trust stack as PE"}, {"name": "trust-verify-msi", "maps_to_native_concept": "`verify-msi` digest consistency then same trust stack as PE"}, {"name": "trust-verify-esd", "maps_to_native_concept": "`verify-esd` digest consistency then same trust stack as PE"}, diff --git a/tests/artifact_signing_cli.rs b/tests/artifact_signing_cli.rs index 4cabda7..ae1f38f 100644 --- a/tests/artifact_signing_cli.rs +++ b/tests/artifact_signing_cli.rs @@ -51,6 +51,41 @@ fn publisher_id_from_certificate_and_pkcs7() { } } +#[test] +fn publisher_id_format_id_prints_only_the_id() { + for (input, path) in [ + ("cert", "artifact-signing/devolutions-signer.pem"), + ("pkcs7", "artifact-signing/psign-tool.pkcs7.der"), + ] { + psign() + .args([ + "portable", + "artifact-signing-publisher-id", + "--format", + "id", + "--input", + input, + ]) + .arg(fixture(path)) + .assert() + .success() + .stdout(format!("{PUBLISHER_ID}\n")); + } + psign() + .args([ + "portable", + "artifact-signing-publisher-id", + "--format", + "id", + ]) + .arg(fixture("pe-authenticode-upstream/tiny64.signed.efi")) + .assert() + .failure() + .stderr(predicate::str::contains( + "no Azure Artifact Signing identity EKU", + )); +} + #[test] fn publisher_id_is_null_for_non_artifact_signing_signer() { psign() From 173591490b0b7311e8f287ab418b5ab6b7d822b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Mon, 26 Oct 2026 14:29:32 -0400 Subject: [PATCH 5/6] Rename publisher ID output format to --format publisher-id Co-Authored-By: Claude Opus 5.5 --- crates/psign-digest-cli/src/main.rs | 6 +++--- docs/artifact-signing-validation.md | 4 ++-- docs/psign-cli-matrix.json | 2 +- tests/artifact_signing_cli.rs | 6 +++--- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/crates/psign-digest-cli/src/main.rs b/crates/psign-digest-cli/src/main.rs index d2f58d2..3e2c807 100644 --- a/crates/psign-digest-cli/src/main.rs +++ b/crates/psign-digest-cli/src/main.rs @@ -2097,7 +2097,7 @@ enum Command { /// /// Reads the signer certificate(s) of a PE, a raw PKCS#7, or a single certificate (PEM/DER). No /// trust checks; use **`trust-verify-pe --artifact-signing`** to validate the signature. - /// **`--format id`** prints only the publisher ID (one line per signature) and fails when the + /// **`--format publisher-id`** prints only the publisher ID (one line per signature) and fails when the /// signer has none. ArtifactSigningPublisherId { path: PathBuf, @@ -3097,7 +3097,7 @@ enum ArtifactSigningIdFormat { /// Identity details as JSON. Json, /// Only the publisher ID (for pinning), one line per signature. - Id, + PublisherId, } #[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] @@ -4467,7 +4467,7 @@ where Command::ArtifactSigningPublisherId { path, input, - format: ArtifactSigningIdFormat::Id, + format: ArtifactSigningIdFormat::PublisherId, } => { let bytes = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?; let ids: Vec = match input { diff --git a/docs/artifact-signing-validation.md b/docs/artifact-signing-validation.md index 8371ed3..1cfeabb 100644 --- a/docs/artifact-signing-validation.md +++ b/docs/artifact-signing-validation.md @@ -114,7 +114,7 @@ psign-tool portable artifact-signing-publisher-id ./signer.cer --input cert To get only the publisher ID for pinning, without JSON parsing: ```bash -psign-tool portable artifact-signing-publisher-id --format id ./MyApp.exe +psign-tool portable artifact-signing-publisher-id --format publisher-id ./MyApp.exe # 285218949.549339218.252840042.443064405 ``` @@ -122,7 +122,7 @@ psign-tool portable artifact-signing-publisher-id --format id ./MyApp.exe (Get-PsignArtifactSigningPublisherId .\MyApp.exe).PublisherId ``` -`--format id` prints one line per signature that carries an identity, and exits non-zero when none does. +`--format publisher-id` prints one line per signature that carries an identity, and exits non-zero when none does. On Windows the same value appears in the certificate's *Enhanced Key Usage* field as `Unknown Key Usage (1.3.6.1.4.1.311.97.…)`. diff --git a/docs/psign-cli-matrix.json b/docs/psign-cli-matrix.json index 7279f24..5b96eb9 100644 --- a/docs/psign-cli-matrix.json +++ b/docs/psign-cli-matrix.json @@ -256,7 +256,7 @@ {"name": "pe-digest", "maps_to_native_concept": "PE SIP image hash (algorithm-selectable)"}, {"name": "verify-pe", "maps_to_native_concept": "Consistency check after conceptual WinTrust success — similar to `psign-tool verify --rust-sip-pe-digest-check`"}, {"name": "trust-verify-pe", "maps_to_native_concept": "Explicit-anchor PKCS#7 trust + picky chain + PE digest (not WinTrust); optional `--as-of YYYY-MM-DD` for expired fixtures; `--artifact-signing`, `--artifact-signing-publisher-id`, `--artifact-signing-private-trust-root`, `--artifact-signing-allow-missing-timestamp` (shared by every `trust-verify-*`) swap the anchors for the pinned Azure Artifact Signing policy"}, - {"name": "artifact-signing-publisher-id", "maps_to_native_concept": "Extract Azure Artifact Signing publisher ID (certificate profile identity EKU `1.3.6.1.4.1.311.97.*`) from PE / PKCS#7 / certificate as JSON, or only the ID with `--format id` (no trust decision)"}, + {"name": "artifact-signing-publisher-id", "maps_to_native_concept": "Extract Azure Artifact Signing publisher ID (certificate profile identity EKU `1.3.6.1.4.1.311.97.*`) from PE / PKCS#7 / certificate as JSON, or only the ID with `--format publisher-id` (no trust decision)"}, {"name": "trust-verify-cab", "maps_to_native_concept": "`verify-cab` digest consistency then same trust stack as PE"}, {"name": "trust-verify-msi", "maps_to_native_concept": "`verify-msi` digest consistency then same trust stack as PE"}, {"name": "trust-verify-esd", "maps_to_native_concept": "`verify-esd` digest consistency then same trust stack as PE"}, diff --git a/tests/artifact_signing_cli.rs b/tests/artifact_signing_cli.rs index ae1f38f..3ffa0bb 100644 --- a/tests/artifact_signing_cli.rs +++ b/tests/artifact_signing_cli.rs @@ -52,7 +52,7 @@ fn publisher_id_from_certificate_and_pkcs7() { } #[test] -fn publisher_id_format_id_prints_only_the_id() { +fn publisher_id_format_prints_only_the_id() { for (input, path) in [ ("cert", "artifact-signing/devolutions-signer.pem"), ("pkcs7", "artifact-signing/psign-tool.pkcs7.der"), @@ -62,7 +62,7 @@ fn publisher_id_format_id_prints_only_the_id() { "portable", "artifact-signing-publisher-id", "--format", - "id", + "publisher-id", "--input", input, ]) @@ -76,7 +76,7 @@ fn publisher_id_format_id_prints_only_the_id() { "portable", "artifact-signing-publisher-id", "--format", - "id", + "publisher-id", ]) .arg(fixture("pe-authenticode-upstream/tiny64.signed.efi")) .assert() From d5c4923e76c277b3ccc1be8281a4a70583da37a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Mon, 26 Oct 2026 14:37:57 -0400 Subject: [PATCH 6/6] Fix irrefutable pattern in NuGet signer without remote providers Without the azure-kv-sign / artifact-signing-rest features SigningProvider has only the Local variant, so `if let SigningProvider::Local` was irrefutable and failed portable clippy (-D warnings). Use a match whose remote-provider arm is feature-gated. Co-Authored-By: Claude Opus 5.5 --- crates/psign-portable-core/src/lib.rs | 66 ++++++++++++++------------- 1 file changed, 35 insertions(+), 31 deletions(-) diff --git a/crates/psign-portable-core/src/lib.rs b/crates/psign-portable-core/src/lib.rs index 703bd96..bffd923 100644 --- a/crates/psign-portable-core/src/lib.rs +++ b/crates/psign-portable-core/src/lib.rs @@ -1330,41 +1330,45 @@ impl SigningProvider { ) -> Result> { let econtent_der = der_encode_octet_string(content)?; let id_data = der::asn1::ObjectIdentifier::new_unwrap(pkcs7::PKCS7_ID_DATA_OID); - if let SigningProvider::Local(local) = self { - let signed_attrs = pkcs7::pkcs7_signed_attrs( + match self { + SigningProvider::Local(local) => { + let signed_attrs = pkcs7::pkcs7_signed_attrs( + id_data, + &econtent_der, + digest_algorithm, + pkcs7::Pkcs7SignedAttributeProfile::NuGetAuthor, + Some(&local.signer_cert), + )?; + let attrs_der = signed_attrs + .to_der() + .context("encode NuGet signed attributes")?; + let signature = sign_xml_signed_info_rsa( + vsix_algorithm_from_authenticode_digest(digest_algorithm), + &attrs_der, + &local.private_key, + )?; + pkcs7::create_pkcs7_signed_data_der_with_signed_attrs_and_rsa_signature( + pkcs7::Pkcs7SignedDataDerInput { + econtent_type: id_data, + econtent_der: &econtent_der, + digest_algorithm, + signer_cert: local.signer_cert.clone(), + chain_certs: local.chain.clone(), + encrypted_digest: &signature, + content_mode: pkcs7::Pkcs7ContentMode::Attached, + signed_attrs, + }, + ) + } + // Remote providers exist only with their features. + #[cfg(any(feature = "azure-kv-sign", feature = "artifact-signing-rest"))] + _ => self.create_pkcs7_signed_data( id_data, &econtent_der, digest_algorithm, - pkcs7::Pkcs7SignedAttributeProfile::NuGetAuthor, - Some(&local.signer_cert), - )?; - let attrs_der = signed_attrs - .to_der() - .context("encode NuGet signed attributes")?; - let signature = sign_xml_signed_info_rsa( - vsix_algorithm_from_authenticode_digest(digest_algorithm), - &attrs_der, - &local.private_key, - )?; - return pkcs7::create_pkcs7_signed_data_der_with_signed_attrs_and_rsa_signature( - pkcs7::Pkcs7SignedDataDerInput { - econtent_type: id_data, - econtent_der: &econtent_der, - digest_algorithm, - signer_cert: local.signer_cert.clone(), - chain_certs: local.chain.clone(), - encrypted_digest: &signature, - content_mode: pkcs7::Pkcs7ContentMode::Attached, - signed_attrs, - }, - ); + pkcs7::Pkcs7ContentMode::Attached, + ), } - self.create_pkcs7_signed_data( - id_data, - &econtent_der, - digest_algorithm, - pkcs7::Pkcs7ContentMode::Attached, - ) } fn create_pkcs7_signed_data(