Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

csharp: improve performance and reduce memory footprint when creating and verifying webhook signatures #1616

Merged
merged 12 commits into from
Jan 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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));
}
tasn marked this conversation as resolved.
Show resolved Hide resolved

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)
tasn marked this conversation as resolved.
Show resolved Hide resolved
{
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
Loading