diff --git a/csharp/benchmarks/VerifierBenchmarks.cs b/csharp/benchmarks/VerifierBenchmarks.cs
index 9ca55479..56fea66e 100644
--- a/csharp/benchmarks/VerifierBenchmarks.cs
+++ b/csharp/benchmarks/VerifierBenchmarks.cs
@@ -1,3 +1,7 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Security.Cryptography;
+using System.Text;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Order;
@@ -5,21 +9,28 @@ namespace TrueLayer.Signing.Benchmarks;
///
/// Benchmarks for signature verification operations.
+/// Compares the builder-based Verifier API with the optimized VerifierSpan API (NET8+).
///
[MemoryDiagnoser]
[Orderer(SummaryOrderPolicy.FastestToSlowest)]
[RankColumn]
public class VerifierBenchmarks
{
- private string? _signature;
- private Verifier? _verifier;
+ private string _signature = null!;
+ private byte[] _publicKeyPemBytes = null!;
+ private string _method = null!;
+ private string _path = null!;
+ private KeyValuePair[] _headersBytes = null!;
+ private byte[] _bodyBytes = null!;
+
+ [Params("SmallPayment", "MediumMandate", "LargeWebhook")]
+ public string Scenario { get; set; } = "SmallPayment";
[GlobalSetup]
public void Setup()
{
var privateKey = TestData.GetPrivateKey();
- var publicKey = TestData.GetPublicKey();
- var scenario = TestData.Scenarios.SmallPayment;
+ var scenario = GetScenario(Scenario);
_signature = Signer.SignWith(TestData.Kid, privateKey)
.Method(scenario.Method)
@@ -28,16 +39,49 @@ public void Setup()
.Body(scenario.Body)
.Sign();
- _verifier = Verifier.VerifyWith(publicKey)
+ // Setup for span-based VerifierSpan
+ _publicKeyPemBytes = Encoding.UTF8.GetBytes(TestData.PublicKeyPem);
+ _method = scenario.Method;
+ _path = scenario.Path;
+ _headersBytes = scenario.Headers
+ .Select(h => new KeyValuePair(h.Key, Encoding.UTF8.GetBytes(h.Value)))
+ .ToArray();
+ _bodyBytes = Encoding.UTF8.GetBytes(scenario.Body);
+ }
+
+ private static TestData.RequestScenario GetScenario(string name) => name switch
+ {
+ "SmallPayment" => TestData.Scenarios.SmallPayment,
+ "MediumMandate" => TestData.Scenarios.MediumMandate,
+ "LargeWebhook" => TestData.Scenarios.LargeWebhook,
+ _ => TestData.Scenarios.SmallPayment
+ };
+
+ [Benchmark(Baseline = true, Description = "Verifier (with PEM parsing)")]
+ public void VerifierBuilderWithPemParsing()
+ {
+ var scenario = GetScenario(Scenario);
+ using var key = ECDsa.Create();
+ key.ImportFromPem(TestData.PublicKeyPem);
+
+ Verifier.VerifyWith(key)
.Method(scenario.Method)
.Path(scenario.Path)
.Headers(scenario.Headers)
- .Body(scenario.Body);
+ .Body(scenario.Body)
+ .Verify(_signature);
}
- [Benchmark(Description = "Verify Request")]
- public void VerifyRequest()
+ [Benchmark(Description = "VerifierSpan (with PEM parsing)")]
+ public void VerifierSpanWithPemParsing()
{
- _verifier!.Verify(_signature!);
+ VerifierSpan.VerifyWithPem(
+ _publicKeyPemBytes,
+ _method,
+ _path,
+ _headersBytes,
+ _bodyBytes,
+ _signature
+ );
}
}
diff --git a/csharp/src/Util.cs b/csharp/src/Util.cs
index 9f993963..6864d9e4 100644
--- a/csharp/src/Util.cs
+++ b/csharp/src/Util.cs
@@ -84,6 +84,108 @@ internal static byte[] BuildV2SigningPayload(
return payload.ToArray();
}
+#if NET8_0_OR_GREATER
+ ///
+ /// Calculate the exact size needed for a V2 signing payload.
+ ///
+ internal static int CalculateV2SigningPayloadSize(
+ string method,
+ string path,
+ ReadOnlySpan<(string, byte[])> headers,
+ ReadOnlySpan body)
+ {
+ int totalSize = 0;
+
+ // Method (uppercase) + space
+ string methodUpper = method.ToUpperInvariant();
+ totalSize += Encoding.UTF8.GetByteCount(methodUpper) + SpaceBytes.Length;
+
+ // Path + newline
+ totalSize += Encoding.UTF8.GetByteCount(path) + NewlineBytes.Length;
+
+ // Headers: "name: value\n" for each
+ for (int i = 0; i < headers.Length; i++)
+ {
+ var (name, value) = headers[i];
+ totalSize += Encoding.UTF8.GetByteCount(name);
+ totalSize += ColonSpaceBytes.Length;
+ totalSize += value.Length;
+ totalSize += NewlineBytes.Length;
+ }
+
+ // Body
+ totalSize += body.Length;
+
+ return totalSize;
+ }
+
+ ///
+ /// Build signing payload directly into a destination span.
+ /// Returns the number of bytes written.
+ /// The destination span must be large enough (use CalculateV2SigningPayloadSize).
+ ///
+ internal static int BuildV2SigningPayloadInto(
+ Span destination,
+ string method,
+ string path,
+ ReadOnlySpan<(string, byte[])> headers,
+ ReadOnlySpan body)
+ {
+ int position = 0;
+
+ // Write method (uppercase) + space
+ string methodUpper = method.ToUpperInvariant();
+ position += Encoding.UTF8.GetBytes(methodUpper, destination.Slice(position));
+ SpaceBytes.AsSpan().CopyTo(destination.Slice(position));
+ position += SpaceBytes.Length;
+
+ // Write path + newline
+ position += Encoding.UTF8.GetBytes(path, destination.Slice(position));
+ NewlineBytes.AsSpan().CopyTo(destination.Slice(position));
+ position += NewlineBytes.Length;
+
+ // Write headers
+ for (int i = 0; i < headers.Length; i++)
+ {
+ var (name, value) = headers[i];
+ position += Encoding.UTF8.GetBytes(name, destination.Slice(position));
+ ColonSpaceBytes.AsSpan().CopyTo(destination.Slice(position));
+ position += ColonSpaceBytes.Length;
+ value.AsSpan().CopyTo(destination.Slice(position));
+ position += value.Length;
+ NewlineBytes.AsSpan().CopyTo(destination.Slice(position));
+ position += NewlineBytes.Length;
+ }
+
+ // Write body
+ body.CopyTo(destination.Slice(position));
+ position += body.Length;
+
+ return position;
+ }
+
+ ///
+ /// Build signing payload from method, path, some/none/all headers and body.
+ /// Optimized for .NET 8+ using span-based operations with pre-calculated size.
+ /// Eliminates List allocations and intermediate copies.
+ ///
+ internal static byte[] BuildV2SigningPayload(
+ string method,
+ string path,
+ ReadOnlySpan<(string, byte[])> headers,
+ ReadOnlySpan body)
+ {
+ // Calculate exact size
+ int totalSize = CalculateV2SigningPayloadSize(method, path, headers, body);
+
+ // Allocate and write
+ byte[] payload = new byte[totalSize];
+ BuildV2SigningPayloadInto(payload, method, path, headers, body);
+
+ return payload;
+ }
+#endif
+
/// Convert to utf-8 bytes
internal static byte[] ToUtf8(this string text) => Encoding.UTF8.GetBytes(text);
diff --git a/csharp/src/Verifier.cs b/csharp/src/Verifier.cs
index e053f7c0..b971483a 100644
--- a/csharp/src/Verifier.cs
+++ b/csharp/src/Verifier.cs
@@ -61,7 +61,7 @@ public static Verifier VerifyWithJwks(ReadOnlySpan jwksJson)
}
}
- /// Start building a `Tl-Signature` header verifier usinga a public key.
+ /// Start building a `Tl-Signature` header verifier using a public key.
public static Verifier VerifyWith(ECDsa publicKey) => new Verifier(publicKey);
/// Extract a header value from unverified jws Tl-Signature.
diff --git a/csharp/src/VerifierSpan.cs b/csharp/src/VerifierSpan.cs
new file mode 100644
index 00000000..324725f5
--- /dev/null
+++ b/csharp/src/VerifierSpan.cs
@@ -0,0 +1,314 @@
+#if NET8_0_OR_GREATER
+using System;
+using System.Buffers.Text;
+using System.Collections.Generic;
+using System.Linq;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using Jose;
+
+namespace TrueLayer.Signing
+{
+ ///
+ /// High-performance span-based verification API for .NET 8+.
+ /// Optimized for zero-allocation scenarios when body is available as ReadOnlySpan<byte>.
+ ///
+ public static class VerifierSpan
+ {
+ ///
+ /// Verify a request signature using public key RFC 7468 PEM-encoded data.
+ /// This method is optimized for high-throughput scenarios and minimizes allocations.
+ ///
+ /// RFC 7468 PEM-encoded public key data
+ /// HTTP request method (e.g., "POST")
+ /// Request absolute path starting with a leading '/' and without any trailing slashes
+ /// Request headers to include in signature verification
+ /// Full unmodified request body
+ /// The Tl-Signature header value to verify
+ /// Optional set of header names that must be included in the signature
+ /// Signature is invalid or verification failed
+ /// Invalid path format
+ public static void VerifyWithPem(
+ ReadOnlySpan publicKeyPem,
+ string method,
+ string path,
+ IEnumerable> headers,
+ ReadOnlySpan body,
+ string tlSignature,
+ IEnumerable? requiredHeaders = null)
+ {
+ ValidatePath(path);
+
+ // Parse PEM and create ECDsa key
+ // Optimization: decode UTF8 to stack-allocated char buffer (saves ~2KB allocation)
+ Span pemChars = publicKeyPem.Length <= 4096
+ ? stackalloc char[publicKeyPem.Length]
+ : new char[publicKeyPem.Length];
+
+ var charCount = Encoding.UTF8.GetChars(publicKeyPem, pemChars);
+ ReadOnlySpan pemSpan = pemChars.Slice(0, charCount);
+ using var publicKey = pemSpan.ParsePem();
+
+ // Perform verification
+ VerifyCore(publicKey, method, path, headers, body, tlSignature, requiredHeaders);
+ }
+
+ ///
+ /// Verify a request signature using a pre-parsed public key.
+ /// Use this when the same key is used for multiple verifications to avoid repeated PEM parsing.
+ /// This is the most optimized verification path.
+ ///
+ /// Pre-parsed ECDsa public key
+ /// HTTP request method (e.g., "POST")
+ /// Request absolute path starting with a leading '/' and without any trailing slashes
+ /// Request headers to include in signature verification
+ /// Full unmodified request body
+ /// The Tl-Signature header value to verify
+ /// Optional set of header names that must be included in the signature
+ /// Signature is invalid or verification failed
+ /// Invalid path format
+ public static void VerifyWith(
+ ECDsa publicKey,
+ string method,
+ string path,
+ IEnumerable> headers,
+ ReadOnlySpan body,
+ string tlSignature,
+ IEnumerable? requiredHeaders = null)
+ {
+ ValidatePath(path);
+ VerifyCore(publicKey, method, path, headers, body, tlSignature, requiredHeaders);
+ }
+
+ ///
+ /// Validate that path starts with '/' as required by the signature specification.
+ ///
+ private static void ValidatePath(string path)
+ {
+ if (!path.StartsWith("/"))
+ {
+ throw new ArgumentException($"Invalid path \"{path}\" must start with '/'");
+ }
+ }
+
+ ///
+ /// Core verification logic shared by all VerifierSpan methods.
+ /// Optimized to use direct ECDsa.VerifyData, bypassing Jose.JWT overhead.
+ ///
+ private static void VerifyCore(
+ ECDsa publicKey,
+ string method,
+ string path,
+ IEnumerable> headers,
+ ReadOnlySpan body,
+ string tlSignature,
+ IEnumerable? requiredHeaders)
+ {
+ // Parse JWS format: base64url(header)..base64url(signature)
+ var parts = tlSignature.Split('.');
+ SignatureException.Ensure(parts.Length == 3, "invalid signature format, expected detached JWS (header..signature)");
+ SignatureException.Ensure(string.IsNullOrEmpty(parts[1]), "expected detached JWS with empty payload");
+
+ // Decode JWS header using System.Text.Json (faster than Jose.JWT.Headers)
+ var headerBytes = Jose.Base64Url.Decode(parts[0]);
+ var jwsHeaders = JsonSerializer.Deserialize>(headerBytes)
+ ?? throw new SignatureException("invalid JWS header");
+
+ // Validate algorithm and version
+ var alg = jwsHeaders.TryGetValue("alg", out var algElem) ? algElem.GetString() : null;
+ SignatureException.Ensure(alg == "ES512", "unsupported jws alg");
+
+ var version = jwsHeaders.TryGetValue("tl_version", out var verElem) ? verElem.GetString() : null;
+ if (version == null)
+ {
+ version = GetHeaderString(headers, "Tl-Signature-Version");
+ }
+ SignatureException.Ensure(version == "2", "unsupported jws tl_version");
+
+ // Get signed header names
+ var tlHeaders = jwsHeaders.TryGetValue("tl_headers", out var headersElem) ? headersElem.GetString() : null;
+ if (tlHeaders == null)
+ {
+ tlHeaders = GetHeaderString(headers, "Tl-Signature-Headers") ?? "";
+ }
+
+ var signatureHeaderNames = tlHeaders
+ .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+
+ // Validate required headers
+ if (requiredHeaders != null)
+ {
+ var requiredSet = new HashSet(requiredHeaders, StringComparer.OrdinalIgnoreCase);
+ var missingRequired = requiredSet.Except(signatureHeaderNames, StringComparer.OrdinalIgnoreCase);
+ if (missingRequired.Any())
+ {
+ throw new SignatureException($"signature is missing required headers {string.Join(",", missingRequired)}");
+ }
+ }
+
+ // Filter and order headers
+ var signedHeaders = FilterOrderHeaders(headers, signatureHeaderNames);
+
+ // Decode signature (IEEE P1363 format: r||s, each 66 bytes for P-521)
+ var signature = Jose.Base64Url.Decode(parts[2]);
+
+ // Pre-encode JWS header to UTF8 bytes (reused for both attempts if needed)
+ int headerUtf8Length = Encoding.UTF8.GetByteCount(parts[0]);
+ Span headerUtf8Bytes = headerUtf8Length <= 512
+ ? stackalloc byte[headerUtf8Length]
+ : new byte[headerUtf8Length];
+ Encoding.UTF8.GetBytes(parts[0], headerUtf8Bytes);
+
+ // Try verification with original path
+ if (TryVerifyWithPath(publicKey, method, path, signedHeaders, body, headerUtf8Bytes, signature))
+ {
+ return;
+ }
+
+ // Try with alternate path (trailing slash handling)
+ var alternatePath = path.EndsWith("/")
+ ? path.Substring(0, path.Length - 1)
+ : path + "/";
+
+ if (TryVerifyWithPath(publicKey, method, alternatePath, signedHeaders, body, headerUtf8Bytes, signature))
+ {
+ return;
+ }
+
+ throw new SignatureException("Invalid signature");
+ }
+
+ ///
+ /// Attempt to verify signature with a specific path.
+ /// Returns true if verification succeeds, false otherwise.
+ ///
+ private static bool TryVerifyWithPath(
+ ECDsa publicKey,
+ string method,
+ string path,
+ ReadOnlySpan<(string, byte[])> signedHeaders,
+ ReadOnlySpan body,
+ ReadOnlySpan headerUtf8Bytes,
+ byte[] signature)
+ {
+ // Calculate signing payload size
+ var payloadSize = Util.CalculateV2SigningPayloadSize(method, path, signedHeaders, body);
+
+ // Optimize: stackalloc for typical payloads (<2KB), heap allocate for larger
+ Span payloadBuffer = payloadSize <= 2048
+ ? stackalloc byte[payloadSize]
+ : new byte[payloadSize];
+
+ // Build signing payload directly into buffer (zero-copy)
+ Util.BuildV2SigningPayloadInto(payloadBuffer, method, path, signedHeaders, body);
+
+ // Construct JWS signing string efficiently: base64url(header) + "." + base64url(payload)
+ int payloadBase64UrlLength = GetBase64UrlLength(payloadSize);
+ int signingStringLength = headerUtf8Bytes.Length + 1 + payloadBase64UrlLength;
+
+ // Allocate buffer for signing string (stackalloc for typical sizes)
+ Span signingStringBuffer = signingStringLength <= 4096
+ ? stackalloc byte[signingStringLength]
+ : new byte[signingStringLength];
+
+ // Build signing string: header_bytes + '.' + base64url(payload)
+ int position = 0;
+ headerUtf8Bytes.CopyTo(signingStringBuffer);
+ position += headerUtf8Bytes.Length;
+ signingStringBuffer[position++] = (byte)'.'; // ASCII '.'
+
+ // Base64url encode payload directly into buffer (span-based, zero-copy)
+ var payloadBase64Span = signingStringBuffer.Slice(position);
+ EncodeBase64Url(payloadBuffer, payloadBase64Span);
+
+ // Verify signature
+ try
+ {
+ return publicKey.VerifyData(signingStringBuffer, signature, HashAlgorithmName.SHA512);
+ }
+ catch (CryptographicException)
+ {
+ // Cryptographic exceptions during verification are expected for invalid signatures
+ return false;
+ }
+ }
+
+ ///
+ /// Filter and order headers to match jws header `tl_headers`.
+ /// Optimized: uses array instead of List to reduce allocations.
+ ///
+ private static (string, byte[])[] FilterOrderHeaders(
+ IEnumerable> headers,
+ string[] signedHeaderNames)
+ {
+ var orderedHeaders = new (string, byte[])[signedHeaderNames.Length];
+ int writeIndex = 0;
+
+ foreach (var name in signedHeaderNames)
+ {
+ bool found = false;
+ foreach (var header in headers)
+ {
+ if (header.Key.AsSpan().Trim().Equals(name, StringComparison.OrdinalIgnoreCase))
+ {
+ orderedHeaders[writeIndex++] = (name, header.Value);
+ found = true;
+ break;
+ }
+ }
+
+ if (!found)
+ {
+ throw new SignatureException($"Missing tl_header `{name}` declared in signature");
+ }
+ }
+
+ return orderedHeaders;
+ }
+
+ ///
+ /// Get a header value as a string, or null if not found.
+ ///
+ private static string? GetHeaderString(IEnumerable> headers, string key)
+ {
+ foreach (var header in headers)
+ {
+ if (string.Equals(header.Key, key, StringComparison.OrdinalIgnoreCase))
+ {
+ return Encoding.UTF8.GetString(header.Value);
+ }
+ }
+ return null;
+ }
+
+ ///
+ /// Calculate the exact length of base64url encoding for a given byte count.
+ /// Base64url removes padding, so it's shorter than standard base64.
+ ///
+ private static int GetBase64UrlLength(int byteCount)
+ {
+ // Standard base64 length with padding
+ int base64Length = ((byteCount + 2) / 3) * 4;
+ // Base64url removes trailing '=' padding
+ int paddingLength = (3 - (byteCount % 3)) % 3;
+ return base64Length - paddingLength;
+ }
+
+ ///
+ /// Efficiently encode bytes to base64url format directly into a span.
+ /// Uses .NET 9's System.Buffers.Text.Base64Url for optimal performance, or Jose.Base64Url for earlier versions.
+ ///
+ private static void EncodeBase64Url(ReadOnlySpan source, Span destination)
+ {
+#if NET9_0_OR_GREATER
+ System.Buffers.Text.Base64Url.EncodeToUtf8(source, destination, out _, out _);
+#else
+ // For .NET 8, use Jose.Base64Url which still avoids some allocations
+ var encoded = Jose.Base64Url.Encode(source.ToArray());
+ Encoding.UTF8.GetBytes(encoded, destination);
+#endif
+ }
+ }
+}
+#endif
diff --git a/csharp/test/VerifierSpanTest.cs b/csharp/test/VerifierSpanTest.cs
new file mode 100644
index 00000000..7533a125
--- /dev/null
+++ b/csharp/test/VerifierSpanTest.cs
@@ -0,0 +1,790 @@
+#if NET8_0_OR_GREATER
+using Xunit;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using AwesomeAssertions;
+using static TrueLayer.Signing.Tests.TestData;
+
+namespace TrueLayer.Signing.Tests
+{
+ ///
+ /// Tests for the high-performance span-based VerifierSpan API.
+ /// These tests mirror the key scenarios from UsageTest.cs to ensure feature parity.
+ ///
+ public class VerifierSpanTest
+ {
+ public static IEnumerable