diff --git a/APIMatic.Core.Test/MockTypes/Http/Request/HttpRequestData.cs b/APIMatic.Core.Test/MockTypes/Http/Request/HttpRequestData.cs new file mode 100644 index 00000000..8b71066e --- /dev/null +++ b/APIMatic.Core.Test/MockTypes/Http/Request/HttpRequestData.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using APIMatic.Core.Http.Abstractions; + +namespace APIMatic.Core.Test.MockTypes.Http.Request +{ + public class HttpRequestData : IHttpRequestData + { + public string Method { get; } + public Uri Url { get; } + public IReadOnlyDictionary Headers { get; } + public Stream Body { get; set; } + public IReadOnlyDictionary Query { get; } + public IReadOnlyDictionary Cookies { get; } + public string Protocol { get; } + public string ContentType { get; } + public long? ContentLength { get; } + + public HttpRequestData( + IDictionary headers, + Stream body) + { + Headers = new ReadOnlyDictionary(headers); + Body = body; + } + } +} diff --git a/APIMatic.Core.Test/Security/Cryptography/DigestCodecTests.cs b/APIMatic.Core.Test/Security/Cryptography/DigestCodecTests.cs new file mode 100644 index 00000000..0f5b4928 --- /dev/null +++ b/APIMatic.Core.Test/Security/Cryptography/DigestCodecTests.cs @@ -0,0 +1,48 @@ +using System; +using APIMatic.Core.Security.Cryptography; +using APIMatic.Core.Types; +using NUnit.Framework; + +namespace APIMatic.Core.Test.Security.Cryptography +{ + public class DigestCodecTests + { + [TestCase(EncodingType.Hex, "4A6F686E", new byte[] { 0x4A, 0x6F, 0x68, 0x6E })] + [TestCase(EncodingType.Base64, "SGVsbG8=", new byte[] { 0x48, 0x65, 0x6C, 0x6C, 0x6F })] + [TestCase(EncodingType.Base64, " ", new byte[]{})] + [TestCase(EncodingType.Base64Url, "SGVsbG8", new byte[] { 0x48, 0x65, 0x6C, 0x6C, 0x6F })] + [TestCase(EncodingType.Base64Url, "SG", new byte[] { 0x48 })] + public void DigestCodec_Decode_Success(EncodingType encodingType, string input, byte[] expected) + { + var codec = DigestCodec.Create(encodingType); + var result = codec.Decode(input); + Assert.AreEqual(expected, result); + } + + [TestCase(EncodingType.Hex, "")] + [TestCase(EncodingType.Hex, null)] + [TestCase(EncodingType.Base64, "")] + [TestCase(EncodingType.Base64, null)] + [TestCase(EncodingType.Base64Url, "")] + [TestCase(EncodingType.Base64Url, null)] + public void DigestCodecIncorrectInput_Decode_DigestCodec_Create_Exception(EncodingType encodingType, string input) + { + var codec = DigestCodec.Create(encodingType); + Assert.Throws(() => codec.Decode(input)); + } + + [TestCase(EncodingType.Hex, "ABC")] + public void DigestCodecIncorrectFormat_Decode_DigestCodec_Create_Exception(EncodingType encodingType, string input) + { + var codec = DigestCodec.Create(encodingType); + Assert.Throws(() => codec.Decode(input)); + } + + [TestCase(-1)] + public void DigestCodec_Create_Exception(int invalidValue) + { + var encodingType = (EncodingType)invalidValue; + Assert.Throws(() => DigestCodec.Create(encodingType)); + } + } +} diff --git a/APIMatic.Core.Test/Security/HmacFactoryTests.cs b/APIMatic.Core.Test/Security/HmacFactoryTests.cs new file mode 100644 index 00000000..4802a5c6 --- /dev/null +++ b/APIMatic.Core.Test/Security/HmacFactoryTests.cs @@ -0,0 +1,35 @@ +using System; +using System.Security.Cryptography; +using APIMatic.Core.Types.Sdk; +using NUnit.Framework; + +namespace APIMatic.Core.Test.Security +{ + [TestFixture] + public class HmacFactoryTests + { + [Test] + public void HmacAlgorithmSha256_HmacFactoryCreate_ReturnsHMACSHA256() + { + var key = new byte[] { 1, 2, 3 }; + var hmac = HmacFactory.Create(HmacAlgorithm.Sha256, key); + Assert.IsInstanceOf(hmac); + } + + [Test] + public void HmacAlgorithmSha512_HmacFactoryCreate_ReturnsHMACSHA512() + { + var key = new byte[] { 4, 5, 6 }; + var hmac = HmacFactory.Create(HmacAlgorithm.Sha512, key); + Assert.IsInstanceOf(hmac); + } + + [Test] + public void UnsupportedAlgorithm_HmacFactoryCreate_ThrowsNotSupportedException() + { + var key = new byte[] { 7, 8, 9 }; + const HmacAlgorithm invalidAlgorithm = (HmacAlgorithm)999; + Assert.Throws(() => HmacFactory.Create(invalidAlgorithm, key)); + } + } +} diff --git a/APIMatic.Core.Test/Security/SignatureVerifier/HmacSignatureVerifierTests.cs b/APIMatic.Core.Test/Security/SignatureVerifier/HmacSignatureVerifierTests.cs new file mode 100644 index 00000000..21e91e2b --- /dev/null +++ b/APIMatic.Core.Test/Security/SignatureVerifier/HmacSignatureVerifierTests.cs @@ -0,0 +1,192 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using APIMatic.Core.Security.SignatureVerifier; +using APIMatic.Core.Test.MockTypes.Http.Request; +using APIMatic.Core.Types; +using NUnit.Framework; + +namespace APIMatic.Core.Test.Security.SignatureVerifier; + +[TestFixture] +public class HmacSignatureVerifierTests +{ + private const string SecretKey = "test_secret"; + private const string HeaderName = "X-Signature"; + private const string Payload = "hello world"; + + private static HttpRequestData CreateRequest(string headerValue, string headerName = HeaderName, string payload = Payload) + { + var headers = headerValue == null + ? new Dictionary() + : new Dictionary { { headerName, new[] { headerValue } } }; + return new HttpRequestData(headers, new MemoryStream(System.Text.Encoding.UTF8.GetBytes(payload))); + } + + private static HmacSignatureVerifier CreateVerifier( + EncodingType encodingType, + string headerName = HeaderName, + string secretKey = SecretKey, + string signatureValueTemplate = "{digest}") + { + return new HmacSignatureVerifier(secretKey, headerName, encodingType, signatureValueTemplate: signatureValueTemplate); + } + + [Test] + public void Constructor_ThrowsOnNullOrEmptySecretKey_OnCreate_ThrowsException() + { + Assert.Throws(() => CreateVerifier(EncodingType.Hex, secretKey: null)); + Assert.Throws(() => CreateVerifier(EncodingType.Hex, secretKey: "")); + } + + [Test] + public void Constructor_ThrowsOnNullOrEmptyHeader_OnCreate_ThrowsException() + { + Assert.Throws(() => CreateVerifier(EncodingType.Hex, headerName: null)); + Assert.Throws(() => CreateVerifier(EncodingType.Hex, headerName: "")); + } + + [Test] + public async Task NullHeader_OnVerifyAsync_ReturnsFailure() + { + var request = CreateRequest(null); + var verifier = CreateVerifier(EncodingType.Hex); + var result = await verifier.VerifyAsync(request); + Assert.IsFalse(result.IsSuccess); + Assert.AreEqual($"Signature header '{HeaderName}' is missing.", result.Errors.First()); + } + + [Test] + public async Task MissingHeader_OnVerifyAsync_ReturnsFailure() + { + var request = CreateRequest(string.Empty); + var verifier = CreateVerifier(EncodingType.Hex); + var result = await verifier.VerifyAsync(request); + Assert.IsFalse(result.IsSuccess); + Assert.AreEqual($"Malformed signature header '{HeaderName}' value.", result.Errors.First()); + } + + [Test] + public async Task MalformedHeader_OnVerifyAsync_ReturnsFailure() + { + var request = CreateRequest(""); + var verifier = CreateVerifier(EncodingType.Hex); + var result = await verifier.VerifyAsync(request); + Assert.IsFalse(result.IsSuccess); + StringAssert.Contains("Malformed", result.Errors.First()); + } + + [Test] + public async Task SignatureDecodingFails_OnVerifyAsync_ReturnsFailure() + { + var request = CreateRequest("not-a-valid-hex"); + var verifier = CreateVerifier(EncodingType.Hex); + var result = await verifier.VerifyAsync(request); + Assert.IsFalse(result.IsSuccess); + } + + [TestCase(EncodingType.Hex)] + [TestCase(EncodingType.Base64)] + [TestCase(EncodingType.Base64Url)] + public async Task CorrectSignature_OnVerifyAsync_ReturnsSuccess(EncodingType encodingType) + { + string encodedDigest = GetDigest(encodingType, SecretKey, Payload); + var request = CreateRequest(encodedDigest); + var verifier = CreateVerifier(encodingType); + var result = await verifier.VerifyAsync(request); + Assert.IsTrue(result.IsSuccess); + } + + [TestCase(EncodingType.Hex, "deadbeef")] + [TestCase(EncodingType.Base64, "Zm9vYmFyYmF6")] + [TestCase(EncodingType.Base64Url, "Zm9vYmFyYmF6")] + public async Task IncorrectSignature_OnVerifyAsync_ReturnsFailure(EncodingType encodingType, string badDigest) + { + var request = CreateRequest(badDigest); + var verifier = CreateVerifier(encodingType); + var result = await verifier.VerifyAsync(request); + Assert.IsFalse(result.IsSuccess); + StringAssert.Contains("failed", result.Errors.First()); + } + + [Test] + public async Task TemplateExtractsDigest_OnVerifyAsync_ReturnsSuccess() + { + var hmac = new System.Security.Cryptography.HMACSHA256(System.Text.Encoding.UTF8.GetBytes(SecretKey)); + var hash = hmac.ComputeHash(System.Text.Encoding.UTF8.GetBytes(Payload)); + var digest = BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); + const string template = "prefix-{digest}-suffix"; + var signatureValue = $"prefix-{digest}-suffix"; + var request = CreateRequest(signatureValue); + var verifier = CreateVerifier(EncodingType.Hex, signatureValueTemplate: template); + var result = await verifier.VerifyAsync(request); + Assert.IsTrue(result.IsSuccess); + } + + [Test] + public async Task TemplateDoesNotMatch_OnVerifyAsync_ReturnsFailure() + { + const string template = "prefix-{digest}-suffix"; + const string signatureValue = $"wrongprefix-deadbeef-wrongsuffix"; + var request = CreateRequest(signatureValue); + var verifier = CreateVerifier(EncodingType.Hex, signatureValueTemplate: template); + var result = await verifier.VerifyAsync(request); + Assert.IsFalse(result.IsSuccess); + } + + [Test] + public async Task TemplateDoesNotContainDigest_OnVerifyAsync_ReturnsFailure() + { + const string template = "prefix-{wrong}-suffix"; + const string signatureValue = $"wrongprefix-deadbeef-wrongsuffix"; + var request = CreateRequest(signatureValue); + var verifier = CreateVerifier(EncodingType.Hex, signatureValueTemplate: template); + var result = await verifier.VerifyAsync(request); + Assert.IsFalse(result.IsSuccess); + } + + [Test] + public async Task CorrectDigestButIncorrectExpectedTemplate_OnVerifyAsync_ReturnsFailure() + { + var hmac = new System.Security.Cryptography.HMACSHA256(System.Text.Encoding.UTF8.GetBytes(SecretKey)); + var hash = hmac.ComputeHash(System.Text.Encoding.UTF8.GetBytes(Payload)); + var digest = BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); + // The expected template doesn't match the signature value template, though digest is correct + const string expectedTemplate = "sha26={digest}"; + var signatureValue = $"sha256={digest}"; + var request = CreateRequest(signatureValue); + var verifier = CreateVerifier(EncodingType.Hex, signatureValueTemplate: expectedTemplate); + var result = await verifier.VerifyAsync(request); + Assert.IsFalse(result.IsSuccess); + } + + [Test] + public async Task CorrectDigestButIncorrectSignatureValue_OnVerifyAsync_ReturnsFailure() + { + var hmac = new System.Security.Cryptography.HMACSHA256(System.Text.Encoding.UTF8.GetBytes(SecretKey)); + var hash = hmac.ComputeHash(System.Text.Encoding.UTF8.GetBytes(Payload)); + var digest = BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); + const string expectedTemplate = "sha256={digest}"; + // The signature value does not match the template, though digest is correct + var signatureValue = $"sha25={digest}"; + var request = CreateRequest(signatureValue); + var verifier = CreateVerifier(EncodingType.Hex, signatureValueTemplate: expectedTemplate); + var result = await verifier.VerifyAsync(request); + Assert.IsFalse(result.IsSuccess); + } + + private static string GetDigest(EncodingType encodingType, string secretKey, string payload) + { + var hmac = new System.Security.Cryptography.HMACSHA256(System.Text.Encoding.UTF8.GetBytes(secretKey)); + var hash = hmac.ComputeHash(System.Text.Encoding.UTF8.GetBytes(payload)); + return encodingType switch + { + EncodingType.Hex => BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(), + EncodingType.Base64 => Convert.ToBase64String(hash), + EncodingType.Base64Url => Convert.ToBase64String(hash).Replace('+', '-').Replace('/', '_').TrimEnd('='), + _ => BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant() + }; + } +} diff --git a/APIMatic.Core.Test/Security/SignatureVerifier/SignatureVerificationExtensionsTests.cs b/APIMatic.Core.Test/Security/SignatureVerifier/SignatureVerificationExtensionsTests.cs new file mode 100644 index 00000000..095ae2fd --- /dev/null +++ b/APIMatic.Core.Test/Security/SignatureVerifier/SignatureVerificationExtensionsTests.cs @@ -0,0 +1,21 @@ +using APIMatic.Core.Security.SignatureVerifier; +using NUnit.Framework; + +namespace APIMatic.Core.Test.Security.SignatureVerifier +{ + [TestFixture] + public class SignatureVerificationExtensionsTests + { + [TestCase(null, null, true)] + [TestCase(null, new byte[] { 1, 2, 3 }, false)] + [TestCase(new byte[] { 1, 2, 3 }, null, false)] + [TestCase(new byte[] { 1, 2, 3 }, new byte[] { 1, 2 }, false)] + [TestCase(new byte[] { }, new byte[] { }, true)] + [TestCase(new byte[] { 1, 2, 3, 4 }, new byte[] { 1, 2, 3, 4 }, true)] + [TestCase(new byte[] { 1, 2, 3, 4 }, new byte[] { 1, 2, 3, 5 }, false)] + public void ConstantTimeEquals_VariousInputs_ReturnsExpected(byte[] a, byte[] b, bool expected) + { + Assert.AreEqual(expected, SignatureVerifierExtensions.FixedTimeEquals(a, b)); + } + } +} diff --git a/APIMatic.Core.Test/Utilities/IHttpRequestDataExtensionsTests.cs b/APIMatic.Core.Test/Utilities/IHttpRequestDataExtensionsTests.cs new file mode 100644 index 00000000..bffd5ce9 --- /dev/null +++ b/APIMatic.Core.Test/Utilities/IHttpRequestDataExtensionsTests.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using APIMatic.Core.Test.MockTypes.Http.Request; +using APIMatic.Core.Utilities; +using NUnit.Framework; + +namespace APIMatic.Core.Test.Utilities +{ + [TestFixture] + public class IHttpRequestDataExtensionsTests + { + + [TestCase(null, new byte[0])] + [TestCase("", new byte[0])] + [TestCase("hello", new byte[] { 104, 101, 108, 108, 111 })] + public async Task ReadBodyStreamToByteArrayAsync_VariousBodies_ReturnsExpected(string body, byte[] expected) + { + var stream = body == null ? null : new MemoryStream(Encoding.UTF8.GetBytes(body)); + var request = new HttpRequestData(new Dictionary(), stream); + + var result = await request.ReadBodyStreamToByteArrayAsync(); + + Assert.AreEqual(expected, result); + } + } +} diff --git a/APIMatic.Core.Test/Utilities/Json/JsonPointerResolverTest.cs b/APIMatic.Core.Test/Utilities/Json/JsonPointerResolverTest.cs index 9cdfbed0..1a0bd5b3 100644 --- a/APIMatic.Core.Test/Utilities/Json/JsonPointerResolverTest.cs +++ b/APIMatic.Core.Test/Utilities/Json/JsonPointerResolverTest.cs @@ -81,5 +81,18 @@ public void ResolveScopedJsonValue_BooleanToken_ReturnsTokenToString() Assert.AreEqual("True", result); } + + [TestCase("#/name", "{\"name\":\"John\",\"age\":30}", "John")] + [TestCase("#/invalid", "{\"name\":\"John\"}", null)] + [TestCase(null, "{\"name\":\"John\"}", null)] + [TestCase("", "{\"name\":\"John\"}", null)] + [TestCase("/name", "{\"name\":\"John\"}", null)] + [TestCase("#/age", "{\"age\":30}", "30")] + [TestCase("#/name", null, null)] + public void ResolveJsonValue_VariousCases_ReturnsExpected(string jsonPointer, string json, string expected) + { + var result = JsonPointerResolver.ResolveJsonValue(jsonPointer, json); + Assert.AreEqual(expected, result); + } } } diff --git a/APIMatic.Core/APIMatic.Core.csproj b/APIMatic.Core/APIMatic.Core.csproj index a7549d72..bb813c55 100644 --- a/APIMatic.Core/APIMatic.Core.csproj +++ b/APIMatic.Core/APIMatic.Core.csproj @@ -49,5 +49,8 @@ + + + diff --git a/APIMatic.Core/Http/Abstractions/IHttpRequestData.cs b/APIMatic.Core/Http/Abstractions/IHttpRequestData.cs new file mode 100644 index 00000000..aaeac0a4 --- /dev/null +++ b/APIMatic.Core/Http/Abstractions/IHttpRequestData.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using System.IO; + +namespace APIMatic.Core.Http.Abstractions +{ + /// + /// Represents the contract for HTTP request data, including method, URL, headers, body, query parameters, cookies, protocol, content type, and content length. + /// + public interface IHttpRequestData + { + /// + /// Gets the HTTP method (e.g., GET, POST, PUT, DELETE). + /// + string Method { get; } + + /// + /// Gets the request URL. + /// + Uri Url { get; } + + /// + /// Gets the collection of HTTP headers. + /// + IReadOnlyDictionary Headers { get; } + + /// + /// Gets the request body as a stream. + /// + Stream Body { get; } + + /// + /// Gets the collection of query parameters. + /// + /// + /// Caller owns disposal. + /// + IReadOnlyDictionary Query { get; } + + /// + /// Gets the collection of cookies. + /// + IReadOnlyDictionary Cookies { get; } + + /// + /// Gets the HTTP protocol version (e.g., "HTTP/1.1"). + /// + string Protocol { get; } + + /// + /// Gets the content type of the request (e.g., "application/json"). + /// + string ContentType { get; } + + /// + /// Gets the content length of the request body, if known. + /// + long? ContentLength { get; } + } +} \ No newline at end of file diff --git a/APIMatic.Core/Security/Abstractions/ISignatureVerifier.cs b/APIMatic.Core/Security/Abstractions/ISignatureVerifier.cs new file mode 100644 index 00000000..f12e4d98 --- /dev/null +++ b/APIMatic.Core/Security/Abstractions/ISignatureVerifier.cs @@ -0,0 +1,23 @@ +using System.Threading; +using System.Threading.Tasks; +using APIMatic.Core.Http.Abstractions; +using APIMatic.Core.Types.Sdk; + +namespace APIMatic.Core.Security.Abstractions +{ + /// + /// Defines a contract for verifying the signature of an HTTP request. + /// + public interface ISignatureVerifier + { + /// + /// Verifies the signature of the specified HTTP request. + /// + /// The HTTP request data to verify. + /// A token to monitor for cancellation requests. + /// + /// VerificationResult containing the outcome of the verification process. + /// + Task VerifyAsync(IHttpRequestData request, CancellationToken cancellationToken = default); + } +} \ No newline at end of file diff --git a/APIMatic.Core/Security/Cryptography/Base64DigestCodec.cs b/APIMatic.Core/Security/Cryptography/Base64DigestCodec.cs new file mode 100644 index 00000000..2890f941 --- /dev/null +++ b/APIMatic.Core/Security/Cryptography/Base64DigestCodec.cs @@ -0,0 +1,24 @@ +using System; + +namespace APIMatic.Core.Security.Cryptography +{ + /// + /// Base64 digest codec implementation. + /// + internal class Base64DigestCodec : DigestCodec + { + /// + /// Decodes a Base64 string back into a byte array. + /// + /// The Base64 string to decode. + /// The decoded byte array. + /// Thrown when the input is not a valid Base64 string. + public override byte[] Decode(string encoded) + { + if (string.IsNullOrEmpty(encoded)) + throw new ArgumentException("Input cannot be null or empty", nameof(encoded)); + + return Convert.FromBase64String(encoded); + } + } +} diff --git a/APIMatic.Core/Security/Cryptography/Base64UrlDigestCodec.cs b/APIMatic.Core/Security/Cryptography/Base64UrlDigestCodec.cs new file mode 100644 index 00000000..d1d5a49c --- /dev/null +++ b/APIMatic.Core/Security/Cryptography/Base64UrlDigestCodec.cs @@ -0,0 +1,39 @@ +using System; + +namespace APIMatic.Core.Security.Cryptography +{ + /// + /// Base64Url digest codec implementation. + /// + internal class Base64UrlDigestCodec : DigestCodec + { + /// + /// Decodes a Base64Url string back into a byte array. + /// + /// The Base64Url string to decode. + /// The decoded byte array. + /// Thrown when the input is null. + /// Thrown when the input is not a valid Base64Url string. + public override byte[] Decode(string encoded) + { + if (string.IsNullOrEmpty(encoded)) + throw new ArgumentException("Input cannot be null or empty", nameof(encoded)); + + // Restore padding and standard Base64 characters + var base64 = encoded.Replace('-', '+').Replace('_', '/'); + + // Add padding if necessary + switch (base64.Length % 4) + { + case 2: + base64 += "=="; + break; + case 3: + base64 += "="; + break; + } + + return Convert.FromBase64String(base64); + } + } +} diff --git a/APIMatic.Core/Security/Cryptography/DigestCodec.cs b/APIMatic.Core/Security/Cryptography/DigestCodec.cs new file mode 100644 index 00000000..dcd91b83 --- /dev/null +++ b/APIMatic.Core/Security/Cryptography/DigestCodec.cs @@ -0,0 +1,35 @@ +using System; +using APIMatic.Core.Types; + +namespace APIMatic.Core.Security.Cryptography +{ + /// + /// Abstract class for encoding and decoding digest values. + /// + internal abstract class DigestCodec + { + /// + /// Decodes a string representation back into a byte array. + /// + /// The encoded string to decode. + /// The decoded byte array. + public abstract byte[] Decode(string encoded); + + /// + /// Creates a digest codec for the specified encoding type. + /// + /// The encoding type to use. + /// A digest codec instance. + /// Thrown when an unsupported encoding type is specified. + public static DigestCodec Create(EncodingType encodingType) + { + return encodingType switch + { + EncodingType.Hex => new HexDigestCodec(), + EncodingType.Base64 => new Base64DigestCodec(), + EncodingType.Base64Url => new Base64UrlDigestCodec(), + _ => throw new NotSupportedException($"Unsupported encoding type: {encodingType}") + }; + } + } +} diff --git a/APIMatic.Core/Security/Cryptography/HexDigestCodec.cs b/APIMatic.Core/Security/Cryptography/HexDigestCodec.cs new file mode 100644 index 00000000..b7f3f55a --- /dev/null +++ b/APIMatic.Core/Security/Cryptography/HexDigestCodec.cs @@ -0,0 +1,47 @@ +using System; + +namespace APIMatic.Core.Security.Cryptography +{ + /// + /// HexDigestCodec digest codec implementation. + /// + internal class HexDigestCodec : DigestCodec + { + /// + /// Decodes a hexadecimal string back into a byte array. + /// + /// The hexadecimal string to decode. + /// The decoded byte array. + /// Thrown when the input is null, empty, or has invalid length. + /// Thrown when the input is not a valid hexadecimal string. + public override byte[] Decode(string encoded) + { + if (string.IsNullOrEmpty(encoded)) + throw new ArgumentException("Input cannot be null or empty", nameof(encoded)); + + // Remove any whitespace and convert to uppercase for consistency + encoded = encoded.Replace(" ", "").Replace("-", "").ToUpperInvariant(); + + // Hex string must have even length + if (encoded.Length % 2 != 0) + throw new FormatException("Hexadecimal string must have even length"); + + byte[] bytes = new byte[encoded.Length / 2]; + + for (int i = 0; i < bytes.Length; i++) + { + string hexPair = encoded.Substring(i * 2, 2); + try + { + bytes[i] = Convert.ToByte(hexPair, 16); + } + catch (FormatException) + { + throw new FormatException($"Invalid hexadecimal character in string at position {i * 2}: '{hexPair}'"); + } + } + + return bytes; + } + } +} diff --git a/APIMatic.Core/Security/SignatureVerifier/HmacSignatureVerifier.cs b/APIMatic.Core/Security/SignatureVerifier/HmacSignatureVerifier.cs new file mode 100644 index 00000000..a3cfedb5 --- /dev/null +++ b/APIMatic.Core/Security/SignatureVerifier/HmacSignatureVerifier.cs @@ -0,0 +1,179 @@ +using System; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using APIMatic.Core.Http.Abstractions; +using APIMatic.Core.Security.Abstractions; +using APIMatic.Core.Security.Cryptography; +using APIMatic.Core.Types; +using APIMatic.Core.Types.Sdk; +using APIMatic.Core.Utilities; + +namespace APIMatic.Core.Security.SignatureVerifier +{ + /// + /// HMAC-based signature verifier for HTTP requests. + /// + public class HmacSignatureVerifier : ISignatureVerifier + { + /// + /// Name of the header carrying the provided signature (case-insensitive lookup). + /// + private readonly string _signatureHeader; + + /// + /// Optional template for the expected signature value, where `{digest}` is replaced + /// by the encoded digest. If omitted, the expected signature is the encoded digest itself. + /// + private readonly string _signatureValueTemplate; + + /// + /// Resolves the request data into a byte array for signature computation. + /// + private readonly Func> _requestSignatureTemplateResolverAsync; + + /// + /// The HMAC algorithm used for signature computation. + /// + private readonly HmacAlgorithm _signatureAlgorithm; + + /// + /// The secret key, encoded as a byte array, used for HMAC operations. + /// + private readonly byte[] _encodedSecretKey; + + /// + /// Codec used for encoding and decoding digests based on the specified encoding type. + /// + private readonly DigestCodec _digestCodec; + + private const string DigestPlaceHolder = "{digest}"; + + /// + /// Initializes a new instance of the HmacSignatureVerifier class. + /// + /// The secret key for HMAC computation. + /// The name of the header containing the signature. + /// Optional custom resolver for extracting data to sign. + /// Optional HMAC algorithm. + /// The encoding type for the signature. + /// Template for signature format. + public HmacSignatureVerifier( + string secretKey, + string signatureHeader, + EncodingType digestEncoding, + Func> requestSignatureTemplateResolverAsync = null, + HmacAlgorithm hashAlgorithm = HmacAlgorithm.Sha256, + string signatureValueTemplate = "{digest}") + { + if (string.IsNullOrWhiteSpace(secretKey)) + throw new ArgumentNullException(nameof(secretKey), "Secret key cannot be null or Empty."); + + if (string.IsNullOrWhiteSpace(signatureHeader)) + throw new ArgumentNullException(nameof(signatureHeader), "Signature header cannot be null or Empty."); + + _encodedSecretKey = Encoding.UTF8.GetBytes(secretKey); + _signatureHeader = signatureHeader; + _signatureAlgorithm = hashAlgorithm; + _digestCodec = DigestCodec.Create(digestEncoding); + _signatureValueTemplate = signatureValueTemplate; + _requestSignatureTemplateResolverAsync = requestSignatureTemplateResolverAsync ?? + (async (request, cancellationToken) => + await request.ReadBodyStreamToByteArrayAsync(cancellationToken) + .ConfigureAwait(false)); + } + + /// + /// Verifies the HMAC signature of the specified HTTP request. + /// + /// The HTTP request data to verify. + /// A token to cancel the asynchronous operation. + /// + /// A indicating whether the signature is valid or the reason for failure. + /// + public async Task VerifyAsync(IHttpRequestData request, + CancellationToken cancellationToken = default) + { + // Case-insensitive header lookup + var headerEntry = request.Headers.FirstOrDefault(h => + string.Equals(h.Key, _signatureHeader, StringComparison.OrdinalIgnoreCase)); + + if (headerEntry.Key == null) + { + return VerificationResult.Failure(new[] { $"Signature header '{_signatureHeader}' is missing." }); + } + + if (!TryExtractSignature(headerEntry.Value.FirstOrDefault(), out var providedSignature)) + { + return VerificationResult.Failure(new[] { $"Malformed signature header '{_signatureHeader}' value." }); + } + + var resolvedTemplateBytes = await _requestSignatureTemplateResolverAsync.Invoke(request, cancellationToken) + .ConfigureAwait(false); + + using (var hmac = HmacFactory.Create(_signatureAlgorithm, _encodedSecretKey)) + { + var computedHash = hmac.ComputeHash(resolvedTemplateBytes); + + return SignatureVerifierExtensions.FixedTimeEquals(computedHash, providedSignature) + ? VerificationResult.Success() + : VerificationResult.Failure(new[] { "Signature verification failed." }); + } + } + + /// + /// Extracts the digest value from the signature header according to the template. + /// + /// The signature header value. + /// The Signature value template. + /// The extracted digest string. + private static string ExtractDigestFromTemplate(string signatureValue, string signatureValueTemplate) + { + if (string.IsNullOrEmpty(signatureValue)) + return string.Empty; + + // If template is just "{digest}", return the signature as-is + if (signatureValueTemplate == DigestPlaceHolder) + return signatureValue; + + // Extract digest from template + var digestIndex = signatureValueTemplate.IndexOf(DigestPlaceHolder, StringComparison.Ordinal); + if (digestIndex == -1) + return string.Empty; + + var prefix = signatureValueTemplate[..digestIndex]; + var suffix = signatureValueTemplate[(digestIndex + DigestPlaceHolder.Length)..]; + + if (!signatureValue.StartsWith(prefix) || !signatureValue.EndsWith(suffix)) + return string.Empty; + + return signatureValue.Substring(prefix.Length, signatureValue.Length - prefix.Length - suffix.Length); + } + + /// + /// Attempts to extract and decode the signature from the header values. + /// + /// The signature header value. + /// The decoded signature bytes. + /// True if extraction and decoding succeeded, false otherwise. + private bool TryExtractSignature(string signatureValue, out byte[] signature) + { + signature = null; + var digest = ExtractDigestFromTemplate(signatureValue, _signatureValueTemplate); + + if (string.IsNullOrEmpty(digest)) + return false; + + try + { + signature = _digestCodec.Decode(digest); + return true; + } + catch + { + return false; + } + } + } +} diff --git a/APIMatic.Core/Security/SignatureVerifier/SignatureVerifierExtensions.cs b/APIMatic.Core/Security/SignatureVerifier/SignatureVerifierExtensions.cs new file mode 100644 index 00000000..4a122a13 --- /dev/null +++ b/APIMatic.Core/Security/SignatureVerifier/SignatureVerifierExtensions.cs @@ -0,0 +1,41 @@ +using System; +using System.Runtime.CompilerServices; + +namespace APIMatic.Core.Security.SignatureVerifier +{ + internal static class SignatureVerifierExtensions + { + /// + /// Performs a secure comparison of two byte arrays to prevent timing attacks. + /// + /// First byte array. + /// Second byte array. + /// True if arrays are equal, false otherwise. + /// + /// This implementation is copied from CryptographicOperations in System.Security.Cryptography + /// + [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)] + public static bool FixedTimeEquals(ReadOnlySpan left, ReadOnlySpan right) + { + // NoOptimization because we want this method to be exactly as non-short-circuiting + // as written. + // + // NoInlining because the NoOptimization would get lost if the method got inlined. + + if (left.Length != right.Length) + { + return false; + } + + int length = left.Length; + int accum = 0; + + for (int i = 0; i < length; i++) + { + accum |= left[i] - right[i]; + } + + return accum == 0; + } + } +} diff --git a/APIMatic.Core/Types/EncodingType.cs b/APIMatic.Core/Types/EncodingType.cs new file mode 100644 index 00000000..34ad0807 --- /dev/null +++ b/APIMatic.Core/Types/EncodingType.cs @@ -0,0 +1,9 @@ +namespace APIMatic.Core.Types +{ + public enum EncodingType + { + Hex, + Base64, + Base64Url + } +} \ No newline at end of file diff --git a/APIMatic.Core/Types/Sdk/HashAlgorithm.cs b/APIMatic.Core/Types/Sdk/HashAlgorithm.cs new file mode 100644 index 00000000..3bb42f4b --- /dev/null +++ b/APIMatic.Core/Types/Sdk/HashAlgorithm.cs @@ -0,0 +1,29 @@ +using System; +using System.Security.Cryptography; + +namespace APIMatic.Core.Types.Sdk +{ + public enum HmacAlgorithm + { + Sha256, + Sha512 + } + + internal static class HmacFactory + { + public static HMAC Create(HmacAlgorithm algorithm, byte[] keyBytes) + { + switch (algorithm) + { + case HmacAlgorithm.Sha256: + return new HMACSHA256(keyBytes); + + case HmacAlgorithm.Sha512: + return new HMACSHA512(keyBytes); + + default: + throw new NotSupportedException($"Unsupported HMAC algorithm: {algorithm}"); + } + } + } +} diff --git a/APIMatic.Core/Types/Sdk/VerificationResult.cs b/APIMatic.Core/Types/Sdk/VerificationResult.cs new file mode 100644 index 00000000..fc764a25 --- /dev/null +++ b/APIMatic.Core/Types/Sdk/VerificationResult.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; + +namespace APIMatic.Core.Types.Sdk +{ + /// + /// Represents the result of an operation that can either succeed + /// or fail with an error message. + /// + public class VerificationResult + { + public bool IsSuccess => Errors == null; + + public IReadOnlyCollection Errors { get; } + + protected VerificationResult(string[] error) + { + Errors = error; + } + + /// + /// Creates a successful result. + /// + public static VerificationResult Success() => + new VerificationResult(null); + + /// + /// Creates a failed result with the given error message. + /// + public static VerificationResult Failure(string[] error) => + new VerificationResult(error); + } +} \ No newline at end of file diff --git a/APIMatic.Core/Utilities/IHttpRequestDataExtensions.cs b/APIMatic.Core/Utilities/IHttpRequestDataExtensions.cs new file mode 100644 index 00000000..a98e59a5 --- /dev/null +++ b/APIMatic.Core/Utilities/IHttpRequestDataExtensions.cs @@ -0,0 +1,36 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using APIMatic.Core.Http.Abstractions; + +namespace APIMatic.Core.Utilities +{ + /// + /// Extension methods for IHttpRequestData. + /// + internal static class IHttpRequestDataExtensions + { + /// + /// Reads the request body stream and converts it to a byte array. + /// + /// The HTTP request data. + /// Cancellation token for the operation. + /// A byte array containing the request body data. + internal static async Task ReadBodyStreamToByteArrayAsync(this IHttpRequestData requestData, + CancellationToken cancellationToken = default) + { + if (requestData.Body == null) + return Array.Empty(); + + if (requestData.Body.CanSeek) + requestData.Body.Position = 0; + + using (var memoryStream = new MemoryStream()) + { + await requestData.Body.CopyToAsync(memoryStream, 81920, cancellationToken).ConfigureAwait(false); + return memoryStream.ToArray(); + } + } + } +} \ No newline at end of file diff --git a/APIMatic.Core/Utilities/Json/JsonPointerResolver.cs b/APIMatic.Core/Utilities/Json/JsonPointerResolver.cs index c9ab7d08..ff932c9f 100644 --- a/APIMatic.Core/Utilities/Json/JsonPointerResolver.cs +++ b/APIMatic.Core/Utilities/Json/JsonPointerResolver.cs @@ -4,9 +4,9 @@ namespace APIMatic.Core.Utilities.Json { - internal static class JsonPointerResolver + public static class JsonPointerResolver { - public static string ResolveScopedJsonValue(string pointerString, string jsonBody, string jsonHeaders) + internal static string ResolveScopedJsonValue(string pointerString, string jsonBody, string jsonHeaders) { if (string.IsNullOrEmpty(pointerString) || !pointerString.Contains('#')) return null; @@ -27,6 +27,15 @@ public static string ResolveScopedJsonValue(string pointerString, string jsonBod return null; } } + + public static string ResolveJsonValue(string pointerString, string json) + { + if (string.IsNullOrEmpty(pointerString) || !pointerString.Contains('#')) + return null; + var path = pointerString.Split('#')[1]; + var jsonPointer = new JsonPointer(path); + return ExtractValueByPointer(jsonPointer, json); + } private static string ExtractValueByPointer(JsonPointer jsonPointer, string json) { diff --git a/README.md b/README.md index 01be839e..3f202e74 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,9 @@ This project contains core logic and the utilities for the APIMatic's C# SDK | [`HttpLoggingConfiguration`](APIMatic.Core/Utilities/Logger/Configuration/HttpLoggingConfiguration.cs) | Abstract class representing configuration settings for HTTP request/response logging | | [`RequestLoggingConfiguration`](APIMatic.Core/Utilities/Logger/Configuration/RequestLoggingConfiguration.cs) | Represents the configuration settings for logging HTTP responses | | [`ResponseLoggingConfiguration`](APIMatic.Core/Utilities/Logger/Configuration/ResponseLoggingConfiguration.cs) | Carries the common configuration that will be applicable to all the ApiCalls | +| [`IHttpRequestData`](APIMatic.Core/Http/Abstractions/IHttpRequestData.cs) | Represents the contract for HTTP request data | +| [`ISignatureVerifier`](APIMatic.Core/Security/Abstractions/ISignatureVerifier.cs) | Defines a contract for verifying the signature of an HTTP request. | +| [`HmacSignatureVerifier`](APIMatic.Core/Security/SignatureVerifier/HmacSignatureVerifier.cs) | HMAC-based signature verifier for HTTP requests. | [nuget-url]: https://www.nuget.org/packages/APIMatic.Core