Skip to content

Commit

Permalink
csharp: improve performance and reduce memory footprint when creating…
Browse files Browse the repository at this point in the history
… and verifying webhook signatures (#1616)

Using latest csharp and dotnet features to increase performance and
reduce memory footprint when creating and verifying webhook signatures

## Motivation

When using the webhook verification process we see lots of memory wasted
and garbage collector kicking when our applications gets hit with a lot
of webhook requests.

## Solution

* using ReadOnly<char> instead of string in Webhook.Verify and
Webhook.Sign
* using stack alloctions instead of heap allocations when creating and
verifing signatures as much as possible
* using new Base64 API to convert to base64 operating on spans


## Benchmarks

Refer to the PR
  • Loading branch information
esskar authored Jan 10, 2025
1 parent 67bea29 commit 18cf625
Show file tree
Hide file tree
Showing 3 changed files with 127 additions and 50 deletions.
16 changes: 16 additions & 0 deletions csharp/Svix.Tests/WebhookTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -207,5 +207,21 @@ public void VerifyWebhookSignWorks()
var signature = wh.Sign(msgId, timestamp, payload);
Assert.Equal(signature, expected);
}

[Theory]
[InlineData(1024 * 1024, "v1,txpEUxqWZJ5nteTnymUVa+7C4NHpBeXJ6CsBAW0c3/A=")]
[InlineData(256 * 1024, "v1,Mw4Pe2WgApuT7NSqnSq0PQPV9gbLdggCl9B865x5Xh0=")]
[InlineData(256 * 1024 - 1, "v1,NTItnqyWMoFhk2Bbe5V/BMdcFRWgcSQaTgqLHX7FC7c=")]
public void VerifyWebhookSignWithLargePayloadWorks(int payloadSize, string expected)
{
var key = "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw";
var msgId = "msg_p5jXN8AQM9LWM0D4loKWxJek";
var timestamp = DateTimeOffset.FromUnixTimeSeconds(1614265330);
var payload = new string('a', payloadSize);

var wh = new Webhook(key);
var signature = wh.Sign(msgId, timestamp, payload);
Assert.Equal(signature, expected);
}
}
}
13 changes: 1 addition & 12 deletions csharp/Svix/Utils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,11 @@ namespace Svix
{
internal static class Utils
{

// Borrowed from Stripe-dotnet https://github.com/stripe/stripe-dotnet/blob/7b62c461d7c0cf2c9e06dce5e564b374a9d232e0/src/Stripe.net/Infrastructure/StringUtils.cs#L30
// basically identical to SecureCompare from Rails::ActiveSupport used in our ruby lib
[MethodImpl(MethodImplOptions.NoOptimization)]
public static bool SecureCompare(string a, string b)
public static bool SecureCompare(ReadOnlySpan<char> a, ReadOnlySpan<char> b)
{
if (a == null)
{
throw new ArgumentNullException(nameof(a));
}

if (b == null)
{
throw new ArgumentNullException(nameof(b));
}

if (a.Length != b.Length)
{
return false;
Expand Down
148 changes: 110 additions & 38 deletions csharp/Svix/Webhook.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using Svix.Exceptions;
using System;
using System.Buffers;
using System.Buffers.Text;
using System.Net;
using System.Security.Cryptography;
using System.Text;
Expand All @@ -17,14 +19,19 @@ public sealed class Webhook
internal const string UNBRANDED_SIGNATURE_HEADER_KEY = "webhook-signature";
internal const string UNBRANDED_TIMESTAMP_HEADER_KEY = "webhook-timestamp";

private const int SIGNATURE_LENGTH_BYTES = HMACSHA256.HashSizeInBytes;
private const int SIGNATURE_LENGTH_BASE64 = 48;
private const int SIGNATURE_LENGTH_STRING = 56;
private const int TOLERANCE_IN_SECONDS = 60 * 5;
private static string prefix = "whsec_";
private byte[] key;
private const int MAX_STACKALLOC = 1024 * 256;
private const string PREFIX = "whsec_";

private readonly byte[] key;
public Webhook(string key)
{
if (key.StartsWith(prefix))
if (key.StartsWith(PREFIX))
{
key = key.Substring(prefix.Length);
key = key.Substring(PREFIX.Length);
}

this.key = Convert.FromBase64String(key);
Expand All @@ -35,63 +42,84 @@ public Webhook(byte[] key)
this.key = key;
}

public void Verify(string payload, WebHeaderCollection headers)
public void Verify(ReadOnlySpan<char> payload, WebHeaderCollection headers)
{
ArgumentNullException.ThrowIfNull(headers);
if (payload == null)
{
throw new ArgumentNullException(nameof(payload));
}
if (headers == null)
{
throw new ArgumentNullException(nameof(headers));
}

Verify(payload, headers.Get);
}

public void Verify(string payload, Func<string, string> headersProvider)
public void Verify(ReadOnlySpan<char> payload, Func<string, string> headersProvider)
{
ArgumentNullException.ThrowIfNull(payload);
ArgumentNullException.ThrowIfNull(headersProvider);
if (payload == null)
{
throw new ArgumentNullException(nameof(payload));
}
if (headersProvider == null)
{
throw new ArgumentNullException(nameof(headersProvider));
}

string msgId = headersProvider(SVIX_ID_HEADER_KEY);
string msgSignature = headersProvider(SVIX_SIGNATURE_HEADER_KEY);
string msgTimestamp = headersProvider(SVIX_TIMESTAMP_HEADER_KEY);

if (String.IsNullOrEmpty(msgId) || String.IsNullOrEmpty(msgSignature) || String.IsNullOrEmpty(msgTimestamp))
ReadOnlySpan<char> msgId = headersProvider(SVIX_ID_HEADER_KEY);
ReadOnlySpan<char> msgTimestamp = headersProvider(SVIX_TIMESTAMP_HEADER_KEY);
ReadOnlySpan<char> msgSignature = headersProvider(SVIX_SIGNATURE_HEADER_KEY);
if (msgId.IsEmpty || msgSignature.IsEmpty || msgTimestamp.IsEmpty)
{
msgId = headersProvider(UNBRANDED_ID_HEADER_KEY);
msgSignature = headersProvider(UNBRANDED_SIGNATURE_HEADER_KEY);
msgTimestamp = headersProvider(UNBRANDED_TIMESTAMP_HEADER_KEY);
if (String.IsNullOrEmpty(msgId) || String.IsNullOrEmpty(msgSignature) || String.IsNullOrEmpty(msgTimestamp))
if (msgId.IsEmpty || msgSignature.IsEmpty || msgTimestamp.IsEmpty)
{
throw new WebhookVerificationException("Missing Required Headers");
}
}

var timestamp = Webhook.VerifyTimestamp(msgTimestamp);
Webhook.VerifyTimestamp(msgTimestamp);

var signature = this.Sign(msgId, timestamp, payload);
var expectedSignature = signature.Split(',')[1];
Span<char> expectedSignature = stackalloc char[SIGNATURE_LENGTH_STRING];
CalculateSignature(msgId, msgTimestamp, payload, expectedSignature, out var charsWritten);
expectedSignature = expectedSignature.Slice(0, charsWritten);

var passedSignatures = msgSignature.Split(' ');
foreach (string versionedSignature in passedSignatures)
var signaturePtr = msgSignature;
var spaceIndex = signaturePtr.IndexOf(' ');
do
{
var parts = versionedSignature.Split(',');
if (parts.Length < 2)
var versionedSignature = spaceIndex < 0
? msgSignature : signaturePtr.Slice(0, spaceIndex);

signaturePtr = signaturePtr.Slice(spaceIndex + 1);
spaceIndex = signaturePtr.IndexOf(' ');

var commaIndex = versionedSignature.IndexOf(',');
if (commaIndex < 0)
{
throw new WebhookVerificationException("Invalid Signature Headers");
}
var version = parts[0];
var passedSignature = parts[1];

if (version != "v1")
var version = versionedSignature.Slice(0, commaIndex);
if (!version.Equals("v1", StringComparison.InvariantCulture))
{
continue;
}
var passedSignature = versionedSignature.Slice(commaIndex + 1);
if (Utils.SecureCompare(expectedSignature, passedSignature))
{
return;
}

}
while(spaceIndex >= 0);

throw new WebhookVerificationException("No matching signature found");
}

private static DateTimeOffset VerifyTimestamp(string timestampHeader)
private static void VerifyTimestamp(ReadOnlySpan<char> timestampHeader)
{
DateTimeOffset timestamp;
var now = DateTimeOffset.UtcNow;
Expand All @@ -105,26 +133,70 @@ private static DateTimeOffset VerifyTimestamp(string timestampHeader)
throw new WebhookVerificationException("Invalid Signature Headers");
}

if (timestamp < (now.AddSeconds(-1 * TOLERANCE_IN_SECONDS)))
if (timestamp < now.AddSeconds(-1 * TOLERANCE_IN_SECONDS))
{
throw new WebhookVerificationException("Message timestamp too old");
}
if (timestamp > (now.AddSeconds(TOLERANCE_IN_SECONDS)))
if (timestamp > now.AddSeconds(TOLERANCE_IN_SECONDS))
{
throw new WebhookVerificationException("Message timestamp too new");
}
return timestamp;
}

public string Sign(ReadOnlySpan<char> msgId, DateTimeOffset timestamp, ReadOnlySpan<char> payload)
{
Span<char> signature = stackalloc char[SIGNATURE_LENGTH_STRING];
signature[0] = 'v';
signature[1] = '1';
signature[2] = ',';
CalculateSignature(msgId, timestamp.ToUnixTimeSeconds().ToString(), payload, signature.Slice(3), out var charsWritten);
return signature.Slice(0, charsWritten + 3).ToString();
}

public string Sign(string msgId, DateTimeOffset timestamp, string payload)
private void CalculateSignature(
ReadOnlySpan<char> msgId,
ReadOnlySpan<char> timestamp,
ReadOnlySpan<char> payload,
Span<char> signature,
out int charsWritten)
{
// Estimate buffer size and use stackalloc for smaller allocations
int msgIdLength = SafeUTF8Encoding.GetByteCount(msgId);
int payloadLength = SafeUTF8Encoding.GetByteCount(payload);
int timestampLength = SafeUTF8Encoding.GetByteCount(timestamp);
int totalLength = msgIdLength + 1 + timestampLength + 1 + payloadLength;

Span<byte> toSignBytes = totalLength <= MAX_STACKALLOC
? stackalloc byte[totalLength]
: new byte[totalLength];

SafeUTF8Encoding.GetBytes(msgId, toSignBytes.Slice(0, msgIdLength));
toSignBytes[msgIdLength] = (byte)'.';
SafeUTF8Encoding.GetBytes(timestamp, toSignBytes.Slice(msgIdLength + 1, timestampLength));
toSignBytes[msgIdLength + 1 + timestampLength] = (byte)'.';
SafeUTF8Encoding.GetBytes(payload, toSignBytes.Slice(msgIdLength + 1 + timestampLength + 1));

Span<byte> signatureBin = stackalloc byte[SIGNATURE_LENGTH_BYTES];
CalculateSignature(toSignBytes, signatureBin);

Span<byte> signatureB64 = stackalloc byte[SIGNATURE_LENGTH_BASE64];
var result = Base64.EncodeToUtf8(signatureBin, signatureB64, out _, out var bytesWritten);
if (result != OperationStatus.Done)
throw new WebhookVerificationException("Failed to encode signature to base64");

if (!SafeUTF8Encoding.TryGetChars(signatureB64.Slice(0, bytesWritten), signature, out charsWritten))
throw new WebhookVerificationException("Failed to convert signature to utf8");
}

private void CalculateSignature(ReadOnlySpan<byte> input, Span<byte> output)
{
var toSign = $"{msgId}.{timestamp.ToUnixTimeSeconds().ToString()}.{payload}";
var toSignBytes = SafeUTF8Encoding.GetBytes(toSign);
using (var hmac = new HMACSHA256(this.key))
try
{
HMACSHA256.HashData(this.key, input, output);
}
catch (Exception)
{
var hash = hmac.ComputeHash(toSignBytes);
var signature = Convert.ToBase64String(hash);
return $"v1,{signature}";
throw new WebhookVerificationException("Output buffer too small");
}
}
}
Expand Down

0 comments on commit 18cf625

Please sign in to comment.