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,
7+ // Portions Copyright (c) 2025 Svix (https://www.svix.com) used under MIT licence,
88// see https://github.com/standard-webhooks/standard-webhooks/blob/main/libraries/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.
@@ -152,6 +182,8 @@ public string Sign(string msgId, DateTimeOffset timestamp, string payload)
152182 /// the payload is serialized.</param>
153183 /// <returns>An <see cref="HttpContent"/> initialised with the JSON serialized payload and necessary
154184 /// headers set.</returns>
185+ [ RequiresUnreferencedCode ( "This code path does not support NativeAOT. Use the JsonSerializationContext overload for NativeAOT Scenarios." ) ]
186+ [ RequiresDynamicCode ( "This code path does not support NativeAOT. Use the JsonSerializationContext overload for NativeAOT Scenarios." ) ]
155187 public HttpContent MakeHttpContent < T > ( T body , string msgId , DateTimeOffset timestamp , JsonSerializerOptions ? jsonOptions = null )
156188 {
157189 var content = WebhookContent < T > . Create ( body , jsonOptions ) ;
@@ -165,7 +197,31 @@ public HttpContent MakeHttpContent<T>(T body, string msgId, DateTimeOffset times
165197 return content ;
166198 }
167199
168- private static DateTimeOffset VerifyTimestamp ( string timestampHeader )
200+ /// <summary>
201+ /// Generates an <see cref="HttpContent"/> that contains the supplied payload, with the appropriate
202+ /// Standard Webhooks headers added, including the signature for the payload.
203+ /// </summary>
204+ /// <typeparam name="T">Type of payload.</typeparam>
205+ /// <param name="body">Content for the webhook payload.</param>
206+ /// <param name="msgId">Message identifier.</param>
207+ /// <param name="timestamp">Sending timestamp.</param>
208+ /// <param name="context">The JsonSerializationContext used to serialize this payload.</param>
209+ /// <returns>An <see cref="HttpContent"/> initialised with the JSON serialized payload and necessary
210+ /// headers set.</returns>
211+ public HttpContent MakeHttpContent < T > ( T body , string msgId , DateTimeOffset timestamp , JsonSerializerContext context )
212+ {
213+ var content = WebhookContent < T > . Create ( body , context ) ;
214+
215+ var signature = Sign ( msgId , timestamp , content . ToString ( ) ) ;
216+
217+ content . Headers . Add ( _idHeaderKey , msgId ) ;
218+ content . Headers . Add ( _timestampHeaderKey , timestamp . ToUnixTimeSeconds ( ) . ToString ( ) ) ;
219+ content . Headers . Add ( _signatureHeaderKey , signature ) ;
220+
221+ return content ;
222+ }
223+
224+ private static void VerifyTimestamp ( ReadOnlySpan < char > timestampHeader )
169225 {
170226 DateTimeOffset timestamp ;
171227
@@ -187,7 +243,66 @@ private static DateTimeOffset VerifyTimestamp(string timestampHeader)
187243
188244 if ( timestamp > now . AddSeconds ( TOLERANCE_IN_SECONDS ) )
189245 throw new WebhookVerificationException ( "Message timestamp too new" ) ;
246+ }
190247
191- return timestamp ;
248+ private void CalculateSignature (
249+ ReadOnlySpan < char > msgId ,
250+ ReadOnlySpan < char > timestamp ,
251+ ReadOnlySpan < char > payload ,
252+ Span < char > signature ,
253+ out int charsWritten )
254+ {
255+ // Estimate buffer size and use stackalloc for smaller allocations
256+ int msgIdLength = SafeUTF8Encoding . GetByteCount ( msgId ) ;
257+ int payloadLength = SafeUTF8Encoding . GetByteCount ( payload ) ;
258+ int timestampLength = SafeUTF8Encoding . GetByteCount ( timestamp ) ;
259+ int totalLength = msgIdLength + 1 + timestampLength + 1 + payloadLength ;
260+
261+ Span < byte > toSignBytes =
262+ totalLength <= MAX_STACKALLOC
263+ ? stackalloc byte [ totalLength ]
264+ : new byte [ totalLength ] ;
265+
266+ SafeUTF8Encoding . GetBytes ( msgId , toSignBytes . Slice ( 0 , msgIdLength ) ) ;
267+ toSignBytes [ msgIdLength ] = ( byte ) '.' ;
268+ SafeUTF8Encoding . GetBytes (
269+ timestamp ,
270+ toSignBytes . Slice ( msgIdLength + 1 , timestampLength ) ) ;
271+ toSignBytes [ msgIdLength + 1 + timestampLength ] = ( byte ) '.' ;
272+ SafeUTF8Encoding . GetBytes (
273+ payload ,
274+ toSignBytes . Slice ( msgIdLength + 1 + timestampLength + 1 ) ) ;
275+
276+ Span < byte > signatureBin = stackalloc byte [ SIGNATURE_LENGTH_BYTES ] ;
277+ CalculateSignature ( toSignBytes , signatureBin ) ;
278+
279+ Span < byte > signatureB64 = stackalloc byte [ SIGNATURE_LENGTH_BASE64 ] ;
280+ var result = Base64 . EncodeToUtf8 (
281+ signatureBin ,
282+ signatureB64 ,
283+ out _ ,
284+ out var bytesWritten ) ;
285+ if ( result != OperationStatus . Done )
286+ throw new WebhookVerificationException ( "Failed to encode signature to base64" ) ;
287+
288+ if (
289+ ! SafeUTF8Encoding . TryGetChars (
290+ signatureB64 . Slice ( 0 , bytesWritten ) ,
291+ signature ,
292+ out charsWritten )
293+ )
294+ throw new WebhookVerificationException ( "Failed to convert signature to utf8" ) ;
295+ }
296+
297+ private void CalculateSignature ( ReadOnlySpan < byte > input , Span < byte > output )
298+ {
299+ try
300+ {
301+ HMACSHA256 . HashData ( _key , input , output ) ;
302+ }
303+ catch ( Exception )
304+ {
305+ throw new WebhookVerificationException ( "Output buffer too small" ) ;
306+ }
192307 }
193308}
0 commit comments