Skip to content

Commit 13b31dd

Browse files
authored
merge: pull request #13 from Hawxy/aotsupport
Improve performance and support NativeAOT
2 parents 9820c5d + a7f3943 commit 13b31dd

File tree

10 files changed

+219
-65
lines changed

10 files changed

+219
-65
lines changed

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -361,4 +361,6 @@ MigrationBackup/
361361
.ionide/
362362

363363
# Fody - auto-generated XML schema
364-
FodyWeavers.xsd
364+
FodyWeavers.xsd
365+
366+
.idea

LICENSE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
2020
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2121
SOFTWARE.
2222

23-
Portions Copyright (c) 2023 Svix (https://www.svix.com) used under MIT licence,
24-
see https://github.com/standard-webhooks/standard-webhooks/blob/main/libraries/LICENSE.
23+
Portions Copyright (c) 2025 Svix (https://www.svix.com) used under MIT licence,
24+
see https://github.com/svix/svix-webhooks/blob/main/LICENSE
2525

2626
Portions Copyright (c) Stripe Inc used under Apache License v2.0, see
2727
https://github.com/stripe/stripe-dotnet/blob/master/LICENSE

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,5 +79,7 @@ leveraging the retry capabilites of the [Polly](https://github.com/App-vNext/Pol
7979
This project leverages the work of the **Standard Webhooks** project, published on Github in the [standard-webhooks](https://github.com/standard-webhooks/standard-webhooks) repository.
8080
Specifically it builds upon the [C# reference implementation](https://github.com/standard-webhooks/standard-webhooks/tree/main/libraries/csharp).
8181

82+
Signature verification is based on the [svix-webhooks implementation](https://github.com/svix/svix-webhooks), used under MIT.
83+
8284
## License
8385
This project is licensed under the MIT License.

build/_build.csproj.DotSettings

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
1+
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
22
<s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=HeapView_002EDelegateAllocation/@EntryIndexedValue">DO_NOT_SHOW</s:String>
33
<s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=VariableHidesOuterVariable/@EntryIndexedValue">DO_NOT_SHOW</s:String>
44
<s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=ClassNeverInstantiated_002EGlobal/@EntryIndexedValue">DO_NOT_SHOW</s:String>
@@ -17,11 +17,15 @@
1717
<s:Boolean x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/PLACE_SIMPLE_ANONYMOUSMETHOD_ON_SINGLE_LINE/@EntryValue">False</s:Boolean>
1818
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/PredefinedNamingRules/=PrivateInstanceFields/@EntryIndexedValue">&lt;Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /&gt;</s:String>
1919
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/PredefinedNamingRules/=PrivateStaticFields/@EntryIndexedValue">&lt;Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /&gt;</s:String>
20+
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/UserRules/=4a98fdf6_002D7d98_002D4f5a_002Dafeb_002Dea44ad98c70c/@EntryIndexedValue">&lt;Policy&gt;&lt;Descriptor Staticness="Instance" AccessRightKinds="Private" Description="Instance fields (private)"&gt;&lt;ElementKinds&gt;&lt;Kind Name="FIELD" /&gt;&lt;Kind Name="READONLY_FIELD" /&gt;&lt;/ElementKinds&gt;&lt;/Descriptor&gt;&lt;Policy Inspect="True" WarnAboutPrefixesAndSuffixes="False" Prefix="" Suffix="" Style="AaBb" /&gt;&lt;/Policy&gt;</s:String>
21+
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/UserRules/=f9fce829_002De6f4_002D4cb2_002D80f1_002D5497c44f51df/@EntryIndexedValue">&lt;Policy&gt;&lt;Descriptor Staticness="Static" AccessRightKinds="Private" Description="Static fields (private)"&gt;&lt;ElementKinds&gt;&lt;Kind Name="FIELD" /&gt;&lt;/ElementKinds&gt;&lt;/Descriptor&gt;&lt;Policy Inspect="True" WarnAboutPrefixesAndSuffixes="False" Prefix="" Suffix="" Style="AaBb" /&gt;&lt;/Policy&gt;</s:String>
2022
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ECSharpAttributeForSingleLineMethodUpgrade/@EntryIndexedValue">True</s:Boolean>
2123
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ECSharpKeepExistingMigration/@EntryIndexedValue">True</s:Boolean>
2224
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ECSharpPlaceEmbeddedOnSameLineMigration/@EntryIndexedValue">True</s:Boolean>
2325
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ECSharpRenamePlacementToArrangementMigration/@EntryIndexedValue">True</s:Boolean>
26+
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ECSharpUseContinuousIndentInsideBracesMigration/@EntryIndexedValue">True</s:Boolean>
2427
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ESettingsUpgrade_002EAddAccessorOwnerDeclarationBracesMigration/@EntryIndexedValue">True</s:Boolean>
2528
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ESettingsUpgrade_002ECSharpPlaceAttributeOnSameLineMigration/@EntryIndexedValue">True</s:Boolean>
2629
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ESettingsUpgrade_002EMigrateBlankLinesAroundFieldToBlankLinesAroundProperty/@EntryIndexedValue">True</s:Boolean>
27-
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ESettingsUpgrade_002EMigrateThisQualifierSettings/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
30+
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ESettingsUpgrade_002EMigrateThisQualifierSettings/@EntryIndexedValue">True</s:Boolean>
31+
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ESettingsUpgrade_002EPredefinedNamingRulesToUserRulesUpgrade/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

src/StandardWebhooks/Properties/launchSettings.json

Lines changed: 0 additions & 12 deletions
This file was deleted.

src/StandardWebhooks/StandardWebhook.cs

Lines changed: 147 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
1-
// Copyright (c) 2024, Codefactors Ltd.
1+
// Copyright (c) 2024, Codefactors Ltd.
22
//
33
// Codefactors Ltd licenses this file to you under the following license(s):
44
//
55
// * The MIT License, see https://opensource.org/license/mit/
66

7-
// Portions Copyright (c) 2023 Svix (https://www.svix.com) used under MIT licence,
8-
// see https://github.com/standard-webhooks/standard-webhooks/blob/main/libraries/LICENSE.
7+
// Portions Copyright (c) 2025 Svix (https://www.svix.com) used under MIT licence,
8+
// see https://github.com/svix/svix-webhooks/blob/main/LICENSE.
99

10-
using StandardWebhooks.Diagnostics;
10+
using System.Buffers;
11+
using System.Buffers.Text;
12+
using System.Diagnostics.CodeAnalysis;
1113
using System.Security.Cryptography;
1214
using System.Text;
1315
using System.Text.Json;
16+
using System.Text.Json.Serialization;
17+
using Microsoft.AspNetCore.Http;
18+
using StandardWebhooks.Diagnostics;
1419

1520
namespace StandardWebhooks;
1621

@@ -20,7 +25,11 @@ namespace StandardWebhooks;
2025
/// </summary>
2126
public sealed class StandardWebhook
2227
{
28+
private const int SIGNATURE_LENGTH_BYTES = HMACSHA256.HashSizeInBytes;
29+
private const int SIGNATURE_LENGTH_BASE64 = 48;
30+
private const int SIGNATURE_LENGTH_STRING = 56;
2331
private const int TOLERANCE_IN_SECONDS = 60 * 5;
32+
private const int MAX_STACKALLOC = 1024 * 256;
2433
private const string PREFIX = "whsec_";
2534

2635
private static readonly UTF8Encoding SafeUTF8Encoding = new UTF8Encoding(false, true);
@@ -83,36 +92,53 @@ public StandardWebhook(byte[] signingKey, WebhookConfigurationOptions options)
8392
/// </exception>
8493
public void Verify(string payload, IHeaderDictionary headers)
8594
{
86-
string msgId = headers[_idHeaderKey].ToString();
87-
string msgSignature = headers[_signatureHeaderKey].ToString();
88-
string msgTimestamp = headers[_timestampHeaderKey].ToString();
95+
ReadOnlySpan<char> msgId = headers[_idHeaderKey].ToString();
96+
ReadOnlySpan<char> msgSignature = headers[_signatureHeaderKey].ToString();
97+
ReadOnlySpan<char> msgTimestamp = headers[_timestampHeaderKey].ToString();
8998

90-
if (string.IsNullOrEmpty(msgId) || string.IsNullOrEmpty(msgSignature) || string.IsNullOrEmpty(msgTimestamp))
99+
if (msgId.IsEmpty || msgSignature.IsEmpty || msgTimestamp.IsEmpty)
91100
throw new WebhookVerificationException($"Missing required headers; {_idHeaderKey}, {_signatureHeaderKey} and {_timestampHeaderKey} must be supplied");
92101

93-
var timestamp = VerifyTimestamp(msgTimestamp);
94-
95-
var expectedSignature = Sign(msgId, timestamp, payload)
96-
.Split(',')[1];
102+
VerifyTimestamp(msgTimestamp);
97103

98-
var passedSignatures = msgSignature.Split(' ');
104+
Span<char> expectedSignature = stackalloc char[SIGNATURE_LENGTH_STRING];
105+
CalculateSignature(
106+
msgId,
107+
msgTimestamp,
108+
payload,
109+
expectedSignature,
110+
out var charsWritten);
111+
expectedSignature = expectedSignature.Slice(0, charsWritten);
99112

100-
foreach (string versionedSignature in passedSignatures)
113+
var signaturePtr = msgSignature;
114+
var spaceIndex = signaturePtr.IndexOf(' ');
115+
do
101116
{
102-
var parts = versionedSignature.Split(',');
117+
var versionedSignature =
118+
spaceIndex < 0 ? msgSignature : signaturePtr.Slice(0, spaceIndex);
103119

104-
if (parts.Length < 2)
105-
throw new WebhookVerificationException("Invalid signature header; must be in the form 'version,signature'");
120+
signaturePtr = signaturePtr.Slice(spaceIndex + 1);
121+
spaceIndex = signaturePtr.IndexOf(' ');
106122

107-
var version = parts[0];
108-
var passedSignature = parts[1];
123+
var commaIndex = versionedSignature.IndexOf(',');
124+
if (commaIndex < 0)
125+
{
126+
throw new WebhookVerificationException("Invalid Signature Headers");
127+
}
109128

110-
if (version != "v1")
129+
var version = versionedSignature.Slice(0, commaIndex);
130+
if (!version.Equals("v1", StringComparison.InvariantCulture))
131+
{
111132
continue;
133+
}
112134

135+
var passedSignature = versionedSignature.Slice(commaIndex + 1);
113136
if (WebhookUtils.SecureCompare(expectedSignature, passedSignature))
137+
{
114138
return;
139+
}
115140
}
141+
while (spaceIndex >= 0);
116142

117143
throw new WebhookVerificationException("No matching signature found");
118144
}
@@ -125,21 +151,25 @@ public void Verify(string payload, IHeaderDictionary headers)
125151
/// <param name="payload">Webhook payload, as a string.</param>
126152
/// <returns>Standard Webhooks signature in the format 'version,signature'.</returns>
127153
/// <remarks>Currently only supports 'v1' signatures.</remarks>
128-
public string Sign(string msgId, DateTimeOffset timestamp, string payload)
154+
public string Sign(
155+
ReadOnlySpan<char> msgId,
156+
DateTimeOffset timestamp,
157+
ReadOnlySpan<char> payload)
129158
{
130-
var toSign = $"{msgId}.{timestamp.ToUnixTimeSeconds()}.{payload}";
131-
var toSignBytes = SafeUTF8Encoding.GetBytes(toSign);
132-
133-
using (var hmac = new HMACSHA256(this._key))
134-
{
135-
var hash = hmac.ComputeHash(toSignBytes);
136-
137-
var signature = Convert.ToBase64String(hash);
138-
139-
return $"v1,{signature}";
140-
}
159+
Span<char> signature = stackalloc char[SIGNATURE_LENGTH_STRING];
160+
signature[0] = 'v';
161+
signature[1] = '1';
162+
signature[2] = ',';
163+
CalculateSignature(
164+
msgId,
165+
timestamp.ToUnixTimeSeconds().ToString(),
166+
payload,
167+
signature.Slice(3),
168+
out var charsWritten);
169+
return signature.Slice(0, charsWritten + 3).ToString();
141170
}
142171

172+
143173
/// <summary>
144174
/// Generates an <see cref="HttpContent"/> that contains the supplied payload, with the appropriate
145175
/// Standard Webhooks headers added, including the signature for the payload.
@@ -165,7 +195,31 @@ public HttpContent MakeHttpContent<T>(T body, string msgId, DateTimeOffset times
165195
return content;
166196
}
167197

168-
private static DateTimeOffset VerifyTimestamp(string timestampHeader)
198+
/// <summary>
199+
/// Generates an <see cref="HttpContent"/> that contains the supplied payload, with the appropriate
200+
/// Standard Webhooks headers added, including the signature for the payload.
201+
/// </summary>
202+
/// <typeparam name="T">Type of payload.</typeparam>
203+
/// <param name="body">Content for the webhook payload.</param>
204+
/// <param name="msgId">Message identifier.</param>
205+
/// <param name="timestamp">Sending timestamp.</param>
206+
/// <param name="context">The JsonSerializationContext used to serialize this payload.</param>
207+
/// <returns>An <see cref="HttpContent"/> initialised with the JSON serialized payload and necessary
208+
/// headers set.</returns>
209+
public HttpContent MakeHttpContent<T>(T body, string msgId, DateTimeOffset timestamp, JsonSerializerContext context)
210+
{
211+
var content = WebhookContent<T>.Create(body, context);
212+
213+
var signature = Sign(msgId, timestamp, content.ToString());
214+
215+
content.Headers.Add(_idHeaderKey, msgId);
216+
content.Headers.Add(_timestampHeaderKey, timestamp.ToUnixTimeSeconds().ToString());
217+
content.Headers.Add(_signatureHeaderKey, signature);
218+
219+
return content;
220+
}
221+
222+
private static void VerifyTimestamp(ReadOnlySpan<char> timestampHeader)
169223
{
170224
DateTimeOffset timestamp;
171225

@@ -187,7 +241,66 @@ private static DateTimeOffset VerifyTimestamp(string timestampHeader)
187241

188242
if (timestamp > now.AddSeconds(TOLERANCE_IN_SECONDS))
189243
throw new WebhookVerificationException("Message timestamp too new");
244+
}
190245

191-
return timestamp;
246+
private void CalculateSignature(
247+
ReadOnlySpan<char> msgId,
248+
ReadOnlySpan<char> timestamp,
249+
ReadOnlySpan<char> payload,
250+
Span<char> signature,
251+
out int charsWritten)
252+
{
253+
// Estimate buffer size and use stackalloc for smaller allocations
254+
int msgIdLength = SafeUTF8Encoding.GetByteCount(msgId);
255+
int payloadLength = SafeUTF8Encoding.GetByteCount(payload);
256+
int timestampLength = SafeUTF8Encoding.GetByteCount(timestamp);
257+
int totalLength = msgIdLength + 1 + timestampLength + 1 + payloadLength;
258+
259+
Span<byte> toSignBytes =
260+
totalLength <= MAX_STACKALLOC
261+
? stackalloc byte[totalLength]
262+
: new byte[totalLength];
263+
264+
SafeUTF8Encoding.GetBytes(msgId, toSignBytes.Slice(0, msgIdLength));
265+
toSignBytes[msgIdLength] = (byte)'.';
266+
SafeUTF8Encoding.GetBytes(
267+
timestamp,
268+
toSignBytes.Slice(msgIdLength + 1, timestampLength));
269+
toSignBytes[msgIdLength + 1 + timestampLength] = (byte)'.';
270+
SafeUTF8Encoding.GetBytes(
271+
payload,
272+
toSignBytes.Slice(msgIdLength + 1 + timestampLength + 1));
273+
274+
Span<byte> signatureBin = stackalloc byte[SIGNATURE_LENGTH_BYTES];
275+
CalculateSignature(toSignBytes, signatureBin);
276+
277+
Span<byte> signatureB64 = stackalloc byte[SIGNATURE_LENGTH_BASE64];
278+
var result = Base64.EncodeToUtf8(
279+
signatureBin,
280+
signatureB64,
281+
out _,
282+
out var bytesWritten);
283+
if (result != OperationStatus.Done)
284+
throw new WebhookVerificationException("Failed to encode signature to base64");
285+
286+
if (
287+
!SafeUTF8Encoding.TryGetChars(
288+
signatureB64.Slice(0, bytesWritten),
289+
signature,
290+
out charsWritten)
291+
)
292+
throw new WebhookVerificationException("Failed to convert signature to utf8");
293+
}
294+
295+
private void CalculateSignature(ReadOnlySpan<byte> input, Span<byte> output)
296+
{
297+
try
298+
{
299+
HMACSHA256.HashData(_key, input, output);
300+
}
301+
catch (Exception)
302+
{
303+
throw new WebhookVerificationException("Output buffer too small");
304+
}
192305
}
193306
}

src/StandardWebhooks/StandardWebhooks.csproj

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
<Project Sdk="Microsoft.NET.Sdk.Web">
1+
<Project Sdk="Microsoft.NET.Sdk">
22

33
<PropertyGroup>
44
<TargetFrameworks>net8.0;net9.0</TargetFrameworks>
55
<ImplicitUsings>enable</ImplicitUsings>
66
<Nullable>enable</Nullable>
77
<OutputType>Library</OutputType>
88
<GenerateDocumentationFile>True</GenerateDocumentationFile>
9+
<IsAotCompatible>true</IsAotCompatible>
910
</PropertyGroup>
1011

1112
<PropertyGroup>
@@ -25,10 +26,14 @@
2526
<PrivateAssets>all</PrivateAssets>
2627
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
2728
</PackageReference>
29+
<PackageReference Include="MinVer" Version="6.0.0">
30+
<PrivateAssets>all</PrivateAssets>
31+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
32+
</PackageReference>
2833
</ItemGroup>
2934

3035
<ItemGroup>
31-
<PackageReference Update="MinVer" Version="6.0.0" />
36+
<FrameworkReference Include="Microsoft.AspNetCore.App" />
3237
</ItemGroup>
3338

3439
</Project>

0 commit comments

Comments
 (0)