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 ;
1113using System . Security . Cryptography ;
1214using System . Text ;
1315using System . Text . Json ;
16+ using System . Text . Json . Serialization ;
17+ using Microsoft . AspNetCore . Http ;
18+ using StandardWebhooks . Diagnostics ;
1419
1520namespace StandardWebhooks ;
1621
@@ -20,7 +25,11 @@ namespace StandardWebhooks;
2025/// </summary>
2126public 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}
0 commit comments