Skip to content

Commit e491c53

Browse files
authored
feat(webhook-callbacks): add support for verifying http request signatures (#144)
- Adds a ISignatureVerifier contract for custom signature verification - Adds configurable HMAC signature verification - Adds test for signature verification - Adds DigestCodec hex, Base64 and Base64Url support
1 parent f0b9d71 commit e491c53

22 files changed

+937
-2
lines changed
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Collections.ObjectModel;
4+
using System.IO;
5+
using APIMatic.Core.Http.Abstractions;
6+
7+
namespace APIMatic.Core.Test.MockTypes.Http.Request
8+
{
9+
public class HttpRequestData : IHttpRequestData
10+
{
11+
public string Method { get; }
12+
public Uri Url { get; }
13+
public IReadOnlyDictionary<string, string[]> Headers { get; }
14+
public Stream Body { get; set; }
15+
public IReadOnlyDictionary<string, string[]> Query { get; }
16+
public IReadOnlyDictionary<string, string> Cookies { get; }
17+
public string Protocol { get; }
18+
public string ContentType { get; }
19+
public long? ContentLength { get; }
20+
21+
public HttpRequestData(
22+
IDictionary<string, string[]> headers,
23+
Stream body)
24+
{
25+
Headers = new ReadOnlyDictionary<string, string[]>(headers);
26+
Body = body;
27+
}
28+
}
29+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
using System;
2+
using APIMatic.Core.Security.Cryptography;
3+
using APIMatic.Core.Types;
4+
using NUnit.Framework;
5+
6+
namespace APIMatic.Core.Test.Security.Cryptography
7+
{
8+
public class DigestCodecTests
9+
{
10+
[TestCase(EncodingType.Hex, "4A6F686E", new byte[] { 0x4A, 0x6F, 0x68, 0x6E })]
11+
[TestCase(EncodingType.Base64, "SGVsbG8=", new byte[] { 0x48, 0x65, 0x6C, 0x6C, 0x6F })]
12+
[TestCase(EncodingType.Base64, " ", new byte[]{})]
13+
[TestCase(EncodingType.Base64Url, "SGVsbG8", new byte[] { 0x48, 0x65, 0x6C, 0x6C, 0x6F })]
14+
[TestCase(EncodingType.Base64Url, "SG", new byte[] { 0x48 })]
15+
public void DigestCodec_Decode_Success(EncodingType encodingType, string input, byte[] expected)
16+
{
17+
var codec = DigestCodec.Create(encodingType);
18+
var result = codec.Decode(input);
19+
Assert.AreEqual(expected, result);
20+
}
21+
22+
[TestCase(EncodingType.Hex, "")]
23+
[TestCase(EncodingType.Hex, null)]
24+
[TestCase(EncodingType.Base64, "")]
25+
[TestCase(EncodingType.Base64, null)]
26+
[TestCase(EncodingType.Base64Url, "")]
27+
[TestCase(EncodingType.Base64Url, null)]
28+
public void DigestCodecIncorrectInput_Decode_DigestCodec_Create_Exception(EncodingType encodingType, string input)
29+
{
30+
var codec = DigestCodec.Create(encodingType);
31+
Assert.Throws<ArgumentException>(() => codec.Decode(input));
32+
}
33+
34+
[TestCase(EncodingType.Hex, "ABC")]
35+
public void DigestCodecIncorrectFormat_Decode_DigestCodec_Create_Exception(EncodingType encodingType, string input)
36+
{
37+
var codec = DigestCodec.Create(encodingType);
38+
Assert.Throws<FormatException>(() => codec.Decode(input));
39+
}
40+
41+
[TestCase(-1)]
42+
public void DigestCodec_Create_Exception(int invalidValue)
43+
{
44+
var encodingType = (EncodingType)invalidValue;
45+
Assert.Throws<NotSupportedException>(() => DigestCodec.Create(encodingType));
46+
}
47+
}
48+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
using System;
2+
using System.Security.Cryptography;
3+
using APIMatic.Core.Types.Sdk;
4+
using NUnit.Framework;
5+
6+
namespace APIMatic.Core.Test.Security
7+
{
8+
[TestFixture]
9+
public class HmacFactoryTests
10+
{
11+
[Test]
12+
public void HmacAlgorithmSha256_HmacFactoryCreate_ReturnsHMACSHA256()
13+
{
14+
var key = new byte[] { 1, 2, 3 };
15+
var hmac = HmacFactory.Create(HmacAlgorithm.Sha256, key);
16+
Assert.IsInstanceOf<HMACSHA256>(hmac);
17+
}
18+
19+
[Test]
20+
public void HmacAlgorithmSha512_HmacFactoryCreate_ReturnsHMACSHA512()
21+
{
22+
var key = new byte[] { 4, 5, 6 };
23+
var hmac = HmacFactory.Create(HmacAlgorithm.Sha512, key);
24+
Assert.IsInstanceOf<HMACSHA512>(hmac);
25+
}
26+
27+
[Test]
28+
public void UnsupportedAlgorithm_HmacFactoryCreate_ThrowsNotSupportedException()
29+
{
30+
var key = new byte[] { 7, 8, 9 };
31+
const HmacAlgorithm invalidAlgorithm = (HmacAlgorithm)999;
32+
Assert.Throws<NotSupportedException>(() => HmacFactory.Create(invalidAlgorithm, key));
33+
}
34+
}
35+
}
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.IO;
4+
using System.Linq;
5+
using System.Threading.Tasks;
6+
using APIMatic.Core.Security.SignatureVerifier;
7+
using APIMatic.Core.Test.MockTypes.Http.Request;
8+
using APIMatic.Core.Types;
9+
using NUnit.Framework;
10+
11+
namespace APIMatic.Core.Test.Security.SignatureVerifier;
12+
13+
[TestFixture]
14+
public class HmacSignatureVerifierTests
15+
{
16+
private const string SecretKey = "test_secret";
17+
private const string HeaderName = "X-Signature";
18+
private const string Payload = "hello world";
19+
20+
private static HttpRequestData CreateRequest(string headerValue, string headerName = HeaderName, string payload = Payload)
21+
{
22+
var headers = headerValue == null
23+
? new Dictionary<string, string[]>()
24+
: new Dictionary<string, string[]> { { headerName, new[] { headerValue } } };
25+
return new HttpRequestData(headers, new MemoryStream(System.Text.Encoding.UTF8.GetBytes(payload)));
26+
}
27+
28+
private static HmacSignatureVerifier CreateVerifier(
29+
EncodingType encodingType,
30+
string headerName = HeaderName,
31+
string secretKey = SecretKey,
32+
string signatureValueTemplate = "{digest}")
33+
{
34+
return new HmacSignatureVerifier(secretKey, headerName, encodingType, signatureValueTemplate: signatureValueTemplate);
35+
}
36+
37+
[Test]
38+
public void Constructor_ThrowsOnNullOrEmptySecretKey_OnCreate_ThrowsException()
39+
{
40+
Assert.Throws<ArgumentNullException>(() => CreateVerifier(EncodingType.Hex, secretKey: null));
41+
Assert.Throws<ArgumentNullException>(() => CreateVerifier(EncodingType.Hex, secretKey: ""));
42+
}
43+
44+
[Test]
45+
public void Constructor_ThrowsOnNullOrEmptyHeader_OnCreate_ThrowsException()
46+
{
47+
Assert.Throws<ArgumentNullException>(() => CreateVerifier(EncodingType.Hex, headerName: null));
48+
Assert.Throws<ArgumentNullException>(() => CreateVerifier(EncodingType.Hex, headerName: ""));
49+
}
50+
51+
[Test]
52+
public async Task NullHeader_OnVerifyAsync_ReturnsFailure()
53+
{
54+
var request = CreateRequest(null);
55+
var verifier = CreateVerifier(EncodingType.Hex);
56+
var result = await verifier.VerifyAsync(request);
57+
Assert.IsFalse(result.IsSuccess);
58+
Assert.AreEqual($"Signature header '{HeaderName}' is missing.", result.Errors.First());
59+
}
60+
61+
[Test]
62+
public async Task MissingHeader_OnVerifyAsync_ReturnsFailure()
63+
{
64+
var request = CreateRequest(string.Empty);
65+
var verifier = CreateVerifier(EncodingType.Hex);
66+
var result = await verifier.VerifyAsync(request);
67+
Assert.IsFalse(result.IsSuccess);
68+
Assert.AreEqual($"Malformed signature header '{HeaderName}' value.", result.Errors.First());
69+
}
70+
71+
[Test]
72+
public async Task MalformedHeader_OnVerifyAsync_ReturnsFailure()
73+
{
74+
var request = CreateRequest("");
75+
var verifier = CreateVerifier(EncodingType.Hex);
76+
var result = await verifier.VerifyAsync(request);
77+
Assert.IsFalse(result.IsSuccess);
78+
StringAssert.Contains("Malformed", result.Errors.First());
79+
}
80+
81+
[Test]
82+
public async Task SignatureDecodingFails_OnVerifyAsync_ReturnsFailure()
83+
{
84+
var request = CreateRequest("not-a-valid-hex");
85+
var verifier = CreateVerifier(EncodingType.Hex);
86+
var result = await verifier.VerifyAsync(request);
87+
Assert.IsFalse(result.IsSuccess);
88+
}
89+
90+
[TestCase(EncodingType.Hex)]
91+
[TestCase(EncodingType.Base64)]
92+
[TestCase(EncodingType.Base64Url)]
93+
public async Task CorrectSignature_OnVerifyAsync_ReturnsSuccess(EncodingType encodingType)
94+
{
95+
string encodedDigest = GetDigest(encodingType, SecretKey, Payload);
96+
var request = CreateRequest(encodedDigest);
97+
var verifier = CreateVerifier(encodingType);
98+
var result = await verifier.VerifyAsync(request);
99+
Assert.IsTrue(result.IsSuccess);
100+
}
101+
102+
[TestCase(EncodingType.Hex, "deadbeef")]
103+
[TestCase(EncodingType.Base64, "Zm9vYmFyYmF6")]
104+
[TestCase(EncodingType.Base64Url, "Zm9vYmFyYmF6")]
105+
public async Task IncorrectSignature_OnVerifyAsync_ReturnsFailure(EncodingType encodingType, string badDigest)
106+
{
107+
var request = CreateRequest(badDigest);
108+
var verifier = CreateVerifier(encodingType);
109+
var result = await verifier.VerifyAsync(request);
110+
Assert.IsFalse(result.IsSuccess);
111+
StringAssert.Contains("failed", result.Errors.First());
112+
}
113+
114+
[Test]
115+
public async Task TemplateExtractsDigest_OnVerifyAsync_ReturnsSuccess()
116+
{
117+
var hmac = new System.Security.Cryptography.HMACSHA256(System.Text.Encoding.UTF8.GetBytes(SecretKey));
118+
var hash = hmac.ComputeHash(System.Text.Encoding.UTF8.GetBytes(Payload));
119+
var digest = BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
120+
const string template = "prefix-{digest}-suffix";
121+
var signatureValue = $"prefix-{digest}-suffix";
122+
var request = CreateRequest(signatureValue);
123+
var verifier = CreateVerifier(EncodingType.Hex, signatureValueTemplate: template);
124+
var result = await verifier.VerifyAsync(request);
125+
Assert.IsTrue(result.IsSuccess);
126+
}
127+
128+
[Test]
129+
public async Task TemplateDoesNotMatch_OnVerifyAsync_ReturnsFailure()
130+
{
131+
const string template = "prefix-{digest}-suffix";
132+
const string signatureValue = $"wrongprefix-deadbeef-wrongsuffix";
133+
var request = CreateRequest(signatureValue);
134+
var verifier = CreateVerifier(EncodingType.Hex, signatureValueTemplate: template);
135+
var result = await verifier.VerifyAsync(request);
136+
Assert.IsFalse(result.IsSuccess);
137+
}
138+
139+
[Test]
140+
public async Task TemplateDoesNotContainDigest_OnVerifyAsync_ReturnsFailure()
141+
{
142+
const string template = "prefix-{wrong}-suffix";
143+
const string signatureValue = $"wrongprefix-deadbeef-wrongsuffix";
144+
var request = CreateRequest(signatureValue);
145+
var verifier = CreateVerifier(EncodingType.Hex, signatureValueTemplate: template);
146+
var result = await verifier.VerifyAsync(request);
147+
Assert.IsFalse(result.IsSuccess);
148+
}
149+
150+
[Test]
151+
public async Task CorrectDigestButIncorrectExpectedTemplate_OnVerifyAsync_ReturnsFailure()
152+
{
153+
var hmac = new System.Security.Cryptography.HMACSHA256(System.Text.Encoding.UTF8.GetBytes(SecretKey));
154+
var hash = hmac.ComputeHash(System.Text.Encoding.UTF8.GetBytes(Payload));
155+
var digest = BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
156+
// The expected template doesn't match the signature value template, though digest is correct
157+
const string expectedTemplate = "sha26={digest}";
158+
var signatureValue = $"sha256={digest}";
159+
var request = CreateRequest(signatureValue);
160+
var verifier = CreateVerifier(EncodingType.Hex, signatureValueTemplate: expectedTemplate);
161+
var result = await verifier.VerifyAsync(request);
162+
Assert.IsFalse(result.IsSuccess);
163+
}
164+
165+
[Test]
166+
public async Task CorrectDigestButIncorrectSignatureValue_OnVerifyAsync_ReturnsFailure()
167+
{
168+
var hmac = new System.Security.Cryptography.HMACSHA256(System.Text.Encoding.UTF8.GetBytes(SecretKey));
169+
var hash = hmac.ComputeHash(System.Text.Encoding.UTF8.GetBytes(Payload));
170+
var digest = BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
171+
const string expectedTemplate = "sha256={digest}";
172+
// The signature value does not match the template, though digest is correct
173+
var signatureValue = $"sha25={digest}";
174+
var request = CreateRequest(signatureValue);
175+
var verifier = CreateVerifier(EncodingType.Hex, signatureValueTemplate: expectedTemplate);
176+
var result = await verifier.VerifyAsync(request);
177+
Assert.IsFalse(result.IsSuccess);
178+
}
179+
180+
private static string GetDigest(EncodingType encodingType, string secretKey, string payload)
181+
{
182+
var hmac = new System.Security.Cryptography.HMACSHA256(System.Text.Encoding.UTF8.GetBytes(secretKey));
183+
var hash = hmac.ComputeHash(System.Text.Encoding.UTF8.GetBytes(payload));
184+
return encodingType switch
185+
{
186+
EncodingType.Hex => BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(),
187+
EncodingType.Base64 => Convert.ToBase64String(hash),
188+
EncodingType.Base64Url => Convert.ToBase64String(hash).Replace('+', '-').Replace('/', '_').TrimEnd('='),
189+
_ => BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant()
190+
};
191+
}
192+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
using APIMatic.Core.Security.SignatureVerifier;
2+
using NUnit.Framework;
3+
4+
namespace APIMatic.Core.Test.Security.SignatureVerifier
5+
{
6+
[TestFixture]
7+
public class SignatureVerificationExtensionsTests
8+
{
9+
[TestCase(null, null, true)]
10+
[TestCase(null, new byte[] { 1, 2, 3 }, false)]
11+
[TestCase(new byte[] { 1, 2, 3 }, null, false)]
12+
[TestCase(new byte[] { 1, 2, 3 }, new byte[] { 1, 2 }, false)]
13+
[TestCase(new byte[] { }, new byte[] { }, true)]
14+
[TestCase(new byte[] { 1, 2, 3, 4 }, new byte[] { 1, 2, 3, 4 }, true)]
15+
[TestCase(new byte[] { 1, 2, 3, 4 }, new byte[] { 1, 2, 3, 5 }, false)]
16+
public void ConstantTimeEquals_VariousInputs_ReturnsExpected(byte[] a, byte[] b, bool expected)
17+
{
18+
Assert.AreEqual(expected, SignatureVerifierExtensions.FixedTimeEquals(a, b));
19+
}
20+
}
21+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
using System.Collections.Generic;
2+
using System.IO;
3+
using System.Text;
4+
using System.Threading.Tasks;
5+
using APIMatic.Core.Test.MockTypes.Http.Request;
6+
using APIMatic.Core.Utilities;
7+
using NUnit.Framework;
8+
9+
namespace APIMatic.Core.Test.Utilities
10+
{
11+
[TestFixture]
12+
public class IHttpRequestDataExtensionsTests
13+
{
14+
15+
[TestCase(null, new byte[0])]
16+
[TestCase("", new byte[0])]
17+
[TestCase("hello", new byte[] { 104, 101, 108, 108, 111 })]
18+
public async Task ReadBodyStreamToByteArrayAsync_VariousBodies_ReturnsExpected(string body, byte[] expected)
19+
{
20+
var stream = body == null ? null : new MemoryStream(Encoding.UTF8.GetBytes(body));
21+
var request = new HttpRequestData(new Dictionary<string, string[]>(), stream);
22+
23+
var result = await request.ReadBodyStreamToByteArrayAsync();
24+
25+
Assert.AreEqual(expected, result);
26+
}
27+
}
28+
}

APIMatic.Core.Test/Utilities/Json/JsonPointerResolverTest.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,5 +81,18 @@ public void ResolveScopedJsonValue_BooleanToken_ReturnsTokenToString()
8181

8282
Assert.AreEqual("True", result);
8383
}
84+
85+
[TestCase("#/name", "{\"name\":\"John\",\"age\":30}", "John")]
86+
[TestCase("#/invalid", "{\"name\":\"John\"}", null)]
87+
[TestCase(null, "{\"name\":\"John\"}", null)]
88+
[TestCase("", "{\"name\":\"John\"}", null)]
89+
[TestCase("/name", "{\"name\":\"John\"}", null)]
90+
[TestCase("#/age", "{\"age\":30}", "30")]
91+
[TestCase("#/name", null, null)]
92+
public void ResolveJsonValue_VariousCases_ReturnsExpected(string jsonPointer, string json, string expected)
93+
{
94+
var result = JsonPointerResolver.ResolveJsonValue(jsonPointer, json);
95+
Assert.AreEqual(expected, result);
96+
}
8497
}
8598
}

APIMatic.Core/APIMatic.Core.csproj

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,5 +49,8 @@
4949
<ItemGroup>
5050
<InternalsVisibleTo Include="APIMatic.Core.Test" />
5151
</ItemGroup>
52+
<ItemGroup>
53+
<Folder Include="Http\Abstractions\" />
54+
</ItemGroup>
5255

5356
</Project>

0 commit comments

Comments
 (0)