diff --git a/CoseSign1.Certificates.Tests/CoseSign1MessageExtensionsTests.cs b/CoseSign1.Certificates.Tests/CoseSign1MessageExtensionsTests.cs new file mode 100644 index 00000000..fd0ff37e --- /dev/null +++ b/CoseSign1.Certificates.Tests/CoseSign1MessageExtensionsTests.cs @@ -0,0 +1,295 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace CoseSign1.Certificates.Tests; + +using System; +using System.IO; +using System.Security.Cryptography; +using System.Security.Cryptography.Cose; +using System.Security.Cryptography.X509Certificates; +using CoseSign1.Certificates.Extensions; + +/// +/// Unit tests for the CoseSign1MessageExtensions methods that verify COSE_Sign1 messages with embedded certificates. +/// Covers all overloads and edge cases. +/// +public class CoseSign1MessageExtensionsTests +{ + /// + /// Setup method + /// + [SetUp] + public void Setup() + { + } + + private static CoseSign1MessageFactory CoseSign1MessageFactory = new CoseSign1MessageFactory(); + // Helper: Create a valid COSE_Sign1 message with embedded certificate and content + private static CoseSign1Message CreateValidCoseSign1Message(byte[] content, X509Certificate2 cert, out byte[] coseBytes, bool detached = false) + { + X509Certificate2CoseSigningKeyProvider testObjRsa = new(cert); + coseBytes = CoseSign1MessageFactory.CreateCoseSign1MessageBytes(content, testObjRsa, !detached).ToArray(); + return CoseMessage.DecodeSign1(coseBytes); + } + + /// + /// Helper to create a COSE_Sign1 message with NO embedded certificate (no x5c/x5t headers). + /// + private static CoseSign1Message CreateCoseSign1MessageWithoutCert(byte[] content) + { + using var rsa = RSA.Create(2048); + + var coseSigner = new CoseSigner(rsa, RSASignaturePadding.Pkcs1, HashAlgorithmName.SHA256, new CoseHeaderMap(), new CoseHeaderMap()); + var coseBytes = CoseSign1Message.SignEmbedded(content, coseSigner); + return CoseMessage.DecodeSign1(coseBytes); + } + + /// + /// Test that VerifyEmbeddedWithCertificate returns true for a valid message with embedded certificate. + /// + [Test] + public void VerifyEmbeddedWithCertificate_ValidMessage_ReturnsTrue() + { + // Arrange + var cert = TestCertificateUtils.CreateCertificate(); + var content = new byte[] { 1, 2, 3, 4 }; + var msg = CreateValidCoseSign1Message(content, cert, out _); + + // Act + bool result = msg.VerifyEmbeddedWithCertificate(); + + // Assert + Assert.That(result, Is.True, "Expected verification to succeed for valid message."); + } + + /// + /// Test that VerifyEmbeddedWithCertificate returns false if message is null. + /// + [Test] + public void VerifyEmbeddedWithCertificate_NullMessage_ReturnsFalse() + { + // Arrange + CoseSign1Message? msg = null!; + + // Act & Assert + Assert.That(msg.VerifyEmbeddedWithCertificate(), Is.False, "Expected false when message is null."); + } + + /// + /// Test that VerifyEmbeddedWithCertificate returns false if no embedded certificate is present. + /// + [Test] + public void VerifyEmbeddedWithCertificate_NoCertificate_ReturnsFalse() + { + // Arrange + var content = new byte[] { 1, 2, 3, 4 }; + var msg = CreateCoseSign1MessageWithoutCert(content); + // Act + bool result = msg.VerifyEmbeddedWithCertificate(); + // Assert + Assert.That(result, Is.False, "Expected verification to fail when no certificate is present."); + } + + /// + /// Test that VerifyDetachedWithCertificate (byte[]) returns true for valid message and content. + /// + [Test] + public void VerifyDetachedWithCertificate_ByteArray_Valid_ReturnsTrue() + { + // Arrange + var cert = TestCertificateUtils.CreateCertificate(); + var content = new byte[] { 10, 20, 30 }; + var msg = CreateValidCoseSign1Message(content, cert, out _, detached: true); // Detached + // Act + bool result = msg.VerifyDetachedWithCertificate(content); + // Assert + Assert.That(result, Is.True, "Expected verification to succeed for valid detached message."); + } + + /// + /// Test that VerifyDetachedWithCertificate (byte[]) returns false if message is null. + /// + [Test] + public void VerifyDetachedWithCertificate_ByteArray_NullMessage_ReturnsFalse() + { + // Arrange + CoseSign1Message msg = null!; + var content = new byte[] { 1, 2 }; + // Act & Assert + Assert.That(msg.VerifyDetachedWithCertificate(content), Is.False, "Expected false when message is null."); + } + + /// + /// Test that VerifyDetachedWithCertificate (byte[]) returns false if content is null. + /// + [Test] + public void VerifyDetachedWithCertificate_ByteArray_NullContent_ReturnsFalse() + { + // Arrange + var cert = TestCertificateUtils.CreateCertificate(); + var content = new byte[] { 1, 2 }; + var msg = CreateValidCoseSign1Message(content, cert, out _, detached: true); // Detached + // Act & Assert + Assert.That(msg.VerifyDetachedWithCertificate((byte[])null!), Is.False, "Expected false when detached content is null."); + } + + /// + /// Test that VerifyDetachedWithCertificate (ReadOnlySpan) returns true for valid message and content. + /// + [Test] + public void VerifyDetachedWithCertificate_ReadOnlySpan_Valid_ReturnsTrue() + { + // Arrange + var cert = TestCertificateUtils.CreateCertificate(); + var content = new byte[] { 42, 43, 44 }; + var msg = CreateValidCoseSign1Message(content, cert, out _, detached: true); // Detached + // Act + bool result = msg.VerifyDetachedWithCertificate(content.AsSpan()); + // Assert + Assert.That(result, Is.True, "Expected verification to succeed for valid detached message."); + } + + /// + /// Test that VerifyDetachedWithCertificate (ReadOnlySpan) returns false if content is empty. + /// + [Test] + public void VerifyDetachedWithCertificate_ReadOnlySpan_EmptyContent_ReturnsFalse() + { + // Arrange + var cert = TestCertificateUtils.CreateCertificate(); + var contentCreate = new byte[] { 99, 100, 101 }; + var contentVerify = Array.Empty(); + var msg = CreateValidCoseSign1Message(contentCreate, cert, out _, detached: true); // Detached + // Act + bool result = msg.VerifyDetachedWithCertificate(contentVerify.AsSpan()); + // Assert + Assert.That(result, Is.False, "Expected verification to fail for empty detached content."); + } + + /// + /// Test that VerifyDetachedWithCertificate (Stream) returns true for valid message and content. + /// + [Test] + public void VerifyDetachedWithCertificate_Stream_Valid_ReturnsTrue() + { + // Arrange + var cert = TestCertificateUtils.CreateCertificate(); + var content = new byte[] { 99, 100, 101 }; + using var stream = new MemoryStream(content); + var msg = CreateValidCoseSign1Message(content, cert, out _, detached: true); // Detached + // Act + bool result = msg.VerifyDetachedWithCertificate(stream); + // Assert + Assert.That(result, Is.True, "Expected verification to succeed for valid detached message."); + } + + /// + /// Test that VerifyDetachedWithCertificate (Stream) returns false if message is null. + /// + [Test] + public void VerifyDetachedWithCertificate_Stream_NullMessage_ReturnsFalse() + { + // Arrange + CoseSign1Message msg = null!; + using var stream = new MemoryStream(new byte[] { 1 }); + // Act & Assert + Assert.That(msg.VerifyDetachedWithCertificate(stream), Is.False, "Expected false when message is null."); + } + + /// + /// Test that VerifyDetachedWithCertificate (Stream) returns false if stream is null. + /// + [Test] + public void VerifyDetachedWithCertificate_Stream_NullStream_ReturnsFalse() + { + // Arrange + var cert = TestCertificateUtils.CreateCertificate(); + var content = new byte[] { 99, 100, 101 }; + var msg = CreateValidCoseSign1Message(content, cert, out _, detached: true); // Detached + // Act & Assert + Assert.That(msg.VerifyDetachedWithCertificate((Stream)null!), Is.False, "Expected false when detached stream is null."); + } + + /// + /// Test that VerifyEmbeddedWithCertificate returns false for a detached message (Content is null). + /// + [Test] + public void VerifyEmbeddedWithCertificate_DetachedMessage_ReturnsFalse() + { + // Arrange + var cert = TestCertificateUtils.CreateCertificate(); + var content = new byte[] { 1, 2, 3, 4 }; + byte[] coseBytes; + // Create a detached message: pass null as content and detached=true + var msg = CreateValidCoseSign1Message(content, cert, out coseBytes, detached: true); + + // Act + bool result = msg.VerifyEmbeddedWithCertificate(); + + // Assert + Assert.That(result, Is.False, "Expected verification to fail for detached message (no content)."); + } + + /// + /// Test that VerifyDetachedWithCertificate (byte[]) returns false if no signing certificate is present. + /// + [Test] + public void VerifyDetachedWithCertificate_ByteArray_NoCertificate_ReturnsFalse() + { + // Arrange + var content = new byte[] { 1, 2, 3 }; + var msg = CreateCoseSign1MessageWithoutCert(content); // No cert in headers + // Act + bool result = msg.VerifyDetachedWithCertificate(content); + // Assert + Assert.That(result, Is.False, "Expected verification to fail when no signing certificate is present."); + } + + /// + /// Test that VerifyDetachedWithCertificate (ReadOnlySpan) returns false if no signing certificate is present. + /// + [Test] + public void VerifyDetachedWithCertificate_ReadOnlySpan_NoCertificate_ReturnsFalse() + { + // Arrange + var content = new byte[] { 1, 2, 3 }; + var msg = CreateCoseSign1MessageWithoutCert(content); // No cert in headers + // Act + bool result = msg.VerifyDetachedWithCertificate(content.AsSpan()); + // Assert + Assert.That(result, Is.False, "Expected verification to fail when no signing certificate is present."); + } + + /// + /// Test that VerifyDetachedWithCertificate (Stream) returns false if no signing certificate is present. + /// + [Test] + public void VerifyDetachedWithCertificate_Stream_NoCertificate_ReturnsFalse() + { + // Arrange + var content = new byte[] { 1, 2, 3 }; + using var stream = new MemoryStream(content); + var msg = CreateCoseSign1MessageWithoutCert(content); // No cert in headers + // Act + bool result = msg.VerifyDetachedWithCertificate(stream); + // Assert + Assert.That(result, Is.False, "Expected verification to fail when no signing certificate is present."); + } + + /// + /// Test that VerifyDetachedWithCertificate (Stream) returns false if no signing certificate is present or public key is null. + /// + [Test] + public void VerifyDetachedWithCertificate_Stream_NoCertificateOrPublicKey_ReturnsFalse() + { + // Arrange + var content = new byte[] { 1, 2, 3 }; + using var stream = new MemoryStream(content); + var msg = CreateCoseSign1MessageWithoutCert(content); // No cert in headers + // Act + bool result = msg.VerifyDetachedWithCertificate(stream); + // Assert + Assert.That(result, Is.False, "Expected verification to fail when no signing certificate or public key is present."); + } +} diff --git a/CoseSign1.Certificates/CertificateCoseHeaderLabels.cs b/CoseSign1.Certificates/CertificateCoseHeaderLabels.cs index 7a8692a6..76db8bb7 100644 --- a/CoseSign1.Certificates/CertificateCoseHeaderLabels.cs +++ b/CoseSign1.Certificates/CertificateCoseHeaderLabels.cs @@ -6,19 +6,21 @@ namespace CoseSign1.Certificates; /// /// objects which are specific to certificate signed objects. /// -internal class CertificateCoseHeaderLabels +public static class CertificateCoseHeaderLabels { // Taken from https://www.iana.org/assignments/cose/cose.xhtml /// /// Represents an unordered list of certificates. /// - internal static readonly CoseHeaderLabel X5Bag = new(32); + public static readonly CoseHeaderLabel X5Bag = new(32); + /// /// Represents an ordered list (leaf first) of the certificate chain for the certificate used to sign the object. /// - internal static readonly CoseHeaderLabel X5Chain = new(33); + public static readonly CoseHeaderLabel X5Chain = new(33); + /// /// Represents the thumbprint for the certificate used to sign the object. /// - internal static readonly CoseHeaderLabel X5T = new(34); + public static readonly CoseHeaderLabel X5T = new(34); } diff --git a/CoseSign1.Certificates/Extensions/CoseSign1MessageExtensions.cs b/CoseSign1.Certificates/Extensions/CoseSign1MessageExtensions.cs index a222b8d8..51625808 100644 --- a/CoseSign1.Certificates/Extensions/CoseSign1MessageExtensions.cs +++ b/CoseSign1.Certificates/Extensions/CoseSign1MessageExtensions.cs @@ -4,6 +4,12 @@ namespace CoseSign1.Certificates.Extensions; using System.Diagnostics; +using System.Formats.Cbor; +using System.IO; +using System.Runtime.Caching; +using System.Security.Cryptography.Cose; +using System.Security.Cryptography.X509Certificates; +using CoseSign1.Certificates; /// /// Extension methods for the objects related to certificate operations. @@ -84,9 +90,9 @@ public static bool TryGetSigningCertificate( // cache the certificate so we can return it faster in the future. MemoryCache.Default.Add(cacheEntry, signingCert, new CacheItemPolicy() { AbsoluteExpiration = DateTimeOffset.UtcNow.AddMinutes(5) }); } - else - { - Trace.TraceWarning($"Failed to find a certificate matching the x5t thumbprint of {thumbprint} in the certificate chain present in the x5chain header, unable to obtain the signing certificate from msg: {msgHashCode}"); + else + { + Trace.TraceWarning($"Failed to find a certificate matching the x5t thumbprint of {thumbprint} in the certificate chain present in the x5chain header, unable to obtain the signing certificate from msg: {msgHashCode}"); } } finally @@ -199,4 +205,174 @@ private static bool TryGetCertificateList( return false; } + + /// + /// Attempts to verify the COSE_Sign1 message using the embedded signing certificate if present and the message is not detached. + /// + /// The COSE_Sign1 message to verify. + /// True if the unprotected headers should be allowed to contribute, false (default - more secure) otherwise. + /// True if the message is verified with the embedded signing certificate; false otherwise. + public static bool VerifyEmbeddedWithCertificate( + this CoseSign1Message @this, + bool allowUnprotected = false) + { + if(@this is null) + { + Trace.TraceWarning($"{nameof(@this)} is null and cannot be verified."); + return false; + } + + AsymmetricAlgorithm? publicKey = @this.GetEmbeddedPublicKey(allowUnprotected, out bool foundCert); + if (!foundCert || publicKey is null) + { + return false; + } + + // If the message is detached, we cannot verify without the payload. + if (@this.Content is null) + { + Trace.TraceWarning($"Message is a detached payload; cannot verify without payload."); + return false; + } + + try + { + return @this.VerifyEmbedded(publicKey); + } + catch (CryptographicException ex) + { + Trace.TraceWarning($"Verification failed: {ex.Message}"); + return false; + } + } + + /// + /// Attempts to verify the COSE_Sign1 message using the embedded signing certificate and the provided detached content (byte array). + /// + /// The COSE_Sign1 message to verify. + /// The detached content to verify against. + /// True if the unprotected headers should be allowed to contribute, false (default - more secure) otherwise. + /// True if the message is verified with the embedded signing certificate; false otherwise. + public static bool VerifyDetachedWithCertificate(this CoseSign1Message @this, byte[] detachedContent, bool allowUnprotected = false) + { + if(@this is null) + { + Trace.TraceWarning($"{nameof(@this)} is null and cannot be verified."); + return false; + } + + if (detachedContent is null || detachedContent.Length == 0) + { + Trace.TraceWarning($"Detached content is null or empty; cannot verify."); + return false; + } + + AsymmetricAlgorithm? publicKey = @this.GetEmbeddedPublicKey(allowUnprotected, out bool foundCert); + if (!foundCert || publicKey is null) + { + return false; + } + try + { + return @this.VerifyDetached(publicKey, detachedContent); + } + catch (CryptographicException ex) + { + Trace.TraceWarning($"Verification failed: {ex.Message}"); + return false; + } + } + + /// + /// Attempts to verify the COSE_Sign1 message using the embedded signing certificate and the provided detached content (ReadOnlySpan). + /// + /// The COSE_Sign1 message to verify. + /// The detached content to verify against. + /// True if the unprotected headers should be allowed to contribute, false (default - more secure) otherwise. + /// True if the message is verified with the embedded signing certificate; false otherwise. + public static bool VerifyDetachedWithCertificate(this CoseSign1Message @this, ReadOnlySpan detachedContent, bool allowUnprotected = false) + { + if(@this is null) + { + Trace.TraceWarning($"{nameof(@this)} is null and cannot be verified."); + return false; + } + + if (detachedContent.IsEmpty) + { + Trace.TraceWarning($"Detached content is empty; cannot verify."); + return false; + } + + AsymmetricAlgorithm? publicKey = @this.GetEmbeddedPublicKey(allowUnprotected, out bool foundCert); + if (!foundCert || publicKey is null) + { + return false; + } + try + { + return @this.VerifyDetached(publicKey, detachedContent); + } + catch (CryptographicException ex) + { + Trace.TraceWarning($"Verification failed: {ex.Message}"); + return false; + } + } + + /// + /// Attempts to verify the COSE_Sign1 message using the embedded signing certificate and the provided detached content (Stream). + /// + /// The COSE_Sign1 message to verify. + /// The detached content to verify against. + /// True if the unprotected headers should be allowed to contribute, false (default - more secure) otherwise. + /// True if the message is verified with the embedded signing certificate; false otherwise. + public static bool VerifyDetachedWithCertificate(this CoseSign1Message @this, Stream detachedContent, bool allowUnprotected = false) + { + if(@this is null) + { + Trace.TraceWarning($"{nameof(@this)} is null and cannot be verified."); + return false; + } + + if(detachedContent is null) + { + Trace.TraceWarning($"Detached content stream is null; cannot verify."); + return false; + } + + AsymmetricAlgorithm? publicKey = @this.GetEmbeddedPublicKey(allowUnprotected, out bool foundCert); + if (!foundCert || publicKey is null) + { + return false; + } + try + { + return @this.VerifyDetached(publicKey, detachedContent); + } + catch (CryptographicException ex) + { + Trace.TraceWarning($"Verification failed: {ex.Message}"); + return false; + } + } + + // Shared logic to extract the public key from the embedded signing certificate + private static AsymmetricAlgorithm? GetEmbeddedPublicKey(this CoseSign1Message @this, bool allowUnprotected, out bool foundCert) + { + foundCert = false; + if (!@this.TryGetSigningCertificate(out X509Certificate2 signingCert, allowUnprotected) || signingCert == null) + { + Trace.TraceWarning($"No signing certificate found in message."); + return null; + } + foundCert = true; + AsymmetricAlgorithm? publicKey = (AsymmetricAlgorithm?)signingCert.GetRSAPublicKey() ?? signingCert.GetECDsaPublicKey(); + if (publicKey is null) + { + Trace.TraceWarning($"Signing certificate does not contain a valid public key for verification."); + return null; + } + return publicKey; + } }