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 TestCases = new[] + { + new TestCase( + "Shared Test Key", + Kid, + PrivateKey, + PublicKey), + new TestCase( + "Length Error Reproduction", + BugReproduction.LengthError.Kid, + BugReproduction.LengthError.PrivateKey, + BugReproduction.LengthError.PublicKey), + }.Select(x => new object[] { x }); + + [Theory] + [MemberData(nameof(TestCases))] + public void SignAndVerify_WithPem(TestCase testCase) + { + var body = "{\"currency\":\"GBP\",\"max_amount_in_minor\":5000000}"; + var idempotency_key = "idemp-2076717c-9005-4811-a321-9e0787fa0382"; + var path = "/merchant_accounts/a61acaef-ee05-4077-92f3-25543a11bd8d/sweeping"; + + var tlSignature = Signer.SignWithPem(testCase.Kid, testCase.PrivateKey) + .Method("POST") + .Path(path) + .Header("Idempotency-Key", idempotency_key) + .Body(body) + .Sign(); + + var headers = new[] + { + new KeyValuePair("X-Whatever-2", Encoding.UTF8.GetBytes("t2345d")), + new KeyValuePair("Idempotency-Key", Encoding.UTF8.GetBytes(idempotency_key)) + }; + + // Verify with PEM parsing + VerifierSpan.VerifyWithPem( + Encoding.UTF8.GetBytes(testCase.PublicKey), + "post", // case-insensitive: no troubles + path, + headers, + Encoding.UTF8.GetBytes(body), + tlSignature + ); // should not throw + } + + [Theory] + [MemberData(nameof(TestCases))] + public void SignAndVerify_PreParsedKey(TestCase testCase) + { + var body = "{\"currency\":\"GBP\",\"max_amount_in_minor\":5000000}"; + var idempotency_key = "idemp-2076717c-9005-4811-a321-9e0787fa0382"; + var path = "/merchant_accounts/a61acaef-ee05-4077-92f3-25543a11bd8d/sweeping"; + + var tlSignature = Signer.SignWithPem(testCase.Kid, testCase.PrivateKey) + .Method("POST") + .Path(path) + .Header("Idempotency-Key", idempotency_key) + .Body(body) + .Sign(); + + var headers = new[] + { + new KeyValuePair("X-Whatever-2", Encoding.UTF8.GetBytes("t2345d")), + new KeyValuePair("Idempotency-Key", Encoding.UTF8.GetBytes(idempotency_key)) + }; + + using var publicKey = testCase.PublicKey.AsSpan().ParsePem(); + + // Verify with pre-parsed key (most optimized path) + VerifierSpan.VerifyWith( + publicKey, + "post", // case-insensitive: no troubles + path, + headers, + Encoding.UTF8.GetBytes(body), + tlSignature + ); // should not throw + } + + [Theory] + [MemberData(nameof(TestCases))] + public void SignAndVerify_NoHeaders(TestCase testCase) + { + var body = "{\"currency\":\"GBP\",\"max_amount_in_minor\":5000000}"; + var path = "/merchant_accounts/a61acaef-ee05-4077-92f3-25543a11bd8d/sweeping"; + + var tlSignature = Signer.SignWithPem(testCase.Kid, testCase.PrivateKey) + .Method("POST") + .Path(path) + .Body(body) + .Sign(); + + VerifierSpan.VerifyWithPem( + Encoding.UTF8.GetBytes(testCase.PublicKey), + "POST", + path, + Array.Empty>(), + Encoding.UTF8.GetBytes(body), + tlSignature + ); // should not throw + } + + [Theory] + [InlineData("/tl-webhook/", "/tl-webhook")] + [InlineData("/tl-webhook", "/tl-webhook/")] + public void SignAndVerify_TrailingSlash(string signedPath, string verifyPath) + { + var body = "{\"foo\":\"bar\"}"; + + var tlSignature = Signer.SignWithPem(Kid, PrivateKey) + .Method("POST") + .Path(signedPath) + .Body(body) + .Sign(); + + VerifierSpan.VerifyWithPem( + Encoding.UTF8.GetBytes(PublicKey), + "POST", + verifyPath, + Array.Empty>(), + Encoding.UTF8.GetBytes(body), + tlSignature + ); + } + + [Fact] + public void VerifyStaticSignature() + { + var body = "{\"currency\":\"GBP\",\"max_amount_in_minor\":5000000,\"name\":\"Foo???\"}"; + var idempotency_key = "idemp-2076717c-9005-4811-a321-9e0787fa0382"; + var path = "/merchant_accounts/a61acaef-ee05-4077-92f3-25543a11bd8d/sweeping"; + var tlSignature = System.IO.File.ReadAllText(TestResourcePath("tl-signature.txt")).Trim(); + + var headers = new[] + { + new KeyValuePair("X-Whatever-2", Encoding.UTF8.GetBytes("t2345d")), + new KeyValuePair("Idempotency-Key", Encoding.UTF8.GetBytes(idempotency_key)) + }; + + VerifierSpan.VerifyWithPem( + Encoding.UTF8.GetBytes(PublicKey), + "POST", + path, + headers, + Encoding.UTF8.GetBytes(body), + tlSignature + ); // should not throw + } + + [Fact] + public void SignAndVerify_MethodMismatch() + { + var body = "{\"currency\":\"GBP\",\"max_amount_in_minor\":5000000}"; + var idempotency_key = "idemp-2076717c-9005-4811-a321-9e0787fa0382"; + var path = "/merchant_accounts/a61acaef-ee05-4077-92f3-25543a11bd8d/sweeping"; + + var tlSignature = Signer.SignWithPem(Kid, PrivateKey) + .Method("POST") + .Path(path) + .Header("Idempotency-Key", idempotency_key) + .Body(body) + .Sign(); + + var headers = new[] + { + new KeyValuePair("Idempotency-Key", Encoding.UTF8.GetBytes(idempotency_key)) + }; + + Action verify = () => VerifierSpan.VerifyWithPem( + Encoding.UTF8.GetBytes(PublicKey), + "DELETE", // different + path, + headers, + Encoding.UTF8.GetBytes(body), + tlSignature + ); + + verify.Should().Throw(); + } + + [Fact] + public void SignAndVerify_PathMismatch() + { + var body = "{\"currency\":\"GBP\",\"max_amount_in_minor\":5000000}"; + var idempotency_key = "idemp-2076717c-9005-4811-a321-9e0787fa0382"; + var path = "/merchant_accounts/a61acaef-ee05-4077-92f3-25543a11bd8d/sweeping"; + + var tlSignature = Signer.SignWithPem(Kid, PrivateKey) + .Method("POST") + .Path(path) + .Header("Idempotency-Key", idempotency_key) + .Body(body) + .Sign(); + + var headers = new[] + { + new KeyValuePair("Idempotency-Key", Encoding.UTF8.GetBytes(idempotency_key)) + }; + + Action verify = () => VerifierSpan.VerifyWithPem( + Encoding.UTF8.GetBytes(PublicKey), + "POST", + "/merchant_accounts/67b5b1cf-1d0c-45d4-a2ea-61bdc044327c/sweeping", // different + headers, + Encoding.UTF8.GetBytes(body), + tlSignature + ); + + verify.Should().Throw(); + } + + [Fact] + public void SignAndVerify_HeaderMismatch() + { + var body = "{\"currency\":\"GBP\",\"max_amount_in_minor\":5000000}"; + var idempotency_key = "idemp-2076717c-9005-4811-a321-9e0787fa0382"; + var path = "/merchant_accounts/a61acaef-ee05-4077-92f3-25543a11bd8d/sweeping"; + + var tlSignature = Signer.SignWithPem(Kid, PrivateKey) + .Method("POST") + .Path(path) + .Header("Idempotency-Key", idempotency_key) + .Body(body) + .Sign(); + + var headers = new[] + { + new KeyValuePair("Idempotency-Key", Encoding.UTF8.GetBytes("something-else")) // different + }; + + Action verify = () => VerifierSpan.VerifyWithPem( + Encoding.UTF8.GetBytes(PublicKey), + "POST", + path, + headers, + Encoding.UTF8.GetBytes(body), + tlSignature + ); + + verify.Should().Throw(); + } + + [Fact] + public void SignAndVerify_BodyMismatch() + { + var body = "{\"currency\":\"GBP\",\"max_amount_in_minor\":5000000}"; + var idempotency_key = "idemp-2076717c-9005-4811-a321-9e0787fa0382"; + var path = "/merchant_accounts/a61acaef-ee05-4077-92f3-25543a11bd8d/sweeping"; + + var tlSignature = Signer.SignWithPem(Kid, PrivateKey) + .Method("POST") + .Path(path) + .Header("Idempotency-Key", idempotency_key) + .Body(body) + .Sign(); + + var headers = new[] + { + new KeyValuePair("Idempotency-Key", Encoding.UTF8.GetBytes(idempotency_key)) + }; + + Action verify = () => VerifierSpan.VerifyWithPem( + Encoding.UTF8.GetBytes(PublicKey), + "POST", + path, + headers, + Encoding.UTF8.GetBytes("{\"currency\":\"GBP\",\"max_amount_in_minor\":5000001}"), // different + tlSignature + ); + + verify.Should().Throw(); + } + + [Fact] + public void SignAndVerify_MissingSignatureHeader() + { + var body = "{\"currency\":\"GBP\",\"max_amount_in_minor\":5000000}"; + var idempotency_key = "idemp-2076717c-9005-4811-a321-9e0787fa0382"; + var path = "/merchant_accounts/a61acaef-ee05-4077-92f3-25543a11bd8d/sweeping"; + + var tlSignature = Signer.SignWithPem(Kid, PrivateKey) + .Method("POST") + .Path(path) + .Header("Idempotency-Key", idempotency_key) + .Body(body) + .Sign(); + + Action verify = () => VerifierSpan.VerifyWithPem( + Encoding.UTF8.GetBytes(PublicKey), + "POST", + path, + Array.Empty>(), // missing Idempotency-Key + Encoding.UTF8.GetBytes(body), + tlSignature + ); + + verify.Should().Throw(); + } + + [Fact] + public void SignAndVerify_RequiredHeaderMissingFromSignature() + { + var body = "{\"currency\":\"GBP\",\"max_amount_in_minor\":5000000}"; + var idempotency_key = "idemp-2076717c-9005-4811-a321-9e0787fa0382"; + var path = "/merchant_accounts/a61acaef-ee05-4077-92f3-25543a11bd8d/sweeping"; + + var tlSignature = Signer.SignWithPem(Kid, PrivateKey) + .Method("POST") + .Path(path) + .Header("Idempotency-Key", idempotency_key) + .Body(body) + .Sign(); + + var headers = new[] + { + new KeyValuePair("Idempotency-Key", Encoding.UTF8.GetBytes(idempotency_key)) + }; + + Action verify = () => VerifierSpan.VerifyWithPem( + Encoding.UTF8.GetBytes(PublicKey), + "POST", + path, + headers, + Encoding.UTF8.GetBytes(body), + tlSignature, + requiredHeaders: new[] { "X-Required" } // missing from signature + ); + + verify.Should().Throw(); + } + + [Fact] + public void SignAndVerify_RequiredHeaderCaseInsensitivity() + { + var body = "{\"currency\":\"GBP\",\"max_amount_in_minor\":5000000}"; + var idempotency_key = "idemp-2076717c-9005-4811-a321-9e0787fa0382"; + var path = "/merchant_accounts/a61acaef-ee05-4077-92f3-25543a11bd8d/sweeping"; + + var tlSignature = Signer.SignWithPem(Kid, PrivateKey) + .Method("POST") + .Path(path) + .Header("Idempotency-Key", idempotency_key) + .Body(body) + .Sign(); + + var headers = new[] + { + new KeyValuePair("iDeMpOtEnCy-kEy", Encoding.UTF8.GetBytes(idempotency_key)) + }; + + VerifierSpan.VerifyWithPem( + Encoding.UTF8.GetBytes(PublicKey), + "POST", + path, + headers, + Encoding.UTF8.GetBytes(body), + tlSignature, + requiredHeaders: new[] { "IdEmPoTeNcY-KeY" } + ); // should not throw + } + + [Fact] + public void SignAndVerify_FlexibleHeaderCaseOrderVerify() + { + var body = "{\"currency\":\"GBP\",\"max_amount_in_minor\":5000000}"; + var idempotency_key = "idemp-2076717c-9005-4811-a321-9e0787fa0382"; + var path = "/merchant_accounts/a61acaef-ee05-4077-92f3-25543a11bd8d/sweeping"; + + var tlSignature = Signer.SignWithPem(Kid, PrivateKey) + .Method("POST") + .Path(path) + .Header("Idempotency-Key", idempotency_key) + .Header("X-Custom", "123") + .Body(body) + .Sign(); + + var headers = new[] + { + new KeyValuePair("X-CUSTOM", Encoding.UTF8.GetBytes("123")), // different order & case + new KeyValuePair("Idempotency-Key", Encoding.UTF8.GetBytes(idempotency_key)) + }; + + VerifierSpan.VerifyWithPem( + Encoding.UTF8.GetBytes(PublicKey), + "POST", + path, + headers, + Encoding.UTF8.GetBytes(body), + tlSignature + ); + } + + [Fact] + public void SignAndVerify_HeaderNameWhitespaceTrimming() + { + var body = "{\"currency\":\"GBP\",\"max_amount_in_minor\":5000000}"; + var idempotency_key = "idemp-2076717c-9005-4811-a321-9e0787fa0382"; + var path = "/merchant_accounts/a61acaef-ee05-4077-92f3-25543a11bd8d/sweeping"; + + // Sign with header names that have leading/trailing whitespace + var tlSignature = Signer.SignWithPem(Kid, PrivateKey) + .Method("POST") + .Path(path) + .Header(" Idempotency-Key ", idempotency_key) + .Header("\tX-Custom\t", "123") + .Body(body) + .Sign(); + + // Verify with trimmed header names - should work + var headers1 = new[] + { + new KeyValuePair("Idempotency-Key", Encoding.UTF8.GetBytes(idempotency_key)), + new KeyValuePair("X-Custom", Encoding.UTF8.GetBytes("123")) + }; + + VerifierSpan.VerifyWithPem( + Encoding.UTF8.GetBytes(PublicKey), + "POST", + path, + headers1, + Encoding.UTF8.GetBytes(body), + tlSignature + ); + + // Verify the reverse: sign without whitespace, verify with whitespace + var tlSignature2 = Signer.SignWithPem(Kid, PrivateKey) + .Method("POST") + .Path(path) + .Header("Idempotency-Key", idempotency_key) + .Header("X-Custom", "123") + .Body(body) + .Sign(); + + var headers2 = new[] + { + new KeyValuePair(" Idempotency-Key ", Encoding.UTF8.GetBytes(idempotency_key)), + new KeyValuePair("\tX-Custom\t", Encoding.UTF8.GetBytes("123")) + }; + + VerifierSpan.VerifyWithPem( + Encoding.UTF8.GetBytes(PublicKey), + "POST", + path, + headers2, + Encoding.UTF8.GetBytes(body), + tlSignature2 + ); + } + + [Theory] + [MemberData(nameof(TestCases))] + public void SignAndVerify_EmptyBody_NotProvided(TestCase testCase) + { + // Test with empty body span + var path = "/test/empty-body"; + + var tlSignature = Signer.SignWithPem(testCase.Kid, testCase.PrivateKey) + .Method("POST") + .Path(path) + .Sign(); + + VerifierSpan.VerifyWithPem( + Encoding.UTF8.GetBytes(testCase.PublicKey), + "POST", + path, + Array.Empty>(), + ReadOnlySpan.Empty, + tlSignature + ); // should not throw + } + + [Theory] + [MemberData(nameof(TestCases))] + public void SignAndVerify_EmptyBody_EmptyArray(TestCase testCase) + { + // Test with empty byte array + var path = "/test/empty-array"; + + var tlSignature = Signer.SignWithPem(testCase.Kid, testCase.PrivateKey) + .Method("POST") + .Path(path) + .Body(Array.Empty()) + .Sign(); + + VerifierSpan.VerifyWithPem( + Encoding.UTF8.GetBytes(testCase.PublicKey), + "POST", + path, + Array.Empty>(), + Array.Empty(), + tlSignature + ); // should not throw + } + + [Theory] + [MemberData(nameof(TestCases))] + public void SignAndVerify_EmptyBody_InterchangeableForms(TestCase testCase) + { + // All forms of empty body should be interchangeable + var path = "/test/empty-interchange"; + + // Sign with not called (empty) + var sig1 = Signer.SignWithPem(testCase.Kid, testCase.PrivateKey) + .Method("POST") + .Path(path) + .Sign(); + + // Verify with empty span - should work + VerifierSpan.VerifyWithPem( + Encoding.UTF8.GetBytes(testCase.PublicKey), + "POST", + path, + Array.Empty>(), + ReadOnlySpan.Empty, + sig1 + ); + + // Sign with empty string + var sig2 = Signer.SignWithPem(testCase.Kid, testCase.PrivateKey) + .Method("POST") + .Path(path) + .Body("") + .Sign(); + + // Verify with empty array - should work + VerifierSpan.VerifyWithPem( + Encoding.UTF8.GetBytes(testCase.PublicKey), + "POST", + path, + Array.Empty>(), + Array.Empty(), + sig2 + ); + } + + [Theory] + [MemberData(nameof(TestCases))] + public void SignAndVerify_EmptyBody_Mismatch(TestCase testCase) + { + // Empty body vs non-empty body should fail verification + var path = "/test/empty-mismatch"; + + var tlSignature = Signer.SignWithPem(testCase.Kid, testCase.PrivateKey) + .Method("POST") + .Path(path) + .Body("") // empty + .Sign(); + + Action verify = () => VerifierSpan.VerifyWithPem( + Encoding.UTF8.GetBytes(testCase.PublicKey), + "POST", + path, + Array.Empty>(), + Encoding.UTF8.GetBytes("{}"), // not empty + tlSignature + ); + + verify.Should().Throw(); + } + + [Theory] + [MemberData(nameof(TestCases))] + public void SignAndVerify_EmptyBody_WithHeaders(TestCase testCase) + { + // Empty body with headers should work + var path = "/test/empty-with-headers"; + var idempotencyKey = "idemp-empty-body-test"; + + var tlSignature = Signer.SignWithPem(testCase.Kid, testCase.PrivateKey) + .Method("DELETE") + .Path(path) + .Header("Idempotency-Key", idempotencyKey) + .Body(Array.Empty()) + .Sign(); + + var headers = new[] + { + new KeyValuePair("Idempotency-Key", Encoding.UTF8.GetBytes(idempotencyKey)) + }; + + VerifierSpan.VerifyWithPem( + Encoding.UTF8.GetBytes(testCase.PublicKey), + "DELETE", + path, + headers, + ReadOnlySpan.Empty, + tlSignature + ); // should not throw + } + + [Fact] + public void InvalidPath_ShouldThrowArgumentException() + { + Action verify = () => VerifierSpan.VerifyWithPem( + Encoding.UTF8.GetBytes(PublicKey), + "POST", + "https://example.com/the-path", // invalid - doesn't start with '/' + Array.Empty>(), + Array.Empty(), + "dummy..signature" + ); + + verify.Should().Throw() + .WithMessage("Invalid path \"https://example.com/the-path\" must start with '/'"); + } + + [Theory] + [InlineData("nodots")] + [InlineData("one.dot")] + [InlineData("too.many.dots.here")] + public void InvalidSignatureFormat_ShouldThrowSignatureException(string invalidSignature) + { + Action verify = () => VerifierSpan.VerifyWithPem( + Encoding.UTF8.GetBytes(PublicKey), + "POST", + "/test", + Array.Empty>(), + Encoding.UTF8.GetBytes("{}"), + invalidSignature + ); + + verify.Should().Throw() + .WithMessage("invalid signature format, expected detached JWS (header..signature)"); + } + + [Fact] + public void BadKey_ShouldThrowArgumentException() + { + Action verify = () => VerifierSpan.VerifyWithPem( + Encoding.UTF8.GetBytes("not-a-key"), + "POST", + "/foo", + Array.Empty>(), + Encoding.UTF8.GetBytes("{}"), + "dummy..signature" + ); + + verify.Should().Throw(); + } + + [Fact] + public void BadSignature_ShouldThrowSignatureException() + { + Action verify = () => VerifierSpan.VerifyWithPem( + Encoding.UTF8.GetBytes(PublicKey), + "POST", + "/foo", + Array.Empty>(), + Encoding.UTF8.GetBytes("{}"), + "not-a-signature" + ); + + verify.Should().Throw(); + } + + [Fact] + public void InvalidButPreAttachedJwsBody_ShouldThrowSignatureException() + { + // Signature for `/bar` but we're verifying against `/foo` - should fail + const string signature = "eyJhbGciOiJFUzUxMiIsImtpZCI6IjQ1ZmM3NWNmLTU2ND" + + "ktNDEzNC04NGIzLTE5MmMyYzc4ZTk5MCIsInRsX3ZlcnNpb24iOiIyIiwidGxfaGV" + + "hZGVycyI6IiJ9.UE9TVCAvYmFyCnt9.ARLa7Q5b8k5CIhfy1qrS-IkNqCDeE-VFRD" + + "z7Lb0fXUMOi_Ktck-R7BHDMXFDzbI5TyaxIo5TGHZV_cs0fg96dlSxAERp3UaN2oC" + + "QHIE5gQ4m5uU3ee69XfwwU_RpEIMFypycxwq1HOf4LzTLXqP_CDT8DdyX8oTwYdUB" + + "d2d3D17Wd9UA"; + + Action verify = () => VerifierSpan.VerifyWithPem( + Encoding.UTF8.GetBytes(PublicKey), + "POST", + "/foo", // not /bar so should fail + Array.Empty>(), + Encoding.UTF8.GetBytes("{}"), + signature + ); + + verify.Should().Throw(); + } + + [Fact] + public void InvalidButPreAttachedJwsBodyTrailingDots_ShouldThrowSignatureException() + { + // Signature for `/bar` but with trailing dots - should fail + const string signature = "eyJhbGciOiJFUzUxMiIsImtpZCI6IjQ1ZmM3NWNmLTU2ND" + + "ktNDEzNC04NGIzLTE5MmMyYzc4ZTk5MCIsInRsX3ZlcnNpb24iOiIyIiwidGxfaGV" + + "hZGVycyI6IiJ9.UE9TVCAvYmFyCnt9.ARLa7Q5b8k5CIhfy1qrS-IkNqCDeE-VFRD" + + "z7Lb0fXUMOi_Ktck-R7BHDMXFDzbI5TyaxIo5TGHZV_cs0fg96dlSxAERp3UaN2oC" + + "QHIE5gQ4m5uU3ee69XfwwU_RpEIMFypycxwq1HOf4LzTLXqP_CDT8DdyX8oTwYdUB" + + "d2d3D17Wd9UA...."; + + Action verify = () => VerifierSpan.VerifyWithPem( + Encoding.UTF8.GetBytes(PublicKey), + "POST", + "/foo", // not /bar so should fail + Array.Empty>(), + Encoding.UTF8.GetBytes("{}"), + signature + ); + + verify.Should().Throw(); + } + + [Fact] + public void TamperedSignature_ShouldThrowSignatureException() + { + // Create a valid signature, then tamper with it + var body = "{\"currency\":\"GBP\",\"max_amount_in_minor\":5000000}"; + var path = "/test"; + + var tlSignature = Signer.SignWithPem(Kid, PrivateKey) + .Method("POST") + .Path(path) + .Body(body) + .Sign(); + + // Tamper with the signature by replacing a character + var tamperedSignature = tlSignature.Substring(0, tlSignature.Length - 5) + "XXXXX"; + + Action verify = () => VerifierSpan.VerifyWithPem( + Encoding.UTF8.GetBytes(PublicKey), + "POST", + path, + Array.Empty>(), + Encoding.UTF8.GetBytes(body), + tamperedSignature + ); + + verify.Should().Throw(); + } + + [Fact] + public void LargePayload_ShouldWork() + { + // Test with a large payload that exceeds stackalloc threshold (>2KB) + var largeBody = new string('x', 3000); + var path = "/test/large"; + + var tlSignature = Signer.SignWithPem(Kid, PrivateKey) + .Method("POST") + .Path(path) + .Body(largeBody) + .Sign(); + + VerifierSpan.VerifyWithPem( + Encoding.UTF8.GetBytes(PublicKey), + "POST", + path, + Array.Empty>(), + Encoding.UTF8.GetBytes(largeBody), + tlSignature + ); // should not throw + } + + public sealed class TestCase + { + public TestCase(string name, string kid, string privateKey, string publicKey) + { + Name = name; + Kid = kid; + PrivateKey = privateKey; + PublicKey = publicKey; + } + + private string Name { get; } + public string Kid { get; } + public string PrivateKey { get; } + public string PublicKey { get; } + + public override string ToString() => Name; + } + } +} +#endif