From f2f4e5eda855ff63de2381c3a05a5b77de536ef9 Mon Sep 17 00:00:00 2001 From: "Jeromy Statia (from Dev Box)" Date: Sun, 1 Feb 2026 16:02:44 -0800 Subject: [PATCH 1/8] Standardize CLI options to double-dash format and simplify plugin build targets - Standardize all CLI options to use -- prefix (e.g., --p, --sf, --iph) - Accept / and - prefixes for backward compatibility (converted to --) - Fix SignWithMissingAndInvalidCommandLineHeaders test (option normalization bug) - Update all documentation to use -- format consistently - Simplify plugin build targets: remove redundant PreparePluginsForPublish and legacy BuildAndDeployPlugins - Consolidate plugin discovery into reusable ItemGroup - Update help text to accurately describe option format --- .github/workflows/dotnet.yml | 106 +++++++-- CoseHandler/CoseHandler.cs | 24 +- CoseSign1.Tests/StreamExtensionsTests.cs | 13 ++ CoseSign1/Extensions/StreamExtensions.cs | 12 +- CoseSignTool.Abstractions/PluginLoader.cs | 19 +- ...SignTool.AzureTrustedSigning.Plugin.csproj | 1 + ...seSignTool.IndirectSignature.Plugin.csproj | 1 + .../CoseSignTool.MST.Plugin.csproj | 1 + CoseSignTool.Tests/MainTests.cs | 220 ++++++++++++++++-- CoseSignTool.Tests/Usings.cs | 3 +- CoseSignTool/CoseCommand.cs | 156 ++++++++++--- CoseSignTool/CoseSignTool.cs | 4 +- CoseSignTool/CoseSignTool.csproj | 96 ++++---- CoseSignTool/GetCommand.cs | 7 +- CoseSignTool/SignCommand.cs | 156 +++++++------ CoseSignTool/ValidateCommand.cs | 62 ++--- README.md | 40 ++-- docs/CertificateProviders.md | 60 ++--- docs/CoseSignTool.md | 186 +++++++-------- docs/PluginBuildDeploy.md | 54 +++-- docs/Plugins.md | 16 +- docs/SCITTCompliance.md | 74 +++--- docs/Troubleshooting.md | 4 +- 23 files changed, 851 insertions(+), 464 deletions(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 5ba318e5..38516f77 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -268,10 +268,12 @@ jobs: matrix: include: - os: windows-latest + runtime_id: win-x64 dir_command: gci -Recurse zip_command_debug: Compress-Archive -Path ./debug/ -DestinationPath CoseSignTool-Windows-debug.zip zip_command_release: Compress-Archive -Path ./release/ -DestinationPath CoseSignTool-Windows-release.zip - os: ubuntu-latest + runtime_id: linux-x64 dir_command: ls -a -R zip_command_debug: zip -r CoseSignTool-Linux-debug.zip ./debug/ zip_command_release: zip -r CoseSignTool-Linux-release.zip ./release/ @@ -292,41 +294,103 @@ jobs: uses: actions/checkout@v3 # Build and publish the binaries to ./published. - # The publish command will automatically deploy plugins via the DeployAllPluginsForPublish target in CoseSignTool.csproj + # Plugins are automatically built and deployed via BuildPlugins and DeployAllPlugins targets (enabled by default) + # PublishSingleFile=true creates a single self-contained executable with plugins bundled inside - name: Publish outputs run: | VERSION=${{ needs.create_release.outputs.tag_name }} # Remove the 'v' prefix from VERSION for VersionNgt property VERSION_WITHOUT_V=$(echo "$VERSION" | sed 's/^v//') - RUNTIME_ID=${{ matrix.runtime_id || '' }} - if [ -n "$RUNTIME_ID" ]; then - echo "Publishing for runtime: $RUNTIME_ID" - dotnet publish --configuration Debug --self-contained true --runtime $RUNTIME_ID --output published/debug --property:FileVersion=$VERSION --property:VersionNgt=$VERSION_WITHOUT_V --property:DeployPlugins=true CoseSignTool/CoseSignTool.csproj - dotnet publish --configuration Release --self-contained true --runtime $RUNTIME_ID --output published/release --property:FileVersion=$VERSION --property:VersionNgt=$VERSION_WITHOUT_V --property:DeployPlugins=true CoseSignTool/CoseSignTool.csproj - else - echo "Publishing for current platform" - dotnet publish --configuration Debug --self-contained true --output published/debug --property:FileVersion=$VERSION --property:VersionNgt=$VERSION_WITHOUT_V --property:DeployPlugins=true CoseSignTool/CoseSignTool.csproj - dotnet publish --configuration Release --self-contained true --output published/release --property:FileVersion=$VERSION --property:VersionNgt=$VERSION_WITHOUT_V --property:DeployPlugins=true CoseSignTool/CoseSignTool.csproj - fi - # Self-contained is needed. Must use .csproj instead of .sln. - # DeployPlugins=true enables automatic plugin deployment during publish - # RUNTIME_ID specifies the target runtime (e.g., osx-x64, osx-arm64) for cross-platform builds - # Ideally we should also verify in the Build and Test job, but that will require pre-caulculating the version number and either - # Running build and test separately because we can't pass the version number to dotnet test, or - # Setting the version number dynamically in the csproj files, using $(VersionBin) + RUNTIME_ID=${{ matrix.runtime_id }} + + echo "Publishing single-file self-contained executable for runtime: $RUNTIME_ID" + echo "Plugins will be bundled inside the executable" + + dotnet publish --configuration Debug --self-contained true --runtime $RUNTIME_ID --output published/debug --property:FileVersion=$VERSION --property:VersionNgt=$VERSION_WITHOUT_V --property:PublishSingleFile=true CoseSignTool/CoseSignTool.csproj + dotnet publish --configuration Release --self-contained true --runtime $RUNTIME_ID --output published/release --property:FileVersion=$VERSION --property:VersionNgt=$VERSION_WITHOUT_V --property:PublishSingleFile=true CoseSignTool/CoseSignTool.csproj + + # PublishSingleFile=true bundles everything into a single executable: + # - The .NET runtime (self-contained) + # - All plugins (bundled and extracted on first run) + # - No separate plugins folder needed - everything is in the exe shell: bash + + # Verify the single-file executable was created correctly + # With PublishSingleFile=true and IncludeAllContentForSelfExtract=true, plugins are bundled INSIDE the exe + - name: Verify single-file executable + run: | + Write-Host "Verifying single-file self-contained executable..." + Write-Host "" + + # Check debug output + Write-Host "=== Debug build ===" + $debugExe = Get-ChildItem "published/debug/CoseSignTool*" -File | Where-Object { $_.Extension -eq '.exe' -or $_.Extension -eq '' } | Select-Object -First 1 + if ($debugExe) { + $sizeMB = [math]::Round($debugExe.Length / 1MB, 2) + Write-Host "✅ Found: $($debugExe.Name) ($sizeMB MB)" + + # Plugins are bundled inside, so exe should be > 40MB (contains runtime + plugins) + if ($sizeMB -gt 40) { + Write-Host "✅ Size indicates plugins are bundled (expected for single-file with plugins)" + } else { + Write-Host "⚠️ Size seems small - plugins may not be bundled correctly" + } + } else { + Write-Host "❌ CoseSignTool executable not found in debug output!" + exit 1 + } + + # Check that plugins folder does NOT exist (should be bundled in exe) + if (Test-Path "published/debug/plugins") { + Write-Host "⚠️ Plugins folder exists - it should be cleaned up for single-file publish" + } else { + Write-Host "✅ No external plugins folder (correctly bundled in exe)" + } + + Write-Host "" + Write-Host "=== Release build ===" + $releaseExe = Get-ChildItem "published/release/CoseSignTool*" -File | Where-Object { $_.Extension -eq '.exe' -or $_.Extension -eq '' } | Select-Object -First 1 + if ($releaseExe) { + $sizeMB = [math]::Round($releaseExe.Length / 1MB, 2) + Write-Host "✅ Found: $($releaseExe.Name) ($sizeMB MB)" + + if ($sizeMB -gt 40) { + Write-Host "✅ Size indicates plugins are bundled" + } else { + Write-Host "⚠️ Size seems small - plugins may not be bundled correctly" + } + } else { + Write-Host "❌ CoseSignTool executable not found in release output!" + exit 1 + } + + if (Test-Path "published/release/plugins") { + Write-Host "⚠️ Plugins folder exists - it should be cleaned up for single-file publish" + } else { + Write-Host "✅ No external plugins folder (correctly bundled in exe)" + } + + Write-Host "" + Write-Host "Single-file verification complete. Plugins are bundled inside the executable." + Write-Host "On first run, the exe will extract to a temp directory including the plugins folder." + shell: pwsh # List the contents of the published directory to make sure all the artifacts are there. - name: List published directory run: ${{ matrix.dir_command }} working-directory: ./published - # Verify that the file versions on the DLLs match the release version + # Verify that the file versions on the exe match the release version - name: Check File Version run: | - $file = Get-Item "CoseSignTool.dll" - $version = $file.VersionInfo.FileVersion - Write-Output "File Version is $version" + # With single-file publish, we have CoseSignTool.exe (Windows) or CoseSignTool (Unix) + $file = Get-Item "CoseSignTool*" -ErrorAction SilentlyContinue | Where-Object { $_.Extension -eq '.exe' -or $_.Extension -eq '' } + if ($file) { + $version = $file.VersionInfo.FileVersion + Write-Output "File Version is $version" + } else { + Write-Output "Warning: Could not find CoseSignTool executable" + } shell: pwsh working-directory: ./published/debug diff --git a/CoseHandler/CoseHandler.cs b/CoseHandler/CoseHandler.cs index 3a90cac5..d2dd017d 100644 --- a/CoseHandler/CoseHandler.cs +++ b/CoseHandler/CoseHandler.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. +// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. namespace CoseX509; @@ -23,7 +23,7 @@ public static class CoseHandler /// True to embed an encoded copy of the payload content into the COSE signature structure. /// By default, the COSE signature uses a hash match to compare to the original content. This is called "detached" signing. /// .Optional. Writes the COSE signature to the specified file location. - /// For file extension, we recommend ".cose" for detached signatures, or ".csm" if the file is embed-signed. + /// We recommend using the ".cose" file extension for COSE signature files. /// Optional. A MIME type value to set as the Content Type of the payload. Default value is "application/cose". /// Optional. A provider to add custom headers to the signed message. /// Unsupported certificate type for COSE signing, or the certificate chain could not be built. @@ -48,7 +48,7 @@ public static ReadOnlyMemory Sign( /// True to embed an encoded copy of the payload content into the COSE signature structure. /// By default, the COSE signature uses a hash match to compare to the original content. This is called "detached" signing. /// .Optional. Writes the COSE signature to the specified file location. - /// For file extension, we recommend ".cose" for detached signatures, or ".csm" if the file is embed-signed. + /// We recommend using the ".cose" file extension for COSE signature files. /// Optional. A MIME type value to set as the Content Type of the payload. Default value is "application/cose". /// Optional. A provider to add custom headers to the signed message. /// Unsupported certificate type for COSE signing, or the certificate chain could not be built. @@ -73,7 +73,7 @@ public static ReadOnlyMemory Sign( /// True to embed an encoded copy of the payload content into the COSE signature structure. /// By default, the COSE signature uses a hash match to compare to the original content. This is called "detached" signing. /// .Optional. Writes the COSE signature to the specified file location. - /// For file extension, we recommend ".cose" for detached signatures, or ".csm" if the file is embed-signed. + /// We recommend using the ".cose" file extension for COSE signature files. /// Optional. A MIME type value to set as the Content Type of the payload. Default value is "application/cose". /// Optional. A provider to add custom headers to the signed message. /// Unsupported certificate type for COSE signing, or the certificate chain could not be built. @@ -102,7 +102,7 @@ public static ReadOnlyMemory Sign( /// True to embed an encoded copy of the payload content into the COSE signature structure. /// By default, the COSE signature uses a hash match to compare to the original content. This is called "detached" signing. /// .Optional. Writes the COSE signature to the specified file location. - /// For file extension, we recommend ".cose" for detached signatures, or ".csm" if the file is embed-signed. + /// We recommend using the ".cose" file extension for COSE signature files. /// Optional. The name of the certificate store that contains the signing certificate. Default is "My". /// Optional. The location of the certificate store that contains the signing certificate. Default is "CurrentUser". /// Optional. A MIME type value to set as the Content Type of the payload. Default value is "application/cose". @@ -131,7 +131,7 @@ public static ReadOnlyMemory Sign( /// True to embed an encoded copy of the payload content into the COSE signature structure. /// By default, the COSE signature uses a hash match to compare to the original content. This is called "detached" signing. /// .Optional. Writes the COSE signature to the specified file location. - /// For file extension, we recommend ".cose" for detached signatures, or ".csm" if the file is embed-signed. + /// We recommend using the ".cose" file extension for COSE signature files. /// Optional. The name of the certificate store that contains the signing certificate. Default is "My". /// Optional. The location of the certificate store that contains the signing certificate. Default is "CurrentUser". /// Optional. A MIME type value to set as the Content Type of the payload. Default value is "application/cose". @@ -160,7 +160,7 @@ public static ReadOnlyMemory Sign( /// True to embed an encoded copy of the payload content into the COSE signature structure. /// By default, the COSE signature uses a hash match to compare to the original content. This is called "detached" signing. /// .Optional. Writes the COSE signature to the specified file location. - /// For file extension, we recommend ".cose" for detached signatures, or ".csm" if the file is embed-signed. + /// We recommend using the ".cose" file extension for COSE signature files. /// Optional. The name of the certificate store that contains the signing certificate. Default is "My". /// Optional. The location of the certificate store that contains the signing certificate. Default is "CurrentUser". /// Optional. A MIME type value to set as the Content Type of the payload. Default value is "application/cose". @@ -194,7 +194,7 @@ public static ReadOnlyMemory Sign( /// True to embed an encoded copy of the payload content into the COSE signature structure. /// By default, the COSE signature uses a hash match to compare to the original content. This is called "detached" signing. /// .Optional. Writes the COSE signature to the specified file location. - /// For file extension, we recommend ".cose" for detached signatures, or ".csm" if the file is embed-signed. + /// We recommend using the ".cose" file extension for COSE signature files. /// Optional. A MIME type value to set as the Content Type of the payload. Default value is "application/cose". /// Optional. A provider to add custom headers to the signed message. /// Unsupported certificate type for COSE signing, or the certificate chain could not be built. @@ -218,7 +218,7 @@ public static ReadOnlyMemory Sign( /// True to embed an encoded copy of the payload content into the COSE signature structure. /// By default, the COSE signature uses a hash match to compare to the original content. This is called "detached" signing. /// .Optional. Writes the COSE signature to the specified file location. - /// For file extension, we recommend ".cose" for detached signatures, or ".csm" if the file is embed-signed. + /// We recommend using the ".cose" file extension for COSE signature files. /// Optional. A MIME type value to set as the Content Type of the payload. Default value is "application/cose". /// Optional. A provider to add custom headers to the signed message. /// Unsupported certificate type for COSE signing, or the certificate chain could not be built. @@ -242,7 +242,7 @@ public static ReadOnlyMemory Sign( /// True to embed an encoded copy of the payload content into the COSE signature structure. /// By default, the COSE signature uses a hash match to compare to the original content. This is called "detached" signing. /// .Optional. Writes the COSE signature to the specified file location. - /// For file extension, we recommend ".cose" for detached signatures, or ".csm" if the file is embed-signed. + /// We recommend using the ".cose" file extension for COSE signature files. /// Optional. A MIME type value to set as the Content Type of the payload. Default value is "application/cose". /// Optional. A provider to add custom headers to the signed message. /// The signing certificate is null or invalid. @@ -268,7 +268,7 @@ public static ReadOnlyMemory Sign( /// An CertificateCoseSigningKeyProvider that contains the signing certificate and hash information. /// True to embed an encoded copy of the payload content into the COSE signature structure. /// .Optional. Writes the COSE signature to the specified file location. - /// For file extension, we recommend ".cose" for detached signatures, or ".csm" if the file is embed-signed. + /// We recommend using the ".cose" file extension for COSE signature files. /// A MIME type value to set as the Content Type of the payload. /// Optional. A provider to add custom headers to the signed message. /// The COSE signature structure in a read-only byte array. @@ -326,7 +326,7 @@ payloadStream is not null ? /// True to embed an encoded copy of the payload content into the COSE signature structure. /// By default, the COSE signature uses a hash match to compare to the original content. This is called "detached" signing. /// .Optional. Writes the COSE signature to the specified file location. - /// For file extension, we recommend ".cose" for detached signatures, or ".csm" if the file is embed-signed. + /// We recommend using the ".cose" file extension for COSE signature files. /// Optional. A MIME type value to set as the Content Type of the payload. Default value is "application/cose". /// Optional. A provider to add custom headers to the signed message. /// Cancellation token to cancel the operation. diff --git a/CoseSign1.Tests/StreamExtensionsTests.cs b/CoseSign1.Tests/StreamExtensionsTests.cs index 9efc3757..c7846d0a 100644 --- a/CoseSign1.Tests/StreamExtensionsTests.cs +++ b/CoseSign1.Tests/StreamExtensionsTests.cs @@ -66,6 +66,19 @@ public void IsNullOrEmpty_NonSeekableStreamNoLength() mockStream.Object.IsNullOrEmpty().Should().Be(true); } + [Test] + public void IsNullOrEmpty_NonSeekablePipedStream_ShouldReturnFalse() + { + // Simulate piped stdin on Linux/macOS where Length throws NotSupportedException + // This should return false (not empty) since we assume readable streams have content + Mock mockStream = new(MockBehavior.Strict); + mockStream.Setup(s => s.CanSeek).Returns(false); + mockStream.Setup(s => s.CanRead).Returns(true); + mockStream.Setup(s => s.Length).Throws(new NotSupportedException("Stream does not support seeking.")); + + mockStream.Object.IsNullOrEmpty().Should().Be(false); + } + [Test] public void IsNullOrEmpty_WithTimeout() { diff --git a/CoseSign1/Extensions/StreamExtensions.cs b/CoseSign1/Extensions/StreamExtensions.cs index b1392026..e10bbf02 100644 --- a/CoseSign1/Extensions/StreamExtensions.cs +++ b/CoseSign1/Extensions/StreamExtensions.cs @@ -53,16 +53,20 @@ private static async Task HasContent(Stream stream, int maxWait = 100) return true; } + // Stream is not seekable (e.g., piped stdin on Linux/macOS). + // We cannot peek without consuming data, so we need to handle this differently. try { - // Stream is not able to seek so we cannot check if it has content, just ensure the stream length is > 0. - // In some cases non-seekable streams can return an exception when accessing content related fields because - // the data can only be read or written as it arrives or is processed sequentially. + // First try to check the Length property if available. return stream.Length > 0; } catch (NotSupportedException) { - return false; + // Length is not supported (common for piped streams). + // For non-seekable streams where we can't check length, we assume there IS content + // if the stream is readable. The actual read operation will fail gracefully if empty. + // This fixes piping on Linux/macOS where stdin.Length throws NotSupportedException. + return stream.CanRead; } } diff --git a/CoseSignTool.Abstractions/PluginLoader.cs b/CoseSignTool.Abstractions/PluginLoader.cs index 594fed8a..62e6ea24 100644 --- a/CoseSignTool.Abstractions/PluginLoader.cs +++ b/CoseSignTool.Abstractions/PluginLoader.cs @@ -99,23 +99,8 @@ public static void ValidatePluginDirectory(string pluginDirectory) "Attempted to load from an empty or null directory path."); } - string executablePath = System.Reflection.Assembly.GetExecutingAssembly().Location; - string executableDirectory; - - if (string.IsNullOrWhiteSpace(executablePath)) - { - // Fallback for single-file deployments or when Location is not available - executableDirectory = Directory.GetCurrentDirectory(); - } - else - { - executableDirectory = Path.GetDirectoryName(executablePath); - if (string.IsNullOrWhiteSpace(executableDirectory)) - { - // Additional fallback if GetDirectoryName returns null/empty - executableDirectory = Directory.GetCurrentDirectory(); - } - } + // Use AppContext.BaseDirectory which works for both regular and single-file deployments + string executableDirectory = AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); string authorizedPluginsDirectory = Path.Join(executableDirectory, "plugins"); diff --git a/CoseSignTool.AzureTrustedSigning.Plugin/CoseSignTool.AzureTrustedSigning.Plugin.csproj b/CoseSignTool.AzureTrustedSigning.Plugin/CoseSignTool.AzureTrustedSigning.Plugin.csproj index 24825430..fe0a46fe 100644 --- a/CoseSignTool.AzureTrustedSigning.Plugin/CoseSignTool.AzureTrustedSigning.Plugin.csproj +++ b/CoseSignTool.AzureTrustedSigning.Plugin/CoseSignTool.AzureTrustedSigning.Plugin.csproj @@ -2,6 +2,7 @@ net8.0 + win-x64;linux-x64;osx-x64;osx-arm64 enable enable true diff --git a/CoseSignTool.IndirectSignature.Plugin/CoseSignTool.IndirectSignature.Plugin.csproj b/CoseSignTool.IndirectSignature.Plugin/CoseSignTool.IndirectSignature.Plugin.csproj index 64732b14..c225bf72 100644 --- a/CoseSignTool.IndirectSignature.Plugin/CoseSignTool.IndirectSignature.Plugin.csproj +++ b/CoseSignTool.IndirectSignature.Plugin/CoseSignTool.IndirectSignature.Plugin.csproj @@ -2,6 +2,7 @@ net8.0 + win-x64;linux-x64;osx-x64;osx-arm64 enable enable true diff --git a/CoseSignTool.MST.Plugin/CoseSignTool.MST.Plugin.csproj b/CoseSignTool.MST.Plugin/CoseSignTool.MST.Plugin.csproj index 05e605c1..97f14fdd 100644 --- a/CoseSignTool.MST.Plugin/CoseSignTool.MST.Plugin.csproj +++ b/CoseSignTool.MST.Plugin/CoseSignTool.MST.Plugin.csproj @@ -2,6 +2,7 @@ net8.0 + win-x64;linux-x64;osx-x64;osx-arm64 enable enable true diff --git a/CoseSignTool.Tests/MainTests.cs b/CoseSignTool.Tests/MainTests.cs index 5557f47f..b03e6171 100644 --- a/CoseSignTool.Tests/MainTests.cs +++ b/CoseSignTool.Tests/MainTests.cs @@ -41,27 +41,27 @@ public void FromMainValid() string certPair = $"\"{PublicKeyIntermediateCertFile}, {PublicKeyRootCertFile}\""; string payloadFile = FileSystemUtils.GeneratePayloadFile(); - // sign detached - string[] args1 = ["sign", @"/p", payloadFile, @"/pfx", PrivateKeyCertFileChained]; + // sign detached - explicitly specify signature file to avoid collision with embedded + string detachedSigFile = payloadFile + ".detached.cose"; + string[] args1 = ["sign", @"/p", payloadFile, @"/pfx", PrivateKeyCertFileChained, @"/sf", detachedSigFile]; CoseSignTool.Main(args1).Should().Be((int)ExitCode.Success, "Detach sign should have succeeded."); - // sign embedded - string[] args2 = ["sign", @"/pfx", PrivateKeyCertFileChained, @"/p", payloadFile, @"/ep"]; + // sign embedded - explicitly specify signature file to avoid collision with detached + string embeddedSigFile = payloadFile + ".embedded.cose"; + string[] args2 = ["sign", @"/pfx", PrivateKeyCertFileChained, @"/p", payloadFile, @"/ep", @"/sf", embeddedSigFile]; CoseSignTool.Main(args2).Should().Be((int)ExitCode.Success, "Embed sign should have succeeded."); // validate detached - string sigFile = payloadFile + ".cose"; - string[] args3 = ["validate", @"/rt", certPair, @"/sf", sigFile, @"/p", payloadFile, "/rm", "NoCheck"]; + string[] args3 = ["validate", @"/rt", certPair, @"/sf", detachedSigFile, @"/p", payloadFile, "/rm", "NoCheck"]; CoseSignTool.Main(args3).Should().Be((int)ExitCode.Success, "Detach validation should have succeeded."); // validate embedded - sigFile = payloadFile + ".csm"; - string[] args4 = ["validate", @"/rt", certPair, @"/sf", sigFile, "/rm", "NoCheck", "/scd"]; + string[] args4 = ["validate", @"/rt", certPair, @"/sf", embeddedSigFile, "/rm", "NoCheck", "/scd"]; CoseSignTool.Main(args4).Should().Be((int)ExitCode.Success, "Embed validation should have succeeded."); // get content string saveFile = payloadFile + ".saved"; - string[] args5 = ["get", @"/rt", certPair, @"/sf", sigFile, "/sa", saveFile, "/rm", "NoCheck"]; + string[] args5 = ["get", @"/rt", certPair, @"/sf", embeddedSigFile, "/sa", saveFile, "/rm", "NoCheck"]; CoseSignTool.Main(args5).Should().Be(0, "Detach validation with save should have suceeded."); File.ReadAllText(payloadFile).Should().Be(File.ReadAllText(saveFile), "Saved content should have matched payload."); } @@ -135,9 +135,11 @@ public void FromMainInvalid() string[] args2 = ["sign", "/badArg", @"/pfx", "fake.pfx", @"/p", "some.file"]; CoseSignTool.Main(args2).Should().Be((int)ExitCode.UnknownArgument); - // empty payload argument - string[] args3 = ["sign", @"/pfx", "fake.pfx", @"/p", ""]; - CoseSignTool.Main(args3).Should().Be((int)ExitCode.MissingRequiredOption); + // missing certificate - no pfx or thumbprint provided + // This results in CertificateLoadFailure because LoadCert() throws ArgumentNullException + string payloadFile = FileSystemUtils.GeneratePayloadFile(); + string[] args3 = ["sign", @"/p", payloadFile]; + CoseSignTool.Main(args3).Should().Be((int)ExitCode.CertificateLoadFailure); } [TestMethod] @@ -568,5 +570,197 @@ public void Main_WithUnknownVerb_ShowsGeneralHelp() // Assert result.Should().Be((int)ExitCode.HelpRequested, "Unknown verb should show general help"); } -} + #region Piping Tests + + /// + /// Tests that piping works end-to-end by running actual process with redirected streams. + /// Simulates: gc mycontent | cosesigntool sign -pfx cert.pfx -ep -po | cosesigntool validate -rt roots.cer + /// + [TestMethod] + public void EndToEndPipelineWithActualProcess() + { + // Arrange - create test payload + string payloadContent = "Test payload content for piped signing " + Guid.NewGuid().ToString(); + byte[] payloadBytes = System.Text.Encoding.UTF8.GetBytes(payloadContent); + + string exePath = Path.Combine(AppContext.BaseDirectory, "CoseSignTool.dll"); + + // Step 1: Sign with piped payload (embedded signature so payload is included) + byte[] signatureBytes; + using (var signProcess = new Process()) + { + signProcess.StartInfo = new ProcessStartInfo + { + FileName = "dotnet", + Arguments = $"\"{exePath}\" sign /pfx \"{PrivateKeyCertFileChained}\" /po /ep", + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + signProcess.Start(); + + // Write payload to stdin + signProcess.StandardInput.BaseStream.Write(payloadBytes, 0, payloadBytes.Length); + signProcess.StandardInput.Close(); + + // Read signature from stdout + using MemoryStream ms = new(); + signProcess.StandardOutput.BaseStream.CopyTo(ms); + signatureBytes = ms.ToArray(); + + signProcess.WaitForExit(30000); + + string stderr = signProcess.StandardError.ReadToEnd(); + signProcess.ExitCode.Should().Be(0, $"Sign process should succeed. StdErr: {stderr}"); + } + + signatureBytes.Should().NotBeEmpty("Signature should be produced"); + + // Step 2: Validate with piped embedded signature + string certPair = $"{PublicKeyIntermediateCertFile}, {PublicKeyRootCertFile}"; + using (var validateProcess = new Process()) + { + validateProcess.StartInfo = new ProcessStartInfo + { + FileName = "dotnet", + Arguments = $"\"{exePath}\" validate /rt \"{certPair}\" /rm NoCheck", + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + validateProcess.Start(); + + // Write signature to stdin + validateProcess.StandardInput.BaseStream.Write(signatureBytes, 0, signatureBytes.Length); + validateProcess.StandardInput.Close(); + + string stdout = validateProcess.StandardOutput.ReadToEnd(); + string stderr = validateProcess.StandardError.ReadToEnd(); + + validateProcess.WaitForExit(30000); + + validateProcess.ExitCode.Should().Be(0, $"Validate process should succeed. StdOut: {stdout}, StdErr: {stderr}"); + stdout.Should().Contain("Validation succeeded", "Output should indicate success"); + } + } + + /// + /// Tests detached signature validation with piped signature and file-based payload. + /// Simulates: gc detached.cose | cosesigntool validate -p payload.txt -rt roots.cer + /// + [TestMethod] + public void ValidateDetachedSignatureWithPipedSignatureAndPayloadFile() + { + // Arrange - create signature file first + string certPair = $"{PublicKeyIntermediateCertFile}, {PublicKeyRootCertFile}"; + string payloadFile = FileSystemUtils.GeneratePayloadFile(); + string sigFile = payloadFile + ".detached.cose"; + + // Create detached signature using file I/O + string[] signArgs = ["sign", @"/p", payloadFile, @"/pfx", PrivateKeyCertFileChained, @"/sf", sigFile]; + CoseSignTool.Main(signArgs).Should().Be((int)ExitCode.Success, "Detached sign should succeed"); + + // Read signature for piping + byte[] signatureBytes = File.ReadAllBytes(sigFile); + + string exePath = Path.Combine(AppContext.BaseDirectory, "CoseSignTool.dll"); + + // Validate with piped signature + using var validateProcess = new Process(); + validateProcess.StartInfo = new ProcessStartInfo + { + FileName = "dotnet", + Arguments = $"\"{exePath}\" validate /p \"{payloadFile}\" /rt \"{certPair}\" /rm NoCheck", + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + validateProcess.Start(); + + // Write signature to stdin + validateProcess.StandardInput.BaseStream.Write(signatureBytes, 0, signatureBytes.Length); + validateProcess.StandardInput.Close(); + + string stdout = validateProcess.StandardOutput.ReadToEnd(); + string stderr = validateProcess.StandardError.ReadToEnd(); + + validateProcess.WaitForExit(30000); + + validateProcess.ExitCode.Should().Be(0, $"Validate with piped detached signature should succeed. StdOut: {stdout}, StdErr: {stderr}"); + stdout.Should().Contain("Validation succeeded", "Output should indicate success"); + + // Cleanup + File.Delete(payloadFile); + File.Delete(sigFile); + } + + /// + /// Tests get command with piped embedded signature. + /// Simulates: gc embedded.cose | cosesigntool get -rt roots.cer + /// Get command writes to stdout by default (no -po needed, unlike sign command) + /// + [TestMethod] + public void GetContentFromPipedEmbeddedSignature() + { + // Arrange - create embedded signature file first + string certPair = $"{PublicKeyIntermediateCertFile}, {PublicKeyRootCertFile}"; + string payloadContent = "Unique payload for get test " + Guid.NewGuid().ToString(); + string payloadFile = Path.GetTempFileName(); + File.WriteAllText(payloadFile, payloadContent); + string sigFile = payloadFile + ".embedded.cose"; + + // Create embedded signature using file I/O + string[] signArgs = ["sign", @"/p", payloadFile, @"/pfx", PrivateKeyCertFileChained, @"/ep", @"/sf", sigFile]; + CoseSignTool.Main(signArgs).Should().Be((int)ExitCode.Success, "Embedded sign should succeed"); + + // Read signature for piping + byte[] signatureBytes = File.ReadAllBytes(sigFile); + + string exePath = Path.Combine(AppContext.BaseDirectory, "CoseSignTool.dll"); + + // Get content with piped signature (get writes to stdout by default when -sa not specified) + using var getProcess = new Process(); + getProcess.StartInfo = new ProcessStartInfo + { + FileName = "dotnet", + Arguments = $"\"{exePath}\" get /rt \"{certPair}\" /rm NoCheck", + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + getProcess.Start(); + + // Write signature to stdin + getProcess.StandardInput.BaseStream.Write(signatureBytes, 0, signatureBytes.Length); + getProcess.StandardInput.Close(); + + using MemoryStream outputMs = new(); + getProcess.StandardOutput.BaseStream.CopyTo(outputMs); + string extractedContent = System.Text.Encoding.UTF8.GetString(outputMs.ToArray()); + string stderr = getProcess.StandardError.ReadToEnd(); + + getProcess.WaitForExit(30000); + + getProcess.ExitCode.Should().Be(0, $"Get from piped embedded signature should succeed. StdErr: {stderr}"); + extractedContent.Should().Contain(payloadContent, "Extracted content should contain original payload"); + + // Cleanup + File.Delete(payloadFile); + File.Delete(sigFile); + } + + #endregion +} diff --git a/CoseSignTool.Tests/Usings.cs b/CoseSignTool.Tests/Usings.cs index 5e00ed86..8757bb15 100644 --- a/CoseSignTool.Tests/Usings.cs +++ b/CoseSignTool.Tests/Usings.cs @@ -1,7 +1,8 @@ -// Copyright (c) Microsoft Corporation. +// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. global using System; +global using System.Diagnostics; global using System.IO; global using System.Linq; global using System.Security.Cryptography.X509Certificates; diff --git a/CoseSignTool/CoseCommand.cs b/CoseSignTool/CoseCommand.cs index ac943e60..b43da09a 100644 --- a/CoseSignTool/CoseCommand.cs +++ b/CoseSignTool/CoseCommand.cs @@ -8,29 +8,33 @@ namespace CoseSignTool; /// public abstract partial class CoseCommand { - [GeneratedRegex(":[^\\/]")] - private static partial Regex PatternColonNotUri(); - [GeneratedRegex("^/")] private static partial Regex PatternStartingSlash(); + // Matches a colon in the OPTION NAME portion (after -/--/) + // Only matches if there's a colon followed by a non-path-separator character + // This allows -Option:Value but not -Option:C:\path (drive letter colon) + [GeneratedRegex(@"^-{1,2}[^:]+:(?![\\/:])")] + private static partial Regex PatternColonDelimitedArg(); + /// /// A map of shared command line options to their abbreviated aliases. + /// All options use -- prefix. Single dash (-) and forward slash (/) are converted to -- for backward compatibility. /// protected internal static readonly Dictionary Options = new() { - ["-PayloadFile"] = "PayloadFile", - ["-payload"] = "PayloadFile", - ["-p"] = "PayloadFile", - ["-SignatureFile"] = "SignatureFile", - ["-sig"] = "SignatureFile", - ["-sf"] = "SignatureFile", - ["-UseAdvancedStreamHandling"] = "UseAdvancedStreamHandling", - ["-adv"] = "UseAdvancedStreamHandling", - ["-MaxWaitTime"] = "MaxWaitTime", - ["-wait"] = "MaxWaitTime", - ["-FailFast"] = "FailFast", - ["-ff"] = "FailFast", + ["--PayloadFile"] = "PayloadFile", + ["--payload"] = "PayloadFile", + ["--p"] = "PayloadFile", + ["--SignatureFile"] = "SignatureFile", + ["--sig"] = "SignatureFile", + ["--sf"] = "SignatureFile", + ["--UseAdvancedStreamHandling"] = "UseAdvancedStreamHandling", + ["--adv"] = "UseAdvancedStreamHandling", + ["--MaxWaitTime"] = "MaxWaitTime", + ["--wait"] = "MaxWaitTime", + ["--FailFast"] = "FailFast", + ["--ff"] = "FailFast", }; #region Public properties @@ -43,7 +47,7 @@ public abstract partial class CoseCommand /// Specifies a file that contains or will contain a COSE X509 signature. /// Detach signed signature files contain the hash of the original payload file to match against. /// Embed signed signature files include an encoded copy of the entire payload. - /// Default filename when signing is [Payload].cose for detached or [Payload].csm for embedded. + /// Default filename when signing is [Payload].cose. /// public FileInfo? SignatureFile { get; set; } @@ -243,7 +247,10 @@ protected static string[] GetOptionArray(CommandLineConfigurationProvider provid { _ = provider.TryGet(name, out string? text); return - text?.Split(",").Select(x => x.Trim().Trim('"', '(', ')', '[', ']', '{', '}')).ToArray() ?? + text?.Split(",") + .Select(x => x.Trim().Trim('"', '(', ')', '[', ']', '{', '}')) + .Where(x => !string.IsNullOrEmpty(x)) + .ToArray() ?? defaultValue ?? []; } @@ -330,30 +337,58 @@ protected static void ThrowIfMissing(string file, string message) } } - // Resolve boolean command line options from "-argname" to "-argname true" - // replace /arg with -arg - // replace "-arg:" with "-arg " + // Normalize boolean command line options from "--argname" to "--argname true" + // Normalize option prefixes: + // /arg becomes --arg + // -arg becomes --arg + // --arg stays as --arg + // Handle colon-delimited args like --option:value private static string[] CleanArgs(string[] args, StringDictionary options) { List argsOut = []; for (int i = 0; i < args.Length; i++) { string arg = args[i]; - if (arg.StartsWith('/')) + + // First normalize prefix: convert / or - to -- + if (arg.StartsWith('/') || (arg.StartsWith('-') && !arg.StartsWith("--"))) { - // Standardize on -arg - arg = $"-{arg.AsSpan(1)}"; + arg = $"--{arg.AsSpan(1)}"; + } + + // Extract the option name (before any colon) + string argWithoutColon = arg; + int colonInArg = arg.StartsWith("--") ? arg.IndexOf(':', 2) : -1; + if (colonInArg > 0) + { + argWithoutColon = arg.Substring(0, colonInArg); } if (arg.StartsWith('-')) { - // arg is an option name - if (PatternColonNotUri().IsMatch(arg)) // Match if arg contains a colon not followed by a \ or / character + // arg is an option name (possibly with colon-delimited value) + // Check for colon-delimited format: -option:value or --option:value + // But NOT for paths like -p:c:\path (where colon is followed by \) + string withoutDashes = arg.StartsWith("--") ? arg.Substring(2) : arg.Substring(1); + int colonIdx = withoutDashes.IndexOf(':'); + + if (colonIdx > 0) { - // Split colon-delimited arg into name/value pair, but only on first colon in case the - // value is a file path - argsOut.AddRange(arg.Split(':', 2, StringSplitOptions.RemoveEmptyEntries)); - continue; + // Check if the character AFTER the colon is a path separator or another colon + // If so, this is likely a Windows path, not a delimiter + int colonPosInArg = arg.IndexOf(':', arg.StartsWith("--") ? 2 : 1); + bool isPathColon = colonPosInArg + 1 < arg.Length && + (arg[colonPosInArg + 1] == '\\' || arg[colonPosInArg + 1] == '/' || arg[colonPosInArg + 1] == ':'); + + if (!isPathColon) + { + // Split colon-delimited arg into name/value pair + string optName = arg.Substring(0, colonPosInArg); + string optValue = arg.Substring(colonPosInArg + 1); + argsOut.Add(optName); + argsOut.Add(optValue); + continue; + } } argsOut.Add(arg); @@ -378,10 +413,37 @@ private static string[] CleanArgs(string[] args, StringDictionary options) return [.. argsOut]; } + /// + /// Normalizes an option name to the internal format. + /// Converts /option and -option to --option for consistency. + /// + private static string NormalizeOptionName(string option) + { + if (option.StartsWith("--")) + { + return option; + } + else if (option.StartsWith('/') || option.StartsWith('-')) + { + // Convert /arg or -arg to --arg + return $"--{option.AsSpan(1)}"; + } + return option; + } + + /// + /// Determines if a string represents a short option name (1-2 letters). + /// + private static bool IsShortOption(string name) + { + return name.Length <= 2 && name.All(char.IsLetter); + } + private static bool IsSwitch(string s, StringDictionary options) { - // replace '/' with '-', and remove ':*' for easy dict lookup - return options.ContainsKey(PatternStartingSlash().Replace(s, "-").Split(":")[0]); + // Normalize and check if it's a recognized switch + string normalized = NormalizeOptionName(s); + return options.ContainsKey(normalized.Split(":")[0]); } @@ -391,10 +453,30 @@ private static bool HasInvalidArgument(string[] args, StringDictionary options, badArg = null; for (int i = 0; i < args.Length; i ++) { - if (args[i].StartsWith('-') && !options.ContainsKey(args[i])) + string arg = args[i]; + if (arg.StartsWith('-')) { - badArg = args[i]; - return true; + // Extract just the option name (before any colon delimiter, handling Windows paths) + string optionName = arg; + int dashOffset = arg.StartsWith("--") ? 2 : 1; + int colonIdx = arg.IndexOf(':', dashOffset); + if (colonIdx > dashOffset) + { + // Check if this colon is a delimiter (not part of a Windows path) + // A Windows path colon is followed by \ or / or another : + bool isDelimiter = colonIdx + 1 >= arg.Length || + (arg[colonIdx + 1] != '\\' && arg[colonIdx + 1] != '/' && arg[colonIdx + 1] != ':'); + if (isDelimiter) + { + optionName = arg.Substring(0, colonIdx); + } + } + + if (!options.ContainsKey(optionName)) + { + badArg = arg; + return true; + } } } @@ -423,6 +505,10 @@ CoseSignTool.exe [sign | validate | get] [options] -- OR -- [source program] | CoseSignTool.exe [sign | validate | get] [options] where [source program] pipes the first required option to CoseSignTool. + +Option format: All options use double-dash prefix (--option), including short aliases (--p, --sf, etc.). + Forward slash (/option) and single-dash (-option) are also accepted for backward compatibility. + Examples: --PayloadFile, --payload, --p, /p, -p are all equivalent. "; /// @@ -435,6 +521,6 @@ is signed with a valid certificate chain. Get: Retrieves and decodes the original payload from a COSE embed signed file or piped signature, writes it to a file or to the console, and writes any validation errors to Standard Error. -To see the options for a specific command, type 'CoseSignTool [sign | validate | get] /?'"; +To see the options for a specific command, type 'CoseSignTool [sign | validate | get] --help'"; #endregion } diff --git a/CoseSignTool/CoseSignTool.cs b/CoseSignTool/CoseSignTool.cs index 094f2e8f..e4f57f71 100644 --- a/CoseSignTool/CoseSignTool.cs +++ b/CoseSignTool/CoseSignTool.cs @@ -163,8 +163,8 @@ private static void LoadPlugins() { try { - string executablePath = System.Reflection.Assembly.GetExecutingAssembly().Location; - string executableDirectory = Path.GetDirectoryName(executablePath) ?? Directory.GetCurrentDirectory(); + // Use AppContext.BaseDirectory which works for both regular and single-file deployments + string executableDirectory = AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); // Only load plugins from the authorized "plugins" subdirectory string pluginsDirectory = Path.Join(executableDirectory, "plugins"); diff --git a/CoseSignTool/CoseSignTool.csproj b/CoseSignTool/CoseSignTool.csproj index 0ab7c934..5514d902 100644 --- a/CoseSignTool/CoseSignTool.csproj +++ b/CoseSignTool/CoseSignTool.csproj @@ -7,6 +7,29 @@ AnyCPU + + + true + false + + + + + + + + + + + true + true + true + true + true + + enable @@ -73,7 +96,9 @@ $(OutputPath)plugins $(PluginsDir)\$(PluginName) - $(MSBuildProjectDirectory)\..\$(PluginName)\bin\$(Configuration)\net8.0 + + $(MSBuildProjectDirectory)\..\$(PluginName)\bin\$(Configuration)\net8.0\$(RuntimeIdentifier) + $(MSBuildProjectDirectory)\..\$(PluginName)\bin\$(Configuration)\net8.0 @@ -105,62 +130,49 @@ - + - - - - - - - - - - - + - + - - - + + + + $(OutputPath)plugins + + + - + - + - + + plugins\%(RecursiveDir)%(Filename)%(Extension) + - - - - + - - - - - - - - - - - - - - + + + + + + + + + + + diff --git a/CoseSignTool/GetCommand.cs b/CoseSignTool/GetCommand.cs index 0d67cff0..b42ccd4c 100644 --- a/CoseSignTool/GetCommand.cs +++ b/CoseSignTool/GetCommand.cs @@ -8,7 +8,8 @@ public class GetCommand : ValidateCommand // public static new readonly Dictionary Options = // Use the same options as the Validate command but remove Payload and add SaveTo. - new Dictionary { ["-SaveTo"] = "SaveTo", ["-sa"] = "SaveTo" } + // All options use -- prefix. Single dash (-) and forward slash (/) are converted to -- for backward compatibility. + new Dictionary { ["--SaveTo"] = "SaveTo", ["--sa"] = "SaveTo" } .Concat(ValidateCommand.Options) .Where(k => !k.Value.Equals(nameof(PayloadFile), StringComparison.OrdinalIgnoreCase)) .ToDictionary(k => k.Key, k => k.Value, StringComparer.InvariantCultureIgnoreCase); @@ -86,9 +87,9 @@ protected internal override ValidationResult RunCoseHandlerCommand( Options: - SignatureFile / sigfile / sf: Required, pipeable. The file or piped stream containing the COSE signature. + --SignatureFile, --sig, -sf: Required, pipeable. The file or piped stream containing the COSE signature. - SaveTo /sa: Specifies a file path to write the decoded payload content to. + --SaveTo, -sa: Specifies a file path to write the decoded payload content to. If no path is specified, output will be written to console. "; } diff --git a/CoseSignTool/SignCommand.cs b/CoseSignTool/SignCommand.cs index faacdf89..7769e881 100644 --- a/CoseSignTool/SignCommand.cs +++ b/CoseSignTool/SignCommand.cs @@ -13,49 +13,50 @@ public class SignCommand : CoseCommand { /// /// A map of command line options to their abbreviated aliases. + /// All options use -- prefix. Single dash (-) and forward slash (/) are converted to -- for backward compatibility. /// private static readonly Dictionary PrivateOptions = new() { - ["-EmbedPayload"] = "EmbedPayload", - ["-ep"] = "EmbedPayload", - ["-PipeOutput"] = "PipeOutput", - ["-po"] = "PipeOutput", - ["-PfxCertificate"] = "PfxCertificate", - ["-pfx"] = "PfxCertificate", - ["-Password"] = "Password", - ["-pw"] = "Password", - ["-Thumbprint"] = "Thumbprint", - ["-th"] = "Thumbprint", - ["-StoreName"] = "StoreName", - ["-sn"] = "StoreName", - ["-StoreLocation"] = "StoreLocation", - ["-sl"] = "StoreLocation", - ["-ContentType"] = "ContentType", - ["-cty"] = "ContentType", - ["-IntHeaders"] = "IntHeaders", - ["-ih"] = "IntHeaders", - ["-StringHeaders"] = "StringHeaders", - ["-sh"] = "StringHeaders", - ["-IntProtectedHeaders"] = "IntProtectedHeaders", - ["-iph"] = "IntProtectedHeaders", - ["-StringProtectedHeaders"] = "StringProtectedHeaders", - ["-sph"] = "StringProtectedHeaders", - ["-IntUnProtectedHeaders"] = "IntUnProtectedHeaders", - ["-iuh"] = "IntUnProtectedHeaders", - ["-StringUnProtectedHeaders"] = "StringUnProtectedHeaders", - ["-suh"] = "StringUnProtectedHeaders", - ["-CwtIssuer"] = "CwtIssuer", - ["-cwt-iss"] = "CwtIssuer", - ["-CwtSubject"] = "CwtSubject", - ["-cwt-sub"] = "CwtSubject", - ["-CwtAudience"] = "CwtAudience", - ["-cwt-aud"] = "CwtAudience", - ["-CwtClaims"] = "CwtClaims", - ["-cwt"] = "CwtClaims", - ["-EnableScittCompliance"] = "EnableScittCompliance", - ["-scitt"] = "EnableScittCompliance", - ["-CertProvider"] = "CertProvider", - ["-cp"] = "CertProvider" + ["--EmbedPayload"] = "EmbedPayload", + ["--ep"] = "EmbedPayload", + ["--PipeOutput"] = "PipeOutput", + ["--po"] = "PipeOutput", + ["--PfxCertificate"] = "PfxCertificate", + ["--pfx"] = "PfxCertificate", + ["--Password"] = "Password", + ["--pw"] = "Password", + ["--Thumbprint"] = "Thumbprint", + ["--th"] = "Thumbprint", + ["--StoreName"] = "StoreName", + ["--sn"] = "StoreName", + ["--StoreLocation"] = "StoreLocation", + ["--sl"] = "StoreLocation", + ["--ContentType"] = "ContentType", + ["--cty"] = "ContentType", + ["--IntHeaders"] = "IntHeaders", + ["--ih"] = "IntHeaders", + ["--StringHeaders"] = "StringHeaders", + ["--sh"] = "StringHeaders", + ["--IntProtectedHeaders"] = "IntProtectedHeaders", + ["--iph"] = "IntProtectedHeaders", + ["--StringProtectedHeaders"] = "StringProtectedHeaders", + ["--sph"] = "StringProtectedHeaders", + ["--IntUnProtectedHeaders"] = "IntUnProtectedHeaders", + ["--iuh"] = "IntUnProtectedHeaders", + ["--StringUnProtectedHeaders"] = "StringUnProtectedHeaders", + ["--suh"] = "StringUnProtectedHeaders", + ["--CwtIssuer"] = "CwtIssuer", + ["--cwt-iss"] = "CwtIssuer", + ["--CwtSubject"] = "CwtSubject", + ["--cwt-sub"] = "CwtSubject", + ["--CwtAudience"] = "CwtAudience", + ["--cwt-aud"] = "CwtAudience", + ["--CwtClaims"] = "CwtClaims", + ["--cwt"] = "CwtClaims", + ["--EnableScittCompliance"] = "EnableScittCompliance", + ["--scitt"] = "EnableScittCompliance", + ["--CertProvider"] = "CertProvider", + ["--cp"] = "CertProvider" }; // Inherited default values @@ -237,8 +238,7 @@ public override ExitCode Run() "CoseSignTool could not determine a path to write the signature file to."); } - string extension = EmbedPayload ? "csm" : "cose"; - SignatureFile = new FileInfo($"{PayloadFile.FullName}.{extension}"); + SignatureFile = new FileInfo($"{PayloadFile.FullName}.cose"); } try @@ -1024,88 +1024,96 @@ A detached signature resides in a separate file and validates against the origin An embedded signature contains a copy of the original payload. Not supported for payload of >2gb in size. Options: - PayloadFile / payload / p: Required, pipeable. The file or piped content to sign. + --PayloadFile, --payload, -p: Required, pipeable. The file or piped content to sign. - SignatureFile / sig / sf: Optional. The file path to write the Cose signature to. - Default value is [payload file].cose for detached signatures or [payload file].csm for embedded. + --SignatureFile, --sig, -sf: Optional. The file path to write the Cose signature to. + Default value is [payload file].cose. Required if neither PayloadFile or PipeOutput are set. A signing certificate from one of the following sources: - Certificate Provider Plugin (--cert-provider / -cp): Use a certificate provider plugin such as Azure - Trusted Signing or custom HSM providers. See Certificate Providers section below for available providers. + --CertProvider, -cp: Use a certificate provider plugin such as Azure Trusted Signing or custom HSM providers. + See Certificate Providers section below for available providers. --OR-- - PfxCertificate / pfx: A path to a private key certificate file (.pfx) to sign with. + --PfxCertificate, --pfx: A path to a private key certificate file (.pfx) to sign with. - Password / pw: Optional. The password for the .pfx file if it has one. (Strongly recommended!) + --Password, -pw: Optional. The password for the .pfx file if it has one. (Strongly recommended!) --OR-- - Thumbprint / th: The SHA1 thumbprint of a certificate in the local certificate store to sign the file with. + --Thumbprint, -th: The SHA1 thumbprint of a certificate in the local certificate store to sign the file with. Use the optional StoreName and StoreLocation parameters to tell CoseSignTool where to find the matching certificate. - StoreName / sn: Optional. The name of the local certificate store to find the signing certificate in. + --StoreName, -sn: Optional. The name of the local certificate store to find the signing certificate in. Default value is 'My'. - StoreLocation / sl: Optional. The location of the local certificate store to find the signing certificate in. + --StoreLocation, -sl: Optional. The location of the local certificate store to find the signing certificate in. Default value is 'CurrentUser'. - PipeOutput /po: Optional. If set, outputs the detached or embedded COSE signature to Standard Out instead of writing - to file. + --PipeOutput, -po: Optional. If set, outputs the detached or embedded COSE signature to Standard Out instead of + writing to file. - EmbedPayload / ep: Optional. If true, embeds a copy of the payload in the COSE signature file .Content property. + --EmbedPayload, -ep: Optional. If true, embeds a copy of the payload in the COSE signature file .Content property. Default behavior is 'detached signing', where the COSE signature file .Content property is empty, and to validate the signature, the payload must be provided separately. When set to true, the payload is embedded in the signature file. Embed-signed files are not readable by standard text editors, but can be read with the CoseSignTool 'Get' command. Advanced Options: - ContentType /cty: Optional. A MIME type to specify as Content Type in the COSE signature header. Default value is + --ContentType, -cty: Optional. A MIME type to specify as Content Type in the COSE signature header. Default value is 'application/cose'. Options to enable SCITT (Supply Chain Integrity, Transparency, and Trust) compliance: - EnableScittCompliance /scitt: Optional. If true (default), automatically adds SCITT-compliant CWT claims + --EnableScittCompliance, --scitt: Optional. If true (default), automatically adds SCITT-compliant CWT claims (issuer and subject) to the signature. Set to false to disable automatic CWT claims addition. - CwtIssuer /cwt-iss: Optional. The CWT issuer (iss) claim for SCITT compliance. If not specified and SCITT + --CwtIssuer, --cwt-iss: Optional. The CWT issuer (iss) claim for SCITT compliance. If not specified and SCITT compliance is enabled, defaults to a DID:x509 identifier derived from the signing certificate chain. - CwtSubject /cwt-sub: Optional. The CWT subject (sub) claim for SCITT compliance. If not specified and SCITT + --CwtSubject, --cwt-sub: Optional. The CWT subject (sub) claim for SCITT compliance. If not specified and SCITT compliance is enabled, defaults to ""unknown.intent"". - CwtAudience /cwt-aud: Optional. The CWT audience (aud) claim for SCITT compliance. + --CwtAudience, --cwt-aud: Optional. The CWT audience (aud) claim for SCITT compliance. - CwtClaims /cwt: Optional. Custom CWT claims as label:value pairs. Can be specified multiple times for multiple claims. + --CwtClaims, --cwt: Optional. Custom CWT claims as label:value pairs. Can be specified multiple times. Labels can be integers (e.g., ""100:custom-value"") or RFC 8392 claim names (iss, sub, aud, exp, nbf, iat, cti). Timestamp claims (exp, nbf, iat) accept date/time strings (e.g., ""2024-12-31T23:59:59Z"") or Unix timestamps. Examples: - /cwt ""cti:abc123"" /cwt ""100:custom-value"" /cwt ""exp:2024-12-31T23:59:59Z"" - /cwt ""iss:custom-issuer"" /cwt ""sub:custom-subject"" /cwt ""nbf:1735689600"" + --cwt ""cti:abc123"" --cwt ""100:custom-value"" --cwt ""exp:2024-12-31T23:59:59Z"" + --cwt ""iss:custom-issuer"" --cwt ""sub:custom-subject"" --cwt ""nbf:1735689600"" Options to customize the headers in the signature: - IntHeaders /ih: Optional. Path to a JSON file containing the header collection to be added to the cose message. The label is a string and the value is int32. - Sample file. [{""label"":""created-at"",""value"":12345678,""protected"":true},{""label"":""customer-count"",""value"":10,""protected"":false}] + --IntHeaders, -ih: Optional. Path to a JSON file containing the header collection to be added to the cose message. + The label is a string and the value is int32. + Sample file: [{""label"":""created-at"",""value"":12345678,""protected"":true}] - StringHeaders /sh: Optional. Path to a JSON file containing the header collection to be added to the cose message. Both the label and value are strings. - Sample file. [{""label"":""message-type"",""value"":""cose"",""protected"":false},{""label"":""customer-name"",""value"":""contoso"",""protected"":true}] + --StringHeaders, -sh: Optional. Path to a JSON file containing the header collection to be added to the cose message. + Both the label and value are strings. + Sample file: [{""label"":""message-type"",""value"":""cose"",""protected"":false}] - IntProtectedHeaders /iph: A collection of name-value pairs with a string label and an int32 value. Sample input: /IntProtectedHeaders created-at=12345678,customer-count=10 + --IntProtectedHeaders, -iph: A collection of name-value pairs with a string label and an int32 value. + Sample input: --iph created-at=12345678,customer-count=10 - StringProtectedHeaders /sph: A collection of name-value pairs with a string label and value. Sample input: /StringProtectedHeaders message-type=cose,customer-name=contoso + --StringProtectedHeaders, -sph: A collection of name-value pairs with a string label and value. + Sample input: --sph message-type=cose,customer-name=contoso - IntUnProtectedHeaders /iuh: A collection of name-value pairs with a string label and an int32 value. Sample input: /IntUnProtectedHeaders created-at=12345678,customer-count=10 + --IntUnProtectedHeaders, -iuh: A collection of name-value pairs with a string label and an int32 value. + Sample input: --iuh created-at=12345678,customer-count=10 - StringUnProtectedHeaders /suh: A collection of name-value pairs with a string label and value. Sample input: /StringUnProtectedHeaders message-type=cose,customer-name=contoso + --StringUnProtectedHeaders, -suh: A collection of name-value pairs with a string label and value. + Sample input: --suh message-type=cose,customer-name=contoso Options to customize file and stream handling: - MaxWaitTime /wait: The maximum number of seconds to wait for a payload or signature file to be available and non-empty before loading it. + --MaxWaitTime, --wait: The maximum number of seconds to wait for a payload or signature file to be available and + non-empty before loading it. - FailFast /ff: If set, limits the timeout on null and empty file checks to 100ms instead of 10 seconds. + --FailFast, -ff: If set, limits the timeout on null and empty file checks to 100ms instead of 10 seconds. - UseAdvancedStreamHandling /adv: If set, uses experimental techniques for verifying files before attempting to read them. + --UseAdvancedStreamHandling, --adv: If set, uses experimental techniques for verifying files before attempting to + read them. "; /// diff --git a/CoseSignTool/ValidateCommand.cs b/CoseSignTool/ValidateCommand.cs index cedd43ca..bce0231d 100644 --- a/CoseSignTool/ValidateCommand.cs +++ b/CoseSignTool/ValidateCommand.cs @@ -6,25 +6,26 @@ public class ValidateCommand : CoseCommand { /// /// Map of command line options specific to the Validate command and their abbreviated aliases. + /// All options use -- prefix. Single dash (-) and forward slash (/) are converted to -- for backward compatibility. /// private static readonly Dictionary PrivateOptions = new() { - ["-Roots"] = "Roots", - ["-rt"] = "Roots", - ["-RevocationMode"] = "RevocationMode", - ["-revmode"] = "RevocationMode", - ["-rm"] = "RevocationMode", - ["-CommonName"] = "CommonName", - ["-cn"] = "CommonName", - ["-AllowUntrusted"] = "AllowUntrusted", - ["-allow"] = "AllowUntrusted", - ["-au"] = "AllowUntrusted", - ["-AllowOutdated"] = "AllowOutdated", - ["-ao"] = "AllowOutdated", - ["-ShowCertificateDetails"] = "ShowCertificateDetails", - ["-scd"] = "ShowCertificateDetails", - ["-Verbose"] = "Verbose", - ["-v"] = "Verbose", + ["--Roots"] = "Roots", + ["--rt"] = "Roots", + ["--RevocationMode"] = "RevocationMode", + ["--revmode"] = "RevocationMode", + ["--rm"] = "RevocationMode", + ["--CommonName"] = "CommonName", + ["--cn"] = "CommonName", + ["--AllowUntrusted"] = "AllowUntrusted", + ["--allow"] = "AllowUntrusted", + ["--au"] = "AllowUntrusted", + ["--AllowOutdated"] = "AllowOutdated", + ["--ao"] = "AllowOutdated", + ["--ShowCertificateDetails"] = "ShowCertificateDetails", + ["--scd"] = "ShowCertificateDetails", + ["--Verbose"] = "Verbose", + ["--v"] = "Verbose", }; /// @@ -257,42 +258,45 @@ payload and is signed with a valid certificate chain. Options: - SignatureFile / sigfile / sf: Required, pipeable. The file or piped stream containing the COSE signature. + --SignatureFile, --sig, -sf: Required, pipeable. The file or piped stream containing the COSE signature. - PayloadFile / payload / p: Required for detached and indirect signatures. The original source file that was signed. + --PayloadFile, --payload, -p: Required for detached and indirect signatures. The original source file that was signed. Do not use for embedded signatures. "; protected const string SharedOptionsText = $@" - Roots / rt: Optional. A comma-separated list of public key certificate files (.cer or .p7b), enclosed in quote + --Roots, -rt: Optional. A comma-separated list of public key certificate files (.cer or .p7b), enclosed in quote marks, to try to chain the signing certificate to. CoseSignTool will try to chain to installed roots first, then user-supplied roots. If the COSE signature is signed with a self-signed certificate, that certificate must either be installed on and trusted by the machine or supplied as a root to pass validation. All user-supplied roots are assumed to be trusted for validation purposes. - RevocationMode / revmode / rm: Optional. The method to check for certificate revocation. + --RevocationMode, --revmode, -rm: Optional. The method to check for certificate revocation. Valid values: Online, Offline, NoCheck Default value: Online - CommonName / cn: Optional. Specifies a Certificate Common Name that the signing certificate must match to pass + --CommonName, -cn: Optional. Specifies a Certificate Common Name that the signing certificate must match to pass validation. - AllowUntrusted / allow / au: Optional flag. Allows validation to succeed when chaining to an arbitrary root + --AllowUntrusted, --allow, -au: Optional flag. Allows validation to succeed when chaining to an arbitrary root certificate on the host machine without that root being trusted. - AllowOutdated / ao: Optional flag. Allows validation to succeed when the signing certificate has expired, unless the - expired certificate has a lifetime EKU. + --AllowOutdated, -ao: Optional flag. Allows validation to succeed when the signing certificate has expired, unless + the expired certificate has a lifetime EKU. - ShowCertificateDetails / scd: Optional flag. Prints the certificate chain details to the console if the certificate chain is available. + --ShowCertificateDetails, -scd: Optional flag. Prints the certificate chain details to the console if the + certificate chain is available. - Verbose / v: Optional flag. Includes certificate chain status errors and exception messages in the error output + --Verbose, -v: Optional flag. Includes certificate chain status errors and exception messages in the error output when validation fails. Advanced options to customize file and stream handling: - MaxWaitTime /wait: The maximum number of seconds to wait for a payload or signature file to be available and non-empty before loading it. + --MaxWaitTime, --wait: The maximum number of seconds to wait for a payload or signature file to be available and + non-empty before loading it. - FailFast /ff: If set, limits the timeout on null and empty file checks to 100ms instead of 10 seconds. + --FailFast, -ff: If set, limits the timeout on null and empty file checks to 100ms instead of 10 seconds. - UseAdvancedStreamHandling /adv: If set, uses experimental techniques for verifying files before attempting to read them."; + --UseAdvancedStreamHandling, --adv: If set, uses experimental techniques for verifying files before attempting to + read them."; } diff --git a/README.md b/README.md index 33fd35e7..4459e0a6 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ CoseSignTool supports **SCITT (Supply Chain Integrity, Transparency, and Trust)* ### Key Features - **Automatic DID:x509 Generation**: Issuer identifiers are automatically derived from your certificate chain - **CWT Claims Support**: Include standardized claims (issuer, subject, audience, expiration, etc.) in your signatures -- **Enabled by Default**: SCITT compliance is automatically enabled when signing with certificates (can be disabled with `--enable-scitt false`) +- **Enabled by Default**: SCITT compliance is automatically enabled when signing with certificates (can be disabled with `--scitt false`) - **Fully Customizable**: Override defaults or add custom claims via CLI or programmatic API - **Opt-Out Available**: Disable automatic CWT claims when not needed for your use case @@ -47,20 +47,20 @@ CoseSignTool supports **SCITT (Supply Chain Integrity, Transparency, and Trust)* ```bash # Basic SCITT-compliant signature # Automatically includes: DID:x509 issuer, default subject, timestamps -CoseSignTool sign -f payload.txt -pfx mycert.pfx -s signature.cose +CoseSignTool sign --payload payload.txt --pfx mycert.pfx --SignatureFile signature.cose # Custom SCITT signature with specific subject and expiration -CoseSignTool sign -f payload.txt -pfx mycert.pfx -s signature.cose \ - --cwt-subject "software.release.v1.0" \ - --cwt-claims "4=1735689600" # exp: Jan 1, 2025 +CoseSignTool sign --payload payload.txt --pfx mycert.pfx --SignatureFile signature.cose \ + --cwt-sub "software.release.v1.0" \ + --cwt "exp:2025-01-01T00:00:00Z" # Disable SCITT compliance (no automatic CWT claims) -CoseSignTool sign -f payload.txt -pfx mycert.pfx -s signature.cose \ - --enable-scitt false +CoseSignTool sign --payload payload.txt --pfx mycert.pfx --SignatureFile signature.cose \ + --scitt false # Using Azure Trusted Signing (cloud-based signing) -CoseSignTool sign -f payload.txt -s signature.cose \ - --cert-provider azure-trusted-signing \ +CoseSignTool sign --payload payload.txt --SignatureFile signature.cose \ + --cp azure-trusted-signing \ --ats-endpoint https://contoso.codesigning.azure.net \ --ats-account-name ContosoAccount \ --ats-cert-profile-name ContosoProfile @@ -75,20 +75,22 @@ CoseSignTool, CoseHandler, and the CoseSign1 libraries are the Microsoft solutio ## How do I get started? ### Using as an executable CLI -Downloadable versions are available in GitHub [releases](https://github.com/microsoft/CoseSignTool/releases) of this repository. Separate page lists the features and how to use them: [CoseSignTool.md](./docs/CoseSignTool.md). +Downloadable versions are available in GitHub [releases](https://github.com/microsoft/CoseSignTool/releases) of this repository. Releases include a single self-contained executable that doesn't require .NET to be installed. See [CoseSignTool.md](./docs/CoseSignTool.md) for full documentation. + +> **Option format:** CoseSignTool uses double-dash options (e.g., `--PayloadFile`, `--payload`, `--p`) for all option names. Forward slash (`/p`) and single-dash (`-p`) are also accepted for backward compatibility and are converted to double-dash internally. #### Linux -Download and extract the folder with the compiled binaries, then make `CoseSignTool` available on the `$PATH`. +Download and extract the release, then make `CoseSignTool` available on the `$PATH`. ```bash -# download and uzip the release +# download and unzip the release mkdir -p ~/cosesigntool curl -L https://github.com/microsoft/CoseSignTool/releases/latest/download/CoseSignTool-Linux-release.zip -o ~/cosesigntool/release.zip unzip ~/cosesigntool/release.zip -d ~/cosesigntool -# move the directory to a stable location -mv ~/cosesigntool/release ~/.local/bin/cosesigntool -export PATH="$PATH":~/.local/bin/cosesigntool -# cleanup of files +# move the executable to a stable location +mv ~/cosesigntool/CoseSignTool ~/.local/bin/ +export PATH="$PATH":~/.local/bin +# cleanup rm -rf ~/cosesigntool # run the binary CoseSignTool @@ -107,13 +109,11 @@ If you're unsure of your Mac's architecture, run `uname -m` in Terminal: - `arm64` = Apple Silicon Mac (use arm64 version) #### Windows -Similar to Linux or MacOS you could use PowerShell to download the release, extract and move it to the desired location and to add it to the Path like shown in the example below: +Similar to Linux or MacOS you could use PowerShell to download the release and add it to the Path: ```ps PS C:\Users\johndoe> Invoke-WebRequest -Uri https://github.com/microsoft/CoseSignTool/releases/latest/download/CoseSignTool-Windows-release.zip -OutFile C:\Users\johndoe\release.zip -PS C:\Users\johndoe> Expand-Archive C:\Users\johndoe\release.zip -DestinationPath C:\Users\johndoe -PS C:\Users\johndoe> Rename-Item -Path "C:\Users\johndoe\release" -NewName "cosesigntool" -PS C:\Users\johndoe> Move-Item -Path C:\Users\johndoe\cosesigntool -Destination C:\Users\johndoe\AppData\Local\ +PS C:\Users\johndoe> Expand-Archive C:\Users\johndoe\release.zip -DestinationPath C:\Users\johndoe\AppData\Local\cosesigntool PS C:\Users\johndoe> $env:Path += ";C:\Users\johndoe\AppData\Local\cosesigntool" PS C:\Users\johndoe> CoseSignTool diff --git a/docs/CertificateProviders.md b/docs/CertificateProviders.md index 7367ef38..3721a8e8 100644 --- a/docs/CertificateProviders.md +++ b/docs/CertificateProviders.md @@ -32,7 +32,7 @@ The plugin architecture automatically: ## Built-in Providers ### Local Certificate Provider (Default) -When no `--cert-provider` is specified, CoseSignTool uses local certificate loading: +When no `--cp` is specified, CoseSignTool uses local certificate loading: - **PFX files**: Load certificates with private keys from `.pfx` files - **Certificate stores**: Access certificates from Windows/macOS/Linux certificate stores @@ -48,7 +48,7 @@ See [Azure Trusted Signing](#azure-trusted-signing) section for details. ### Basic Syntax ```bash -CoseSignTool sign --payload --cert-provider [provider-options] +CoseSignTool sign --p --cp [provider-options] ``` ### List Available Providers @@ -60,9 +60,9 @@ CoseSignTool sign --help ### Example with Azure Trusted Signing ```bash CoseSignTool sign \ - --payload payload.txt \ - --signature signature.cose \ - --cert-provider azure-trusted-signing \ + --p payload.txt \ + --sf signature.cose \ + --cp azure-trusted-signing \ --ats-endpoint https://contoso.codesigning.azure.net \ --ats-account-name ContosoAccount \ --ats-cert-profile-name ContosoProfile @@ -120,9 +120,9 @@ Azure Trusted Signing uses **Azure DefaultAzureCredential** for authentication, az login CoseSignTool sign \ - --payload document.pdf \ - --signature document.pdf.cose \ - --cert-provider azure-trusted-signing \ + --p document.pdf \ + --sf document.pdf.cose \ + --cp azure-trusted-signing \ --ats-endpoint https://contoso.codesigning.azure.net \ --ats-account-name ContosoAccount \ --ats-cert-profile-name ContosoProfile @@ -148,9 +148,9 @@ jobs: AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} run: | CoseSignTool sign \ - --payload release-artifact.bin \ - --signature release-artifact.bin.cose \ - --cert-provider azure-trusted-signing \ + --p release-artifact.bin \ + --sf release-artifact.bin.cose \ + --cp azure-trusted-signing \ --ats-endpoint ${{ secrets.ATS_ENDPOINT }} \ --ats-account-name ${{ secrets.ATS_ACCOUNT_NAME }} \ --ats-cert-profile-name ${{ secrets.ATS_CERT_PROFILE_NAME }} @@ -174,9 +174,9 @@ steps: scriptLocation: 'inlineScript' inlineScript: | CoseSignTool sign \ - --payload $(Build.ArtifactStagingDirectory)/artifact.bin \ - --signature $(Build.ArtifactStagingDirectory)/artifact.bin.cose \ - --cert-provider azure-trusted-signing \ + --p $(Build.ArtifactStagingDirectory)/artifact.bin \ + --sf $(Build.ArtifactStagingDirectory)/artifact.bin.cose \ + --cp azure-trusted-signing \ --ats-endpoint $(ATS_ENDPOINT) \ --ats-account-name $(ATS_ACCOUNT_NAME) \ --ats-cert-profile-name $(ATS_CERT_PROFILE_NAME) @@ -185,16 +185,16 @@ steps: #### Embedded Signature with SCITT Claims ```bash CoseSignTool sign \ - --payload payload.txt \ - --signature payload.csm \ - --embed-payload \ - --cert-provider azure-trusted-signing \ + --p payload.txt \ + --sf payload.cose \ + --ep \ + --cp azure-trusted-signing \ --ats-endpoint https://contoso.codesigning.azure.net \ --ats-account-name ContosoAccount \ --ats-cert-profile-name ContosoProfile \ - --cwt-subject "software.release.v2.0" \ - --cwt-audience "production.systems" \ - --cwt-claims "exp:2025-12-31T23:59:59Z" + --cwt-sub "software.release.v2.0" \ + --cwt-aud "production.systems" \ + --cwt "exp:2025-12-31T23:59:59Z" ``` #### Batch Signing with Environment Variables @@ -212,9 +212,9 @@ export AZURE_CLIENT_SECRET="your-client-secret" # Sign multiple files for file in *.bin; do CoseSignTool sign \ - --payload "$file" \ - --signature "${file}.cose" \ - --cert-provider azure-trusted-signing \ + --p "$file" \ + --sf "${file}.cose" \ + --cp azure-trusted-signing \ --ats-endpoint "$ATS_ENDPOINT" \ --ats-account-name "$ATS_ACCOUNT_NAME" \ --ats-cert-profile-name "$ATS_CERT_PROFILE_NAME" @@ -225,9 +225,9 @@ done | Parameter | Alias | Required | Description | |-----------|-------|----------|-------------| -| `--ats-endpoint` | `-e` | Yes | Azure Trusted Signing endpoint URL (e.g., `https://contoso.codesigning.azure.net`) | -| `--ats-account-name` | `-a` | Yes | Azure Trusted Signing account name | -| `--ats-cert-profile-name` | `-p` | Yes | Certificate profile name within the account | +| `--ats-endpoint` | | Yes | Azure Trusted Signing endpoint URL (e.g., `https://contoso.codesigning.azure.net`) | +| `--ats-account-name` | | Yes | Azure Trusted Signing account name | +| `--ats-cert-profile-name` | | Yes | Certificate profile name within the account | ### Troubleshooting Azure Trusted Signing @@ -270,11 +270,11 @@ Error: Certificate provider 'azure-trusted-signing' cannot create a provider wit **Solution**: Verify all required parameters are provided ```bash CoseSignTool sign \ - --cert-provider azure-trusted-signing \ + --cp azure-trusted-signing \ --ats-endpoint "https://your-endpoint.codesigning.azure.net" \ --ats-account-name "YourAccount" \ --ats-cert-profile-name "YourProfile" \ - --payload test.txt + --p test.txt ``` ## Creating Custom Certificate Providers @@ -288,7 +288,7 @@ public interface ICertificateProviderPlugin { /// /// Gets the unique name of this certificate provider (e.g., "azure-trusted-signing"). - /// Used with the --cert-provider command line parameter. + /// Used with the --cp command line parameter. /// string ProviderName { get; } diff --git a/docs/CoseSignTool.md b/docs/CoseSignTool.md index 2d9677fe..34417afc 100644 --- a/docs/CoseSignTool.md +++ b/docs/CoseSignTool.md @@ -42,20 +42,20 @@ CoseSignTool mst_register --endpoint https://your-mst.azure.com --payload file.t The **Sign** command signs a file or stream. You will need to specify: -* The payload content to sign. This may be a file specified with the **/Payload** option or you can pipe it in on the Standard Input channel when you call CoseSignTool. Piping in the content is generally considered more secure and performant option but large streams of > 2gb in length are not yet supported. +* The payload content to sign. This may be a file specified with the **--PayloadFile** or **--p** option or you can pipe it in on the Standard Input channel when you call CoseSignTool. Piping in the content is generally considered more secure and performant option but large streams of > 2gb in length are not yet supported. * A signing key provider. You have three options: - 1. **Certificate Provider Plugin** (recommended for cloud/HSM signing): Use the **/CertProvider** or **/cp** option to specify a certificate provider plugin (e.g., `azure-trusted-signing`). See [Certificate Providers](#certificate-providers) section below. - 2. **Local PFX Certificate**: Use the **/PfxCertificate** option to point to a .pfx certificate file and a **/Password** to open the certificate file with if it is locked. The certificate must include a private key. - * **PFX Certificate Chain Handling**: When using a PFX file that contains multiple certificates (such as a complete certificate chain), CoseSignTool will automatically use all certificates in the PFX for proper chain building. If you specify a **/Thumbprint** along with the PFX file, CoseSignTool will use the certificate matching that thumbprint for signing and treat the remaining certificates as additional roots for chain validation. If no thumbprint is specified, the first certificate with a private key will be used for signing. - 3. **Local Certificate Store**: Use the **/Thumbprint** option to pass the SHA1 thumbprint of an installed certificate. The certificate must include a private key. + 1. **Certificate Provider Plugin** (recommended for cloud/HSM signing): Use the **--CertProvider** or **--cp** option to specify a certificate provider plugin (e.g., `azure-trusted-signing`). See [Certificate Providers](#certificate-providers) section below. + 2. **Local PFX Certificate**: Use the **--PfxCertificate** or **--pfx** option to point to a .pfx certificate file and a **--Password** or **--pw** to open the certificate file with if it is locked. The certificate must include a private key. + * **PFX Certificate Chain Handling**: When using a PFX file that contains multiple certificates (such as a complete certificate chain), CoseSignTool will automatically use all certificates in the PFX for proper chain building. If you specify a **--Thumbprint** or **--th** along with the PFX file, CoseSignTool will use the certificate matching that thumbprint for signing and treat the remaining certificates as additional roots for chain validation. If no thumbprint is specified, the first certificate with a private key will be used for signing. + 3. **Local Certificate Store**: Use the **--Thumbprint** or **--th** option to pass the SHA1 thumbprint of an installed certificate. The certificate must include a private key. You may also want to specify: -* Detached or embedded: By default, CoseSignTool creates a detached signature, which contains a hash of the original payoad. If you want it embedded, meaning that the signature file includes a copy of the payload, use the **/EmbedPayload option.** Note that embedded signatures are only supported for payload of less than 2gb. +* Detached or embedded: By default, CoseSignTool creates a detached signature, which contains a hash of the original payoad. If you want it embedded, meaning that the signature file includes a copy of the payload, use the **--EmbedPayload** or **--ep** option. Note that embedded signatures are only supported for payload of less than 2gb. * Where to write the signature to. You have three ways to go here: - 1. Write to the Standard Output channel (STDOUT) / console by using the **/PipeOutput** option. - 1. Specify an output file with **/SignatureFile** - 1. Let CoseSignTool decide. It will write to *payload-file*.cose for detached or *payload-file*.csm for embedded signatures. But if you don't specify a payload file at all, it will exit with an error. -* What certificate store to use. If you passed in a thumbprint instead of a .pfx certificate, CoseSignTool will assume that certificate is in the default store (My/CurrentUser on Windows) unless you tell it otherwise. Use the **/StoreName** and **/StoreLocation** options to specify a store. + 1. Write to the Standard Output channel (STDOUT) / console by using the **--PipeOutput** or **--po** option. + 1. Specify an output file with **--SignatureFile** or **--sf** + 1. Let CoseSignTool decide. It will write to *payload-file*.cose. But if you don't specify a payload file at all, it will exit with an error. +* What certificate store to use. If you passed in a thumbprint instead of a .pfx certificate, CoseSignTool will assume that certificate is in the default store (My/CurrentUser on Windows) unless you tell it otherwise. Use the **--StoreName** or **--sn** and **--StoreLocation** or **--sl** options to specify a store. >Pro tip: Certificate store operations run faster if you use a custom store containing only the certificates you will sign with. You can create a custom store by adding a certificate to a store with a unique Store Name and pre-defined Store Location. For example, in Powershell: ~~~ Import-Certificate -FilePath 'c:\my\cert.pfx' -CertStoreLocation 'Cert:CurrentUser\MyNewStore' @@ -66,44 +66,44 @@ Import-Certificate -FilePath 'c:\my\cert.pfx' -CertStoreLocation 'Cert:CurrentUs **CoseSignTool automatically enables SCITT (Supply Chain Integrity, Transparency, and Trust) compliance** when signing with certificates. This includes adding CWT (CBOR Web Token) Claims to your signatures with DID:x509 identifiers. #### SCITT Arguments: -* **/EnableScitt**, **/scitt** - Enable or disable SCITT compliance (default: **true**). +* **--EnableScittCompliance**, **--scitt** - Enable or disable SCITT compliance (default: **true**). ```bash - CoseSignTool sign /p payload.txt /pfx cert.pfx /scitt false # Disable SCITT + CoseSignTool sign --p payload.txt --pfx cert.pfx --scitt false # Disable SCITT ``` -* **/CwtIssuer**, **/cwt-iss** - Set the issuer claim. If not specified, a **DID:x509 identifier is automatically generated** from your certificate chain. +* **--CwtIssuer**, **--cwt-iss** - Set the issuer claim. If not specified, a **DID:x509 identifier is automatically generated** from your certificate chain. ```bash - CoseSignTool sign /p payload.txt /pfx cert.pfx /cwt-iss "did:example:custom-issuer" + CoseSignTool sign --p payload.txt --pfx cert.pfx --cwt-iss "did:example:custom-issuer" ``` -* **/CwtSubject**, **/cwt-sub** - Set the subject claim (default: **"unknown.intent"**). This should describe the purpose or intent of the signature. +* **--CwtSubject**, **--cwt-sub** - Set the subject claim (default: **"unknown.intent"**). This should describe the purpose or intent of the signature. ```bash - CoseSignTool sign /p payload.txt /pfx cert.pfx /cwt-sub "software.release.v1.2.3" + CoseSignTool sign --p payload.txt --pfx cert.pfx --cwt-sub "software.release.v1.2.3" ``` -* **/CwtAudience**, **/cwt-aud** - Set the audience claim. Specifies the intended recipient or system. +* **--CwtAudience**, **--cwt-aud** - Set the audience claim. Specifies the intended recipient or system. ```bash - CoseSignTool sign /p payload.txt /pfx cert.pfx /cwt-aud "production.systems" + CoseSignTool sign --p payload.txt --pfx cert.pfx --cwt-aud "production.systems" ``` -* **/CwtClaims**, **/cwt** - Add custom CWT claims using `label:value` format. Can be specified multiple times. +* **--CwtClaims**, **--cwt** - Add custom CWT claims using `label:value` format. Can be specified multiple times. ```bash # Standard timestamp claims (accepts ISO 8601 date/time strings or Unix timestamps) - CoseSignTool sign /p payload.txt /pfx cert.pfx /cwt "exp:2025-12-31T23:59:59Z" - CoseSignTool sign /p payload.txt /pfx cert.pfx /cwt "nbf:2024-01-01T00:00:00Z" - CoseSignTool sign /p payload.txt /pfx cert.pfx /cwt "iat:2024-11-19T10:30:00-05:00" + CoseSignTool sign --p payload.txt --pfx cert.pfx --cwt "exp:2025-12-31T23:59:59Z" + CoseSignTool sign --p payload.txt --pfx cert.pfx --cwt "nbf:2024-01-01T00:00:00Z" + CoseSignTool sign --p payload.txt --pfx cert.pfx --cwt "iat:2024-11-19T10:30:00-05:00" # Using Unix timestamps - CoseSignTool sign /p payload.txt /pfx cert.pfx /cwt "exp:1735689600" + CoseSignTool sign --p payload.txt --pfx cert.pfx --cwt "exp:1735689600" # Custom claims (use integer labels 100+) - CoseSignTool sign /p payload.txt /pfx cert.pfx /cwt "100:custom-value" /cwt "101:another-value" + CoseSignTool sign --p payload.txt --pfx cert.pfx --cwt "100:custom-value" --cwt "101:another-value" # Combining multiple claims - CoseSignTool sign /p payload.txt /pfx cert.pfx \ - /cwt-sub "release.v2.0" \ - /cwt "exp:2025-12-31T23:59:59Z" \ - /cwt "200:build-metadata" + CoseSignTool sign --p payload.txt --pfx cert.pfx \ + --cwt-sub "release.v2.0" \ + --cwt "exp:2025-12-31T23:59:59Z" \ + --cwt "200:build-metadata" ``` #### Standard CWT Claim Labels: @@ -121,36 +121,36 @@ For comprehensive SCITT documentation, examples, and programmatic API usage, see **Basic SCITT-compliant signature (automatic DID:x509 issuer + default subject):** ```bash -CoseSignTool sign /p payload.txt /pfx mycert.pfx /sf signature.cose +CoseSignTool sign --p payload.txt --pfx mycert.pfx --sf signature.cose ``` **Custom subject and expiration:** ```bash -CoseSignTool sign /p payload.txt /pfx mycert.pfx /sf signature.cose \ - /cwt-sub "software.release.v1.0" \ - /cwt "exp:2025-12-31T23:59:59Z" +CoseSignTool sign --p payload.txt --pfx mycert.pfx --sf signature.cose \ + --cwt-sub "software.release.v1.0" \ + --cwt "exp:2025-12-31T23:59:59Z" ``` **Full SCITT signature with all standard claims:** ```bash -CoseSignTool sign /p payload.txt /pfx mycert.pfx /sf signature.cose \ - /cwt-sub "container.image.production" \ - /cwt-aud "production.kubernetes.cluster" \ - /cwt "exp:2025-06-30T23:59:59Z" \ - /cwt "nbf:2024-01-01T00:00:00Z" \ - /cwt "iat:2024-11-19T15:30:00Z" +CoseSignTool sign --p payload.txt --pfx mycert.pfx --sf signature.cose \ + --cwt-sub "container.image.production" \ + --cwt-aud "production.kubernetes.cluster" \ + --cwt "exp:2025-06-30T23:59:59Z" \ + --cwt "nbf:2024-01-01T00:00:00Z" \ + --cwt "iat:2024-11-19T15:30:00Z" ``` **Custom issuer (override DID:x509 auto-generation):** ```bash -CoseSignTool sign /p payload.txt /pfx mycert.pfx /sf signature.cose \ - /cwt-iss "did:example:custom-issuer" \ - /cwt-sub "document.approval" +CoseSignTool sign --p payload.txt --pfx mycert.pfx --sf signature.cose \ + --cwt-iss "did:example:custom-issuer" \ + --cwt-sub "document.approval" ``` **Disable SCITT compliance:** ```bash -CoseSignTool sign /p payload.txt /pfx mycert.pfx /sf signature.cose /scitt false +CoseSignTool sign --p payload.txt --pfx mycert.pfx --sf signature.cose --scitt false ``` ### Headers: @@ -159,13 +159,13 @@ CoseSignTool sign /p payload.txt /pfx mycert.pfx /sf signature.cose /scitt false >Note: SCITT CWT Claims are automatically added to protected headers when enabled. Custom headers are applied in addition to CWT Claims. * Command-line: - * /IntProtectedHeaders, /iph - A collection of name-value pairs (separated by comma ',') with the value being an int32. Example: /IntProtectedHeaders created-at=12345678,customer-count=10 - * /StringProtectedHeaders, /sph - A collection of name-value pairs (separated by comma ',') with the value being a string. Example: /StringProtectedHeaders message-type="cose",customer-name="contoso" - * /IntUnProtectedHeaders, /iuh - A collection of name-value pairs (separated by comma ',') with the value being an int32. Example: /IntUnProtectedHeaders created-at=12345678,customer-count=10 - * /StringUnProtectedHeaders, /suh - A collection of name-value pairs (separated by comma ',') with the value being a string. Example: /StringUnProtectedHeaders message-type="cose",customer-name="contoso" + * **--IntProtectedHeaders**, **--iph** - A collection of name-value pairs (separated by comma ',') with the value being an int32. Example: `--iph created-at=12345678,customer-count=10` + * **--StringProtectedHeaders**, **--sph** - A collection of name-value pairs (separated by comma ',') with the value being a string. Example: `--sph message-type="cose",customer-name="contoso"` + * **--IntUnProtectedHeaders**, **--iuh** - A collection of name-value pairs (separated by comma ',') with the value being an int32. Example: `--iuh created-at=12345678,customer-count=10` + * **--StringUnProtectedHeaders**, **--suh** - A collection of name-value pairs (separated by comma ',') with the value being a string. Example: `--suh message-type="cose",customer-name="contoso"` * File: - * /IntHeaders, /ih - A JSON file containing the headers with the value being an int32. - * /StringHeaders, /sh - A JSON file containing the headers with the value being a string. + * **--IntHeaders**, **--ih** - A JSON file containing the headers with the value being an int32. + * **--StringHeaders**, **--sh** - A JSON file containing the headers with the value being a string. The JSON schema is the same for both types of header files. Sample int32 and string headers file are shown below. @@ -201,23 +201,23 @@ The JSON schema is the same for both types of header files. Sample int32 and str ] ~~~ -Run *CoseSignTool sign /?* for the complete command line usage. +Run *CoseSignTool sign --help* for the complete command line usage. ### PFX Certificate Chain Examples **Sign with a PFX containing a certificate chain (uses first certificate with private key):** ```bash -CoseSignTool sign /p payload.txt /pfx certificates.pfx /pw password123 /sf signature.cose +CoseSignTool sign --p payload.txt --pfx certificates.pfx --pw password123 --sf signature.cose ``` **Sign with a specific certificate from a PFX chain using thumbprint:** ```bash -CoseSignTool sign /p payload.txt /pfx certificates.pfx /pw password123 /th A1B2C3D4E5F6789... /sf signature.cose +CoseSignTool sign --p payload.txt --pfx certificates.pfx --pw password123 --th A1B2C3D4E5F6789... --sf signature.cose ``` **Sign with embedded payload using PFX certificate chain:** ```bash -CoseSignTool sign /p payload.txt /pfx certificates.pfx /pw password123 /ep /sf signature.csm +CoseSignTool sign --p payload.txt --pfx certificates.pfx --pw password123 --ep --sf signature.cose ``` When using these commands with a PFX file containing multiple certificates, CoseSignTool will automatically embed the complete certificate chain in the COSE signature, ensuring proper validation without requiring additional root certificates to be specified during validation. @@ -232,10 +232,10 @@ CoseSignTool supports an extensible **Certificate Provider Plugin Architecture** Microsoft's cloud-based signing service providing managed certificates, FIPS 140-2 Level 3 HSM-backed signing, and seamless Azure integration. **Parameters:** -* **/CertProvider**, **/cp** - Set to `azure-trusted-signing` to use Azure Trusted Signing -* **/ats-endpoint**, **/e** - Azure Trusted Signing endpoint URL (e.g., `https://contoso.codesigning.azure.net`) -* **/ats-account-name**, **/a** - Azure Trusted Signing account name -* **/ats-cert-profile-name**, **/p** - Certificate profile name within the account +* **--CertProvider**, **--cp** - Set to `azure-trusted-signing` to use Azure Trusted Signing +* **--ats-endpoint** - Azure Trusted Signing endpoint URL (e.g., `https://contoso.codesigning.azure.net`) +* **--ats-account-name** - Azure Trusted Signing account name +* **--ats-cert-profile-name** - Certificate profile name within the account **Authentication:** Azure Trusted Signing uses Azure DefaultAzureCredential, which automatically tries: @@ -250,11 +250,11 @@ Azure Trusted Signing uses Azure DefaultAzureCredential, which automatically tri ```bash # Basic usage with Azure CLI authentication az login -CoseSignTool sign /p payload.txt /sf signature.cose \ - /cp azure-trusted-signing \ - /ats-endpoint https://contoso.codesigning.azure.net \ - /ats-account-name ContosoAccount \ - /ats-cert-profile-name ContosoProfile +CoseSignTool sign --p payload.txt --sf signature.cose \ + --cp azure-trusted-signing \ + --ats-endpoint https://contoso.codesigning.azure.net \ + --ats-account-name ContosoAccount \ + --ats-cert-profile-name ContosoProfile ``` ```bash @@ -263,31 +263,31 @@ export AZURE_TENANT_ID="your-tenant-id" export AZURE_CLIENT_ID="your-client-id" export AZURE_CLIENT_SECRET="your-client-secret" -CoseSignTool sign /p payload.txt /sf signature.cose \ - /cp azure-trusted-signing \ - /ats-endpoint https://contoso.codesigning.azure.net \ - /ats-account-name ContosoAccount \ - /ats-cert-profile-name ContosoProfile +CoseSignTool sign --p payload.txt --sf signature.cose \ + --cp azure-trusted-signing \ + --ats-endpoint https://contoso.codesigning.azure.net \ + --ats-account-name ContosoAccount \ + --ats-cert-profile-name ContosoProfile ``` ```bash # With SCITT compliance and embedded payload -CoseSignTool sign /p payload.txt /sf payload.csm /ep \ - /cp azure-trusted-signing \ - /ats-endpoint https://contoso.codesigning.azure.net \ - /ats-account-name ContosoAccount \ - /ats-cert-profile-name ContosoProfile \ - /cwt-sub "software.release.v2.0" \ - /cwt "exp:2025-12-31T23:59:59Z" +CoseSignTool sign --p payload.txt --sf payload.cose --ep \ + --cp azure-trusted-signing \ + --ats-endpoint https://contoso.codesigning.azure.net \ + --ats-account-name ContosoAccount \ + --ats-cert-profile-name ContosoProfile \ + --cwt-sub "software.release.v2.0" \ + --cwt "exp:2025-12-31T23:59:59Z" ``` ```bash # Batch signing with piped input -cat payload.txt | CoseSignTool sign /po \ - /cp azure-trusted-signing \ - /ats-endpoint https://contoso.codesigning.azure.net \ - /ats-account-name ContosoAccount \ - /ats-cert-profile-name ContosoProfile > signature.cose +cat payload.txt | CoseSignTool sign --po \ + --cp azure-trusted-signing \ + --ats-endpoint https://contoso.codesigning.azure.net \ + --ats-account-name ContosoAccount \ + --ats-cert-profile-name ContosoProfile > signature.cose ``` ### Creating Custom Certificate Providers @@ -311,34 +311,34 @@ For detailed certificate provider documentation, see **[CertificateProviders.md] The **Validate** command validates that a COSE signature is properly constructed, matches the signed payload, and roots to a valid certificate chain. You will need to specify: -* What to validate. This may be a file specified with the **/SignatureFile** option or you can pipe it in on the Standard Input channel when you call CoseSignTool. Piping in the content is generally considered more secure and performant option but large streams of > 2gb in length may be truncated, depending on what operating system and command shell you use. -* (For detached signatures only) the **/Payload** that was signed. If validating an embedded signature (including indirect signatures), skip this part as the payload/hash is embedded in the signature. +* What to validate. This may be a file specified with the **--SignatureFile** or **--sf** option or you can pipe it in on the Standard Input channel when you call CoseSignTool. Piping in the content is generally considered more secure and performant option but large streams of > 2gb in length may be truncated, depending on what operating system and command shell you use. +* (For detached signatures only) the **--PayloadFile** or **--p** that was signed. If validating an embedded signature (including indirect signatures), skip this part as the payload/hash is embedded in the signature. You may also want to specify: -* Some root certificates. By default, CoseSignTool will try to chain the signing certificate to whatever certificates are installed on the machine. If you want to chain to certificates that are not installed, use the **/Roots** option. +* Some root certificates. By default, CoseSignTool will try to chain the signing certificate to whatever certificates are installed on the machine. If you want to chain to certificates that are not installed, use the **--Roots** or **--rt** option. * User-specified roots will be treated as "trusted" for purposes of validation. * Root certificates for validation do not have to include a private key, so .cer files are acceptable. * To supply multiple root certificates, separate the file paths with commas. -* Certificate Details. You can use the */ShowCertificateDetails** option to print out the details of the signing certificate chain. -* Verbosity. You can use the */Verbose** option to get more detailed output on validation failures. +* Certificate Details. You can use the **--ShowCertificateDetails** or **--scd** option to print out the details of the signing certificate chain. +* Verbosity. You can use the **--Verbose** or **--v** option to get more detailed output on validation failures. And in some cases: -* **/RevocationMode** -- By default, CoseSignTool checks the signing certificate against an online database to see if it has been revoked. You can skip this check by setting **/RevocationMode** to **none**. RevocationMode.Offline is not yet implemented. -* **/CommonName** -- Forces validation to require that the signing certificate match a specific Common Name value. -* **/AllowUntrusted** -- Prevents CoseSignTool from failing validation when the certificate chain has an untrusted root. This is intended for test purposes and should not generally be used for production. -* **/AllowOutdated** -- Prevents CoseSignTool from failing validation when the certificate chain has an expired certificate, unless the expired certificate has a lifetime EKU. +* **--RevocationMode** or **--rm** -- By default, CoseSignTool checks the signing certificate against an online database to see if it has been revoked. You can skip this check by setting **--RevocationMode** to **NoCheck**. RevocationMode.Offline is not yet implemented. +* **--CommonName** or **--cn** -- Forces validation to require that the signing certificate match a specific Common Name value. +* **--AllowUntrusted** or **--au** -- Prevents CoseSignTool from failing validation when the certificate chain has an untrusted root. This is intended for test purposes and should not generally be used for production. +* **--AllowOutdated** or **--ao** -- Prevents CoseSignTool from failing validation when the certificate chain has an expired certificate, unless the expired certificate has a lifetime EKU. -Run *CoseSignTool validate /?* for the complete command line usage. +Run *CoseSignTool validate --help* for the complete command line usage. ## Get -The **Get** command retrieves the payload from a COSE embed-signed file and writes the text to cosole or to a file. It also runs the Validate command and prints out any errors on the Standard Error pipe. +The **Get** command retrieves the payload from a COSE embed-signed file and writes the text to console or to a file. It also runs the Validate command and prints out any errors on the Standard Error pipe. You will need to specify: -* What to validate. This may be an embed-signed file specified with the **/SignatureFile** option or you can pipe it in on the Standard Input channel when you call CoseSignTool. +* What to validate. This may be an embed-signed file specified with the **--SignatureFile** or **--sf** option or you can pipe it in on the Standard Input channel when you call CoseSignTool. You may also want to specify: -* A file to write the payload to. Use the **/SaveTo** option to specify a file path; otherwise the payload will be printed to Standard Out. -* **/Roots**, **/Verbosity**, **/RevocationMode**, **/CommonName**, **/AllowUntrusted**, and **/AllowOutdated** exactly as with the Validate command. +* A file to write the payload to. Use the **--SaveTo** or **--sa** option to specify a file path; otherwise the payload will be printed to Standard Out. +* **--Roots**, **--Verbose**, **--RevocationMode**, **--CommonName**, **--AllowUntrusted**, and **--AllowOutdated** exactly as with the Validate command. -Run *CoseSignTool get /?* for the complete command line usage. +Run *CoseSignTool get --help* for the complete command line usage. diff --git a/docs/PluginBuildDeploy.md b/docs/PluginBuildDeploy.md index c8b725ed..1f9911d3 100644 --- a/docs/PluginBuildDeploy.md +++ b/docs/PluginBuildDeploy.md @@ -84,23 +84,28 @@ The GitHub Actions workflow handles plugin deployment in the release process: ### Local Development -For local development, you can use MSBuild targets to automatically deploy plugins with the enhanced subdirectory architecture: +For local development, plugins are automatically built and deployed with the main application: ```bash -# Build and deploy all plugins to subdirectories -dotnet build CoseSignTool -p:DeployPlugins=true +# Build CoseSignTool - plugins are automatically built and deployed (default) +dotnet build CoseSignTool + +# To build WITHOUT plugins (faster for quick iterations) +dotnet build CoseSignTool -p:NoPlugins=true # This automatically: +# - Discovers all *.Plugin.csproj projects +# - Builds each plugin project # - Creates plugins/CoseSignTool.MST.Plugin/ subdirectory # - Creates plugins/CoseSignTool.IndirectSignature.Plugin/ subdirectory # - Copies each plugin and its dependencies to respective subdirectories -# - Maintains backward compatibility with legacy deployment ``` **What happens during plugin deployment:** +- **Plugin Discovery**: All `*.Plugin.csproj` projects are automatically found +- **Build**: Each plugin is built with the same Configuration and RuntimeIdentifier - **MST Plugin**: Deployed to `plugins/CoseSignTool.MST.Plugin/` with Azure dependencies - **Indirect Signature Plugin**: Deployed to `plugins/CoseSignTool.IndirectSignature.Plugin/` with minimal dependencies -- **Legacy Support**: Also copies plugins to flat structure for backward compatibility - **Dependency Isolation**: Each plugin's dependencies are isolated in its subdirectory ## Plugin Deployment Structure @@ -228,32 +233,22 @@ Add your plugin to the CI/CD pipeline in `.github/workflows/dotnet.yml`: copy_if_exists "YourPlugin/bin/Release/net8.0/YourSpecificDependency.dll" "published/release/plugins/" ``` -### 3. Update Local Development Targets +### 3. Automatic Deployment (No Manual Configuration Needed) + +If your plugin project follows the `*.Plugin.csproj` naming convention and is located anywhere under the CoseSignTool solution directory, it will be **automatically discovered, built, and deployed**. -Add your plugin to the MSBuild targets in `CoseSignTool.csproj`: +The consolidated plugin discovery in `CoseSignTool.csproj` handles everything: ```xml - - - $(MSBuildProjectDirectory)\..\YourPlugin\bin\$(Configuration)\net8.0\YourPlugin.Plugin.dll - - - - - - - - - - - + + + + + ``` +**No changes to CoseSignTool.csproj are required** - just name your project correctly and it will be included automatically. + ### 4. Update Tests Add your plugin tests to the CI/CD pipeline: @@ -278,8 +273,11 @@ The CI/CD pipeline includes automated verification: For manual verification: ```bash -# Build with plugins -dotnet build CoseSignTool --target BuildAndDeployPlugins +# Build with plugins (default behavior) +dotnet build CoseSignTool + +# Build without plugins (faster) +dotnet build CoseSignTool -p:NoPlugins=true # Navigate to output directory cd CoseSignTool/bin/Debug/net8.0 diff --git a/docs/Plugins.md b/docs/Plugins.md index 26464610..7d6e3a3c 100644 --- a/docs/Plugins.md +++ b/docs/Plugins.md @@ -1024,7 +1024,21 @@ The plugin system includes comprehensive error handling: ### Automated Deployment with MSBuild -For automated plugin deployment, you can use MSBuild targets like those used in the CoseSignTool project: +For plugins following the `*.Plugin.csproj` naming convention, deployment is **fully automatic**. Simply name your project correctly and place it under the CoseSignTool solution directory: + +``` +YourCompany.YourService.Plugin.csproj → Automatically discovered and deployed +``` + +The build system will: +1. Discover your plugin during `dotnet build CoseSignTool` +2. Build it with the same Configuration and RuntimeIdentifier +3. Deploy it to `plugins/YourCompany.YourService.Plugin/` subdirectory +4. Copy all plugin-specific dependencies + +**No manual MSBuild target configuration is required** when following the naming convention. + +For custom deployment scenarios (non-standard naming or special requirements), you can add a custom target: ```xml diff --git a/docs/SCITTCompliance.md b/docs/SCITTCompliance.md index cf961024..6b6a0c3a 100644 --- a/docs/SCITTCompliance.md +++ b/docs/SCITTCompliance.md @@ -110,7 +110,7 @@ SCITT compliance is **enabled by default** when signing with certificates: ```bash # Basic signing with automatic SCITT compliance -CoseSignTool sign -f payload.txt -pfx mycert.pfx -s signature.cose +CoseSignTool sign --p payload.txt --pfx mycert.pfx --sf signature.cose ``` This automatically adds: @@ -125,13 +125,13 @@ This automatically adds: ```bash # Set custom issuer and subject -CoseSignTool sign -f payload.txt -pfx mycert.pfx -s signature.cose \ - --cwt-issuer "did:example:123" \ - --cwt-subject "software.release.v1.0" +CoseSignTool sign --p payload.txt --pfx mycert.pfx --sf signature.cose \ + --cwt-iss "did:example:123" \ + --cwt-sub "software.release.v1.0" # Add audience claim -CoseSignTool sign -f payload.txt -pfx mycert.pfx -s signature.cose \ - --cwt-audience "production.systems" +CoseSignTool sign --p payload.txt --pfx mycert.pfx --sf signature.cose \ + --cwt-aud "production.systems" ``` #### Setting Timestamp Claims @@ -140,14 +140,14 @@ Timestamp claims accept **date/time strings** or **Unix timestamps**: ```bash # Using ISO 8601 date/time format (recommended) -CoseSignTool sign -f payload.txt -pfx mycert.pfx -s signature.cose \ - --cwt-claims "exp:2024-12-31T23:59:59Z" \ - --cwt-claims "nbf:2024-01-01T00:00:00Z" \ - --cwt-claims "iat:2024-11-19T10:30:00-05:00" +CoseSignTool sign --p payload.txt --pfx mycert.pfx --sf signature.cose \ + --cwt "exp:2024-12-31T23:59:59Z" \ + --cwt "nbf:2024-01-01T00:00:00Z" \ + --cwt "iat:2024-11-19T10:30:00-05:00" # Using Unix timestamps -CoseSignTool sign -f payload.txt -pfx mycert.pfx -s signature.cose \ - --cwt-claims "exp:1735689600" +CoseSignTool sign --p payload.txt --pfx mycert.pfx --sf signature.cose \ + --cwt "exp:1735689600" ``` #### Custom Claims @@ -156,33 +156,33 @@ Add custom claims using integer labels (including negative labels), string label ```bash # Using positive integer labels (custom claims) -CoseSignTool sign -f payload.txt -pfx mycert.pfx -s signature.cose \ - --cwt-claims "100:custom-value" \ - --cwt-claims "101:another-value" +CoseSignTool sign --p payload.txt --pfx mycert.pfx --sf signature.cose \ + --cwt "100:custom-value" \ + --cwt "101:another-value" # Using string labels (text string keys per CBOR spec) -CoseSignTool sign -f payload.txt -pfx mycert.pfx -s signature.cose \ - --cwt-claims "svn:2" \ - --cwt-claims "build-id:abc123" \ - --cwt-claims "custom-metadata:value" +CoseSignTool sign --p payload.txt --pfx mycert.pfx --sf signature.cose \ + --cwt "svn:2" \ + --cwt "build-id:abc123" \ + --cwt "custom-metadata:value" # Using negative integer labels (private use per IANA registry) -CoseSignTool sign -f payload.txt -pfx mycert.pfx -s signature.cose \ - --cwt-claims "-65537:private-claim" \ - --cwt-claims "-100:organization-specific" +CoseSignTool sign --p payload.txt --pfx mycert.pfx --sf signature.cose \ + --cwt "-65537:private-claim" \ + --cwt "-100:organization-specific" # Using standard claim names -CoseSignTool sign -f payload.txt -pfx mycert.pfx -s signature.cose \ - --cwt-claims "cti:abc123" \ - --cwt-claims "aud:production" +CoseSignTool sign --p payload.txt --pfx mycert.pfx --sf signature.cose \ + --cwt "cti:abc123" \ + --cwt "aud:production" # Combining multiple claim types -CoseSignTool sign -f payload.txt -pfx mycert.pfx -s signature.cose \ - --cwt-issuer "did:example:issuer" \ - --cwt-subject "release.v2.0" \ - --cwt-claims "exp:2025-12-31T23:59:59Z" \ - --cwt-claims "svn:42" \ - --cwt-claims "200:custom-metadata" +CoseSignTool sign --p payload.txt --pfx mycert.pfx --sf signature.cose \ + --cwt-iss "did:example:issuer" \ + --cwt-sub "release.v2.0" \ + --cwt "exp:2025-12-31T23:59:59Z" \ + --cwt "svn:42" \ + --cwt "200:custom-metadata" ``` **Label Types Supported**: @@ -200,8 +200,8 @@ If your use case doesn't require SCITT compliance, you can disable automatic CWT ```bash # Disable SCITT compliance - no automatic CWT claims will be added -CoseSignTool sign -f payload.txt -pfx mycert.pfx -s signature.cose \ - --enable-scitt false +CoseSignTool sign --p payload.txt --pfx mycert.pfx --sf signature.cose \ + --scitt false ``` When SCITT compliance is disabled: @@ -211,10 +211,10 @@ When SCITT compliance is disabled: ```bash # SCITT disabled but custom claims still work -CoseSignTool sign -f payload.txt -pfx mycert.pfx -s signature.cose \ - --enable-scitt false \ - --cwt-issuer "custom-issuer" \ - --cwt-subject "custom-subject" +CoseSignTool sign --p payload.txt --pfx mycert.pfx --sf signature.cose \ + --scitt false \ + --cwt-iss "custom-issuer" \ + --cwt-sub "custom-subject" ``` ### Indirect Signatures diff --git a/docs/Troubleshooting.md b/docs/Troubleshooting.md index 0a8e1b23..dc3cbb7c 100644 --- a/docs/Troubleshooting.md +++ b/docs/Troubleshooting.md @@ -7,7 +7,7 @@ The best way to 'shoot' trouble is to avoid it in the first place. The error mes * When signing with installed certificates you will get quicker lookups if you load the certificate from a custom store instead of from My/CurrentUser. * When signing with a loose certificate file, you should delete the file as soon as you are done signing with it to protect your private keys. * **PFX Certificate Chain Files**: When using a PFX file that contains multiple certificates (such as a complete certificate chain with root, intermediate, and leaf certificates), CoseSignTool will automatically extract and use all certificates for proper chain building. This ensures that the complete certificate chain is embedded in the COSE signature for proper validation. - * If you need to sign with a specific certificate from a multi-certificate PFX file, use the **/Thumbprint** option to specify which certificate to use for signing. + * If you need to sign with a specific certificate from a multi-certificate PFX file, use the **--Thumbprint** or **--th** option to specify which certificate to use for signing. * The remaining certificates in the PFX will be used as additional roots for chain validation, ensuring proper trust chain establishment. ## Detached vs embedded signatures @@ -15,7 +15,7 @@ The best way to 'shoot' trouble is to avoid it in the first place. The error mes * The advantage of embedded signatures is that you can share them without sending the original payload, so long as the recipient has CoseSignTool or other means available to extract the content. ## Payload and signature handling -* Use a standard naming convention for signature files, such as payload_filename*.cose* for detached and payload_filename*.csm* for embedded signatures. This will help ensure that you always validate your signatures against the correct payload. +* Use a standard naming convention for signature files, such as *payload_filename.cose*. This will help ensure that you always validate your signatures against the correct payload. * Do not supply payload when validating an embed-signed file. This is why a naming convention that differentiates between the two signature types is your friend. * When signing it is generally more secure to keep everything in memory and not write anything to disk until the signing operation is complete. This prevents the payload from getting tampered with before it is signed. Therefore, you should use streams or byte arrays where possible. However, when working with large payloads you may run into difficulties: * Arrays and most stream types cannot hold more than 2gb of data. This may include the stream used used to pipe data to applications in your operating system or command shell. From a04fd9e41c3c7fa6fd626ddc27a8657a1bc173fd Mon Sep 17 00:00:00 2001 From: "Jeromy Statia (from Dev Box)" Date: Sun, 1 Feb 2026 16:16:16 -0800 Subject: [PATCH 2/8] Add PEM certificate support for Linux/Unix environments - Add --pem/--PemCertificate option to specify PEM certificate file - Add --key/--PemKey option to specify separate PEM private key file - Support RSA and ECDSA keys in PKCS#1, PKCS#8, or encrypted PKCS#8 format - Support PEM certificate chains (multiple certificates in one file) - Reuse --pw/--Password for encrypted PEM private keys - Add 6 new tests for PEM functionality - Update documentation with PEM examples for Linux/Unix users --- CoseSignTool.Tests/SignCommandTests.cs | 320 ++++++++++++++++++++++++- CoseSignTool.Tests/Usings.cs | 2 + CoseSignTool/SignCommand.cs | 226 ++++++++++++++++- README.md | 5 +- docs/CoseSignTool.md | 44 +++- 5 files changed, 584 insertions(+), 13 deletions(-) diff --git a/CoseSignTool.Tests/SignCommandTests.cs b/CoseSignTool.Tests/SignCommandTests.cs index df5f4f24..13e687bd 100644 --- a/CoseSignTool.Tests/SignCommandTests.cs +++ b/CoseSignTool.Tests/SignCommandTests.cs @@ -1566,5 +1566,323 @@ public void SignWithScittExplicitlyEnabled_ShouldIncludeDefaultCwtClaims() } #endregion -} + #region PEM Certificate Tests + + /// + /// Tests that signing works with a PEM certificate file that contains both certificate and private key. + /// + [TestMethod] + public void SignWithPemCertificateAndInlineKey_ShouldSucceed() + { + // Arrange + string payloadFile = FileSystemUtils.GeneratePayloadFile(); + string signatureFile = Path.GetTempFileName() + ".cose"; + string pemFile = Path.GetTempFileName() + ".pem"; + + try + { + // Create PEM file with certificate and RSA private key + CreatePemFileWithKey(SelfSignedCert, pemFile); + + // Act + string[] args = ["sign", "--p", payloadFile, "--pem", pemFile, "--sf", signatureFile]; + + Microsoft.Extensions.Configuration.CommandLine.CommandLineConfigurationProvider provider = + CoseCommand.LoadCommandLineArgs(args, SignCommand.Options, out string? badArg)!; + badArg.Should().BeNull("badArg should be null."); + + SignCommand cmd = new SignCommand(); + cmd.ApplyOptions(provider); + ExitCode result = cmd.Run(); + + // Assert + result.Should().Be(ExitCode.Success, "Sign operation should succeed with PEM certificate"); + File.Exists(signatureFile).Should().BeTrue("Signature file should be created"); + + // Verify signature is valid + byte[] signatureBytes = File.ReadAllBytes(signatureFile); + signatureBytes.Length.Should().BeGreaterThan(0, "Signature should not be empty"); + } + finally + { + CleanupFile(payloadFile); + CleanupFile(signatureFile); + CleanupFile(pemFile); + } + } + + /// + /// Tests that signing works with separate PEM certificate and key files. + /// + [TestMethod] + public void SignWithSeparatePemCertAndKeyFiles_ShouldSucceed() + { + // Arrange + string payloadFile = FileSystemUtils.GeneratePayloadFile(); + string signatureFile = Path.GetTempFileName() + ".cose"; + string certPemFile = Path.GetTempFileName() + ".crt"; + string keyPemFile = Path.GetTempFileName() + ".key"; + + try + { + // Create separate PEM certificate and key files + CreateSeparatePemFiles(SelfSignedCert, certPemFile, keyPemFile); + + // Act + string[] args = ["sign", "--p", payloadFile, "--pem", certPemFile, "--key", keyPemFile, "--sf", signatureFile]; + + Microsoft.Extensions.Configuration.CommandLine.CommandLineConfigurationProvider provider = + CoseCommand.LoadCommandLineArgs(args, SignCommand.Options, out string? badArg)!; + badArg.Should().BeNull("badArg should be null."); + + SignCommand cmd = new SignCommand(); + cmd.ApplyOptions(provider); + ExitCode result = cmd.Run(); + + // Assert + result.Should().Be(ExitCode.Success, "Sign operation should succeed with separate PEM cert and key files"); + File.Exists(signatureFile).Should().BeTrue("Signature file should be created"); + } + finally + { + CleanupFile(payloadFile); + CleanupFile(signatureFile); + CleanupFile(certPemFile); + CleanupFile(keyPemFile); + } + } + + /// + /// Tests that signing works with a PEM certificate chain (multiple certificates in one file). + /// + [TestMethod] + public void SignWithPemCertificateChain_ShouldExtractAllCertificates() + { + // Arrange + string payloadFile = FileSystemUtils.GeneratePayloadFile(); + string signatureFile = Path.GetTempFileName() + ".cose"; + string pemChainFile = Path.GetTempFileName() + "_chain.pem"; + string keyFile = Path.GetTempFileName() + ".key"; + + try + { + // Create PEM file with certificate chain (leaf, intermediate, root) and separate key file + CreatePemChainFiles(CertChain1, pemChainFile, keyFile); + + // Act + string[] args = ["sign", "--p", payloadFile, "--pem", pemChainFile, "--key", keyFile, "--sf", signatureFile]; + + Microsoft.Extensions.Configuration.CommandLine.CommandLineConfigurationProvider provider = + CoseCommand.LoadCommandLineArgs(args, SignCommand.Options, out string? badArg)!; + badArg.Should().BeNull("badArg should be null."); + + SignCommand cmd = new SignCommand(); + cmd.ApplyOptions(provider); + + // Verify LoadCert extracts the chain correctly + (X509Certificate2 cert, List? additionalRoots) = cmd.LoadCert(); + + cert.Should().NotBeNull("Signing certificate should be found"); + cert.HasPrivateKey.Should().BeTrue("Signing certificate should have private key"); + additionalRoots.Should().NotBeNull("Additional certificates should be extracted from PEM chain"); + additionalRoots!.Count.Should().BeGreaterThan(0, "Should have additional certificates from the chain"); + + // Run the actual sign operation + ExitCode result = cmd.Run(); + result.Should().Be(ExitCode.Success, "Sign operation should succeed with PEM certificate chain"); + } + finally + { + CleanupFile(payloadFile); + CleanupFile(signatureFile); + CleanupFile(pemChainFile); + CleanupFile(keyFile); + } + } + + /// + /// Tests that signing fails gracefully when PEM certificate is specified without a key. + /// + [TestMethod] + public void SignWithPemCertificateWithoutKey_ShouldFail() + { + // Arrange + string payloadFile = FileSystemUtils.GeneratePayloadFile(); + string certOnlyPemFile = Path.GetTempFileName() + ".crt"; + + try + { + // Create PEM file with certificate only (no private key) + string certPem = ExportCertificateToPem(SelfSignedCert); + File.WriteAllText(certOnlyPemFile, certPem); + + // Act + string[] args = ["sign", "--p", payloadFile, "--pem", certOnlyPemFile]; + + Microsoft.Extensions.Configuration.CommandLine.CommandLineConfigurationProvider provider = + CoseCommand.LoadCommandLineArgs(args, SignCommand.Options, out string? badArg)!; + badArg.Should().BeNull("badArg should be null."); + + SignCommand cmd = new SignCommand(); + cmd.ApplyOptions(provider); + ExitCode result = cmd.Run(); + + // Assert + result.Should().Be(ExitCode.CertificateLoadFailure, "Sign should fail without private key"); + } + finally + { + CleanupFile(payloadFile); + CleanupFile(certOnlyPemFile); + } + } + + /// + /// Tests that PEM options are correctly applied from command line. + /// + [TestMethod] + public void ApplyOptions_WithPemOptions_ShouldSetProperties() + { + // Arrange + string[] args = ["sign", "--p", "payload.txt", "--pem", "/path/to/cert.pem", "--key", "/path/to/key.pem", "--pw", "secret123"]; + + // Act + Microsoft.Extensions.Configuration.CommandLine.CommandLineConfigurationProvider provider = + CoseCommand.LoadCommandLineArgs(args, SignCommand.Options, out string? badArg)!; + badArg.Should().BeNull("badArg should be null."); + + SignCommand cmd = new SignCommand(); + cmd.ApplyOptions(provider); + + // Assert + cmd.PemCertificate.Should().Be("/path/to/cert.pem", "PemCertificate should be set"); + cmd.PemKey.Should().Be("/path/to/key.pem", "PemKey should be set"); + cmd.Password.Should().Be("secret123", "Password should be set"); + } + + /// + /// Tests signing with an ECDSA PEM certificate. + /// + [TestMethod] + public void SignWithEcdsaPemCertificate_ShouldSucceed() + { + // Arrange + X509Certificate2 ecdsaCert = TestCertificateUtils.CreateCertificate( + nameof(SignWithEcdsaPemCertificate_ShouldSucceed), + useEcc: true); + + string payloadFile = FileSystemUtils.GeneratePayloadFile(); + string signatureFile = Path.GetTempFileName() + ".cose"; + string pemFile = Path.GetTempFileName() + ".pem"; + + try + { + // Create PEM file with ECDSA certificate and key + CreatePemFileWithKey(ecdsaCert, pemFile); + + // Act + string[] args = ["sign", "--p", payloadFile, "--pem", pemFile, "--sf", signatureFile]; + + Microsoft.Extensions.Configuration.CommandLine.CommandLineConfigurationProvider provider = + CoseCommand.LoadCommandLineArgs(args, SignCommand.Options, out string? badArg)!; + badArg.Should().BeNull("badArg should be null."); + + SignCommand cmd = new SignCommand(); + cmd.ApplyOptions(provider); + ExitCode result = cmd.Run(); + + // Assert + result.Should().Be(ExitCode.Success, "Sign operation should succeed with ECDSA PEM certificate"); + File.Exists(signatureFile).Should().BeTrue("Signature file should be created"); + } + finally + { + ecdsaCert.Dispose(); + CleanupFile(payloadFile); + CleanupFile(signatureFile); + CleanupFile(pemFile); + } + } + + #region PEM Helper Methods + + private static void CreatePemFileWithKey(X509Certificate2 cert, string pemFile) + { + StringBuilder sb = new StringBuilder(); + + // Export certificate + sb.AppendLine(ExportCertificateToPem(cert)); + + // Export private key + sb.AppendLine(ExportPrivateKeyToPem(cert)); + + File.WriteAllText(pemFile, sb.ToString()); + } + + private static void CreateSeparatePemFiles(X509Certificate2 cert, string certFile, string keyFile) + { + // Export certificate + File.WriteAllText(certFile, ExportCertificateToPem(cert)); + + // Export private key + File.WriteAllText(keyFile, ExportPrivateKeyToPem(cert)); + } + + private static void CreatePemChainFiles(X509Certificate2Collection chain, string chainFile, string keyFile) + { + StringBuilder sb = new StringBuilder(); + + // Get the leaf certificate (the one with private key) + X509Certificate2 leafCert = chain.Cast().First(c => c.HasPrivateKey); + + // Export leaf certificate first + sb.AppendLine(ExportCertificateToPem(leafCert)); + + // Export the rest of the chain (intermediates and root) + foreach (X509Certificate2 cert in chain.Cast().Where(c => !c.Equals(leafCert))) + { + sb.AppendLine(ExportCertificateToPem(cert)); + } + + File.WriteAllText(chainFile, sb.ToString()); + + // Export private key separately + File.WriteAllText(keyFile, ExportPrivateKeyToPem(leafCert)); + } + + private static string ExportCertificateToPem(X509Certificate2 cert) + { + return cert.ExportCertificatePem(); + } + + private static string ExportPrivateKeyToPem(X509Certificate2 cert) + { + if (cert.GetRSAPrivateKey() is RSA rsa) + { + return rsa.ExportRSAPrivateKeyPem(); + } + else if (cert.GetECDsaPrivateKey() is ECDsa ecdsa) + { + return ecdsa.ExportECPrivateKeyPem(); + } + + throw new InvalidOperationException("Certificate does not have an RSA or ECDSA private key"); + } + + private static void CleanupFile(string filePath) + { + if (File.Exists(filePath)) + { + try + { + File.Delete(filePath); + } + catch { /* ignore cleanup errors */ } + } + } + + #endregion + + #endregion +} \ No newline at end of file diff --git a/CoseSignTool.Tests/Usings.cs b/CoseSignTool.Tests/Usings.cs index 8757bb15..beaca35b 100644 --- a/CoseSignTool.Tests/Usings.cs +++ b/CoseSignTool.Tests/Usings.cs @@ -5,7 +5,9 @@ global using System.Diagnostics; global using System.IO; global using System.Linq; +global using System.Security.Cryptography; global using System.Security.Cryptography.X509Certificates; +global using System.Text; global using CoseIndirectSignature; global using CoseSign1.Certificates.Local; global using CoseSign1.Tests.Common; diff --git a/CoseSignTool/SignCommand.cs b/CoseSignTool/SignCommand.cs index 7769e881..fd98ff6a 100644 --- a/CoseSignTool/SignCommand.cs +++ b/CoseSignTool/SignCommand.cs @@ -23,6 +23,10 @@ public class SignCommand : CoseCommand ["--po"] = "PipeOutput", ["--PfxCertificate"] = "PfxCertificate", ["--pfx"] = "PfxCertificate", + ["--PemCertificate"] = "PemCertificate", + ["--pem"] = "PemCertificate", + ["--PemKey"] = "PemKey", + ["--key"] = "PemKey", ["--Password"] = "Password", ["--pw"] = "Password", ["--Thumbprint"] = "Thumbprint", @@ -90,7 +94,19 @@ public class SignCommand : CoseCommand public string? PfxCertificate { get; set; } /// - /// Optional. Gets or sets the password for the .pfx file if it requires one. + /// Optional. Gets or sets the path to a PEM-encoded certificate file to sign with. + /// Common on Linux/Unix systems. Use with --PemKey to specify the private key file. + /// + public string? PemCertificate { get; set; } + + /// + /// Optional. Gets or sets the path to a PEM-encoded private key file. + /// Used together with --PemCertificate for signing. The key may be encrypted (use --Password to decrypt). + /// + public string? PemKey { get; set; } + + /// + /// Optional. Gets or sets the password for the .pfx file or encrypted PEM private key if it requires one. /// public string? Password { get; set; } @@ -342,6 +358,8 @@ protected internal override void ApplyOptions(CommandLineConfigurationProvider p PipeOutput = GetOptionBool(provider, nameof (PipeOutput)); Thumbprint = GetOptionString(provider, nameof(Thumbprint)); PfxCertificate = GetOptionString(provider, nameof(PfxCertificate)); + PemCertificate = GetOptionString(provider, nameof(PemCertificate)); + PemKey = GetOptionString(provider, nameof(PemKey)); Password = GetOptionString(provider, nameof(Password)); ContentType = GetOptionString(provider, nameof(ContentType), CoseSign1MessageFactory.DEFAULT_CONTENT_TYPE); StoreName = GetOptionString(provider, nameof(StoreName), DefaultStoreName); @@ -575,7 +593,12 @@ private static TypeV HeaderValueConverter(string[]? labelValue = null) X509Certificate2 cert; List? additionalRoots = null; - if (PfxCertificate is not null) + if (PemCertificate is not null) + { + // Load from PEM files (common on Linux/Unix systems) + (cert, additionalRoots) = LoadCertFromPem(); + } + else if (PfxCertificate is not null) { // Load the PFX certificate. This will throw a CryptographicException if the password is wrong or missing. ThrowIfMissing(PfxCertificate, "Could not find the certificate file"); @@ -604,12 +627,189 @@ private static TypeV HeaderValueConverter(string[]? labelValue = null) { // Load certificate from thumbprint. cert = Thumbprint is not null ? CoseHandler.LookupCertificate(Thumbprint, StoreName!, StoreLocation) : - throw new ArgumentNullException("You must specify a certificate file or thumbprint to sign with."); + throw new ArgumentNullException("You must specify a certificate file (--pfx or --pem) or thumbprint to sign with."); } return (cert, additionalRoots); } + /// + /// Loads a certificate and private key from PEM files. + /// + /// The certificate with private key and optional additional root certificates. + /// The PEM certificate or key file was not found. + /// The PEM files could not be parsed or the key is encrypted and no password was provided. + /// The private key file was not specified. + private (X509Certificate2 certificate, List? additionalRoots) LoadCertFromPem() + { + ThrowIfMissing(PemCertificate!, "Could not find the PEM certificate file"); + + // Read the PEM certificate file + string certPem = File.ReadAllText(PemCertificate!); + + // Parse all certificates from the PEM file (may contain a chain) + List certificates = ParsePemCertificates(certPem); + + if (certificates.Count == 0) + { + throw new CryptographicException($"No valid certificates found in PEM file: {PemCertificate}"); + } + + // The first certificate is typically the leaf/signing certificate + X509Certificate2 leafCert = certificates[0]; + + // If a separate key file is provided, load and combine with the certificate + if (PemKey is not null) + { + ThrowIfMissing(PemKey, "Could not find the PEM private key file"); + string keyPem = File.ReadAllText(PemKey); + leafCert = LoadCertificateWithPrivateKey(leafCert, keyPem); + } + else if (!leafCert.HasPrivateKey) + { + // Try to find the private key in the same PEM file as the certificate + leafCert = LoadCertificateWithPrivateKey(leafCert, certPem); + } + + if (!leafCert.HasPrivateKey) + { + throw new CryptographicException( + "The certificate does not have a private key. " + + "Specify the private key file using --key or include it in the PEM certificate file."); + } + + // Additional certificates in the PEM file are treated as the certificate chain + List? additionalRoots = certificates.Count > 1 + ? certificates.Skip(1).ToList() + : null; + + return (leafCert, additionalRoots); + } + + /// + /// Parses all X.509 certificates from a PEM-encoded string. + /// + /// The PEM-encoded string containing one or more certificates. + /// A list of X509Certificate2 objects. + private static List ParsePemCertificates(string pem) + { + List certificates = []; + + // Match all certificate blocks in the PEM + const string certHeader = "-----BEGIN CERTIFICATE-----"; + const string certFooter = "-----END CERTIFICATE-----"; + + int startIndex = 0; + while ((startIndex = pem.IndexOf(certHeader, startIndex, StringComparison.Ordinal)) >= 0) + { + int endIndex = pem.IndexOf(certFooter, startIndex, StringComparison.Ordinal); + if (endIndex < 0) + { + break; + } + + endIndex += certFooter.Length; + string certBlock = pem.Substring(startIndex, endIndex - startIndex); + + try + { + X509Certificate2 cert = X509Certificate2.CreateFromPem(certBlock); + certificates.Add(cert); + } + catch (CryptographicException) + { + // Skip invalid certificate blocks + } + + startIndex = endIndex; + } + + return certificates; + } + + /// + /// Loads a certificate with its private key from a PEM-encoded key string. + /// Supports RSA, ECDSA, and encrypted private keys. + /// + /// The certificate without private key. + /// The PEM-encoded private key string. + /// A new X509Certificate2 instance with the private key attached. + private X509Certificate2 LoadCertificateWithPrivateKey(X509Certificate2 certificate, string keyPem) + { + // Try to load as RSA key first + if (keyPem.Contains("-----BEGIN RSA PRIVATE KEY-----") || + keyPem.Contains("-----BEGIN PRIVATE KEY-----") || + keyPem.Contains("-----BEGIN ENCRYPTED PRIVATE KEY-----")) + { + try + { + using RSA rsa = RSA.Create(); + + if (keyPem.Contains("-----BEGIN ENCRYPTED PRIVATE KEY-----")) + { + if (string.IsNullOrEmpty(Password)) + { + throw new CryptographicException( + "The private key is encrypted. Please provide a password using --pw or --Password."); + } + rsa.ImportFromEncryptedPem(keyPem, Password); + } + else + { + rsa.ImportFromPem(keyPem); + } + + return certificate.CopyWithPrivateKey(rsa); + } + catch (CryptographicException) when (!keyPem.Contains("RSA")) + { + // Not an RSA key, try ECDSA below + } + } + + // Try to load as ECDSA key + if (keyPem.Contains("-----BEGIN EC PRIVATE KEY-----") || + keyPem.Contains("-----BEGIN PRIVATE KEY-----") || + keyPem.Contains("-----BEGIN ENCRYPTED PRIVATE KEY-----")) + { + try + { + using ECDsa ecdsa = ECDsa.Create(); + + if (keyPem.Contains("-----BEGIN ENCRYPTED PRIVATE KEY-----")) + { + if (string.IsNullOrEmpty(Password)) + { + throw new CryptographicException( + "The private key is encrypted. Please provide a password using --pw or --Password."); + } + ecdsa.ImportFromEncryptedPem(keyPem, Password); + } + else + { + ecdsa.ImportFromPem(keyPem); + } + + return certificate.CopyWithPrivateKey(ecdsa); + } + catch (CryptographicException) + { + // Not an ECDSA key either + } + } + + // If we get here with a private key marker but couldn't load it, throw + if (keyPem.Contains("PRIVATE KEY")) + { + throw new CryptographicException( + "Could not load the private key. The key format may be unsupported or corrupted. " + + "Supported formats: RSA and ECDSA keys in PEM format (PKCS#1, PKCS#8, or encrypted PKCS#8)."); + } + + // No private key found in the PEM content + return certificate; + } + /// /// Loads a signing key provider, either from a certificate provider plugin or from local certificates. /// @@ -1037,20 +1237,30 @@ Default value is [payload file].cose. --OR-- - --PfxCertificate, --pfx: A path to a private key certificate file (.pfx) to sign with. + --PfxCertificate, --pfx: A path to a private key certificate file (.pfx) to sign with. Common on Windows. + + --Password, --pw: Optional. The password for the .pfx file or encrypted PEM key if it has one. + + --OR-- + + --PemCertificate, --pem: A path to a PEM-encoded certificate file to sign with. Common on Linux/Unix. + The certificate file may contain the full certificate chain (leaf first, then intermediates, then root). + + --PemKey, --key: The path to a PEM-encoded private key file. Required if the certificate file does not contain + the private key. Supports RSA and ECDSA keys in PKCS#1, PKCS#8, or encrypted PKCS#8 format. - --Password, -pw: Optional. The password for the .pfx file if it has one. (Strongly recommended!) + --Password, --pw: Optional. The password for an encrypted PEM private key. --OR-- - --Thumbprint, -th: The SHA1 thumbprint of a certificate in the local certificate store to sign the file with. + --Thumbprint, --th: The SHA1 thumbprint of a certificate in the local certificate store to sign the file with. Use the optional StoreName and StoreLocation parameters to tell CoseSignTool where to find the matching certificate. - --StoreName, -sn: Optional. The name of the local certificate store to find the signing certificate in. + --StoreName, --sn: Optional. The name of the local certificate store to find the signing certificate in. Default value is 'My'. - --StoreLocation, -sl: Optional. The location of the local certificate store to find the signing certificate in. + --StoreLocation, --sl: Optional. The location of the local certificate store to find the signing certificate in. Default value is 'CurrentUser'. --PipeOutput, -po: Optional. If set, outputs the detached or embedded COSE signature to Standard Out instead of diff --git a/README.md b/README.md index 4459e0a6..512f943c 100644 --- a/README.md +++ b/README.md @@ -45,10 +45,13 @@ CoseSignTool supports **SCITT (Supply Chain Integrity, Transparency, and Trust)* ### Quick Example ```bash -# Basic SCITT-compliant signature +# Basic SCITT-compliant signature with PFX certificate (Windows) # Automatically includes: DID:x509 issuer, default subject, timestamps CoseSignTool sign --payload payload.txt --pfx mycert.pfx --SignatureFile signature.cose +# Using PEM certificate and key files (Linux/Unix) +CoseSignTool sign --payload payload.txt --pem mycert.pem --key mykey.pem --SignatureFile signature.cose + # Custom SCITT signature with specific subject and expiration CoseSignTool sign --payload payload.txt --pfx mycert.pfx --SignatureFile signature.cose \ --cwt-sub "software.release.v1.0" \ diff --git a/docs/CoseSignTool.md b/docs/CoseSignTool.md index 34417afc..693848b2 100644 --- a/docs/CoseSignTool.md +++ b/docs/CoseSignTool.md @@ -43,11 +43,14 @@ The **Sign** command signs a file or stream. You will need to specify: * The payload content to sign. This may be a file specified with the **--PayloadFile** or **--p** option or you can pipe it in on the Standard Input channel when you call CoseSignTool. Piping in the content is generally considered more secure and performant option but large streams of > 2gb in length are not yet supported. -* A signing key provider. You have three options: +* A signing key provider. You have four options: 1. **Certificate Provider Plugin** (recommended for cloud/HSM signing): Use the **--CertProvider** or **--cp** option to specify a certificate provider plugin (e.g., `azure-trusted-signing`). See [Certificate Providers](#certificate-providers) section below. - 2. **Local PFX Certificate**: Use the **--PfxCertificate** or **--pfx** option to point to a .pfx certificate file and a **--Password** or **--pw** to open the certificate file with if it is locked. The certificate must include a private key. + 2. **Local PFX Certificate** (common on Windows): Use the **--PfxCertificate** or **--pfx** option to point to a .pfx certificate file and a **--Password** or **--pw** to open the certificate file with if it is locked. The certificate must include a private key. * **PFX Certificate Chain Handling**: When using a PFX file that contains multiple certificates (such as a complete certificate chain), CoseSignTool will automatically use all certificates in the PFX for proper chain building. If you specify a **--Thumbprint** or **--th** along with the PFX file, CoseSignTool will use the certificate matching that thumbprint for signing and treat the remaining certificates as additional roots for chain validation. If no thumbprint is specified, the first certificate with a private key will be used for signing. - 3. **Local Certificate Store**: Use the **--Thumbprint** or **--th** option to pass the SHA1 thumbprint of an installed certificate. The certificate must include a private key. + 3. **PEM Certificate Files** (common on Linux/Unix): Use the **--PemCertificate** or **--pem** option to point to a PEM-encoded certificate file. If the private key is in a separate file, use **--PemKey** or **--key** to specify the key file. Use **--Password** or **--pw** if the private key is encrypted. + * **PEM Certificate Chain Handling**: The PEM certificate file may contain multiple certificates (leaf first, then intermediates, then root). All certificates will be used for proper chain building. + * **Supported Key Formats**: RSA and ECDSA keys in PKCS#1, PKCS#8, or encrypted PKCS#8 format. + 4. **Local Certificate Store**: Use the **--Thumbprint** or **--th** option to pass the SHA1 thumbprint of an installed certificate. The certificate must include a private key. You may also want to specify: * Detached or embedded: By default, CoseSignTool creates a detached signature, which contains a hash of the original payoad. If you want it embedded, meaning that the signature file includes a copy of the payload, use the **--EmbedPayload** or **--ep** option. Note that embedded signatures are only supported for payload of less than 2gb. @@ -153,6 +156,41 @@ CoseSignTool sign --p payload.txt --pfx mycert.pfx --sf signature.cose \ CoseSignTool sign --p payload.txt --pfx mycert.pfx --sf signature.cose --scitt false ``` +### PEM Certificate Examples (Linux/Unix): + +**Sign with combined PEM file (certificate + key in one file):** +```bash +CoseSignTool sign --p payload.txt --pem mycert.pem --sf signature.cose +``` + +**Sign with separate certificate and key files:** +```bash +CoseSignTool sign --p payload.txt --pem mycert.crt --key mykey.pem --sf signature.cose +``` + +**Sign with encrypted private key:** +```bash +CoseSignTool sign --p payload.txt --pem mycert.pem --key mykey.encrypted.pem --pw "keypassword" --sf signature.cose +``` + +**Sign with PEM certificate chain (leaf + intermediates + root in one file):** +```bash +# The PEM file should contain certificates in order: leaf first, then intermediates, then root +CoseSignTool sign --p payload.txt --pem fullchain.pem --key privkey.pem --sf signature.cose +``` + +**Creating PEM files from existing certificates:** +```bash +# Extract certificate from PFX to PEM format +openssl pkcs12 -in mycert.pfx -clcerts -nokeys -out mycert.crt + +# Extract private key from PFX to PEM format +openssl pkcs12 -in mycert.pfx -nocerts -nodes -out mykey.pem + +# Combine certificate and key into single PEM file +cat mycert.crt mykey.pem > combined.pem +``` + ### Headers: * There are two ways to supply headers: (1) command-line, and, (2) a JSON file. Both options support providing protected and un-protected headers with int32 and string values. The header label is always a string value. >Note: When both file and command-line header options are specified, the command-line input is ignored. From 2cbe1993d99892ac0153c97fd0d8f25caba084b5 Mon Sep 17 00:00:00 2001 From: "Jeromy Statia (from Dev Box)" Date: Sun, 1 Feb 2026 16:22:34 -0800 Subject: [PATCH 3/8] Add tests for encrypted PEM private key support - Add test for signing with encrypted PEM private key and correct password - Add test for failure when no password provided for encrypted key - Add test for failure when wrong password provided for encrypted key - Add CreateEncryptedPemKeyFile helper method for test setup --- CoseSignTool.Tests/SignCommandTests.cs | 157 +++++++++++++++++++++++++ 1 file changed, 157 insertions(+) diff --git a/CoseSignTool.Tests/SignCommandTests.cs b/CoseSignTool.Tests/SignCommandTests.cs index 13e687bd..67b66372 100644 --- a/CoseSignTool.Tests/SignCommandTests.cs +++ b/CoseSignTool.Tests/SignCommandTests.cs @@ -1805,6 +1805,140 @@ public void SignWithEcdsaPemCertificate_ShouldSucceed() } } + /// + /// Tests signing with an encrypted PEM private key. + /// + [TestMethod] + public void SignWithEncryptedPemPrivateKey_ShouldSucceed() + { + // Arrange + string payloadFile = FileSystemUtils.GeneratePayloadFile(); + string signatureFile = Path.GetTempFileName() + ".cose"; + string certPemFile = Path.GetTempFileName() + ".crt"; + string encryptedKeyFile = Path.GetTempFileName() + ".key"; + string keyPassword = "test-password-123"; + + try + { + // Create PEM certificate file + File.WriteAllText(certPemFile, ExportCertificateToPem(SelfSignedCert)); + + // Create encrypted PEM private key file + CreateEncryptedPemKeyFile(SelfSignedCert, encryptedKeyFile, keyPassword); + + // Act + string[] args = ["sign", "--p", payloadFile, "--pem", certPemFile, "--key", encryptedKeyFile, + "--pw", keyPassword, "--sf", signatureFile]; + + Microsoft.Extensions.Configuration.CommandLine.CommandLineConfigurationProvider provider = + CoseCommand.LoadCommandLineArgs(args, SignCommand.Options, out string? badArg)!; + badArg.Should().BeNull("badArg should be null."); + + SignCommand cmd = new SignCommand(); + cmd.ApplyOptions(provider); + ExitCode result = cmd.Run(); + + // Assert + result.Should().Be(ExitCode.Success, "Sign operation should succeed with encrypted PEM private key"); + File.Exists(signatureFile).Should().BeTrue("Signature file should be created"); + } + finally + { + CleanupFile(payloadFile); + CleanupFile(signatureFile); + CleanupFile(certPemFile); + CleanupFile(encryptedKeyFile); + } + } + + /// + /// Tests that signing fails with encrypted PEM key when no password is provided. + /// + [TestMethod] + public void SignWithEncryptedPemKeyWithoutPassword_ShouldFail() + { + // Arrange + string payloadFile = FileSystemUtils.GeneratePayloadFile(); + string certPemFile = Path.GetTempFileName() + ".crt"; + string encryptedKeyFile = Path.GetTempFileName() + ".key"; + string keyPassword = "test-password-456"; + + try + { + // Create PEM certificate file + File.WriteAllText(certPemFile, ExportCertificateToPem(SelfSignedCert)); + + // Create encrypted PEM private key file + CreateEncryptedPemKeyFile(SelfSignedCert, encryptedKeyFile, keyPassword); + + // Act - Note: No --pw argument provided + string[] args = ["sign", "--p", payloadFile, "--pem", certPemFile, "--key", encryptedKeyFile]; + + Microsoft.Extensions.Configuration.CommandLine.CommandLineConfigurationProvider provider = + CoseCommand.LoadCommandLineArgs(args, SignCommand.Options, out string? badArg)!; + badArg.Should().BeNull("badArg should be null."); + + SignCommand cmd = new SignCommand(); + cmd.ApplyOptions(provider); + ExitCode result = cmd.Run(); + + // Assert + result.Should().Be(ExitCode.CertificateLoadFailure, + "Sign should fail when encrypted key is provided without password"); + } + finally + { + CleanupFile(payloadFile); + CleanupFile(certPemFile); + CleanupFile(encryptedKeyFile); + } + } + + /// + /// Tests that signing fails with encrypted PEM key when wrong password is provided. + /// + [TestMethod] + public void SignWithEncryptedPemKeyWithWrongPassword_ShouldFail() + { + // Arrange + string payloadFile = FileSystemUtils.GeneratePayloadFile(); + string certPemFile = Path.GetTempFileName() + ".crt"; + string encryptedKeyFile = Path.GetTempFileName() + ".key"; + string keyPassword = "correct-password"; + string wrongPassword = "wrong-password"; + + try + { + // Create PEM certificate file + File.WriteAllText(certPemFile, ExportCertificateToPem(SelfSignedCert)); + + // Create encrypted PEM private key file + CreateEncryptedPemKeyFile(SelfSignedCert, encryptedKeyFile, keyPassword); + + // Act - Provide wrong password + string[] args = ["sign", "--p", payloadFile, "--pem", certPemFile, "--key", encryptedKeyFile, + "--pw", wrongPassword]; + + Microsoft.Extensions.Configuration.CommandLine.CommandLineConfigurationProvider provider = + CoseCommand.LoadCommandLineArgs(args, SignCommand.Options, out string? badArg)!; + badArg.Should().BeNull("badArg should be null."); + + SignCommand cmd = new SignCommand(); + cmd.ApplyOptions(provider); + ExitCode result = cmd.Run(); + + // Assert + result.Should().Be(ExitCode.CertificateLoadFailure, + "Sign should fail when wrong password is provided for encrypted key"); + } + finally + { + CleanupFile(payloadFile); + CleanupFile(certPemFile); + CleanupFile(encryptedKeyFile); + } + } + #region PEM Helper Methods private static void CreatePemFileWithKey(X509Certificate2 cert, string pemFile) @@ -1870,6 +2004,29 @@ private static string ExportPrivateKeyToPem(X509Certificate2 cert) throw new InvalidOperationException("Certificate does not have an RSA or ECDSA private key"); } + private static void CreateEncryptedPemKeyFile(X509Certificate2 cert, string keyFile, string password) + { + PbeParameters pbeParameters = new PbeParameters( + PbeEncryptionAlgorithm.Aes256Cbc, + HashAlgorithmName.SHA256, + iterationCount: 100_000); + + if (cert.GetRSAPrivateKey() is RSA rsa) + { + string encryptedPem = rsa.ExportEncryptedPkcs8PrivateKeyPem(password, pbeParameters); + File.WriteAllText(keyFile, encryptedPem); + } + else if (cert.GetECDsaPrivateKey() is ECDsa ecdsa) + { + string encryptedPem = ecdsa.ExportEncryptedPkcs8PrivateKeyPem(password, pbeParameters); + File.WriteAllText(keyFile, encryptedPem); + } + else + { + throw new InvalidOperationException("Certificate does not have an RSA or ECDSA private key"); + } + } + private static void CleanupFile(string filePath) { if (File.Exists(filePath)) From f6eb939dcf0be4bd5a60af11c34d35baa056a24e Mon Sep 17 00:00:00 2001 From: "Jeromy Statia (from Dev Box)" Date: Tue, 3 Feb 2026 12:53:46 -0800 Subject: [PATCH 4/8] PEM password via env/prompt; keep PFX --pw --- CoseHandler.Tests/CoseExtensionsTests.cs | 42 +++-- CoseHandler/Extensions/FileInfoExtensions.cs | 37 +++-- CoseSignTool.Tests/SignCommandTests.cs | 109 +++++++++++-- CoseSignTool/SignCommand.cs | 156 +++++++++++++++++-- docs/CoseSignTool.md | 17 +- 5 files changed, 304 insertions(+), 57 deletions(-) diff --git a/CoseHandler.Tests/CoseExtensionsTests.cs b/CoseHandler.Tests/CoseExtensionsTests.cs index eafb5478..634e8a55 100644 --- a/CoseHandler.Tests/CoseExtensionsTests.cs +++ b/CoseHandler.Tests/CoseExtensionsTests.cs @@ -49,14 +49,23 @@ public void FileLoadPartialWriteBytes() // Arrange string text = "This is some text being written slowly."; // 39 chars FileInfo f = new(Path.GetTempFileName()); + Task? writerTask = null; - // Act - // Start the file write then start the loading task before the write completes. - _ = Task.Run(() => f.WriteAllBytesDelayedAsync(Encoding.UTF8.GetBytes(text), 1, 100)); - byte[] bytes = f.GetBytesResilient(writeTo: OutputTarget.StdOut); + try + { + // Act + // Start the file write then start the loading task before the write completes. + writerTask = Task.Run(() => f.WriteAllBytesDelayedAsync(Encoding.UTF8.GetBytes(text), 1, 100)); + byte[] bytes = f.GetBytesResilient(writeTo: OutputTarget.StdOut); - // Assert - bytes.Length.Should().BeGreaterThan(38, "GetBytesResilient should keep reading until the write is complete."); + // Assert + bytes.Length.Should().BeGreaterThan(38, "GetBytesResilient should keep reading until the write is complete."); + } + finally + { + writerTask?.GetAwaiter().GetResult(); + try { f.Delete(); } catch { /* best-effort cleanup */ } + } } [TestMethod] @@ -71,14 +80,23 @@ public void FileLoadPartialWriteStream() // Arrange string text = "This is some text being written slowly."; // 39 chars FileInfo f = new(Path.GetTempFileName()); + Task? writerTask = null; - // Act - // Start the file write then start the loading task before the write completes. - _ = Task.Run(() => f.WriteAllBytesDelayedAsync(Encoding.UTF8.GetBytes(text), 1, 100)); - FileStream? stream = f.GetStreamResilient(writeTo: OutputTarget.StdOut); + try + { + // Act + // Start the file write then start the loading task before the write completes. + writerTask = Task.Run(() => f.WriteAllBytesDelayedAsync(Encoding.UTF8.GetBytes(text), 1, 100)); + using FileStream stream = f.GetStreamResilient(writeTo: OutputTarget.StdOut)!; - // Assert - stream!.Length.Should().BeGreaterThan(38, "GetStreamResilient should keep reading until the write is complete."); + // Assert + stream.Length.Should().BeGreaterThan(38, "GetStreamResilient should keep reading until the write is complete."); + } + finally + { + writerTask?.GetAwaiter().GetResult(); + try { f.Delete(); } catch { /* best-effort cleanup */ } + } } [TestMethod] diff --git a/CoseHandler/Extensions/FileInfoExtensions.cs b/CoseHandler/Extensions/FileInfoExtensions.cs index f07a62bb..94342b54 100644 --- a/CoseHandler/Extensions/FileInfoExtensions.cs +++ b/CoseHandler/Extensions/FileInfoExtensions.cs @@ -89,30 +89,35 @@ private static (byte[]?, FileStream?) GetBytesOrStream(FileInfo f, bool isStream writer ??= OutputTarget.StdErr; bool waitMessageStarted = false; byte ticks = 0; + long lastLength = -1; while (IsFileLocked(f)) { - long lastLength = f.Length; - Thread.Sleep(250); - if (OutOfTime(startTime, maxWaitTime) && lastLength == f.Length) + f.Refresh(); + long currentLength = f.Length; + + // If the file is still growing, treat that as progress and keep waiting. + if (currentLength != lastLength) { - if (f.Length > lastLength) + lastLength = currentLength; + startTime = DateTime.Now; + + ticks++; + if (!waitMessageStarted) { - ticks++; - if (!waitMessageStarted) - { - waitMessageStarted = true; - writer.WriteLine($"Waiting for write of file '{f.FullName}' to complete."); - } - else if (ticks % 4 == 0) - { - writer.Write("."); - } + waitMessageStarted = true; + writer.WriteLine($"Waiting for write of file '{f.FullName}' to complete."); } - else + else if (ticks % 4 == 0) { - throw new IOException($"The file '{f.FullName}' is still locked by another process after {SecondsSince(startTime)} seconds."); + writer.Write("."); } } + + Thread.Sleep(250); + if (OutOfTime(startTime, maxWaitTime)) + { + throw new IOException($"The file '{f.FullName}' is still locked by another process after {SecondsSince(startTime)} seconds."); + } } startTime = DateTime.Now; diff --git a/CoseSignTool.Tests/SignCommandTests.cs b/CoseSignTool.Tests/SignCommandTests.cs index 67b66372..ab7416dc 100644 --- a/CoseSignTool.Tests/SignCommandTests.cs +++ b/CoseSignTool.Tests/SignCommandTests.cs @@ -415,7 +415,7 @@ public void LoadCertFromPfxWithInvalidThumbprintThrowsException() // Create a PFX file containing a full certificate chain with only leaf having private key X509Certificate2Collection pfxChain = TestCertificateUtils.CreateTestChainForPfx(nameof(LoadCertFromPfxWithInvalidThumbprintThrowsException)); string pfxFileWithChain = Path.GetTempFileName() + "_chain.pfx"; - + try { // Export the full certificate chain to a PFX file @@ -472,7 +472,7 @@ public void SignWithPfxAndThumbprintSucceeds() X509Certificate2 leafCert = pfxChain.Cast().First(c => c.HasPrivateKey); // Sign using the PFX with a specific thumbprint - string[] args = ["sign", "/p", payloadFile, "/pfx", pfxFileWithChain, "/pw", CertPassword, "/th", leafCert.Thumbprint, "/ep"]; + string[] args = ["sign", "--p", payloadFile, "--pfx", pfxFileWithChain, "--pw", CertPassword, "--th", leafCert.Thumbprint, "--ep"]; Microsoft.Extensions.Configuration.CommandLine.CommandLineConfigurationProvider provider = CoseCommand.LoadCommandLineArgs(args, SignCommand.Options, out string? badArg)!; badArg.Should().BeNull("badArg should be null."); @@ -1745,7 +1745,7 @@ public void SignWithPemCertificateWithoutKey_ShouldFail() public void ApplyOptions_WithPemOptions_ShouldSetProperties() { // Arrange - string[] args = ["sign", "--p", "payload.txt", "--pem", "/path/to/cert.pem", "--key", "/path/to/key.pem", "--pw", "secret123"]; + string[] args = ["sign", "--p", "payload.txt", "--pem", "/path/to/cert.pem", "--key", "/path/to/key.pem"]; // Act Microsoft.Extensions.Configuration.CommandLine.CommandLineConfigurationProvider provider = @@ -1758,7 +1758,6 @@ public void ApplyOptions_WithPemOptions_ShouldSetProperties() // Assert cmd.PemCertificate.Should().Be("/path/to/cert.pem", "PemCertificate should be set"); cmd.PemKey.Should().Be("/path/to/key.pem", "PemKey should be set"); - cmd.Password.Should().Be("secret123", "Password should be set"); } /// @@ -1817,6 +1816,7 @@ public void SignWithEncryptedPemPrivateKey_ShouldSucceed() string certPemFile = Path.GetTempFileName() + ".crt"; string encryptedKeyFile = Path.GetTempFileName() + ".key"; string keyPassword = "test-password-123"; + string envVarName = "TEST_PEM_PASSWORD_" + Guid.NewGuid().ToString("N")[..8]; try { @@ -1826,9 +1826,12 @@ public void SignWithEncryptedPemPrivateKey_ShouldSucceed() // Create encrypted PEM private key file CreateEncryptedPemKeyFile(SelfSignedCert, encryptedKeyFile, keyPassword); - // Act + // Set password via environment variable (secure method) + Environment.SetEnvironmentVariable(envVarName, keyPassword); + + // Act - Use --pwenv to specify the environment variable string[] args = ["sign", "--p", payloadFile, "--pem", certPemFile, "--key", encryptedKeyFile, - "--pw", keyPassword, "--sf", signatureFile]; + "--pwenv", envVarName, "--sf", signatureFile]; Microsoft.Extensions.Configuration.CommandLine.CommandLineConfigurationProvider provider = CoseCommand.LoadCommandLineArgs(args, SignCommand.Options, out string? badArg)!; @@ -1844,6 +1847,58 @@ public void SignWithEncryptedPemPrivateKey_ShouldSucceed() } finally { + Environment.SetEnvironmentVariable(envVarName, null); + CleanupFile(payloadFile); + CleanupFile(signatureFile); + CleanupFile(certPemFile); + CleanupFile(encryptedKeyFile); + } + } + + /// + /// Tests that signing works with password from default COSESIGNTOOL_PASSWORD environment variable. + /// + [TestMethod] + public void SignWithEncryptedPemPrivateKey_DefaultEnvVar_ShouldSucceed() + { + // Arrange + string payloadFile = FileSystemUtils.GeneratePayloadFile(); + string signatureFile = Path.GetTempFileName() + ".cose"; + string certPemFile = Path.GetTempFileName() + ".crt"; + string encryptedKeyFile = Path.GetTempFileName() + ".key"; + string keyPassword = "test-password-default"; + string? originalEnvValue = Environment.GetEnvironmentVariable(SignCommand.DefaultPasswordEnvVar); + + try + { + // Create PEM certificate file + File.WriteAllText(certPemFile, ExportCertificateToPem(SelfSignedCert)); + + // Create encrypted PEM private key file + CreateEncryptedPemKeyFile(SelfSignedCert, encryptedKeyFile, keyPassword); + + // Set password via default environment variable + Environment.SetEnvironmentVariable(SignCommand.DefaultPasswordEnvVar, keyPassword); + + // Act - Don't specify --pwenv, should use default COSESIGNTOOL_PASSWORD + string[] args = ["sign", "--p", payloadFile, "--pem", certPemFile, "--key", encryptedKeyFile, + "--sf", signatureFile]; + + Microsoft.Extensions.Configuration.CommandLine.CommandLineConfigurationProvider provider = + CoseCommand.LoadCommandLineArgs(args, SignCommand.Options, out string? badArg)!; + badArg.Should().BeNull("badArg should be null."); + + SignCommand cmd = new SignCommand(); + cmd.ApplyOptions(provider); + ExitCode result = cmd.Run(); + + // Assert + result.Should().Be(ExitCode.Success, "Sign operation should succeed with password from default env var"); + File.Exists(signatureFile).Should().BeTrue("Signature file should be created"); + } + finally + { + Environment.SetEnvironmentVariable(SignCommand.DefaultPasswordEnvVar, originalEnvValue); CleanupFile(payloadFile); CleanupFile(signatureFile); CleanupFile(certPemFile); @@ -1862,6 +1917,7 @@ public void SignWithEncryptedPemKeyWithoutPassword_ShouldFail() string certPemFile = Path.GetTempFileName() + ".crt"; string encryptedKeyFile = Path.GetTempFileName() + ".key"; string keyPassword = "test-password-456"; + string? originalEnvValue = Environment.GetEnvironmentVariable(SignCommand.DefaultPasswordEnvVar); try { @@ -1871,7 +1927,10 @@ public void SignWithEncryptedPemKeyWithoutPassword_ShouldFail() // Create encrypted PEM private key file CreateEncryptedPemKeyFile(SelfSignedCert, encryptedKeyFile, keyPassword); - // Act - Note: No --pw argument provided + // Clear the default password env var to ensure no password is available + Environment.SetEnvironmentVariable(SignCommand.DefaultPasswordEnvVar, null); + + // Act - Note: No password env var set string[] args = ["sign", "--p", payloadFile, "--pem", certPemFile, "--key", encryptedKeyFile]; Microsoft.Extensions.Configuration.CommandLine.CommandLineConfigurationProvider provider = @@ -1888,6 +1947,7 @@ public void SignWithEncryptedPemKeyWithoutPassword_ShouldFail() } finally { + Environment.SetEnvironmentVariable(SignCommand.DefaultPasswordEnvVar, originalEnvValue); CleanupFile(payloadFile); CleanupFile(certPemFile); CleanupFile(encryptedKeyFile); @@ -1895,7 +1955,7 @@ public void SignWithEncryptedPemKeyWithoutPassword_ShouldFail() } /// - /// Tests that signing fails with encrypted PEM key when wrong password is provided. + /// Tests that signing fails with encrypted PEM key when wrong password is provided via environment variable. /// [TestMethod] public void SignWithEncryptedPemKeyWithWrongPassword_ShouldFail() @@ -1906,6 +1966,7 @@ public void SignWithEncryptedPemKeyWithWrongPassword_ShouldFail() string encryptedKeyFile = Path.GetTempFileName() + ".key"; string keyPassword = "correct-password"; string wrongPassword = "wrong-password"; + string envVarName = "TEST_WRONG_PASSWORD_" + Guid.NewGuid().ToString("N")[..8]; try { @@ -1915,9 +1976,12 @@ public void SignWithEncryptedPemKeyWithWrongPassword_ShouldFail() // Create encrypted PEM private key file CreateEncryptedPemKeyFile(SelfSignedCert, encryptedKeyFile, keyPassword); - // Act - Provide wrong password + // Set wrong password via environment variable + Environment.SetEnvironmentVariable(envVarName, wrongPassword); + + // Act - Provide wrong password via env var string[] args = ["sign", "--p", payloadFile, "--pem", certPemFile, "--key", encryptedKeyFile, - "--pw", wrongPassword]; + "--pwenv", envVarName]; Microsoft.Extensions.Configuration.CommandLine.CommandLineConfigurationProvider provider = CoseCommand.LoadCommandLineArgs(args, SignCommand.Options, out string? badArg)!; @@ -1933,12 +1997,37 @@ public void SignWithEncryptedPemKeyWithWrongPassword_ShouldFail() } finally { + Environment.SetEnvironmentVariable(envVarName, null); CleanupFile(payloadFile); CleanupFile(certPemFile); CleanupFile(encryptedKeyFile); } } + /// + /// Tests that PasswordEnvVar and PasswordPrompt options are correctly applied. + /// + [TestMethod] + public void ApplyOptions_WithPasswordEnvVarOption_ShouldSetProperties() + { + // Arrange + string[] args = ["sign", "--p", "payload.txt", "--pem", "/path/to/cert.pem", "--key", "/path/to/key.pem", + "--pwenv", "MY_CUSTOM_PASSWORD_VAR"]; + + // Act + Microsoft.Extensions.Configuration.CommandLine.CommandLineConfigurationProvider provider = + CoseCommand.LoadCommandLineArgs(args, SignCommand.Options, out string? badArg)!; + badArg.Should().BeNull("badArg should be null."); + + SignCommand cmd = new SignCommand(); + cmd.ApplyOptions(provider); + + // Assert + cmd.PemCertificate.Should().Be("/path/to/cert.pem", "PemCertificate should be set"); + cmd.PemKey.Should().Be("/path/to/key.pem", "PemKey should be set"); + cmd.PasswordEnvVar.Should().Be("MY_CUSTOM_PASSWORD_VAR", "PasswordEnvVar should be set"); + } + #region PEM Helper Methods private static void CreatePemFileWithKey(X509Certificate2 cert, string pemFile) diff --git a/CoseSignTool/SignCommand.cs b/CoseSignTool/SignCommand.cs index fd98ff6a..99fe24e5 100644 --- a/CoseSignTool/SignCommand.cs +++ b/CoseSignTool/SignCommand.cs @@ -29,6 +29,10 @@ public class SignCommand : CoseCommand ["--key"] = "PemKey", ["--Password"] = "Password", ["--pw"] = "Password", + ["--PasswordEnvVar"] = "PasswordEnvVar", + ["--pwenv"] = "PasswordEnvVar", + ["--PasswordPrompt"] = "PasswordPrompt", + ["--pwprompt"] = "PasswordPrompt", ["--Thumbprint"] = "Thumbprint", ["--th"] = "Thumbprint", ["--StoreName"] = "StoreName", @@ -101,15 +105,35 @@ public class SignCommand : CoseCommand /// /// Optional. Gets or sets the path to a PEM-encoded private key file. - /// Used together with --PemCertificate for signing. The key may be encrypted (use --Password to decrypt). + /// Used together with --PemCertificate for signing. The key may be encrypted (use --PasswordEnvVar or --PasswordPrompt to provide password). /// public string? PemKey { get; set; } /// - /// Optional. Gets or sets the password for the .pfx file or encrypted PEM private key if it requires one. + /// Optional. Gets or sets the name of an environment variable containing the password for encrypted PEM private keys. + /// For PEM files with encrypted private keys, use this or --PasswordPrompt to securely provide the password. + /// Default environment variable is COSESIGNTOOL_PASSWORD. + /// + public string? PasswordEnvVar { get; set; } + + /// + /// Optional. If set, prompts the user interactively to enter the password for encrypted PEM private keys. + /// Cannot be used when input is piped or in non-interactive environments. + /// + public bool PasswordPrompt { get; set; } + + /// + /// Optional. Gets or sets the password for the .pfx file. + /// For PFX files, this can be provided directly on the command line. + /// For PEM files with encrypted keys, prefer using --PasswordEnvVar or --PasswordPrompt for security. /// public string? Password { get; set; } + /// + /// The default environment variable name for password. + /// + public const string DefaultPasswordEnvVar = "COSESIGNTOOL_PASSWORD"; + /// /// Optional. Gets or sets the SHA1 thumbprint of a certificate in the Certificate Store to sign the file with. /// @@ -360,7 +384,13 @@ protected internal override void ApplyOptions(CommandLineConfigurationProvider p PfxCertificate = GetOptionString(provider, nameof(PfxCertificate)); PemCertificate = GetOptionString(provider, nameof(PemCertificate)); PemKey = GetOptionString(provider, nameof(PemKey)); + + // Password handling: direct from command line (for PFX backward compat) + // or via environment variable / interactive prompt (preferred for PEM) Password = GetOptionString(provider, nameof(Password)); + PasswordEnvVar = GetOptionString(provider, nameof(PasswordEnvVar)); + PasswordPrompt = GetOptionBool(provider, nameof(PasswordPrompt)); + ContentType = GetOptionString(provider, nameof(ContentType), CoseSign1MessageFactory.DEFAULT_CONTENT_TYPE); StoreName = GetOptionString(provider, nameof(StoreName), DefaultStoreName); string? sl = GetOptionString(provider, nameof(StoreLocation), DefaultStoreLocation); @@ -418,6 +448,90 @@ protected internal override void ApplyOptions(CommandLineConfigurationProvider p base.ApplyOptions(provider); } + /// + /// Resolves the password for encrypted PEM private keys from environment variable or interactive prompt. + /// This is only used for PEM files - PFX password is provided directly via --pw option. + /// + /// The resolved password, or null if not needed. + private string? ResolvePemPassword() + { + // For security, do not accept plaintext passwords from the command line for PEM keys. + // The --pw option is retained for PFX backward compatibility only. + if (!string.IsNullOrEmpty(Password)) + { + Console.Error.WriteLine("Error: --pw/--Password is only supported for PFX files. For encrypted PEM private keys, use --pwenv/--PasswordEnvVar or --pwprompt/--PasswordPrompt."); + return null; + } + + // For PEM files, check environment variable + string envVarName = PasswordEnvVar ?? DefaultPasswordEnvVar; + + // Try to get password from environment variable + string? envPassword = Environment.GetEnvironmentVariable(envVarName); + if (!string.IsNullOrEmpty(envPassword)) + { + return envPassword; + } + + // If PasswordEnvVar was explicitly set but the variable is empty/missing, that's an error + if (PasswordEnvVar != null) + { + Console.Error.WriteLine($"Warning: Environment variable '{PasswordEnvVar}' is not set or empty."); + } + + // If interactive prompt is requested, prompt for password + if (PasswordPrompt) + { + return PromptForPassword(); + } + + // No password provided + return null; + } + + /// + /// Prompts the user to enter a password interactively with masked input. + /// + /// The entered password. + private static string? PromptForPassword() + { + // Check if we're in an interactive terminal + if (!Environment.UserInteractive || Console.IsInputRedirected) + { + Console.Error.WriteLine("Error: Cannot prompt for password in non-interactive mode. Use --pwenv to specify an environment variable."); + return null; + } + + Console.Error.Write("Enter password: "); + + StringBuilder password = new StringBuilder(); + while (true) + { + ConsoleKeyInfo key = Console.ReadKey(intercept: true); + + if (key.Key == ConsoleKey.Enter) + { + Console.Error.WriteLine(); + break; + } + else if (key.Key == ConsoleKey.Backspace) + { + if (password.Length > 0) + { + password.Length--; + Console.Error.Write("\b \b"); + } + } + else if (!char.IsControl(key.KeyChar)) + { + password.Append(key.KeyChar); + Console.Error.Write("*"); + } + } + + return password.Length > 0 ? password.ToString() : null; + } + /// /// Parses and applies custom CWT claims from a list of label:value strings to the CWTClaimsHeaderExtender. /// Supports both integer labels and RFC 8392 claim names. @@ -747,12 +861,13 @@ private X509Certificate2 LoadCertificateWithPrivateKey(X509Certificate2 certific if (keyPem.Contains("-----BEGIN ENCRYPTED PRIVATE KEY-----")) { - if (string.IsNullOrEmpty(Password)) + string? pemPassword = ResolvePemPassword(); + if (string.IsNullOrEmpty(pemPassword)) { throw new CryptographicException( - "The private key is encrypted. Please provide a password using --pw or --Password."); + "The private key is encrypted. Please provide a password using --pwenv or --pwprompt."); } - rsa.ImportFromEncryptedPem(keyPem, Password); + rsa.ImportFromEncryptedPem(keyPem, pemPassword); } else { @@ -778,12 +893,13 @@ private X509Certificate2 LoadCertificateWithPrivateKey(X509Certificate2 certific if (keyPem.Contains("-----BEGIN ENCRYPTED PRIVATE KEY-----")) { - if (string.IsNullOrEmpty(Password)) + string? pemPassword = ResolvePemPassword(); + if (string.IsNullOrEmpty(pemPassword)) { throw new CryptographicException( - "The private key is encrypted. Please provide a password using --pw or --Password."); + "The private key is encrypted. Please provide a password using --pwenv or --pwprompt."); } - ecdsa.ImportFromEncryptedPem(keyPem, Password); + ecdsa.ImportFromEncryptedPem(keyPem, pemPassword); } else { @@ -1239,8 +1355,6 @@ Default value is [payload file].cose. --PfxCertificate, --pfx: A path to a private key certificate file (.pfx) to sign with. Common on Windows. - --Password, --pw: Optional. The password for the .pfx file or encrypted PEM key if it has one. - --OR-- --PemCertificate, --pem: A path to a PEM-encoded certificate file to sign with. Common on Linux/Unix. @@ -1249,8 +1363,6 @@ The certificate file may contain the full certificate chain (leaf first, then in --PemKey, --key: The path to a PEM-encoded private key file. Required if the certificate file does not contain the private key. Supports RSA and ECDSA keys in PKCS#1, PKCS#8, or encrypted PKCS#8 format. - --Password, --pw: Optional. The password for an encrypted PEM private key. - --OR-- --Thumbprint, --th: The SHA1 thumbprint of a certificate in the local certificate store to sign the file with. @@ -1263,17 +1375,31 @@ Default value is 'My'. --StoreLocation, --sl: Optional. The location of the local certificate store to find the signing certificate in. Default value is 'CurrentUser'. - --PipeOutput, -po: Optional. If set, outputs the detached or embedded COSE signature to Standard Out instead of + Password options: + + --Password, --pw: Optional. The password for opening a password-protected PFX file. + Example: --pw MyP@ssword + + For PEM files with encrypted private keys, use secure password options instead: + + --PasswordEnvVar, --pwenv: The name of an environment variable containing the password. + If not specified, defaults to checking COSESIGNTOOL_PASSWORD environment variable. + Example: --pwenv MY_CERT_PASSWORD + + --PasswordPrompt, --pwprompt: If set, prompts interactively for the password with masked input. + Cannot be used in non-interactive environments or when input is piped. + + --PipeOutput, --po: Optional. If set, outputs the detached or embedded COSE signature to Standard Out instead of writing to file. - --EmbedPayload, -ep: Optional. If true, embeds a copy of the payload in the COSE signature file .Content property. + --EmbedPayload, --ep: Optional. If true, embeds a copy of the payload in the COSE signature file .Content property. Default behavior is 'detached signing', where the COSE signature file .Content property is empty, and to validate the signature, the payload must be provided separately. When set to true, the payload is embedded in the signature file. Embed-signed files are not readable by standard text editors, but can be read with the CoseSignTool 'Get' command. Advanced Options: - --ContentType, -cty: Optional. A MIME type to specify as Content Type in the COSE signature header. Default value is + --ContentType, --cty: Optional. A MIME type to specify as Content Type in the COSE signature header. Default value is 'application/cose'. Options to enable SCITT (Supply Chain Integrity, Transparency, and Trust) compliance: diff --git a/docs/CoseSignTool.md b/docs/CoseSignTool.md index 693848b2..457a35b1 100644 --- a/docs/CoseSignTool.md +++ b/docs/CoseSignTool.md @@ -45,9 +45,9 @@ You will need to specify: * The payload content to sign. This may be a file specified with the **--PayloadFile** or **--p** option or you can pipe it in on the Standard Input channel when you call CoseSignTool. Piping in the content is generally considered more secure and performant option but large streams of > 2gb in length are not yet supported. * A signing key provider. You have four options: 1. **Certificate Provider Plugin** (recommended for cloud/HSM signing): Use the **--CertProvider** or **--cp** option to specify a certificate provider plugin (e.g., `azure-trusted-signing`). See [Certificate Providers](#certificate-providers) section below. - 2. **Local PFX Certificate** (common on Windows): Use the **--PfxCertificate** or **--pfx** option to point to a .pfx certificate file and a **--Password** or **--pw** to open the certificate file with if it is locked. The certificate must include a private key. + 2. **Local PFX Certificate** (common on Windows): Use the **--PfxCertificate** or **--pfx** option to point to a .pfx certificate file and **--Password** or **--pw** to provide the password if the file is password-protected. The certificate must include a private key. * **PFX Certificate Chain Handling**: When using a PFX file that contains multiple certificates (such as a complete certificate chain), CoseSignTool will automatically use all certificates in the PFX for proper chain building. If you specify a **--Thumbprint** or **--th** along with the PFX file, CoseSignTool will use the certificate matching that thumbprint for signing and treat the remaining certificates as additional roots for chain validation. If no thumbprint is specified, the first certificate with a private key will be used for signing. - 3. **PEM Certificate Files** (common on Linux/Unix): Use the **--PemCertificate** or **--pem** option to point to a PEM-encoded certificate file. If the private key is in a separate file, use **--PemKey** or **--key** to specify the key file. Use **--Password** or **--pw** if the private key is encrypted. + 3. **PEM Certificate Files** (common on Linux/Unix): Use the **--PemCertificate** or **--pem** option to point to a PEM-encoded certificate file. If the private key is in a separate file, use **--PemKey** or **--key** to specify the key file. For encrypted private keys, use **--PasswordEnvVar** / **--pwenv** to specify an environment variable containing the password, or **--PasswordPrompt** / **--pwprompt** to enter interactively. * **PEM Certificate Chain Handling**: The PEM certificate file may contain multiple certificates (leaf first, then intermediates, then root). All certificates will be used for proper chain building. * **Supported Key Formats**: RSA and ECDSA keys in PKCS#1, PKCS#8, or encrypted PKCS#8 format. 4. **Local Certificate Store**: Use the **--Thumbprint** or **--th** option to pass the SHA1 thumbprint of an installed certificate. The certificate must include a private key. @@ -168,9 +168,18 @@ CoseSignTool sign --p payload.txt --pem mycert.pem --sf signature.cose CoseSignTool sign --p payload.txt --pem mycert.crt --key mykey.pem --sf signature.cose ``` -**Sign with encrypted private key:** +**Sign with encrypted private key (using environment variable):** ```bash -CoseSignTool sign --p payload.txt --pem mycert.pem --key mykey.encrypted.pem --pw "keypassword" --sf signature.cose +# First set the password in an environment variable +export COSESIGNTOOL_PASSWORD="keypassword" +# Or specify a custom environment variable: +export MY_KEY_PASSWORD="keypassword" +CoseSignTool sign --p payload.txt --pem mycert.pem --key mykey.encrypted.pem --pwenv MY_KEY_PASSWORD --sf signature.cose +``` + +**Sign with encrypted private key (interactive prompt):** +```bash +CoseSignTool sign --p payload.txt --pem mycert.pem --key mykey.encrypted.pem --pwprompt --sf signature.cose ``` **Sign with PEM certificate chain (leaf + intermediates + root in one file):** From b4594ffbb39800b9c095af78c50fa3fbdc8c161d Mon Sep 17 00:00:00 2001 From: "Jeromy Statia (from Dev Box)" Date: Tue, 3 Feb 2026 13:50:16 -0800 Subject: [PATCH 5/8] Replace ITransparencyService with TransparencyService base class --- .../MstTransparencyServiceExtensionsTests.cs | 8 +- .../MstTransparencyServiceExtensions.cs | 18 +- .../MstTransparencyService.cs | 28 +- ...Sign1TransparencyMessageExtensionsTests.cs | 313 ++++++++++-------- .../CoseSign1TransparencyMessageExtensions.cs | 70 +++- .../Interfaces/ITransparencyService.cs | 63 ---- CoseSign1.Transparent/TransparencyService.cs | 137 ++++++++ CoseSignTool.MST.Plugin/RegisterCommand.cs | 2 +- CoseSignTool.MST.Plugin/VerifyCommand.cs | 2 +- docs/CoseSign1.Transparent.md | 158 +++++---- 10 files changed, 486 insertions(+), 313 deletions(-) delete mode 100644 CoseSign1.Transparent/Interfaces/ITransparencyService.cs create mode 100644 CoseSign1.Transparent/TransparencyService.cs diff --git a/CoseSign1.Transparent.MST.Tests/MstTransparencyServiceExtensionsTests.cs b/CoseSign1.Transparent.MST.Tests/MstTransparencyServiceExtensionsTests.cs index 87b7ffef..ec09abc8 100644 --- a/CoseSign1.Transparent.MST.Tests/MstTransparencyServiceExtensionsTests.cs +++ b/CoseSign1.Transparent.MST.Tests/MstTransparencyServiceExtensionsTests.cs @@ -3,9 +3,9 @@ using System; using Azure.Security.CodeTransparency; +using CoseSign1.Transparent; using CoseSign1.Transparent.MST; using CoseSign1.Transparent.MST.Extensions; -using CoseSign1.Transparent.Interfaces; using Moq; using NUnit.Framework; @@ -35,7 +35,7 @@ public void ToCoseSign1TransparencyService_ThrowsArgumentNullException_WhenClien /// /// Tests the method - /// to ensure it returns a valid instance when the input client is valid. + /// to ensure it returns a valid instance when the input client is valid. /// [Test] public void ToCoseSign1TransparencyService_ReturnsTransparencyService_WhenClientIsValid() @@ -44,11 +44,11 @@ public void ToCoseSign1TransparencyService_ReturnsTransparencyService_WhenClient Mock mockClient = new Mock(); // Act - ITransparencyService result = mockClient.Object.ToCoseSign1TransparencyService(); + TransparencyService result = mockClient.Object.ToCoseSign1TransparencyService(); // Assert Assert.That(result, Is.Not.Null); Assert.That(result, Is.InstanceOf()); } } - + diff --git a/CoseSign1.Transparent.MST/Extensions/MstTransparencyServiceExtensions.cs b/CoseSign1.Transparent.MST/Extensions/MstTransparencyServiceExtensions.cs index 73188941..5cb00356 100644 --- a/CoseSign1.Transparent.MST/Extensions/MstTransparencyServiceExtensions.cs +++ b/CoseSign1.Transparent.MST/Extensions/MstTransparencyServiceExtensions.cs @@ -5,28 +5,28 @@ namespace CoseSign1.Transparent.MST.Extensions; using System; using Azure.Security.CodeTransparency; -using CoseSign1.Transparent.Interfaces; +using CoseSign1.Transparent; /// /// Provides extension methods for working with the -/// to integrate it with the interface. +/// to integrate it with the base class. /// public static class MstTransparencyServiceExtensions { /// - /// Converts a instance into an implementation. + /// Converts a instance into a implementation. /// /// The to be converted. /// - /// An instance of that wraps the provided . + /// An instance of that wraps the provided . /// /// Thrown if is null. /// /// This extension method simplifies the integration of the Azure Code Transparency Service (CTS) - /// with the interface, enabling seamless usage of CTS + /// with the base class, enabling seamless usage of CTS /// within the CoseSign1 transparency ecosystem. /// - public static ITransparencyService ToCoseSign1TransparencyService(this CodeTransparencyClient client) + public static TransparencyService ToCoseSign1TransparencyService(this CodeTransparencyClient client) { if (client == null) { @@ -37,21 +37,21 @@ public static ITransparencyService ToCoseSign1TransparencyService(this CodeTrans } /// - /// Converts a instance into an implementation with logging support. + /// Converts a instance into a implementation with logging support. /// /// The to be converted. /// Optional callback for verbose logging. /// Optional callback for warning logging. /// Optional callback for error logging. /// - /// An instance of that wraps the provided with logging enabled. + /// An instance of that wraps the provided with logging enabled. /// /// Thrown if is null. /// /// This extension method enables logging integration for transparency operations, allowing /// diagnostic output during registration and verification processes. /// - public static ITransparencyService ToCoseSign1TransparencyService( + public static TransparencyService ToCoseSign1TransparencyService( this CodeTransparencyClient client, Action? logVerbose = null, Action? logWarning = null, diff --git a/CoseSign1.Transparent.MST/MstTransparencyService.cs b/CoseSign1.Transparent.MST/MstTransparencyService.cs index 554f5f0a..9e32727e 100644 --- a/CoseSign1.Transparent.MST/MstTransparencyService.cs +++ b/CoseSign1.Transparent.MST/MstTransparencyService.cs @@ -14,24 +14,17 @@ namespace CoseSign1.Transparent.MST; using Azure.Security.CodeTransparency; using CoseSign1.Transparent.MST.Extensions; using CoseSign1.Transparent.Extensions; -using CoseSign1.Transparent.Interfaces; +using CoseSign1.Transparent; /// -/// Provides an implementation of the interface using Microsoft's Signing Transparency (MST). +/// Provides an implementation of the base class using Microsoft's Signing Transparency (MST). /// This service enables the creation and verification of transparent COSE Sign1 messages. /// -public class MstTransparencyService : ITransparencyService +public class MstTransparencyService : TransparencyService { private readonly CodeTransparencyClient TransparencyClient; private readonly CodeTransparencyVerificationOptions? VerificationOptions; private readonly CodeTransparencyClientOptions? ClientOptions; - private readonly Action? LogVerbose; - private readonly Action? LogError; - - // LogWarning is reserved for future use when warning scenarios are identified - #pragma warning disable IDE0052 // Remove unread private members - private readonly Action? LogWarning; - #pragma warning restore IDE0052 // Remove unread private members /// /// Initializes a new instance of the class. @@ -75,13 +68,11 @@ public MstTransparencyService( Action? logVerbose, Action? logWarning, Action? logError) + : base(logVerbose, logWarning, logError) { TransparencyClient = transparencyClient ?? throw new ArgumentNullException(nameof(transparencyClient)); VerificationOptions = verificationOptions; ClientOptions = clientOptions; - LogVerbose = logVerbose; - LogWarning = logWarning; - LogError = logError; } /// @@ -98,13 +89,8 @@ public MstTransparencyService( /// /// Thrown if is null. /// Thrown if the transparency operation fails. - public async Task MakeTransparentAsync(CoseSign1Message message, CancellationToken cancellationToken = default) + protected override async Task MakeTransparentCoreAsync(CoseSign1Message message, CancellationToken cancellationToken = default) { - if (message == null) - { - throw new ArgumentNullException(nameof(message)); - } - LogVerbose?.Invoke("Starting MakeTransparentAsync operation"); // Encode the CoseSign1Message to a byte array @@ -159,7 +145,7 @@ public async Task MakeTransparentAsync(CoseSign1Message messag /// /// Thrown if is null. /// Thrown if the message does not contain a transparency header. - public Task VerifyTransparencyAsync(CoseSign1Message message, CancellationToken cancellationToken = default) + public override Task VerifyTransparencyAsync(CoseSign1Message message, CancellationToken cancellationToken = default) { if (message == null) { @@ -249,7 +235,7 @@ public Task VerifyTransparencyAsync(CoseSign1Message message, Cancellation /// /// Thrown if or is null. /// Thrown if is empty. - public Task VerifyTransparencyAsync(CoseSign1Message message, byte[] receipt, CancellationToken cancellationToken = default) + public override Task VerifyTransparencyAsync(CoseSign1Message message, byte[] receipt, CancellationToken cancellationToken = default) { if (message == null) { diff --git a/CoseSign1.Transparent.Tests/CoseSign1TransparencyMessageExtensionsTests.cs b/CoseSign1.Transparent.Tests/CoseSign1TransparencyMessageExtensionsTests.cs index dd4aaf83..6693d129 100644 --- a/CoseSign1.Transparent.Tests/CoseSign1TransparencyMessageExtensionsTests.cs +++ b/CoseSign1.Transparent.Tests/CoseSign1TransparencyMessageExtensionsTests.cs @@ -1,8 +1,8 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -namespace CoseSign1.Transparent.Tests; - +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace CoseSign1.Transparent.Tests; + using System; using System.Collections.Generic; using System.Formats.Cbor; @@ -18,34 +18,34 @@ namespace CoseSign1.Transparent.Tests; using CoseSign1.Interfaces; using CoseSign1.Tests.Common; using CoseSign1.Transparent.Extensions; -using CoseSign1.Transparent.Interfaces; using Moq; +using Moq.Protected; using NUnit.Framework; /// /// Unit tests for the class. /// -[TestFixture] -[Parallelizable(ParallelScope.All)] +[TestFixture] +[Parallelizable(ParallelScope.All)] public class CoseSign1TransparencyMessageExtensionsTests -{ - private CoseSign1MessageFactory? messageFactory; - private ICoseSigningKeyProvider? signingKeyProvider; - - [SetUp] - public void Setup() - { +{ + private CoseSign1MessageFactory? messageFactory; + private ICoseSigningKeyProvider? signingKeyProvider; + + [SetUp] + public void Setup() + { X509Certificate2 testSigningCert = TestCertificateUtils.CreateCertificate(); //create object of custom ChainBuilder - ICertificateChainBuilder testChainBuilder = new TestChainBuilder(); - - //create coseSignKeyProvider with custom chainbuilder and local cert - //if no chainbuilder is specified, it will default to X509ChainBuilder, but that can't be used for integration tests + ICertificateChainBuilder testChainBuilder = new TestChainBuilder(); + + //create coseSignKeyProvider with custom chainbuilder and local cert + //if no chainbuilder is specified, it will default to X509ChainBuilder, but that can't be used for integration tests signingKeyProvider = new X509Certificate2CoseSigningKeyProvider(testChainBuilder, testSigningCert); messageFactory = new(); - } + } /// /// Tests the method. @@ -58,12 +58,12 @@ public void Setup() public void MakeTransparentAsync_ThrowsArgumentNullException(bool messageIsNull, bool serviceIsNull) { // Arrange - CoseSign1Message message = messageIsNull ? null : CreateMockCoseSign1Message(); - ITransparencyService transparencyService = serviceIsNull ? null : Mock.Of(); + CoseSign1Message? message = messageIsNull ? null : CreateMockCoseSign1Message(); + TransparencyService? transparencyService = serviceIsNull ? null : Mock.Of(); // Act & Assert Assert.That( - () => message.MakeTransparentAsync(transparencyService), + () => message!.MakeTransparentAsync(transparencyService!), Throws.TypeOf()); } @@ -74,13 +74,15 @@ public void MakeTransparentAsync_ThrowsArgumentNullException(bool messageIsNull, public async Task MakeTransparentAsync_ReturnsExpectedResult() { // Arrange - CoseSign1Message message = CreateMockCoseSign1Message(); - MockCoseHeaderValue(message, new List { new byte[] { 1, 2, 3 } }); - Mock mockService = new Mock(); - CoseSign1Message expectedMessage = CreateMockCoseSign1Message(); - MockCoseHeaderValue(expectedMessage, new List { new byte[] { 1, 2, 3 } }); - mockService - .Setup(service => service.MakeTransparentAsync(message, It.IsAny())) + CoseSign1Message message = CreateMockCoseSign1Message(); + byte[] originalReceipt = new byte[] { 1, 2, 3 }; + MockCoseHeaderValue(message, new List { originalReceipt }); + CoseSign1Message expectedMessage = CreateMockCoseSign1Message(); + byte[] newReceipt = new byte[] { 9, 9, 9 }; + MockCoseHeaderValue(expectedMessage, new List { newReceipt }); + Mock mockService = new Mock() { CallBase = true }; + mockService.Protected() + .Setup>("MakeTransparentCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) .ReturnsAsync(expectedMessage); // Act @@ -88,6 +90,12 @@ public async Task MakeTransparentAsync_ReturnsExpectedResult() // Assert Assert.That(result, Is.EqualTo(expectedMessage)); + + Assert.That(result.TryGetReceipts(out List? receipts), Is.True); + Assert.That(receipts, Is.Not.Null); + Assert.That(receipts!.Count, Is.EqualTo(2)); + Assert.That(receipts[0], Is.EquivalentTo(newReceipt)); + Assert.That(receipts[1], Is.EquivalentTo(originalReceipt)); } /// @@ -114,7 +122,7 @@ public void ContainsTransparencyHeader_ThrowsArgumentNullException(bool messageI public void ContainsTransparencyHeader_ReturnsExpectedResult() { // Arrange - CoseSign1Message message = CreateMockCoseSign1Message(); + CoseSign1Message message = CreateMockCoseSign1Message(); MockCoseHeaderValue(message, new List { new byte[] { 1, 2, 3 } }); // Act @@ -135,12 +143,12 @@ public void ContainsTransparencyHeader_ReturnsExpectedResult() public void VerifyTransparencyAsync_ThrowsArgumentNullException(bool messageIsNull, bool serviceIsNull) { // Arrange - CoseSign1Message message = messageIsNull ? null : CreateMockCoseSign1Message(); - ITransparencyService transparencyService = serviceIsNull ? null : Mock.Of(); + CoseSign1Message? message = messageIsNull ? null : CreateMockCoseSign1Message(); + TransparencyService? transparencyService = serviceIsNull ? null : Mock.Of(); // Act & Assert Assert.That( - () => message.VerifyTransparencyAsync(transparencyService), + () => message!.VerifyTransparencyAsync(transparencyService!), Throws.TypeOf()); } @@ -151,11 +159,11 @@ public void VerifyTransparencyAsync_ThrowsArgumentNullException(bool messageIsNu public async Task VerifyTransparencyAsync_ReturnsExpectedResult() { // Arrange - CoseSign1Message message = CreateMockCoseSign1Message(); + CoseSign1Message message = CreateMockCoseSign1Message(); MockCoseHeaderValue(message, new List { new byte[] { 1, 2, 3 } }); - Mock mockService = new Mock(); - mockService - .Setup(service => service.VerifyTransparencyAsync(message, It.IsAny())) + Mock mockService = new Mock() { CallBase = true }; + mockService.Protected() + .Setup>("VerifyTransparencyCoreAsync", ItExpr.IsAny(), ItExpr.IsAny()) .ReturnsAsync(true); // Act @@ -172,8 +180,8 @@ public async Task VerifyTransparencyAsync_ReturnsExpectedResult() public void TryGetReceipts_ReturnsExpectedResult() { // Arrange - CoseSign1Message message = CreateMockCoseSign1Message(); - List expectedReceipts = new List { new byte[] { 1, 2, 3 } }; + CoseSign1Message message = CreateMockCoseSign1Message(); + List expectedReceipts = new List { new byte[] { 1, 2, 3 } }; MockCoseHeaderValue(message, expectedReceipts); @@ -183,8 +191,8 @@ public void TryGetReceipts_ReturnsExpectedResult() // Assert Assert.That(result, Is.True); Assert.That(receipts, Is.EquivalentTo(expectedReceipts)); - } - + } + /// /// Tests the method. /// @@ -192,14 +200,14 @@ public void TryGetReceipts_ReturnsExpectedResult() public void TryGetReceipts_ThrowsArgumentNullException_WhenArgumentsAreNull() { // Arrange - CoseSign1Message message = null; - - // Act & Assert - Assert.That( - () => message.TryGetReceipts(out _), + CoseSign1Message message = null; + + // Act & Assert + Assert.That( + () => message.TryGetReceipts(out _), Throws.TypeOf().With.Property("ParamName").EqualTo("message")); - } - + } + /// /// Tests the method. /// @@ -207,12 +215,12 @@ public void TryGetReceipts_ThrowsArgumentNullException_WhenArgumentsAreNull() public void TryGetReceipts_NoProtectedHeader_ReturnsFalse() { // Arrange - CoseSign1Message message = CreateMockCoseSign1Message(); - - // Act & Assert + CoseSign1Message message = CreateMockCoseSign1Message(); + + // Act & Assert Assert.That(message.TryGetReceipts(out _), Is.False); - } - + } + /// /// Tests the method. /// @@ -220,13 +228,13 @@ public void TryGetReceipts_NoProtectedHeader_ReturnsFalse() public void TryGetReceipts_InvalidProtectedHeader_ReturnsFalse() { // Arrange - CoseSign1Message message = CreateMockCoseSign1Message(); - message.UnprotectedHeaders.Add(CoseSign1TransparencyMessageExtensions.TransparencyHeaderLabel, CoseHeaderValue.FromBytes(new byte[]{ 1, 2, 3})); - - // Act & Assert + CoseSign1Message message = CreateMockCoseSign1Message(); + message.UnprotectedHeaders.Add(CoseSign1TransparencyMessageExtensions.TransparencyHeaderLabel, CoseHeaderValue.FromBytes(new byte[]{ 1, 2, 3})); + + // Act & Assert Assert.That(message.TryGetReceipts(out _), Is.False); - } - + } + /// /// Tests the method. /// @@ -234,83 +242,104 @@ public void TryGetReceipts_InvalidProtectedHeader_ReturnsFalse() public void TryGetReceipts_ValidProtectedHeader_AdditionalFields_ReturnsTrue() { // Arrange - CoseSign1Message message = CreateMockCoseSign1Message(); - CborWriter cborWriter = new CborWriter(); - cborWriter.WriteStartArray(2); - cborWriter.WriteDouble(1.0); - cborWriter.WriteByteString(new byte[] { 1, 2, 3 }); - cborWriter.WriteEndArray(); - - message.UnprotectedHeaders.Add(CoseSign1TransparencyMessageExtensions.TransparencyHeaderLabel, CoseHeaderValue.FromEncodedValue(cborWriter.Encode())); - - // Act & Assert + CoseSign1Message message = CreateMockCoseSign1Message(); + CborWriter cborWriter = new CborWriter(); + cborWriter.WriteStartArray(2); + cborWriter.WriteDouble(1.0); + cborWriter.WriteByteString(new byte[] { 1, 2, 3 }); + cborWriter.WriteEndArray(); + + message.UnprotectedHeaders.Add(CoseSign1TransparencyMessageExtensions.TransparencyHeaderLabel, CoseHeaderValue.FromEncodedValue(cborWriter.Encode())); + + // Act & Assert Assert.That(message.TryGetReceipts(out _), Is.True); - } - - /// - /// Tests the method for null arguments. - /// - [Test] - public void AddReceipts_ThrowsArgumentNullException_WhenArgumentsAreNull() - { - // Arrange - CoseSign1Message message = null; - CoseSign1Message message2 = CreateMockCoseSign1Message(); - List receipts = null; - List receipts2 = new List(); - - // Act & Assert - Assert.Multiple(() => - { - Assert.That( - () => message.AddReceipts(receipts), - Throws.TypeOf().With.Property("ParamName").EqualTo("message")); - - Assert.That( - () => message2.AddReceipts(receipts), - Throws.TypeOf().With.Property("ParamName").EqualTo("receipts")); - - Assert.That( - () => message2.AddReceipts(receipts2), - Throws.TypeOf().With.Property("ParamName").EqualTo("receipts")); - }); - } - - /// - /// Tests the method for valid cases. - /// - [Test] - public void AddReceipts_AddsReceiptsSuccessfully() - { - // Arrange - CoseSign1Message message = CreateMockCoseSign1Message(); - List receipts = new List { new byte[] { 1, 2, 3 } }; - // Act - message.AddReceipts(receipts); - // Assert - Assert.That(message.TryGetReceipts(out List? result), Is.True); - Assert.That(result, Is.EquivalentTo(receipts)); - } - - /// - /// Tests the method for valid cases. - /// - [Test] - public void AddReceipts_AddsReceipts_WithExistingReceipts_Successfully() - { - // Arrange - CoseSign1Message message = CreateMockCoseSign1Message(); - byte[] firstReceipt = new byte[] { 4, 5, 6 }; - byte[] secondReceipt = new byte[] { 1, 2, 3 }; - message.AddReceipts(new List { firstReceipt }); - List receipts = new List { secondReceipt }; - // Act - message.AddReceipts(receipts); - // Assert - Assert.That(message.TryGetReceipts(out List? result), Is.True); - Assert.That(result[0], Is.EquivalentTo(firstReceipt)); - Assert.That(result[1], Is.EquivalentTo(secondReceipt)); - } + } + + /// + /// Tests the method for null arguments. + /// + [Test] + public void AddReceipts_ThrowsArgumentNullException_WhenArgumentsAreNull() + { + // Arrange + CoseSign1Message message = null; + CoseSign1Message message2 = CreateMockCoseSign1Message(); + List receipts = null; + List receipts2 = new List(); + + // Act & Assert + Assert.Multiple(() => + { + Assert.That( + () => message.AddReceipts(receipts), + Throws.TypeOf().With.Property("ParamName").EqualTo("message")); + + Assert.That( + () => message2.AddReceipts(receipts), + Throws.TypeOf().With.Property("ParamName").EqualTo("receipts")); + + Assert.That( + () => message2.AddReceipts(receipts2), + Throws.TypeOf().With.Property("ParamName").EqualTo("receipts")); + }); + } + + /// + /// Tests the method for valid cases. + /// + [Test] + public void AddReceipts_AddsReceiptsSuccessfully() + { + // Arrange + CoseSign1Message message = CreateMockCoseSign1Message(); + List receipts = new List { new byte[] { 1, 2, 3 } }; + // Act + message.AddReceipts(receipts); + // Assert + Assert.That(message.TryGetReceipts(out List? result), Is.True); + Assert.That(result, Is.EquivalentTo(receipts)); + } + + /// + /// Tests the method for valid cases. + /// + [Test] + public void AddReceipts_AddsReceipts_WithExistingReceipts_Successfully() + { + // Arrange + CoseSign1Message message = CreateMockCoseSign1Message(); + byte[] firstReceipt = new byte[] { 4, 5, 6 }; + byte[] secondReceipt = new byte[] { 1, 2, 3 }; + message.AddReceipts(new List { firstReceipt }); + List receipts = new List { secondReceipt }; + // Act + message.AddReceipts(receipts); + // Assert + Assert.That(message.TryGetReceipts(out List? result), Is.True); + Assert.That(result[0], Is.EquivalentTo(firstReceipt)); + Assert.That(result[1], Is.EquivalentTo(secondReceipt)); + } + + /// + /// Tests that duplicate receipts are not stored more than once. + /// + [Test] + public void AddReceipts_DeduplicatesReceipts_ByContent() + { + // Arrange + CoseSign1Message message = CreateMockCoseSign1Message(); + byte[] receipt = new byte[] { 1, 2, 3 }; + message.AddReceipts(new List { receipt }); + + // Act - Add a different array instance with same content + message.AddReceipts(new List { new byte[] { 1, 2, 3 } }); + + // Assert + Assert.That(message.TryGetReceipts(out List? result), Is.True); + Assert.That(result, Is.Not.Null); + Assert.That(result!.Count, Is.EqualTo(1)); + Assert.That(result[0], Is.EquivalentTo(receipt)); + } /// /// Helper method to mock the behavior of a for receipts. @@ -320,11 +349,11 @@ public void AddReceipts_AddsReceipts_WithExistingReceipts_Successfully() private static void MockCoseHeaderValue(CoseSign1Message message, List receipts) { message.AddReceipts(receipts); - } - - private CoseSign1Message CreateMockCoseSign1Message() - { - byte[] testPayload = Encoding.ASCII.GetBytes("Payload1!"); - return messageFactory!.CreateCoseSign1Message(testPayload, signingKeyProvider!, embedPayload: false); + } + + private CoseSign1Message CreateMockCoseSign1Message() + { + byte[] testPayload = Encoding.ASCII.GetBytes("Payload1!"); + return messageFactory!.CreateCoseSign1Message(testPayload, signingKeyProvider!, embedPayload: false); } } diff --git a/CoseSign1.Transparent/Extensions/CoseSign1TransparencyMessageExtensions.cs b/CoseSign1.Transparent/Extensions/CoseSign1TransparencyMessageExtensions.cs index 96b25092..82c089d8 100644 --- a/CoseSign1.Transparent/Extensions/CoseSign1TransparencyMessageExtensions.cs +++ b/CoseSign1.Transparent/Extensions/CoseSign1TransparencyMessageExtensions.cs @@ -4,16 +4,17 @@ namespace CoseSign1.Transparent.Extensions; using System; +using System.Collections; using System.Collections.Generic; using System.Formats.Cbor; using System.Security.Cryptography.Cose; using System.Threading; using System.Threading.Tasks; -using CoseSign1.Transparent.Interfaces; +using CoseSign1.Transparent; /// /// Provides extension methods for enhancing the functionality of -/// with transparency features using an . +/// with transparency features using a . /// public static class CoseSign1TransparencyMessageExtensions { @@ -30,10 +31,10 @@ public static class CoseSign1TransparencyMessageExtensions /// /// Asynchronously transforms a into a transparent message - /// by leveraging the provided . + /// by leveraging the provided . /// /// The original to be made transparent. - /// The used to apply transparency. + /// The used to apply transparency. /// /// A to observe while waiting for the task to complete. /// @@ -44,7 +45,7 @@ public static class CoseSign1TransparencyMessageExtensions /// /// Thrown if or is null. /// - public static Task MakeTransparentAsync(this CoseSign1Message message, ITransparencyService transparencyService, CancellationToken cancellationToken = default) + public static Task MakeTransparentAsync(this CoseSign1Message message, TransparencyService transparencyService, CancellationToken cancellationToken = default) { if (message == null) { @@ -79,10 +80,10 @@ public static bool ContainsTransparencyHeader(this CoseSign1Message message) /// /// Asynchronously verifies the transparency of a given - /// using the provided . + /// using the provided . /// /// The to verify for transparency. - /// The used to perform the verification. + /// The used to perform the verification. /// /// A to observe while waiting for the task to complete. /// @@ -93,7 +94,7 @@ public static bool ContainsTransparencyHeader(this CoseSign1Message message) /// /// Thrown if or is null. /// - public static Task VerifyTransparencyAsync(this CoseSign1Message message, ITransparencyService transparencyService, CancellationToken cancellationToken = default) + public static Task VerifyTransparencyAsync(this CoseSign1Message message, TransparencyService transparencyService, CancellationToken cancellationToken = default) { if (message == null) { @@ -183,21 +184,51 @@ public static void AddReceipts(this CoseSign1Message message, List recei _ = message.TryGetReceipts(out List? existingReceiptsList); - // Write the receipts to a CBOR-encoded array - CborWriter cborWriter = new(); - cborWriter.WriteStartArray(receipts.Count + (existingReceiptsList?.Count ?? 0)); + // Merge and de-duplicate receipts (by byte content), preserving stable order: + // existing receipts first, then newly provided receipts. + List mergedReceipts = new(); + HashSet seen = new(ByteArrayComparer.Instance); - // Add existing receipts to the array if they exist if (existingReceiptsList != null) { foreach (byte[] receipt in existingReceiptsList) { - cborWriter.WriteByteString(receipt); + if (receipt == null || receipt.Length == 0) + { + continue; + } + + if (seen.Add(receipt)) + { + mergedReceipts.Add(receipt); + } } } - // Add the new receipts to the array foreach (byte[] receipt in receipts) + { + if (receipt == null || receipt.Length == 0) + { + continue; + } + + if (seen.Add(receipt)) + { + mergedReceipts.Add(receipt); + } + } + + if (mergedReceipts.Count == 0) + { + throw new ArgumentOutOfRangeException(nameof(receipts), "Receipts cannot be empty."); + } + + // Write the receipts to a CBOR-encoded array + CborWriter cborWriter = new(); + cborWriter.WriteStartArray(mergedReceipts.Count); + + // Add merged receipts to the array + foreach (byte[] receipt in mergedReceipts) { cborWriter.WriteByteString(receipt); } @@ -215,6 +246,17 @@ public static void AddReceipts(this CoseSign1Message message, List recei message.UnprotectedHeaders.Add(TransparencyHeaderLabel, CoseHeaderValue.FromEncodedValue(cborWriter.Encode())); } + private sealed class ByteArrayComparer : IEqualityComparer + { + public static readonly ByteArrayComparer Instance = new(); + + public bool Equals(byte[]? x, byte[]? y) + => StructuralComparisons.StructuralEqualityComparer.Equals(x, y); + + public int GetHashCode(byte[] obj) + => StructuralComparisons.StructuralEqualityComparer.GetHashCode(obj); + } + /// /// Parses a into a list of byte arrays. /// diff --git a/CoseSign1.Transparent/Interfaces/ITransparencyService.cs b/CoseSign1.Transparent/Interfaces/ITransparencyService.cs deleted file mode 100644 index cfb6b366..00000000 --- a/CoseSign1.Transparent/Interfaces/ITransparencyService.cs +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -namespace CoseSign1.Transparent.Interfaces; - -using System.Security.Cryptography.Cose; -using System.Threading; -using System.Threading.Tasks; - -/// -/// Defines a service for creating and verifying transparent COSE Sign1 messages. -/// Transparency in this context refers to embedding additional metadata or headers -/// into COSE Sign1 messages to ensure traceability and auditability. -/// -public interface ITransparencyService -{ - /// - /// Creates a new transparent COSE Sign1 message by embedding additional metadata or headers - /// into the provided COSE Sign1 message. - /// - /// The original to be transformed into a transparent message. - /// - /// A to observe while waiting for the task to complete. - /// - /// - /// A task that represents the asynchronous operation. The task result contains a new - /// with the transparency metadata or headers applied. - /// - /// Thrown if is null. - Task MakeTransparentAsync(CoseSign1Message message, CancellationToken cancellationToken = default); - - /// - /// Verifies the transparency of a given COSE Sign1 message by checking its metadata or headers - /// against the expected transparency rules. - /// - /// The to verify for transparency. - /// - /// A to observe while waiting for the task to complete. - /// - /// - /// A task that represents the asynchronous operation. The task result is a boolean value indicating - /// whether the message meets the transparency requirements (true if valid, false otherwise). - /// - /// Thrown if is null. - Task VerifyTransparencyAsync(CoseSign1Message message, CancellationToken cancellationToken = default); - - /// - /// Verifies the transparency of a given COSE Sign1 message using a specific receipt. - /// - /// The to verify for transparency. - /// The receipt to use for verification. - /// - /// A to observe while waiting for the task to complete. - /// - /// - /// A task that represents the asynchronous operation. The task result is a boolean value indicating - /// whether the message meets the transparency requirements when verified with the provided receipt (true if valid, false otherwise). - /// - /// - /// Thrown if or is null. - /// - Task VerifyTransparencyAsync(CoseSign1Message message, byte[] receipt, CancellationToken cancellationToken = default); -} diff --git a/CoseSign1.Transparent/TransparencyService.cs b/CoseSign1.Transparent/TransparencyService.cs new file mode 100644 index 00000000..4df24a5c --- /dev/null +++ b/CoseSign1.Transparent/TransparencyService.cs @@ -0,0 +1,137 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace CoseSign1.Transparent; + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Security.Cryptography.Cose; +using System.Threading; +using System.Threading.Tasks; +using CoseSign1.Transparent.Extensions; + +/// +/// Base class for transparency services that ensures receipts are preserved when a service returns a new +/// instance. +/// +public abstract class TransparencyService +{ + protected TransparencyService() + : this(null, null, null) + { + } + + protected TransparencyService(Action? logVerbose = null, Action? logWarning = null, Action? logError = null) + { + LogVerbose = logVerbose; + LogWarning = logWarning; + LogError = logError; + } + + protected Action? LogVerbose { get; } + protected Action? LogWarning { get; } + protected Action? LogError { get; } + + /// + /// Creates a new transparent COSE Sign1 message by embedding additional metadata or headers into the provided message. + /// + public virtual async Task MakeTransparentAsync(CoseSign1Message message, CancellationToken cancellationToken = default) + { + if (message == null) + { + throw new ArgumentNullException(nameof(message)); + } + + string serviceName = GetType().Name; + Stopwatch stopwatch = Stopwatch.StartNew(); + + int inputReceiptCount = 0; + _ = message.TryGetReceipts(out List? existingReceipts); + inputReceiptCount = existingReceipts?.Count ?? 0; + + LogVerbose?.Invoke($"[{serviceName}] MakeTransparentAsync starting. Input receipts: {inputReceiptCount}."); + + CoseSign1Message result; + try + { + result = await MakeTransparentCoreAsync(message, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + LogError?.Invoke($"[{serviceName}] MakeTransparentAsync failed after {stopwatch.ElapsedMilliseconds}ms: {ex.Message}"); + throw; + } + + if (result == null) + { + throw new InvalidOperationException($"[{serviceName}] MakeTransparentCoreAsync returned null."); + } + + _ = result.TryGetReceipts(out List? resultReceiptsBeforeMerge); + int resultReceiptCountBeforeMerge = resultReceiptsBeforeMerge?.Count ?? 0; + + if (existingReceipts is { Count: > 0 }) + { + result.AddReceipts(existingReceipts); + } + + _ = result.TryGetReceipts(out List? resultReceiptsAfterMerge); + int resultReceiptCountAfterMerge = resultReceiptsAfterMerge?.Count ?? 0; + + LogVerbose?.Invoke( + $"[{serviceName}] MakeTransparentAsync completed in {stopwatch.ElapsedMilliseconds}ms. " + + $"Result receipts: {resultReceiptCountBeforeMerge} -> {resultReceiptCountAfterMerge}."); + + return result; + } + + /// + /// Implemented by derived services to perform the actual transparency operation. + /// The returned message may be a new instance. + /// + protected virtual Task MakeTransparentCoreAsync(CoseSign1Message message, CancellationToken cancellationToken = default) + => throw new NotImplementedException("Derived classes must override MakeTransparentCoreAsync or override MakeTransparentAsync."); + + /// + /// Verifies the transparency of the message. + /// + public virtual Task VerifyTransparencyAsync(CoseSign1Message message, CancellationToken cancellationToken = default) + { + if (message == null) + { + throw new ArgumentNullException(nameof(message)); + } + + return VerifyTransparencyCoreAsync(message, cancellationToken); + } + + /// + /// Verifies the transparency of the message using a specific receipt. + /// + public virtual Task VerifyTransparencyAsync(CoseSign1Message message, byte[] receipt, CancellationToken cancellationToken = default) + { + if (message == null) + { + throw new ArgumentNullException(nameof(message)); + } + if (receipt == null) + { + throw new ArgumentNullException(nameof(receipt)); + } + + return VerifyTransparencyWithReceiptCoreAsync(message, receipt, cancellationToken); + } + + /// + /// Implemented by derived services to verify transparency. + /// + protected virtual Task VerifyTransparencyCoreAsync(CoseSign1Message message, CancellationToken cancellationToken = default) + => throw new NotImplementedException("Derived classes must override VerifyTransparencyCoreAsync or override VerifyTransparencyAsync."); + + /// + /// Implemented by derived services to verify transparency using a receipt. + /// + protected virtual Task VerifyTransparencyWithReceiptCoreAsync(CoseSign1Message message, byte[] receipt, CancellationToken cancellationToken = default) + => throw new NotImplementedException("Derived classes must override VerifyTransparencyWithReceiptCoreAsync or override VerifyTransparencyAsync."); +} diff --git a/CoseSignTool.MST.Plugin/RegisterCommand.cs b/CoseSignTool.MST.Plugin/RegisterCommand.cs index 3b311fba..1596d88a 100644 --- a/CoseSignTool.MST.Plugin/RegisterCommand.cs +++ b/CoseSignTool.MST.Plugin/RegisterCommand.cs @@ -48,7 +48,7 @@ protected override string GetExamples() { Logger.LogVerbose("Creating transparency service"); // Create the transparency service with logging - CoseSign1.Transparent.Interfaces.ITransparencyService transparencyService = client.ToCoseSign1TransparencyService( + CoseSign1.Transparent.TransparencyService transparencyService = client.ToCoseSign1TransparencyService( logVerbose: Logger.LogVerbose, logWarning: Logger.LogWarning, logError: Logger.LogError); diff --git a/CoseSignTool.MST.Plugin/VerifyCommand.cs b/CoseSignTool.MST.Plugin/VerifyCommand.cs index c6fcb805..c1e4eef1 100644 --- a/CoseSignTool.MST.Plugin/VerifyCommand.cs +++ b/CoseSignTool.MST.Plugin/VerifyCommand.cs @@ -85,7 +85,7 @@ protected override void AddAdditionalFileValidation(Dictionary r Logger.LogVerbose("Creating transparency service with verification options"); // Create the transparency service with verification options and logging - CoseSign1.Transparent.Interfaces.ITransparencyService transparencyService = + CoseSign1.Transparent.TransparencyService transparencyService = new CoseSign1.Transparent.MST.MstTransparencyService( client, verificationOptions, diff --git a/docs/CoseSign1.Transparent.md b/docs/CoseSign1.Transparent.md index 6ae3a03d..ab4c596e 100644 --- a/docs/CoseSign1.Transparent.md +++ b/docs/CoseSign1.Transparent.md @@ -13,127 +13,117 @@ Before using this library, ensure the following: To use this library, add a reference to the `CoseSign1.Transparent` project in your solution. If using NuGet, ensure the package is installed: ```text -dotnet add package CoseSign1.Transparency +dotnet add package CoseSign1.Transparent ``` ## Namespace -Include the following namesapces in your code: +Include the following namespaces in your code: ```csharp +using System.Security.Cryptography.Cose; +using CoseSign1.Transparent; using CoseSign1.Transparent.Extensions; -using CoseSign1.Transparent.Interfaces; +using CoseSign1.Transparent.MST; +using CoseSign1.Transparent.MST.Extensions; ``` ## Features -- **Transparent Message Creation**: Generate COSE messages with transparency metadata. -- **Validation**: Validate COSE messages against transparency requirements. +- **Transparent Message Creation**: Register a message with a transparency service and embed receipts. +- **Verification**: Verify embedded receipts (and/or specific receipts) against a transparency service. + +## Usage + +This library uses the abstract base class `TransparencyService` as the single service abstraction (the previous `ITransparencyService` interface is not used). -## Usage -### 1. Creating a Transparent COSE Message -To create a transparent COSE Sign1 message, use the `MakeTransparentAsync` method. This method embeds transparency metadata into the message. -#### Example: Creating a Transparent Message: +### 1. Creating a Transparent COSE Message +To create a transparent COSE Sign1 message, use `MakeTransparentAsync`. + +#### Example: Creating a Transparent Message ```csharp using System; +using System.Collections.Generic; using System.Security.Cryptography.Cose; using System.Threading.Tasks; -using CoseSign1.Transparent.Interfaces; +using Azure.Security.CodeTransparency; +using CoseSign1.Transparent; +using CoseSign1.Transparent.Extensions; using CoseSign1.Transparent.MST; public class TransparencyExample { - public async Task CreateTransparentMessage() + public async Task CreateTransparentMessage(CodeTransparencyClient transparencyClient) { - // Create a COSE Sign1 message (example payload) CoseSign1Message message = new CoseSign1Message { Content = new byte[] { 1, 2, 3, 4 } }; - // Initialize the transparency service (using Azure CTS as an example) - CodeTransparencyClient transparencyClient = new CodeTransparencyClient(); - - // Optional: Configure verification options for advanced receipt validation var verificationOptions = new CodeTransparencyVerificationOptions { AuthorizedDomains = new List { "trusted-cts.azure.com" }, AuthorizedReceiptBehavior = AuthorizedReceiptBehavior.RequireAll, UnauthorizedReceiptBehavior = UnauthorizedReceiptBehavior.FailIfPresent }; - - ITransparencyService transparencyService = new MstTransparencyService( - transparencyClient, - verificationOptions, - null); - // Make the message transparent + TransparencyService transparencyService = new MstTransparencyService( + transparencyClient, + verificationOptions, + clientOptions: null); + CoseSign1Message transparentMessage = await message.MakeTransparentAsync(transparencyService); Console.WriteLine("Transparent message created successfully."); } } ``` -### 2. Verifying Transparency -To verify the transparency of a COSE Sign1 message, use the `VerifyTransparencyAsync` method. This ensures the message complies with transparency rules. -#### Example: Verifying a Transparent Message with embedded receipt: + +### 2. Verifying Transparency +To verify transparency for a COSE Sign1 message, use `VerifyTransparencyAsync`. + +#### Example: Verifying a Transparent Message with Embedded Receipt(s) ```csharp using System; using System.Security.Cryptography.Cose; using System.Threading.Tasks; -using CoseSign1.Transparent.Interfaces; -using CoseSign1.Transparent.MST; +using Azure.Security.CodeTransparency; +using CoseSign1.Transparent; +using CoseSign1.Transparent.Extensions; +using CoseSign1.Transparent.MST.Extensions; public class TransparencyExample { - public async Task VerifyTransparentMessage() + public async Task VerifyTransparentMessage(CodeTransparencyClient transparencyClient, CoseSign1Message message) { - // Example COSE Sign1 message - CoseSign1Message message = new CoseSign1Message - { - Content = new byte[] { 1, 2, 3, 4 } - }; + TransparencyService transparencyService = transparencyClient.ToCoseSign1TransparencyService(); - // Initialize the transparency service - ITransparencyService transparencyService = new CodeTransparencyClient().ToCoseSign1TransparentService(); - - // Verify the transparency of the message bool isTransparent = await message.VerifyTransparencyAsync(transparencyService); Console.WriteLine($"Message transparency verification result: {isTransparent}"); } } ``` -#### Example: Verifying a Transparent Message without an embedded receipt: + +#### Example: Verifying with a Specific Receipt ```csharp using System; using System.Security.Cryptography.Cose; using System.Threading.Tasks; -using CoseSign1.Transparent.Interfaces; -using CoseSign1.Transparent.MST; +using Azure.Security.CodeTransparency; +using CoseSign1.Transparent; +using CoseSign1.Transparent.MST.Extensions; public class TransparencyExample { - public async Task VerifyTransparentMessageWithReceipt() + public async Task VerifyTransparentMessageWithReceipt(CodeTransparencyClient transparencyClient, CoseSign1Message message, byte[] receipt) { - // Example COSE Sign1 message - CoseSign1Message message = new CoseSign1Message - { - Content = new byte[] { 1, 2, 3, 4 } - }; - - // Example receipt - byte[] receipt = new byte[] { 5, 6, 7, 8 }; + TransparencyService transparencyService = transparencyClient.ToCoseSign1TransparencyService(); - // Initialize the transparency service - ITransparencyService transparencyService = new CodeTransparencyClient().ToCoseSign1TransparentService(); - - // Verify the transparency of the message with the receipt - bool isTransparent = await message.VerifyTransparencyAsync(transparencyService, receipt); + bool isTransparent = await transparencyService.VerifyTransparencyAsync(message, receipt); Console.WriteLine($"Message transparency verification with receipt result: {isTransparent}"); } } - ``` ### 3. Managing Receipts -Receipts may embedded in the transparency-related headers of COSE Sign1 messages. You can extract or add receipts using the following methods: +Receipts may be embedded in the transparency-related headers of COSE Sign1 messages. You can extract or add receipts using the following methods: #### Extracting Receipts Use the `TryGetReceipts` method to extract receipts from a COSE Sign1 message. ```csharp @@ -190,6 +180,10 @@ public class ReceiptExample } } ``` + +Notes: +- `AddReceipts` merges with any existing embedded receipts and deduplicates by exact byte-content (not by reference). +- Null or empty receipts are ignored; if no valid receipts remain after filtering, an exception is thrown. ## Advanced Topics ### Transparency Header The transparency header is identified by the `TransparencyHeaderLabel` field. This label is used to embed and retrieve transparency-related metadata. @@ -198,8 +192,56 @@ using CoseSign1.Transparent.Extensions; Console.WriteLine($"Transparency Header Label: {CoseSign1TransparencyMessageExtensions.TransparencyHeaderLabel}"); ``` + +### Receipt Preservation When Chaining Services +Some transparency services may return a *new* `CoseSign1Message` instance (rather than mutating the input message). When calling `MakeTransparentAsync`, this library preserves any existing embedded receipts and merges them into the returned message. + +This is especially important when chaining multiple transparency services (e.g., registering with multiple systems). Receipt merging is stable-order (existing receipts first) and deduplicated by byte-content. + ### Custom Transparency Services -You can implement your own transparency service by creating a class that implements the `ITransparencyService` interface. This allows you to define custom behavior for creating and verifying transparent messages. +You can implement your own transparency service by deriving from the `TransparencyService` base class. It automatically: +- Preserves and merges existing receipts into the returned message. +- Provides optional logging hooks for basic diagnostics (timings and receipt counts). + +#### Example: Deriving from `TransparencyService` +```csharp +using System; +using System.Security.Cryptography.Cose; +using System.Threading; +using System.Threading.Tasks; +using CoseSign1.Transparent; + +public sealed class MyTransparencyService : TransparencyService +{ + public MyTransparencyService( + Action? logVerbose = null, + Action? logWarning = null, + Action? logError = null) + : base(logVerbose, logWarning, logError) + { + } + + protected override Task MakeTransparentCoreAsync(CoseSign1Message message, CancellationToken cancellationToken = default) + { + // Register message, embed receipts (or return a new message instance). + return Task.FromResult(message); + } + + protected override Task VerifyTransparencyCoreAsync(CoseSign1Message message, CancellationToken cancellationToken = default) + => Task.FromResult(true); + + protected override Task VerifyTransparencyWithReceiptCoreAsync(CoseSign1Message message, byte[] receipt, CancellationToken cancellationToken = default) + => Task.FromResult(true); +} +``` + +#### Example: Wiring Logging Hooks +```csharp +var service = new MyTransparencyService( + logVerbose: Console.WriteLine, + logWarning: Console.Error.WriteLine, + logError: Console.Error.WriteLine); +``` ### Error Handling #### Common Exceptions @@ -244,7 +286,7 @@ The project is packaged with the following metadata: For issues or feature requests, please contact the maintainers or open an issue in the repository. ## Conclusion -The `CoseSign1.Transparency`` library provides a robust solution for creating and verifying transparent COSE Sign1 messages. By embedding transparency metadata, you can ensure traceability and auditability in your software supply chain. For advanced scenarios, consider implementing custom transparency services or managing receipts programmatically. +The `CoseSign1.Transparent` library provides a robust solution for creating and verifying transparent COSE Sign1 messages. By embedding transparency metadata, you can ensure traceability and auditability in your software supply chain. For advanced scenarios, consider implementing custom transparency services or managing receipts programmatically.

For more information, refer to the ./docs/CoseSignTool.md. From 0e988094bc9cb11c27f4f0057b94a4d133a07c51 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Tue, 10 Feb 2026 18:18:29 +0000 Subject: [PATCH 6/8] Update changelog for release --- CHANGELOG.md | 137 ++++++++++++++++++++++++++------------------------- 1 file changed, 69 insertions(+), 68 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3e9dc0e..8db30095 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,12 @@ # Changelog -## [Unreleased](https://github.com/microsoft/CoseSignTool/tree/HEAD) +## [v1.7.0](https://github.com/microsoft/CoseSignTool/tree/v1.7.0) (2026-01-28) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.6.9...HEAD) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.6.9...v1.7.0) **Merged pull requests:** +- Users/jstatia/fix plugin switch mappings [\#159](https://github.com/microsoft/CoseSignTool/pull/159) ([JeromySt](https://github.com/JeromySt)) - Add --payload-location option for IndirectSignature [\#158](https://github.com/microsoft/CoseSignTool/pull/158) ([JeromySt](https://github.com/JeromySt)) - Enabled CodeQL scans [\#156](https://github.com/microsoft/CoseSignTool/pull/156) ([NN2000X](https://github.com/NN2000X)) @@ -37,40 +38,40 @@ ## [v1.6.6](https://github.com/microsoft/CoseSignTool/tree/v1.6.6) (2025-11-25) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.6.5...v1.6.6) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.5.4-pre4...v1.6.6) **Merged pull requests:** - Users/jstatia/azure trusted signing [\#148](https://github.com/microsoft/CoseSignTool/pull/148) ([JeromySt](https://github.com/JeromySt)) - Users/jstatia/package upgrades [\#145](https://github.com/microsoft/CoseSignTool/pull/145) ([JeromySt](https://github.com/JeromySt)) -## [v1.6.5](https://github.com/microsoft/CoseSignTool/tree/v1.6.5) (2025-08-05) +## [v1.5.4-pre4](https://github.com/microsoft/CoseSignTool/tree/v1.5.4-pre4) (2025-08-05) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.6.4...v1.6.5) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.6.3...v1.5.4-pre4) -## [v1.6.4](https://github.com/microsoft/CoseSignTool/tree/v1.6.4) (2025-08-05) +## [v1.6.3](https://github.com/microsoft/CoseSignTool/tree/v1.6.3) (2025-08-05) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.6.3...v1.6.4) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.6.5...v1.6.3) -## [v1.6.3](https://github.com/microsoft/CoseSignTool/tree/v1.6.3) (2025-08-05) +## [v1.6.5](https://github.com/microsoft/CoseSignTool/tree/v1.6.5) (2025-08-05) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.5.4-pre4...v1.6.3) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.6.4...v1.6.5) -## [v1.5.4-pre4](https://github.com/microsoft/CoseSignTool/tree/v1.5.4-pre4) (2025-08-05) +## [v1.6.4](https://github.com/microsoft/CoseSignTool/tree/v1.6.4) (2025-08-05) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.5.4-pre3...v1.5.4-pre4) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/1.6.2...v1.6.4) **Merged pull requests:** - Remove 'v' prefix from VERSION for VersionNgt property in publish steps [\#143](https://github.com/microsoft/CoseSignTool/pull/143) ([JeromySt](https://github.com/JeromySt)) -## [v1.5.4-pre3](https://github.com/microsoft/CoseSignTool/tree/v1.5.4-pre3) (2025-08-05) +## [1.6.2](https://github.com/microsoft/CoseSignTool/tree/1.6.2) (2025-08-05) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/1.6.2...v1.5.4-pre3) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.5.4-pre3...1.6.2) -## [1.6.2](https://github.com/microsoft/CoseSignTool/tree/1.6.2) (2025-08-05) +## [v1.5.4-pre3](https://github.com/microsoft/CoseSignTool/tree/v1.5.4-pre3) (2025-08-05) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.6.1...1.6.2) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.6.1...v1.5.4-pre3) **Merged pull requests:** @@ -99,20 +100,20 @@ ## [v1.5.7](https://github.com/microsoft/CoseSignTool/tree/v1.5.7) (2025-07-17) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v.1.5.5...v1.5.7) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.5.6...v1.5.7) **Merged pull requests:** - Enhance PFX certificate handling in SignCommand and update documentation [\#138](https://github.com/microsoft/CoseSignTool/pull/138) ([JeromySt](https://github.com/JeromySt)) - Migrate from VSTest to MTP [\#124](https://github.com/microsoft/CoseSignTool/pull/124) ([Youssef1313](https://github.com/Youssef1313)) -## [v.1.5.5](https://github.com/microsoft/CoseSignTool/tree/v.1.5.5) (2025-07-15) +## [v1.5.6](https://github.com/microsoft/CoseSignTool/tree/v1.5.6) (2025-07-15) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.5.6...v.1.5.5) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v.1.5.5...v1.5.6) -## [v1.5.6](https://github.com/microsoft/CoseSignTool/tree/v1.5.6) (2025-07-15) +## [v.1.5.5](https://github.com/microsoft/CoseSignTool/tree/v.1.5.5) (2025-07-15) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.5.4-pre1...v1.5.6) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.5.4-pre1...v.1.5.5) ## [v1.5.4-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.5.4-pre1) (2025-07-15) @@ -184,19 +185,19 @@ ## [v1.4.0-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.4.0-pre1) (2025-04-28) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.3.0-pre5...v1.4.0-pre1) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.4.0...v1.4.0-pre1) **Merged pull requests:** - Added support for Transparency to CoseSign1 libraries to leverage services such as Azure Code Transparency Service [\#127](https://github.com/microsoft/CoseSignTool/pull/127) ([JeromySt](https://github.com/JeromySt)) -## [v1.3.0-pre5](https://github.com/microsoft/CoseSignTool/tree/v1.3.0-pre5) (2025-03-18) +## [v1.4.0](https://github.com/microsoft/CoseSignTool/tree/v1.4.0) (2025-03-18) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.4.0...v1.3.0-pre5) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.3.0-pre5...v1.4.0) -## [v1.4.0](https://github.com/microsoft/CoseSignTool/tree/v1.4.0) (2025-03-18) +## [v1.3.0-pre5](https://github.com/microsoft/CoseSignTool/tree/v1.3.0-pre5) (2025-03-18) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.3.0-pre4...v1.4.0) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.3.0-pre4...v1.3.0-pre5) **Merged pull requests:** @@ -276,19 +277,19 @@ ## [v1.2.8-pre3](https://github.com/microsoft/CoseSignTool/tree/v1.2.8-pre3) (2024-10-15) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.2.8-pre1...v1.2.8-pre3) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.2.8-pre2...v1.2.8-pre3) **Merged pull requests:** - Increase timeout for checking for empty streams [\#113](https://github.com/microsoft/CoseSignTool/pull/113) ([lemccomb](https://github.com/lemccomb)) -## [v1.2.8-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.2.8-pre1) (2024-09-25) +## [v1.2.8-pre2](https://github.com/microsoft/CoseSignTool/tree/v1.2.8-pre2) (2024-09-25) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.2.8-pre2...v1.2.8-pre1) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.2.8-pre1...v1.2.8-pre2) -## [v1.2.8-pre2](https://github.com/microsoft/CoseSignTool/tree/v1.2.8-pre2) (2024-09-25) +## [v1.2.8-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.2.8-pre1) (2024-09-25) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.2.6-pre2...v1.2.8-pre2) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.2.6-pre2...v1.2.8-pre1) **Merged pull requests:** @@ -438,31 +439,31 @@ ## [v1.2.1-pre2](https://github.com/microsoft/CoseSignTool/tree/v1.2.1-pre2) (2024-03-15) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.2.1-pre1...v1.2.1-pre2) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.2.2...v1.2.1-pre2) **Merged pull requests:** - more granular error codes [\#86](https://github.com/microsoft/CoseSignTool/pull/86) ([lemccomb](https://github.com/lemccomb)) -## [v1.2.1-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.2.1-pre1) (2024-03-12) +## [v1.2.2](https://github.com/microsoft/CoseSignTool/tree/v1.2.2) (2024-03-12) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.2.2...v1.2.1-pre1) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.2.1-pre1...v1.2.2) -## [v1.2.2](https://github.com/microsoft/CoseSignTool/tree/v1.2.2) (2024-03-12) +## [v1.2.1-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.2.1-pre1) (2024-03-12) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.2.0-pre1...v1.2.2) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.2.1...v1.2.1-pre1) **Merged pull requests:** - Revert "Add .exe to CoseSignTool NuGet" [\#83](https://github.com/microsoft/CoseSignTool/pull/83) ([elantiguamsft](https://github.com/elantiguamsft)) -## [v1.2.0-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.2.0-pre1) (2024-03-07) +## [v1.2.1](https://github.com/microsoft/CoseSignTool/tree/v1.2.1) (2024-03-07) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.2.1...v1.2.0-pre1) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.2.0-pre1...v1.2.1) -## [v1.2.1](https://github.com/microsoft/CoseSignTool/tree/v1.2.1) (2024-03-07) +## [v1.2.0-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.2.0-pre1) (2024-03-07) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.2.exeTest...v1.2.1) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.2.exeTest...v1.2.0-pre1) **Merged pull requests:** @@ -470,15 +471,15 @@ ## [v1.2.exeTest](https://github.com/microsoft/CoseSignTool/tree/v1.2.exeTest) (2024-03-06) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.8-pre1...v1.2.exeTest) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.2.0...v1.2.exeTest) -## [v1.1.8-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.1.8-pre1) (2024-03-04) +## [v1.2.0](https://github.com/microsoft/CoseSignTool/tree/v1.2.0) (2024-03-04) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.2.0...v1.1.8-pre1) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.8-pre1...v1.2.0) -## [v1.2.0](https://github.com/microsoft/CoseSignTool/tree/v1.2.0) (2024-03-04) +## [v1.1.8-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.1.8-pre1) (2024-03-04) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.7-pre3...v1.2.0) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.7-pre3...v1.1.8-pre1) **Merged pull requests:** @@ -506,19 +507,19 @@ ## [v1.1.7-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.1.7-pre1) (2024-02-14) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.7...v1.1.7-pre1) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.6-pre1...v1.1.7-pre1) **Merged pull requests:** - Command Line Validation of Indirect Signatures [\#78](https://github.com/microsoft/CoseSignTool/pull/78) ([elantiguamsft](https://github.com/elantiguamsft)) -## [v1.1.7](https://github.com/microsoft/CoseSignTool/tree/v1.1.7) (2024-02-07) +## [v1.1.6-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.1.6-pre1) (2024-02-07) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.6-pre1...v1.1.7) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.7...v1.1.6-pre1) -## [v1.1.6-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.1.6-pre1) (2024-02-07) +## [v1.1.7](https://github.com/microsoft/CoseSignTool/tree/v1.1.7) (2024-02-07) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.6...v1.1.6-pre1) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.6...v1.1.7) **Merged pull requests:** @@ -526,31 +527,31 @@ ## [v1.1.6](https://github.com/microsoft/CoseSignTool/tree/v1.1.6) (2024-02-07) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.5...v1.1.6) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.4-pre1...v1.1.6) **Merged pull requests:** - Only hit iterator once [\#75](https://github.com/microsoft/CoseSignTool/pull/75) ([JeromySt](https://github.com/JeromySt)) -## [v1.1.5](https://github.com/microsoft/CoseSignTool/tree/v1.1.5) (2024-01-31) +## [v1.1.4-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.1.4-pre1) (2024-01-31) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.4-pre1...v1.1.5) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.5...v1.1.4-pre1) -## [v1.1.4-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.1.4-pre1) (2024-01-31) +## [v1.1.5](https://github.com/microsoft/CoseSignTool/tree/v1.1.5) (2024-01-31) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.4...v1.1.4-pre1) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.3-pre1...v1.1.5) **Merged pull requests:** - write validation output to standard out [\#74](https://github.com/microsoft/CoseSignTool/pull/74) ([elantiguamsft](https://github.com/elantiguamsft)) -## [v1.1.4](https://github.com/microsoft/CoseSignTool/tree/v1.1.4) (2024-01-26) +## [v1.1.3-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.1.3-pre1) (2024-01-26) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.3-pre1...v1.1.4) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.4...v1.1.3-pre1) -## [v1.1.3-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.1.3-pre1) (2024-01-26) +## [v1.1.4](https://github.com/microsoft/CoseSignTool/tree/v1.1.4) (2024-01-26) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.3...v1.1.3-pre1) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.3...v1.1.4) **Merged pull requests:** @@ -562,19 +563,19 @@ ## [v1.1.2-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.1.2-pre1) (2024-01-24) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.1-pre2...v1.1.2-pre1) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.2...v1.1.2-pre1) **Merged pull requests:** - Updating snk for internal package compatibility [\#72](https://github.com/microsoft/CoseSignTool/pull/72) ([elantiguamsft](https://github.com/elantiguamsft)) -## [v1.1.1-pre2](https://github.com/microsoft/CoseSignTool/tree/v1.1.1-pre2) (2024-01-18) +## [v1.1.2](https://github.com/microsoft/CoseSignTool/tree/v1.1.2) (2024-01-18) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.2...v1.1.1-pre2) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.1-pre2...v1.1.2) -## [v1.1.2](https://github.com/microsoft/CoseSignTool/tree/v1.1.2) (2024-01-18) +## [v1.1.1-pre2](https://github.com/microsoft/CoseSignTool/tree/v1.1.1-pre2) (2024-01-18) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.1-pre1...v1.1.2) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.1-pre1...v1.1.1-pre2) **Merged pull requests:** @@ -582,19 +583,19 @@ ## [v1.1.1-pre1](https://github.com/microsoft/CoseSignTool/tree/v1.1.1-pre1) (2024-01-17) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.0-pre7...v1.1.1-pre1) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.1...v1.1.1-pre1) **Merged pull requests:** - Move CreateChangelog to after build in PR build [\#70](https://github.com/microsoft/CoseSignTool/pull/70) ([lemccomb](https://github.com/lemccomb)) -## [v1.1.0-pre7](https://github.com/microsoft/CoseSignTool/tree/v1.1.0-pre7) (2024-01-12) +## [v1.1.1](https://github.com/microsoft/CoseSignTool/tree/v1.1.1) (2024-01-12) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.1...v1.1.0-pre7) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.0-pre7...v1.1.1) -## [v1.1.1](https://github.com/microsoft/CoseSignTool/tree/v1.1.1) (2024-01-12) +## [v1.1.0-pre7](https://github.com/microsoft/CoseSignTool/tree/v1.1.0-pre7) (2024-01-12) -[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.0-pre6...v1.1.1) +[Full Changelog](https://github.com/microsoft/CoseSignTool/compare/v1.1.0-pre6...v1.1.0-pre7) **Merged pull requests:** From 5a8a8b17f2be19e1b325cbdc9ec73f6c3ab2e1d4 Mon Sep 17 00:00:00 2001 From: "Jeromy Statia (from Dev Box)" Date: Tue, 10 Feb 2026 10:26:12 -0800 Subject: [PATCH 7/8] Address code scanning findings from PR review - Remove unused argWithoutColon variable in CoseCommand.cs - Replace generic catch clauses with specific IOException catches - Remove redundant ToString() calls on Guid - Use Path.Join instead of Path.Combine to avoid silent argument dropping - Use 'using' declaration for X509Certificate2 disposal in test --- .gitignore | 4 ++++ CoseHandler.Tests/CoseExtensionsTests.cs | 4 ++-- CoseSignTool.Tests/MainTests.cs | 12 ++++++------ CoseSignTool.Tests/SignCommandTests.cs | 5 ++--- CoseSignTool/CoseCommand.cs | 8 -------- 5 files changed, 14 insertions(+), 19 deletions(-) diff --git a/.gitignore b/.gitignore index 74c870b1..3d5ad6fa 100644 --- a/.gitignore +++ b/.gitignore @@ -366,3 +366,7 @@ FodyWeavers.xsd # Visual Studio live unit testing configuration files. *.lutconfig + +# Copilot Orchestrator +.orchestrator/ +.worktrees/ diff --git a/CoseHandler.Tests/CoseExtensionsTests.cs b/CoseHandler.Tests/CoseExtensionsTests.cs index 634e8a55..c8b7639e 100644 --- a/CoseHandler.Tests/CoseExtensionsTests.cs +++ b/CoseHandler.Tests/CoseExtensionsTests.cs @@ -64,7 +64,7 @@ public void FileLoadPartialWriteBytes() finally { writerTask?.GetAwaiter().GetResult(); - try { f.Delete(); } catch { /* best-effort cleanup */ } + try { f.Delete(); } catch (IOException) { /* best-effort cleanup */ } } } @@ -95,7 +95,7 @@ public void FileLoadPartialWriteStream() finally { writerTask?.GetAwaiter().GetResult(); - try { f.Delete(); } catch { /* best-effort cleanup */ } + try { f.Delete(); } catch (IOException) { /* best-effort cleanup */ } } } diff --git a/CoseSignTool.Tests/MainTests.cs b/CoseSignTool.Tests/MainTests.cs index b03e6171..b522a4bb 100644 --- a/CoseSignTool.Tests/MainTests.cs +++ b/CoseSignTool.Tests/MainTests.cs @@ -581,10 +581,10 @@ public void Main_WithUnknownVerb_ShowsGeneralHelp() public void EndToEndPipelineWithActualProcess() { // Arrange - create test payload - string payloadContent = "Test payload content for piped signing " + Guid.NewGuid().ToString(); + string payloadContent = "Test payload content for piped signing " + Guid.NewGuid(); byte[] payloadBytes = System.Text.Encoding.UTF8.GetBytes(payloadContent); - string exePath = Path.Combine(AppContext.BaseDirectory, "CoseSignTool.dll"); + string exePath = Path.Join(AppContext.BaseDirectory, "CoseSignTool.dll"); // Step 1: Sign with piped payload (embedded signature so payload is included) byte[] signatureBytes; @@ -670,7 +670,7 @@ public void ValidateDetachedSignatureWithPipedSignatureAndPayloadFile() // Read signature for piping byte[] signatureBytes = File.ReadAllBytes(sigFile); - string exePath = Path.Combine(AppContext.BaseDirectory, "CoseSignTool.dll"); + string exePath = Path.Join(AppContext.BaseDirectory, "CoseSignTool.dll"); // Validate with piped signature using var validateProcess = new Process(); @@ -714,7 +714,7 @@ public void GetContentFromPipedEmbeddedSignature() { // Arrange - create embedded signature file first string certPair = $"{PublicKeyIntermediateCertFile}, {PublicKeyRootCertFile}"; - string payloadContent = "Unique payload for get test " + Guid.NewGuid().ToString(); + string payloadContent = "Unique payload for get test " + Guid.NewGuid(); string payloadFile = Path.GetTempFileName(); File.WriteAllText(payloadFile, payloadContent); string sigFile = payloadFile + ".embedded.cose"; @@ -726,9 +726,9 @@ public void GetContentFromPipedEmbeddedSignature() // Read signature for piping byte[] signatureBytes = File.ReadAllBytes(sigFile); - string exePath = Path.Combine(AppContext.BaseDirectory, "CoseSignTool.dll"); + string exePath = Path.Join(AppContext.BaseDirectory, "CoseSignTool.dll"); - // Get content with piped signature (get writes to stdout by default when -sa not specified) + // Get content with piped signature(get writes to stdout by default when -sa not specified) using var getProcess = new Process(); getProcess.StartInfo = new ProcessStartInfo { diff --git a/CoseSignTool.Tests/SignCommandTests.cs b/CoseSignTool.Tests/SignCommandTests.cs index ab7416dc..a98b804b 100644 --- a/CoseSignTool.Tests/SignCommandTests.cs +++ b/CoseSignTool.Tests/SignCommandTests.cs @@ -1767,7 +1767,7 @@ public void ApplyOptions_WithPemOptions_ShouldSetProperties() public void SignWithEcdsaPemCertificate_ShouldSucceed() { // Arrange - X509Certificate2 ecdsaCert = TestCertificateUtils.CreateCertificate( + using X509Certificate2 ecdsaCert = TestCertificateUtils.CreateCertificate( nameof(SignWithEcdsaPemCertificate_ShouldSucceed), useEcc: true); @@ -1797,7 +1797,6 @@ public void SignWithEcdsaPemCertificate_ShouldSucceed() } finally { - ecdsaCert.Dispose(); CleanupFile(payloadFile); CleanupFile(signatureFile); CleanupFile(pemFile); @@ -2124,7 +2123,7 @@ private static void CleanupFile(string filePath) { File.Delete(filePath); } - catch { /* ignore cleanup errors */ } + catch (IOException) { /* ignore cleanup errors */ } } } diff --git a/CoseSignTool/CoseCommand.cs b/CoseSignTool/CoseCommand.cs index b43da09a..cb76df15 100644 --- a/CoseSignTool/CoseCommand.cs +++ b/CoseSignTool/CoseCommand.cs @@ -356,14 +356,6 @@ private static string[] CleanArgs(string[] args, StringDictionary options) arg = $"--{arg.AsSpan(1)}"; } - // Extract the option name (before any colon) - string argWithoutColon = arg; - int colonInArg = arg.StartsWith("--") ? arg.IndexOf(':', 2) : -1; - if (colonInArg > 0) - { - argWithoutColon = arg.Substring(0, colonInArg); - } - if (arg.StartsWith('-')) { // arg is an option name (possibly with colon-delimited value) From 143025083543017ae41e370c40e2cd7c64a78676 Mon Sep 17 00:00:00 2001 From: "Jeromy Statia (from Dev Box)" Date: Thu, 12 Feb 2026 16:54:20 -0800 Subject: [PATCH 8/8] Address PR feedback: determine key algorithm from cert OID, fix CVE-2026-21218, fix redundant ToString - Refactor LoadCertificateWithPrivateKey to use certificate.PublicKey.Oid.Value to determine RSA vs ECDSA instead of trial-and-error exception handling - Extract ImportPemKey helper to eliminate duplicated encrypted/unencrypted logic - Update System.Security.Cryptography.Cose from 10.0.0 to 10.0.3 (CVE-2026-21218) - Fix redundant ToString() calls in MainTests.cs --- CoseSignTool.Tests/MainTests.cs | 5 +- CoseSignTool/SignCommand.cs | 113 +++++++++++++------------------- Directory.Packages.props | 2 +- 3 files changed, 51 insertions(+), 69 deletions(-) diff --git a/CoseSignTool.Tests/MainTests.cs b/CoseSignTool.Tests/MainTests.cs index b522a4bb..17b3b128 100644 --- a/CoseSignTool.Tests/MainTests.cs +++ b/CoseSignTool.Tests/MainTests.cs @@ -93,8 +93,9 @@ public void FromMainValidationStdOut() string[] actualErrors = stderrLines.Where(line => !line.StartsWith("Warning: Command '") || !line.Contains("conflicts with an existing command")).ToArray(); actualErrors.Should().BeEmpty("There should be no errors (excluding plugin conflict warnings from test infrastructure)."); - redirectedOut.ToString().Should().Contain("Validation succeeded.", "Validation should succeed."); - redirectedOut.ToString().Should().Contain("validation type: Detached", "Validation type should be detached."); + string stdoutContent = redirectedOut.ToString(); + stdoutContent.Should().Contain("Validation succeeded.", "Validation should succeed."); + stdoutContent.Should().Contain("validation type: Detached", "Validation type should be detached."); } [TestMethod] diff --git a/CoseSignTool/SignCommand.cs b/CoseSignTool/SignCommand.cs index 99fe24e5..b0bf59df 100644 --- a/CoseSignTool/SignCommand.cs +++ b/CoseSignTool/SignCommand.cs @@ -850,80 +850,61 @@ private static List ParsePemCertificates(string pem) /// A new X509Certificate2 instance with the private key attached. private X509Certificate2 LoadCertificateWithPrivateKey(X509Certificate2 certificate, string keyPem) { - // Try to load as RSA key first - if (keyPem.Contains("-----BEGIN RSA PRIVATE KEY-----") || - keyPem.Contains("-----BEGIN PRIVATE KEY-----") || - keyPem.Contains("-----BEGIN ENCRYPTED PRIVATE KEY-----")) + // If the PEM content doesn't contain a private key, return the certificate as-is + if (!keyPem.Contains("PRIVATE KEY")) { - try - { - using RSA rsa = RSA.Create(); - - if (keyPem.Contains("-----BEGIN ENCRYPTED PRIVATE KEY-----")) - { - string? pemPassword = ResolvePemPassword(); - if (string.IsNullOrEmpty(pemPassword)) - { - throw new CryptographicException( - "The private key is encrypted. Please provide a password using --pwenv or --pwprompt."); - } - rsa.ImportFromEncryptedPem(keyPem, pemPassword); - } - else - { - rsa.ImportFromPem(keyPem); - } - - return certificate.CopyWithPrivateKey(rsa); - } - catch (CryptographicException) when (!keyPem.Contains("RSA")) - { - // Not an RSA key, try ECDSA below - } + return certificate; } - - // Try to load as ECDSA key - if (keyPem.Contains("-----BEGIN EC PRIVATE KEY-----") || - keyPem.Contains("-----BEGIN PRIVATE KEY-----") || - keyPem.Contains("-----BEGIN ENCRYPTED PRIVATE KEY-----")) + + // Determine the key algorithm from the certificate's public key + // rather than guessing from PEM headers via trial-and-error. + string keyAlgorithm = certificate.PublicKey.Oid.Value ?? string.Empty; + bool isEncrypted = keyPem.Contains("-----BEGIN ENCRYPTED PRIVATE KEY-----"); + + // RSA OID: 1.2.840.113549.1.1.1 + if (keyAlgorithm == "1.2.840.113549.1.1.1") { - try - { - using ECDsa ecdsa = ECDsa.Create(); - - if (keyPem.Contains("-----BEGIN ENCRYPTED PRIVATE KEY-----")) - { - string? pemPassword = ResolvePemPassword(); - if (string.IsNullOrEmpty(pemPassword)) - { - throw new CryptographicException( - "The private key is encrypted. Please provide a password using --pwenv or --pwprompt."); - } - ecdsa.ImportFromEncryptedPem(keyPem, pemPassword); - } - else - { - ecdsa.ImportFromPem(keyPem); - } - - return certificate.CopyWithPrivateKey(ecdsa); - } - catch (CryptographicException) + using RSA rsa = RSA.Create(); + ImportPemKey(rsa, keyPem, isEncrypted); + return certificate.CopyWithPrivateKey(rsa); + } + + // EC OID: 1.2.840.10045.2.1 + if (keyAlgorithm == "1.2.840.10045.2.1") + { + using ECDsa ecdsa = ECDsa.Create(); + ImportPemKey(ecdsa, keyPem, isEncrypted); + return certificate.CopyWithPrivateKey(ecdsa); + } + + throw new CryptographicException( + $"Unsupported certificate key algorithm: {certificate.PublicKey.Oid.FriendlyName ?? keyAlgorithm}. " + + "Supported algorithms: RSA and ECDSA keys in PEM format (PKCS#1, PKCS#8, or encrypted PKCS#8)."); + } + + /// + /// Imports a PEM-encoded private key into the given asymmetric algorithm instance, + /// handling both encrypted and unencrypted PEM formats. + /// + /// The asymmetric algorithm instance to import the key into. + /// The PEM-encoded private key string. + /// Whether the PEM key is encrypted. + private void ImportPemKey(AsymmetricAlgorithm algorithm, string keyPem, bool isEncrypted) + { + if (isEncrypted) + { + string? pemPassword = ResolvePemPassword(); + if (string.IsNullOrEmpty(pemPassword)) { - // Not an ECDSA key either + throw new CryptographicException( + "The private key is encrypted. Please provide a password using --pwenv or --pwprompt."); } + algorithm.ImportFromEncryptedPem(keyPem, pemPassword); } - - // If we get here with a private key marker but couldn't load it, throw - if (keyPem.Contains("PRIVATE KEY")) + else { - throw new CryptographicException( - "Could not load the private key. The key format may be unsupported or corrupted. " + - "Supported formats: RSA and ECDSA keys in PEM format (PKCS#1, PKCS#8, or encrypted PKCS#8)."); + algorithm.ImportFromPem(keyPem); } - - // No private key found in the PEM content - return certificate; } /// diff --git a/Directory.Packages.props b/Directory.Packages.props index ecbe6e9a..bc9c8564 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,7 +4,7 @@ - +