diff --git a/csharp/benchmarks/InternalPerformanceBenchmarks.cs b/csharp/benchmarks/InternalPerformanceBenchmarks.cs deleted file mode 100644 index 2b778ea7..00000000 --- a/csharp/benchmarks/InternalPerformanceBenchmarks.cs +++ /dev/null @@ -1,84 +0,0 @@ -using BenchmarkDotNet.Attributes; -using BenchmarkDotNet.Order; -using System.Linq; -using System.Security.Cryptography; -using System.Text; -using TrueLayer.Signing; - -namespace TrueLayer.Signing.Benchmarks; - -/// -/// Benchmarks for JWS detached payload verification optimization. -/// Compares manual JWS reconstruction vs native detached payload support. -/// -[MemoryDiagnoser] -[Orderer(SummaryOrderPolicy.FastestToSlowest)] -[RankColumn] -public class JwsVerificationBenchmarks -{ - private ECDsa? _privateKey; - private ECDsa? _publicKey; - private string? _testSignature; - - [GlobalSetup] - public void Setup() - { - _privateKey = TestData.GetPrivateKey(); - _publicKey = TestData.GetPublicKey(); - - var scenario = TestData.Scenarios.ManyHeaders; - _testSignature = Signer.SignWith(TestData.Kid, _privateKey) - .Method(scenario.Method) - .Path(scenario.Path) - .Headers(scenario.Headers) - .Body(scenario.Body) - .Sign(); - } - - [Benchmark(Baseline = true, Description = "JWS Verify - OLD (Manual Reconstruction)")] - public string? JwsVerify_Old_ManualReconstruction() - { - var signatureParts = _testSignature!.Split('.'); - var scenario = TestData.Scenarios.ManyHeaders; - var headers = scenario.Headers.Select(h => (h.Key, Encoding.UTF8.GetBytes(h.Value))); - var signingPayload = Util.BuildV2SigningPayload( - scenario.Method, - scenario.Path, - headers, - Encoding.UTF8.GetBytes(scenario.Body) - ); - - var jws = $"{signatureParts[0]}.{Jose.Base64Url.Encode(signingPayload)}.{signatureParts[2]}"; - - try - { - return Jose.JWT.Decode(jws, _publicKey!); - } - catch - { - return null; - } - } - - [Benchmark(Description = "JWS Verify - NEW (Detached Payload)")] - public byte[]? JwsVerify_New_DetachedPayload() - { - var scenario = TestData.Scenarios.ManyHeaders; - var headers = scenario.Headers.Select(h => (h.Key, Encoding.UTF8.GetBytes(h.Value))); - var signingPayload = Util.BuildV2SigningPayload( - scenario.Method, - scenario.Path, - headers, - Encoding.UTF8.GetBytes(scenario.Body) - ); - - try - { - return Jose.JWT.DecodeBytes(_testSignature!, _publicKey!, payload: signingPayload); - } - catch - { - return null; - } - } -} diff --git a/csharp/benchmarks/Program.cs b/csharp/benchmarks/Program.cs index d7c55292..13f0287b 100644 --- a/csharp/benchmarks/Program.cs +++ b/csharp/benchmarks/Program.cs @@ -17,10 +17,6 @@ case "verifier": BenchmarkRunner.Run(config); break; - case "jws": - case "jws-verify": - BenchmarkRunner.Run(config); - break; case "all": default: BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args, config); diff --git a/csharp/benchmarks/TrueLayer.Signing.Benchmarks.csproj b/csharp/benchmarks/TrueLayer.Signing.Benchmarks.csproj index 6e6ca7db..a38ca39c 100644 --- a/csharp/benchmarks/TrueLayer.Signing.Benchmarks.csproj +++ b/csharp/benchmarks/TrueLayer.Signing.Benchmarks.csproj @@ -1,7 +1,7 @@ Exe - net9.0 + net10.0 enable TrueLayer.Signing.Benchmarks TrueLayer.Signing.Benchmarks diff --git a/csharp/src/Base64Url.cs b/csharp/src/Base64Url.cs new file mode 100644 index 00000000..8689fdb5 --- /dev/null +++ b/csharp/src/Base64Url.cs @@ -0,0 +1,23 @@ +using System; + +namespace TrueLayer.Signing +{ + /// Internal Base64Url encoding/decoding for AOT compatibility. + internal static class Base64Url + { + /// Encode bytes to base64url string. + public static string Encode(byte[] input) => Convert.ToBase64String(input).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + /// Decode base64url string to bytes. + public static byte[] Decode(string input) + { + var base64 = input.Replace('-', '+').Replace('_', '/'); + switch (input.Length % 4) + { + case 2: base64 += "=="; break; + case 3: base64 += "="; break; + } + return Convert.FromBase64String(base64); + } + } +} diff --git a/csharp/src/Signer.cs b/csharp/src/Signer.cs index 78cb3aa1..23bf710d 100644 --- a/csharp/src/Signer.cs +++ b/csharp/src/Signer.cs @@ -5,7 +5,6 @@ using System.Text; using System.Text.Json; using System.Threading.Tasks; -using Jose; namespace TrueLayer.Signing { @@ -39,10 +38,13 @@ public static Signer SignWithPem(string kid, ReadOnlySpan privateKeyPem) /// /// Start building a request Tl-Signature header value using the key ID of the signing key (kid) - /// and a function that accepts the payload to sign and returns the signature in IEEE P1363 format. + /// and a function that accepts the payload to sign and returns the signature in IEEE P1363 format. /// public static AsyncSigner SignWithFunction(string kid, Func> signAsync) => new FunctionSigner(kid, signAsync); - + + /// + /// Initializes a new instance of the Signer class with the specified key ID. + /// protected internal Signer(string kid) : base(kid) { } @@ -51,16 +53,27 @@ protected internal Signer(string kid) : base(kid) public abstract string Sign(); } + /// + /// Base class for signature builders with fluent API support. + /// public abstract class Signer where TSigner : Signer { private readonly string _kid; private readonly TSigner _this; + /// HTTP method for the request. protected internal string _method = "POST"; + /// Request path. protected internal string _path = ""; + /// Request headers to include in the signature. protected internal readonly Dictionary _headers = new(StringComparer.OrdinalIgnoreCase); + /// Request body. protected internal byte[] _body = Array.Empty(); + /// JSON Web Key Set URL. protected internal string? _jku; + /// + /// Initializes a new instance of the Signer class with the specified key ID. + /// protected internal Signer(string kid) { _kid = kid; @@ -157,7 +170,7 @@ public TSigner Body(byte[] body) public TSigner Body(string body) => Body(body.ToUtf8()); /// - /// Sets the jku (JSON Web Key Set URL) in the JWS headers of the signature. + /// Sets the jku (JSON Web Key Set URL) in the JWS headers of the signature. /// public TSigner Jku(string jku) { @@ -165,6 +178,9 @@ public TSigner Jku(string jku) return _this; } + /// + /// Creates the JWS headers for the signature. + /// protected internal Dictionary CreateJwsHeaders() { var jwsHeaders = new Dictionary @@ -189,6 +205,9 @@ protected internal Dictionary CreateJwsHeaders() /// public abstract class AsyncSigner : Signer { + /// + /// Initializes a new instance of the AsyncSigner class with the specified key ID. + /// protected internal AsyncSigner(string kid) : base(kid) { } @@ -209,15 +228,26 @@ internal PrivateKeySigner(string kid, ECDsa privateKey) : base(kid) public override string Sign() { var jwsHeaders = CreateJwsHeaders(); +#if NET5_0_OR_GREATER + var serializedJwsHeaders = JsonSerializer.SerializeToUtf8Bytes(jwsHeaders, SigningJsonContext.Default.DictionaryStringObject); +#else + var serializedJwsHeaders = JsonSerializer.SerializeToUtf8Bytes(jwsHeaders); +#endif + var serializedJwsHeadersB64 = Base64Url.Encode(serializedJwsHeaders); + var headerList = _headers.Select(e => (e.Key, e.Value)); var signingPayload = Util.BuildV2SigningPayload(_method, _path, headerList, _body); + var signingPayloadB64 = Base64Url.Encode(signingPayload); + + // Compute SHA-512 hash of the JWS signing input + var hash = Util.ComputeJwsSigningHash(serializedJwsHeadersB64, signingPayloadB64); + + // Sign the hash using ECDSA - signature will be in IEEE P1363 format (r||s) + var signature = _key.SignHash(hash); + var signatureB64 = Base64Url.Encode(signature); - return JWT.EncodeBytes( - signingPayload, - _key, - JwsAlgorithm.ES512, - jwsHeaders, - options: new JwtOptions {DetachPayload = true}); + // Return detached JWS format: header..signature (empty payload) + return $"{serializedJwsHeadersB64}..{signatureB64}"; } } @@ -233,7 +263,11 @@ internal FunctionSigner(string kid, Func> signAsync) : base public override async Task SignAsync() { var jwsHeaders = CreateJwsHeaders(); +#if NET5_0_OR_GREATER + var serializedJwsHeaders = JsonSerializer.SerializeToUtf8Bytes(jwsHeaders, SigningJsonContext.Default.DictionaryStringObject); +#else var serializedJwsHeaders = JsonSerializer.SerializeToUtf8Bytes(jwsHeaders); +#endif var serializedJwsHeadersB64 = Base64Url.Encode(serializedJwsHeaders); var headerList = _headers.Select(e => (e.Key, e.Value)); diff --git a/csharp/src/Util.cs b/csharp/src/Util.cs index 9f993963..597ca3a8 100644 --- a/csharp/src/Util.cs +++ b/csharp/src/Util.cs @@ -3,6 +3,8 @@ using System.IO; using System.Security.Cryptography; using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; namespace TrueLayer.Signing { @@ -140,10 +142,19 @@ private static void PreNet5ImportPem(this ECDsa key, ReadOnlySpan pem) } /// Gets a value from the map as a string or null. - public static string? GetString(this IDictionary dict, string key) + public static string? GetString(this Dictionary dict, string key) { - dict.TryGetValue(key, out var value); - return value as string; + if (!dict.TryGetValue(key, out var element)) + { + return null; + } + + if (element.ValueKind == JsonValueKind.String) + { + return element.GetString(); + } + + return null; } /// @@ -160,6 +171,39 @@ public static byte[] PrependZeroPad(this byte[] bytes, int length) Array.Copy(bytes, 0, padded, 1, bytes.Length); return padded; } + + /// + /// Builds the JWS signing input in the format "base64url(header).base64url(payload)" + /// and computes the SHA-512 hash for ES512 signing/verification. + /// + /// Base64url-encoded JWS header + /// Base64url-encoded payload + /// SHA-512 hash of the signing input + internal static byte[] ComputeJwsSigningHash(string headerB64, string payloadB64) + { + // Build the signing input: base64url(header).base64url(payload) + var signingInput = new byte[headerB64.Length + 1 + payloadB64.Length]; +#if NET5_0_OR_GREATER + // Use Span-based API for better performance on modern .NET + Encoding.ASCII.GetBytes(headerB64, signingInput.AsSpan(0, headerB64.Length)); + signingInput[headerB64.Length] = (byte)'.'; + Encoding.ASCII.GetBytes(payloadB64, signingInput.AsSpan(headerB64.Length + 1)); +#else + Encoding.ASCII.GetBytes(headerB64, 0, headerB64.Length, signingInput, 0); + signingInput[headerB64.Length] = (byte)'.'; + Encoding.ASCII.GetBytes(payloadB64, 0, payloadB64.Length, signingInput, headerB64.Length + 1); +#endif + + // Compute SHA-512 hash of the signing input (ES512 uses SHA-512) +#if NET5_0_OR_GREATER + return SHA512.HashData(signingInput); +#else + using (var sha512 = SHA512.Create()) + { + return sha512.ComputeHash(signingInput); + } +#endif + } } @@ -178,4 +222,18 @@ internal class Jwk public string X { get; set; } = ""; public string Y { get; set; } = ""; } + +#if NET5_0_OR_GREATER + /// AOT-compatible JSON serialization context for JWKS. + [JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.Never)] + [JsonSerializable(typeof(Jwks))] + [JsonSerializable(typeof(Jwk))] + [JsonSerializable(typeof(Dictionary))] + [JsonSerializable(typeof(Dictionary))] + internal partial class SigningJsonContext : JsonSerializerContext + { + } +#endif } diff --git a/csharp/src/Verifier.cs b/csharp/src/Verifier.cs index e053f7c0..f7d68720 100644 --- a/csharp/src/Verifier.cs +++ b/csharp/src/Verifier.cs @@ -4,7 +4,6 @@ using System.Security.Cryptography; using System.Text; using System.Text.Json; -using Jose; using Microsoft.Extensions.Primitives; namespace TrueLayer.Signing @@ -14,11 +13,6 @@ namespace TrueLayer.Signing /// public sealed class Verifier { - private static readonly JsonSerializerOptions JwksJsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase - }; - /// /// Start building a `Tl-Signature` header verifier using public key RFC 7468 PEM-encoded data. /// @@ -49,7 +43,15 @@ public static Verifier VerifyWithJwks(ReadOnlySpan jwksJson) { try { - var jwks = JsonSerializer.Deserialize(jwksJson, JwksJsonOptions); +#if NET5_0_OR_GREATER + var jwks = JsonSerializer.Deserialize(jwksJson, SigningJsonContext.Default.Jwks); +#else + var jwksJsonOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + var jwks = JsonSerializer.Deserialize(jwksJson, jwksJsonOptions); +#endif // ecdsa fully setup later once we know the jwk kid var verifier = VerifyWith(ECDsa.Create()); verifier._jwks = jwks ?? new Jwks(); @@ -68,21 +70,65 @@ public static Verifier VerifyWithJwks(ReadOnlySpan jwksJson) /// Signature is invalid private static string ExtractJwsHeader(string tlSignature, string headerName) { - IDictionary? jwsHeaders; + var jwsHeaders = ParseJwsHeaders(tlSignature); + var value = jwsHeaders.GetString(headerName); + if (value == null) + { + throw new SignatureException($"missing {headerName}"); + } + return value; + } + + /// Parse JWS headers from a JWS token in an AOT-compatible way. + /// Signature is invalid + private static Dictionary ParseJwsHeaders(string tlSignature) + { try { - jwsHeaders = Jose.JWT.Headers(tlSignature); + // JWS format: header.payload.signature + // For detached payload: header..signature + var firstDot = tlSignature.IndexOf('.'); + if (firstDot <= 0) + { + throw new SignatureException("invalid JWS format"); + } + + var headerB64 = tlSignature.Substring(0, firstDot); + return ParseJwsHeadersFromB64(headerB64); + } + catch (SignatureException) + { + throw; } catch (Exception e) { throw new SignatureException($"Failed to parse JWS: {e.Message}", e); } - var value = jwsHeaders.GetString(headerName); - if (value == null) + } + + /// Parse JWS headers from base64url-encoded header in an AOT-compatible way. + /// Signature is invalid + private static Dictionary ParseJwsHeadersFromB64(string headerB64) + { + try { - throw new SignatureException($"missing {headerName}"); + var headerJson = Base64Url.Decode(headerB64); + +#if NET5_0_OR_GREATER + var headers = JsonSerializer.Deserialize(headerJson, SigningJsonContext.Default.DictionaryStringJsonElement); +#else + var headers = JsonSerializer.Deserialize>(headerJson); +#endif + return headers ?? new Dictionary(); + } + catch (SignatureException) + { + throw; + } + catch (Exception e) + { + throw new SignatureException($"Failed to parse JWS: {e.Message}", e); } - return value; } /// Extract kid from unverified jws Tl-Signature. @@ -230,15 +276,12 @@ public void Verify(string tlSignature) var dotCount = tlSignature.Count(c => c == '.'); SignatureException.Ensure(dotCount == 2, "invalid signature format, expected detached JWS (header..signature)"); - IDictionary? jwsHeaders; - try - { - jwsHeaders = Jose.JWT.Headers(tlSignature); - } - catch (Exception e) - { - throw new SignatureException($"Failed to parse JWS: {e.Message}", e); - } + // Parse the JWS parts once + var parts = tlSignature.Split('.'); + var headerB64 = parts[0]; + var signatureB64 = parts[2]; + + var jwsHeaders = ParseJwsHeadersFromB64(headerB64); if (_jwks is Jwks jwkeys) { // initialize public key using jwks data @@ -277,20 +320,52 @@ public void Verify(string tlSignature) { try { - return Jose.JWT.DecodeBytes(tlSignature, _key, payload: signingPayload); + VerifyJwsSignature(headerB64, signatureB64, signingPayload, _key); + return true; } - catch (Jose.IntegrityException) + catch (SignatureException) { // try again with/without a trailing slash (#80) var path2 = _path.EndsWith("/") ? _path.Substring(0, _path.Length - 1) : _path + "/"; var alternatePayload = Util.BuildV2SigningPayload(_method, path2, signedHeaders, _body); - return Jose.JWT.DecodeBytes(tlSignature, _key, payload: alternatePayload); + VerifyJwsSignature(headerB64, signatureB64, alternatePayload, _key); + return true; } }, "Invalid signature"); } + /// Verify JWS signature manually without using reflection-based deserialization (AOT-compatible). + /// Signature is invalid + private static void VerifyJwsSignature(string headerB64, string signatureB64, byte[] payload, ECDsa key) + { + try + { + // Decode the signature - ES512 signatures are IEEE P1363 format (raw r||s) + var signature = Base64Url.Decode(signatureB64); + + // Compute SHA-512 hash of the JWS signing input + var payloadB64 = Base64Url.Encode(payload); + var hash = Util.ComputeJwsSigningHash(headerB64, payloadB64); + + // Verify the signature using ECDSA + // The signature is in IEEE P1363 format (concatenated r||s), which is what VerifyHash expects + if (!key.VerifyHash(hash, signature)) + { + throw new SignatureException("signature verification failed"); + } + } + catch (SignatureException) + { + throw; + } + catch (Exception e) + { + throw new SignatureException($"signature verification failed: {e.Message}", e); + } + } + /// Find and import jwk into `key` private void FindAndImportJwk(Jwks jwks, string kid) { diff --git a/csharp/src/truelayer-signing.csproj b/csharp/src/truelayer-signing.csproj index 790d280f..732de725 100644 --- a/csharp/src/truelayer-signing.csproj +++ b/csharp/src/truelayer-signing.csproj @@ -1,6 +1,6 @@ - 0.2.5 + 0.2.6-rc8 net10.0;net9.0;net8.0;netstandard2.0;netstandard2.1 bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml enable @@ -23,23 +23,44 @@ true true TrueLayer.Signing + true + true + true + true + + + + + - - - - - + + + + + + + + + + - + + + + + + + + <_Parameter1>TrueLayer.Signing.Tests diff --git a/csharp/test/Base64UrlTest.cs b/csharp/test/Base64UrlTest.cs new file mode 100644 index 00000000..a56c1bbe --- /dev/null +++ b/csharp/test/Base64UrlTest.cs @@ -0,0 +1,656 @@ +using Xunit; +using System; +using System.Text; +using AwesomeAssertions; + +namespace TrueLayer.Signing.Tests +{ + /// + /// Comprehensive unit tests for the custom Base64Url encoding/decoding implementation. + /// This is a security-critical component that replaced standard library functionality for AOT compatibility. + /// + public class Base64UrlTest + { + #region Encode Tests + + [Fact] + public void Encode_EmptyInput_ShouldReturnEmptyString() + { + var input = Array.Empty(); + var result = Base64Url.Encode(input); + result.Should().Be(""); + } + + [Fact] + public void Encode_SingleByte_ShouldEncode() + { + var input = new byte[] { 0xFF }; + var result = Base64Url.Encode(input); + + // Standard base64 would be "_w==" but base64url removes padding + result.Should().Be("_w"); + } + + [Fact] + public void Encode_TwoBytes_ShouldEncode() + { + var input = new byte[] { 0xFF, 0xFE }; + var result = Base64Url.Encode(input); + + // Standard base64 would be "__4=" but base64url removes padding + result.Should().Be("__4"); + } + + [Fact] + public void Encode_ThreeBytes_ShouldEncodeWithoutPadding() + { + var input = new byte[] { 0xFF, 0xFE, 0xFD }; + var result = Base64Url.Encode(input); + + // Standard base64 would be "__79" (no padding needed) + result.Should().Be("__79"); + } + + [Fact] + public void Encode_FourBytes_ShouldEncode() + { + var input = new byte[] { 0xFF, 0xFE, 0xFD, 0xFC }; + var result = Base64Url.Encode(input); + + // Length % 4 == 0, should have no padding + result.Should().NotContain("="); + } + + [Fact] + public void Encode_PaddingLength1_ShouldRemovePadding() + { + // Input length that results in 1 padding character + var input = new byte[] { 0x61, 0x62, 0x63, 0x64, 0x65 }; // "abcde" + var result = Base64Url.Encode(input); + + // Standard base64: "YWJjZGU=" + // Base64url: "YWJjZGU" + result.Should().NotContain("="); + result.Should().Be("YWJjZGU"); + } + + [Fact] + public void Encode_PaddingLength2_ShouldRemovePadding() + { + // Input length that results in 2 padding characters + var input = new byte[] { 0x61, 0x62, 0x63, 0x64 }; // "abcd" + var result = Base64Url.Encode(input); + + // Standard base64: "YWJjZA==" + // Base64url: "YWJjZA" + result.Should().NotContain("="); + result.Should().Be("YWJjZA"); + } + + [Fact] + public void Encode_WithPlus_ShouldReplaceWithDash() + { + // Crafted input that produces '+' in standard base64 + // 0x03 0xE3 produces "A+M=" in standard base64 + var input = new byte[] { 0x03, 0xE3 }; + var result = Base64Url.Encode(input); + + result.Should().NotContain("+"); + result.Should().Contain("-"); + result.Should().Be("A-M"); + } + + [Fact] + public void Encode_WithSlash_ShouldReplaceWithUnderscore() + { + // Crafted input that produces '/' in standard base64 + // 0xFF produces "_w==" in base64url + var input = new byte[] { 0xFF }; + var result = Base64Url.Encode(input); + + result.Should().NotContain("/"); + result.Should().Contain("_"); + } + + [Fact] + public void Encode_AllPossibleBytes_ShouldNotContainPaddingOrSpecialChars() + { + // Test with all byte values 0-255 + var input = new byte[256]; + for (int i = 0; i < 256; i++) + { + input[i] = (byte)i; + } + + var result = Base64Url.Encode(input); + + result.Should().NotContain("="); + result.Should().NotContain("+"); + result.Should().NotContain("/"); + } + + [Fact] + public void Encode_SimpleText_ShouldEncodeCorrectly() + { + var input = Encoding.UTF8.GetBytes("Hello World"); + var result = Base64Url.Encode(input); + + // Standard base64: "SGVsbG8gV29ybGQ=" + // Base64url: "SGVsbG8gV29ybGQ" + result.Should().Be("SGVsbG8gV29ybGQ"); + } + + [Fact] + public void Encode_JsonPayload_ShouldEncodeCorrectly() + { + var input = Encoding.UTF8.GetBytes("{\"test\":\"data\"}"); + var result = Base64Url.Encode(input); + + result.Should().NotContain("="); + result.Should().NotContain("+"); + result.Should().NotContain("/"); + } + + [Fact] + public void Encode_BinaryData_ShouldHandle() + { + var input = new byte[] { 0x00, 0x01, 0x02, 0x03, 0xFD, 0xFE, 0xFF }; + var result = Base64Url.Encode(input); + + result.Should().NotContain("="); + result.Should().NotBeEmpty(); + } + + [Fact] + public void Encode_LargeInput_ShouldHandle() + { + var input = new byte[10000]; + new Random(42).NextBytes(input); + + var result = Base64Url.Encode(input); + + result.Should().NotContain("="); + result.Should().NotContain("+"); + result.Should().NotContain("/"); + result.Length.Should().BeGreaterThan(0); + } + + #endregion + + #region Decode Tests + + [Fact] + public void Decode_EmptyInput_ShouldReturnEmptyArray() + { + var result = Base64Url.Decode(""); + result.Should().NotBeNull(); + result.Length.Should().Be(0); + } + + [Fact] + public void Decode_WithoutPadding_Length2_ShouldDecode() + { + // "YQ" without padding should decode to "a" + var result = Base64Url.Decode("YQ"); + result.Should().Equal(new byte[] { 0x61 }); // 'a' + } + + [Fact] + public void Decode_WithoutPadding_Length3_ShouldDecode() + { + // "YWI" without padding should decode to "ab" + var result = Base64Url.Decode("YWI"); + result.Should().Equal(new byte[] { 0x61, 0x62 }); // 'ab' + } + + [Fact] + public void Decode_WithoutPadding_Length4Multiple_ShouldDecode() + { + // "AQID" is length 4 (no padding needed) + var result = Base64Url.Decode("AQID"); + result.Should().Equal(new byte[] { 0x01, 0x02, 0x03 }); + } + + [Fact] + public void Decode_WithDash_ShouldDecodeAsPlus() + { + // "A-M" should decode same as "A+M=" in standard base64 + var result = Base64Url.Decode("A-M"); + result.Should().Equal(new byte[] { 0x03, 0xE3 }); + } + + [Fact] + public void Decode_WithUnderscore_ShouldDecodeAsSlash() + { + // "_w" should decode same as "/w==" in standard base64 + var result = Base64Url.Decode("_w"); + result.Should().Equal(new byte[] { 0xFF }); + } + + [Fact] + public void Decode_MultipleUnderscoresAndDashes_ShouldDecode() + { + // Base64url with both special characters + var input = "A-M_"; + var result = Base64Url.Decode(input); + result.Should().NotBeNull(); + result.Length.Should().BeGreaterThan(0); + } + + [Fact] + public void Decode_SimpleText_ShouldDecodeCorrectly() + { + var result = Base64Url.Decode("SGVsbG8gV29ybGQ"); + var decoded = Encoding.UTF8.GetString(result); + decoded.Should().Be("Hello World"); + } + + [Fact] + public void Decode_PaddingLength1_ShouldAddPadding() + { + // "YWJjZGU" -> needs 1 '=' to be valid base64 + var result = Base64Url.Decode("YWJjZGU"); + var decoded = Encoding.UTF8.GetString(result); + decoded.Should().Be("abcde"); + } + + [Fact] + public void Decode_PaddingLength2_ShouldAddPadding() + { + // "YWJjZA" -> needs 2 '==' to be valid base64 + var result = Base64Url.Decode("YWJjZA"); + var decoded = Encoding.UTF8.GetString(result); + decoded.Should().Be("abcd"); + } + + [Fact] + public void Decode_AllBase64UrlAlphabet_ShouldDecode() + { + // Test string containing all valid base64url characters + var input = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + var result = Base64Url.Decode(input); + result.Should().NotBeNull(); + result.Length.Should().BeGreaterThan(0); + } + + [Fact] + public void Decode_InvalidBase64_ShouldThrow() + { + // Invalid base64 characters + Action decode = () => Base64Url.Decode("!!!invalid!!!"); + decode.Should().Throw(); + } + + [Fact] + public void Decode_WithWhitespace_ShouldThrow() + { + // Base64 with whitespace (not valid base64url) + Action decode = () => Base64Url.Decode("SGVs bG8"); + decode.Should().Throw(); + } + + [Fact] + public void Decode_BinaryData_ShouldDecode() + { + // Encoded binary data + var encoded = "AAECAw"; + var result = Base64Url.Decode(encoded); + result.Should().Equal(new byte[] { 0x00, 0x01, 0x02, 0x03 }); + } + + #endregion + + #region Round-trip Tests + + [Fact] + public void RoundTrip_EmptyArray_ShouldMatch() + { + var original = Array.Empty(); + var encoded = Base64Url.Encode(original); + var decoded = Base64Url.Decode(encoded); + decoded.Should().Equal(original); + } + + [Fact] + public void RoundTrip_SingleByte_ShouldMatch() + { + var original = new byte[] { 0x42 }; + var encoded = Base64Url.Encode(original); + var decoded = Base64Url.Decode(encoded); + decoded.Should().Equal(original); + } + + [Fact] + public void RoundTrip_TwoBytes_ShouldMatch() + { + var original = new byte[] { 0x42, 0x43 }; + var encoded = Base64Url.Encode(original); + var decoded = Base64Url.Decode(encoded); + decoded.Should().Equal(original); + } + + [Fact] + public void RoundTrip_ThreeBytes_ShouldMatch() + { + var original = new byte[] { 0x42, 0x43, 0x44 }; + var encoded = Base64Url.Encode(original); + var decoded = Base64Url.Decode(encoded); + decoded.Should().Equal(original); + } + + [Fact] + public void RoundTrip_FourBytes_ShouldMatch() + { + var original = new byte[] { 0x42, 0x43, 0x44, 0x45 }; + var encoded = Base64Url.Encode(original); + var decoded = Base64Url.Decode(encoded); + decoded.Should().Equal(original); + } + + [Fact] + public void RoundTrip_AllPaddingLengths_ShouldMatch() + { + // Test all possible padding scenarios (length % 4 == 0, 1, 2, 3) + for (int length = 0; length < 20; length++) + { + var original = new byte[length]; + for (int i = 0; i < length; i++) + { + original[i] = (byte)(i % 256); + } + + var encoded = Base64Url.Encode(original); + var decoded = Base64Url.Decode(encoded); + + decoded.Should().Equal(original, $"Round-trip failed for length {length}"); + } + } + + [Fact] + public void RoundTrip_AllByteValues_ShouldMatch() + { + // Test with all possible byte values + var original = new byte[256]; + for (int i = 0; i < 256; i++) + { + original[i] = (byte)i; + } + + var encoded = Base64Url.Encode(original); + var decoded = Base64Url.Decode(encoded); + + decoded.Should().Equal(original); + } + + [Fact] + public void RoundTrip_RandomData_ShouldMatch() + { + var random = new Random(42); + + for (int i = 0; i < 100; i++) + { + var length = random.Next(0, 1000); + var original = new byte[length]; + random.NextBytes(original); + + var encoded = Base64Url.Encode(original); + var decoded = Base64Url.Decode(encoded); + + decoded.Should().Equal(original, $"Round-trip failed for random data iteration {i}"); + } + } + + [Fact] + public void RoundTrip_Utf8Text_ShouldMatch() + { + var texts = new[] + { + "", + "a", + "ab", + "abc", + "abcd", + "Hello World", + "The quick brown fox jumps over the lazy dog", + "{\"key\":\"value\"}", + "Unicode: δ½ ε₯½δΈ–η•Œ 🌍", + "Special chars: !@#$%^&*()_+-=[]{}|;':\",./<>?" + }; + + foreach (var text in texts) + { + var original = Encoding.UTF8.GetBytes(text); + var encoded = Base64Url.Encode(original); + var decoded = Base64Url.Decode(encoded); + + decoded.Should().Equal(original, $"Round-trip failed for text: {text}"); + Encoding.UTF8.GetString(decoded).Should().Be(text); + } + } + + [Fact] + public void RoundTrip_LargeData_ShouldMatch() + { + var original = new byte[100000]; + new Random(42).NextBytes(original); + + var encoded = Base64Url.Encode(original); + var decoded = Base64Url.Decode(encoded); + + decoded.Should().Equal(original); + } + + [Fact] + public void RoundTrip_BinaryWithNulls_ShouldMatch() + { + var original = new byte[] { 0x00, 0xFF, 0x00, 0xFF, 0x00 }; + var encoded = Base64Url.Encode(original); + var decoded = Base64Url.Decode(encoded); + decoded.Should().Equal(original); + } + + [Fact] + public void RoundTrip_JsonPayload_ShouldMatch() + { + var json = "{\"alg\":\"ES512\",\"kid\":\"test-key\",\"tl_version\":\"2\"}"; + var original = Encoding.UTF8.GetBytes(json); + var encoded = Base64Url.Encode(original); + var decoded = Base64Url.Decode(encoded); + + decoded.Should().Equal(original); + Encoding.UTF8.GetString(decoded).Should().Be(json); + } + + #endregion + + #region Edge Cases and Security Tests + + [Fact] + public void Encode_NullArray_ShouldThrow() + { + Action encode = () => Base64Url.Encode(null!); + encode.Should().Throw(); + } + + [Fact] + public void Decode_NullString_ShouldThrow() + { + Action decode = () => Base64Url.Decode(null!); + // The current implementation throws NullReferenceException + decode.Should().Throw(); + } + + [Fact] + public void Decode_WithStandardBase64Padding_ShouldStillWork() + { + // If someone passes base64 with padding, it should still decode + // (though our encoder never produces padding) + var result = Base64Url.Decode("YWJjZA=="); + var decoded = Encoding.UTF8.GetString(result); + decoded.Should().Be("abcd"); + } + + [Fact] + public void Decode_WithPlusAndSlash_ShouldDecode() + { + // Standard base64 characters should still work + // (for backwards compatibility or external input) + var result = Base64Url.Decode("A+M="); + result.Should().Equal(new byte[] { 0x03, 0xE3 }); + } + + [Fact] + public void Encode_ResultNeverContainsPadding() + { + // Comprehensive check that no padding is ever produced + var random = new Random(42); + for (int length = 0; length < 100; length++) + { + var input = new byte[length]; + random.NextBytes(input); + + var encoded = Base64Url.Encode(input); + encoded.Should().NotContain("=", $"Padding found for length {length}"); + } + } + + [Fact] + public void Encode_ResultOnlyContainsValidChars() + { + // Valid base64url chars: A-Z, a-z, 0-9, -, _ + var validChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + + var random = new Random(42); + for (int i = 0; i < 50; i++) + { + var input = new byte[random.Next(1, 100)]; + random.NextBytes(input); + + var encoded = Base64Url.Encode(input); + foreach (var c in encoded) + { + validChars.Should().Contain(c.ToString(), + $"Invalid character '{c}' found in encoded output"); + } + } + } + + [Fact] + public void RoundTrip_ConsecutiveOperations_ShouldMatch() + { + // Test multiple consecutive encode/decode operations + var original = Encoding.UTF8.GetBytes("test data"); + + var encoded1 = Base64Url.Encode(original); + var decoded1 = Base64Url.Decode(encoded1); + decoded1.Should().Equal(original); + + var encoded2 = Base64Url.Encode(decoded1); + var decoded2 = Base64Url.Decode(encoded2); + decoded2.Should().Equal(original); + + encoded1.Should().Be(encoded2); + } + + [Fact] + public void Decode_CaseSensitive_ShouldBeDifferent() + { + // Base64 is case-sensitive + var upper = Base64Url.Decode("AQID"); + var lower = Base64Url.Decode("aqid"); + + upper.Should().NotEqual(lower); + } + + [Fact] + public void RoundTrip_TypicalJwsHeader_ShouldMatch() + { + // Test with actual JWS header structure + var header = "{\"alg\":\"ES512\",\"kid\":\"45fc75cf-5649-4134-84b3-192c2c78e990\",\"tl_version\":\"2\",\"tl_headers\":\"Idempotency-Key\"}"; + var original = Encoding.UTF8.GetBytes(header); + + var encoded = Base64Url.Encode(original); + var decoded = Base64Url.Decode(encoded); + + decoded.Should().Equal(original); + Encoding.UTF8.GetString(decoded).Should().Be(header); + } + + [Fact] + public void RoundTrip_TypicalSignature_ShouldMatch() + { + // Test with signature-like data (132 bytes for ES512) + var signature = new byte[132]; + new Random(42).NextBytes(signature); + + var encoded = Base64Url.Encode(signature); + var decoded = Base64Url.Decode(encoded); + + decoded.Should().Equal(signature); + decoded.Length.Should().Be(132); + } + + #endregion + + #region RFC 4648 Compliance Tests + + [Fact] + public void Rfc4648_TestVector1_ShouldEncode() + { + // RFC 4648 test vectors adapted for base64url + var input = Encoding.ASCII.GetBytes(""); + var result = Base64Url.Encode(input); + result.Should().Be(""); + } + + [Fact] + public void Rfc4648_TestVector2_ShouldEncode() + { + var input = Encoding.ASCII.GetBytes("f"); + var result = Base64Url.Encode(input); + result.Should().Be("Zg"); // No padding + } + + [Fact] + public void Rfc4648_TestVector3_ShouldEncode() + { + var input = Encoding.ASCII.GetBytes("fo"); + var result = Base64Url.Encode(input); + result.Should().Be("Zm8"); // No padding + } + + [Fact] + public void Rfc4648_TestVector4_ShouldEncode() + { + var input = Encoding.ASCII.GetBytes("foo"); + var result = Base64Url.Encode(input); + result.Should().Be("Zm9v"); + } + + [Fact] + public void Rfc4648_TestVector5_ShouldEncode() + { + var input = Encoding.ASCII.GetBytes("foob"); + var result = Base64Url.Encode(input); + result.Should().Be("Zm9vYg"); // No padding + } + + [Fact] + public void Rfc4648_TestVector6_ShouldEncode() + { + var input = Encoding.ASCII.GetBytes("fooba"); + var result = Base64Url.Encode(input); + result.Should().Be("Zm9vYmE"); // No padding + } + + [Fact] + public void Rfc4648_TestVector7_ShouldEncode() + { + var input = Encoding.ASCII.GetBytes("foobar"); + var result = Base64Url.Encode(input); + result.Should().Be("Zm9vYmFy"); + } + + #endregion + } +} diff --git a/csharp/test/ErrorTest.cs b/csharp/test/ErrorTest.cs index 60f8aede..50e3bc7a 100644 --- a/csharp/test/ErrorTest.cs +++ b/csharp/test/ErrorTest.cs @@ -1,7 +1,6 @@ using Xunit; using System; using AwesomeAssertions; -using Jose; using System.Text; using System.Collections.Generic; using System.Text.Json; diff --git a/csharp/test/JwsVerificationTest.cs b/csharp/test/JwsVerificationTest.cs new file mode 100644 index 00000000..07a739a0 --- /dev/null +++ b/csharp/test/JwsVerificationTest.cs @@ -0,0 +1,778 @@ +using Xunit; +using System; +using System.Collections.Generic; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using AwesomeAssertions; +using static TrueLayer.Signing.Tests.TestData; + +namespace TrueLayer.Signing.Tests +{ + /// + /// Comprehensive unit tests for custom JWS verification logic (VerifyJwsSignature and ParseJwsHeadersFromB64). + /// These security-critical functions replaced the Jose.JWT library and require thorough testing. + /// + public class JwsVerificationTest + { + #region ParseJwsHeadersFromB64 Tests + + [Fact] + public void ParseJwsHeaders_ValidHeader_ShouldSucceed() + { + // Valid JWS header with required fields - test that a properly signed request verifies + var tlSignature = Signer.SignWithPem(Kid, PrivateKey) + .Method("POST") + .Path("/test") + .Header("Idempotency-Key", "test-value") + .Body("{}") + .Sign(); + + // Should not throw + Verifier.VerifyWithPem(PublicKey) + .Method("POST") + .Path("/test") + .Header("Idempotency-Key", "test-value") + .Body("{}") + .Verify(tlSignature); + } + + [Fact] + public void ParseJwsHeaders_EmptyHeader_ShouldFail() + { + // Empty string is not valid base64url + Action verify = () => Verifier.VerifyWithPem(PublicKey) + .Method("POST") + .Path("/test") + .Body("{}") + .Verify(".."); + + verify.Should().Throw(); + } + + [Fact] + public void ParseJwsHeaders_InvalidBase64Url_ShouldFail() + { + // Invalid base64url characters + Action verify = () => Verifier.VerifyWithPem(PublicKey) + .Method("POST") + .Path("/test") + .Body("{}") + .Verify("!!!invalid-base64!!.."); + + verify.Should().Throw(); + } + + [Fact] + public void ParseJwsHeaders_InvalidJson_ShouldFail() + { + // Valid base64url but invalid JSON + var invalidJson = "not-json-at-all"; + var headerB64 = Base64Url.Encode(Encoding.UTF8.GetBytes(invalidJson)); + + Action verify = () => Verifier.VerifyWithPem(PublicKey) + .Method("POST") + .Path("/test") + .Body("{}") + .Verify($"{headerB64}.."); + + verify.Should().Throw(); + } + + [Fact] + public void ParseJwsHeaders_MalformedJson_ShouldFail() + { + // Malformed JSON (missing closing brace) + var malformedJson = "{\"alg\":\"ES512\",\"kid\":\"test\""; + var headerB64 = Base64Url.Encode(Encoding.UTF8.GetBytes(malformedJson)); + + Action verify = () => Verifier.VerifyWithPem(PublicKey) + .Method("POST") + .Path("/test") + .Body("{}") + .Verify($"{headerB64}.."); + + verify.Should().Throw(); + } + + [Fact] + public void ParseJwsHeaders_EmptyJsonObject_ShouldParseButFailValidation() + { + // Empty JSON object - parses but should fail validation (missing required fields) + var emptyJson = "{}"; + var headerB64 = Base64Url.Encode(Encoding.UTF8.GetBytes(emptyJson)); + + Action verify = () => Verifier.VerifyWithPem(PublicKey) + .Method("POST") + .Path("/test") + .Body("{}") + .Verify($"{headerB64}.."); + + verify.Should().Throw(); + } + + [Fact] + public void ParseJwsHeaders_NullJsonObject_ShouldFail() + { + // JSON literal null + var nullJson = "null"; + var headerB64 = Base64Url.Encode(Encoding.UTF8.GetBytes(nullJson)); + + Action verify = () => Verifier.VerifyWithPem(PublicKey) + .Method("POST") + .Path("/test") + .Body("{}") + .Verify($"{headerB64}.."); + + verify.Should().Throw(); + } + + [Fact] + public void ParseJwsHeaders_JsonArray_ShouldFail() + { + // JSON array instead of object + var arrayJson = "[\"alg\",\"ES512\"]"; + var headerB64 = Base64Url.Encode(Encoding.UTF8.GetBytes(arrayJson)); + + Action verify = () => Verifier.VerifyWithPem(PublicKey) + .Method("POST") + .Path("/test") + .Body("{}") + .Verify($"{headerB64}.."); + + verify.Should().Throw(); + } + + [Fact] + public void ParseJwsHeaders_Base64UrlPaddingVariations_ShouldSucceed() + { + // Test various padding scenarios in base64url encoding + var tlSignature = Signer.SignWithPem(Kid, PrivateKey) + .Method("POST") + .Path("/test") + .Body("{}") + .Sign(); + + // Signature should work regardless of padding + Verifier.VerifyWithPem(PublicKey) + .Method("POST") + .Path("/test") + .Body("{}") + .Verify(tlSignature); + } + + #endregion + + #region VerifyJwsSignature Tests - Malformed Tokens + + [Fact] + public void VerifyJws_MissingSignaturePart_ShouldFail() + { + // JWS with empty signature part + var headerDict = new Dictionary + { + ["alg"] = "ES512", + ["kid"] = Kid, + ["tl_version"] = "2", + ["tl_headers"] = "" + }; + var headerB64 = Base64Url.Encode(JsonSerializer.SerializeToUtf8Bytes(headerDict)); + + Action verify = () => Verifier.VerifyWithPem(PublicKey) + .Method("POST") + .Path("/test") + .Body("{}") + .Verify($"{headerB64}.."); + + verify.Should().Throw(); + } + + [Fact] + public void VerifyJws_InvalidSignatureBase64_ShouldFail() + { + // Invalid base64url in signature part + var headerDict = new Dictionary + { + ["alg"] = "ES512", + ["kid"] = Kid, + ["tl_version"] = "2", + ["tl_headers"] = "" + }; + var headerB64 = Base64Url.Encode(JsonSerializer.SerializeToUtf8Bytes(headerDict)); + + Action verify = () => Verifier.VerifyWithPem(PublicKey) + .Method("POST") + .Path("/test") + .Body("{}") + .Verify($"{headerB64}..!!!invalid!!!"); + + verify.Should().Throw(); + } + + [Fact] + public void VerifyJws_WrongSignatureLength_ShouldFail() + { + // ES512 signatures should be 132 bytes (IEEE P1363 format) + // Test with wrong length signature + var headerDict = new Dictionary + { + ["alg"] = "ES512", + ["kid"] = Kid, + ["tl_version"] = "2", + ["tl_headers"] = "" + }; + var headerB64 = Base64Url.Encode(JsonSerializer.SerializeToUtf8Bytes(headerDict)); + + // Create a signature that's too short + var shortSignature = Base64Url.Encode(new byte[64]); // Should be 132 bytes for ES512 + + Action verify = () => Verifier.VerifyWithPem(PublicKey) + .Method("POST") + .Path("/test") + .Body("{}") + .Verify($"{headerB64}..{shortSignature}"); + + verify.Should().Throw(); + } + + [Fact] + public void VerifyJws_AllZeroSignature_ShouldFail() + { + // Valid length but all zeros (invalid signature) + var headerDict = new Dictionary + { + ["alg"] = "ES512", + ["kid"] = Kid, + ["tl_version"] = "2", + ["tl_headers"] = "" + }; + var headerB64 = Base64Url.Encode(JsonSerializer.SerializeToUtf8Bytes(headerDict)); + var zeroSignature = Base64Url.Encode(new byte[132]); + + Action verify = () => Verifier.VerifyWithPem(PublicKey) + .Method("POST") + .Path("/test") + .Body("{}") + .Verify($"{headerB64}..{zeroSignature}"); + + verify.Should().Throw(); + } + + #endregion + + #region VerifyJwsSignature Tests - Tampered Signatures + + [Fact] + public void VerifyJws_TamperedSignatureLastByte_ShouldFail() + { + // Sign normally, then tamper with the signature + var tlSignature = Signer.SignWithPem(Kid, PrivateKey) + .Method("POST") + .Path("/test") + .Body("{\"test\":\"data\"}") + .Sign(); + + // Tamper with the signature by flipping the last byte + var parts = tlSignature.Split('.'); + var signatureBytes = Base64Url.Decode(parts[2]); + signatureBytes[signatureBytes.Length - 1] ^= 0xFF; // Flip all bits in last byte + var tamperedSignature = Base64Url.Encode(signatureBytes); + + var tamperedJws = $"{parts[0]}..{tamperedSignature}"; + + Action verify = () => Verifier.VerifyWithPem(PublicKey) + .Method("POST") + .Path("/test") + .Body("{\"test\":\"data\"}") + .Verify(tamperedJws); + + verify.Should().Throw(); + } + + [Fact] + public void VerifyJws_TamperedSignatureFirstByte_ShouldFail() + { + var tlSignature = Signer.SignWithPem(Kid, PrivateKey) + .Method("POST") + .Path("/test") + .Body("{}") + .Sign(); + + var parts = tlSignature.Split('.'); + var signatureBytes = Base64Url.Decode(parts[2]); + signatureBytes[0] ^= 0x01; // Flip one bit in first byte + var tamperedSignature = Base64Url.Encode(signatureBytes); + + var tamperedJws = $"{parts[0]}..{tamperedSignature}"; + + Action verify = () => Verifier.VerifyWithPem(PublicKey) + .Method("POST") + .Path("/test") + .Body("{}") + .Verify(tamperedJws); + + verify.Should().Throw(); + } + + [Fact] + public void VerifyJws_TamperedSignatureMiddleByte_ShouldFail() + { + var tlSignature = Signer.SignWithPem(Kid, PrivateKey) + .Method("POST") + .Path("/test") + .Body("{}") + .Sign(); + + var parts = tlSignature.Split('.'); + var signatureBytes = Base64Url.Decode(parts[2]); + signatureBytes[signatureBytes.Length / 2] ^= 0x01; + var tamperedSignature = Base64Url.Encode(signatureBytes); + + var tamperedJws = $"{parts[0]}..{tamperedSignature}"; + + Action verify = () => Verifier.VerifyWithPem(PublicKey) + .Method("POST") + .Path("/test") + .Body("{}") + .Verify(tamperedJws); + + verify.Should().Throw(); + } + + [Fact] + public void VerifyJws_SwappedRAndSComponents_ShouldFail() + { + // ES512 signature is r||s (each 66 bytes) + // Swapping them should fail verification + var tlSignature = Signer.SignWithPem(Kid, PrivateKey) + .Method("POST") + .Path("/test") + .Body("{}") + .Sign(); + + var parts = tlSignature.Split('.'); + var signatureBytes = Base64Url.Decode(parts[2]); + + // Swap r and s components + var r = new byte[66]; + var s = new byte[66]; + Array.Copy(signatureBytes, 0, r, 0, 66); + Array.Copy(signatureBytes, 66, s, 0, 66); + + var swapped = new byte[132]; + Array.Copy(s, 0, swapped, 0, 66); + Array.Copy(r, 0, swapped, 66, 66); + + var swappedSignature = Base64Url.Encode(swapped); + var tamperedJws = $"{parts[0]}..{swappedSignature}"; + + Action verify = () => Verifier.VerifyWithPem(PublicKey) + .Method("POST") + .Path("/test") + .Body("{}") + .Verify(tamperedJws); + + verify.Should().Throw(); + } + + #endregion + + #region VerifyJwsSignature Tests - Tampered Headers + + [Fact] + public void VerifyJws_TamperedHeaderAlg_ShouldFail() + { + // Create a valid signature, then change the algorithm in the header + var tlSignature = Signer.SignWithPem(Kid, PrivateKey) + .Method("POST") + .Path("/test") + .Body("{}") + .Sign(); + + var parts = tlSignature.Split('.'); + var headerBytes = Base64Url.Decode(parts[0]); + var headerDict = JsonSerializer.Deserialize>(headerBytes); + + // Change algorithm + if (headerDict != null) + { + headerDict["alg"] = "ES256"; + var tamperedHeaderB64 = Base64Url.Encode(JsonSerializer.SerializeToUtf8Bytes(headerDict)); + var tamperedJws = $"{tamperedHeaderB64}..{parts[2]}"; + + Action verify = () => Verifier.VerifyWithPem(PublicKey) + .Method("POST") + .Path("/test") + .Body("{}") + .Verify(tamperedJws); + + verify.Should().Throw(); + } + } + + [Fact] + public void VerifyJws_TamperedHeaderKid_ShouldFail() + { + var tlSignature = Signer.SignWithPem(Kid, PrivateKey) + .Method("POST") + .Path("/test") + .Body("{}") + .Sign(); + + var parts = tlSignature.Split('.'); + var headerBytes = Base64Url.Decode(parts[0]); + var headerDict = JsonSerializer.Deserialize>(headerBytes); + + if (headerDict != null) + { + headerDict["kid"] = "different-kid"; + var tamperedHeaderB64 = Base64Url.Encode(JsonSerializer.SerializeToUtf8Bytes(headerDict)); + var tamperedJws = $"{tamperedHeaderB64}..{parts[2]}"; + + Action verify = () => Verifier.VerifyWithPem(PublicKey) + .Method("POST") + .Path("/test") + .Body("{}") + .Verify(tamperedJws); + + verify.Should().Throw(); + } + } + + [Fact] + public void VerifyJws_TamperedHeaderTlHeaders_ShouldFail() + { + var tlSignature = Signer.SignWithPem(Kid, PrivateKey) + .Method("POST") + .Path("/test") + .Header("X-Custom", "value") + .Body("{}") + .Sign(); + + var parts = tlSignature.Split('.'); + var headerBytes = Base64Url.Decode(parts[0]); + var headerDict = JsonSerializer.Deserialize>(headerBytes); + + if (headerDict != null) + { + // Remove a header from tl_headers + headerDict["tl_headers"] = ""; + var tamperedHeaderB64 = Base64Url.Encode(JsonSerializer.SerializeToUtf8Bytes(headerDict)); + var tamperedJws = $"{tamperedHeaderB64}..{parts[2]}"; + + Action verify = () => Verifier.VerifyWithPem(PublicKey) + .Method("POST") + .Path("/test") + .Header("X-Custom", "value") + .Body("{}") + .Verify(tamperedJws); + + verify.Should().Throw(); + } + } + + #endregion + + #region VerifyJwsSignature Tests - Invalid Hash + + [Fact] + public void VerifyJws_SignatureForDifferentPayload_ShouldFail() + { + // Sign one payload, verify with a different one + var tlSignature = Signer.SignWithPem(Kid, PrivateKey) + .Method("POST") + .Path("/test") + .Body("{\"original\":\"payload\"}") + .Sign(); + + Action verify = () => Verifier.VerifyWithPem(PublicKey) + .Method("POST") + .Path("/test") + .Body("{\"tampered\":\"payload\"}") // Different payload + .Verify(tlSignature); + + verify.Should().Throw(); + } + + [Fact] + public void VerifyJws_SignatureForDifferentMethod_ShouldFail() + { + var tlSignature = Signer.SignWithPem(Kid, PrivateKey) + .Method("POST") + .Path("/test") + .Body("{}") + .Sign(); + + Action verify = () => Verifier.VerifyWithPem(PublicKey) + .Method("GET") // Different method + .Path("/test") + .Body("{}") + .Verify(tlSignature); + + verify.Should().Throw(); + } + + [Fact] + public void VerifyJws_SignatureForDifferentPath_ShouldFail() + { + var tlSignature = Signer.SignWithPem(Kid, PrivateKey) + .Method("POST") + .Path("/original") + .Body("{}") + .Sign(); + + Action verify = () => Verifier.VerifyWithPem(PublicKey) + .Method("POST") + .Path("/tampered") // Different path + .Body("{}") + .Verify(tlSignature); + + verify.Should().Throw(); + } + + [Fact] + public void VerifyJws_SignatureForDifferentHeaders_ShouldFail() + { + var tlSignature = Signer.SignWithPem(Kid, PrivateKey) + .Method("POST") + .Path("/test") + .Header("X-Custom", "original") + .Body("{}") + .Sign(); + + Action verify = () => Verifier.VerifyWithPem(PublicKey) + .Method("POST") + .Path("/test") + .Header("X-Custom", "tampered") // Different header value + .Body("{}") + .Verify(tlSignature); + + verify.Should().Throw(); + } + + #endregion + + #region VerifyJwsSignature Tests - Edge Cases + + [Fact] + public void VerifyJws_EmptyPayload_ShouldSucceed() + { + // Valid signature with empty payload + var tlSignature = Signer.SignWithPem(Kid, PrivateKey) + .Method("GET") + .Path("/test") + .Body("") + .Sign(); + + Verifier.VerifyWithPem(PublicKey) + .Method("GET") + .Path("/test") + .Body("") + .Verify(tlSignature); // Should not throw + } + + [Fact] + public void VerifyJws_LargePayload_ShouldSucceed() + { + // Test with a large payload + var largePayload = new string('x', 100000); + + var tlSignature = Signer.SignWithPem(Kid, PrivateKey) + .Method("POST") + .Path("/test") + .Body(largePayload) + .Sign(); + + Verifier.VerifyWithPem(PublicKey) + .Method("POST") + .Path("/test") + .Body(largePayload) + .Verify(tlSignature); // Should not throw + } + + [Fact] + public void VerifyJws_UnicodeInPayload_ShouldSucceed() + { + // Test with Unicode characters + var unicodePayload = "{\"message\":\"Hello δΈ–η•Œ 🌍\"}"; + + var tlSignature = Signer.SignWithPem(Kid, PrivateKey) + .Method("POST") + .Path("/test") + .Body(unicodePayload) + .Sign(); + + Verifier.VerifyWithPem(PublicKey) + .Method("POST") + .Path("/test") + .Body(unicodePayload) + .Verify(tlSignature); // Should not throw + } + + [Fact] + public void VerifyJws_BinaryPayload_ShouldSucceed() + { + // Test with binary data (not valid UTF-8) + var binaryPayload = new byte[] { 0x00, 0xFF, 0xFE, 0xFD, 0xAA, 0x55 }; + + var tlSignature = Signer.SignWithPem(Kid, PrivateKey) + .Method("POST") + .Path("/test") + .Body(binaryPayload) + .Sign(); + + Verifier.VerifyWithPem(PublicKey) + .Method("POST") + .Path("/test") + .Body(binaryPayload) + .Verify(tlSignature); // Should not throw + } + + [Fact] + public void VerifyJws_MultipleConcurrentVerifications_ShouldSucceed() + { + // Test thread-safety by performing multiple verifications + var tlSignature = Signer.SignWithPem(Kid, PrivateKey) + .Method("POST") + .Path("/test") + .Body("{}") + .Sign(); + + // Each verification should succeed independently + for (int i = 0; i < 10; i++) + { + Verifier.VerifyWithPem(PublicKey) + .Method("POST") + .Path("/test") + .Body("{}") + .Verify(tlSignature); // Should not throw + } + } + + [Fact] + public void VerifyJws_WrongPublicKey_ShouldFail() + { + // Sign with one key, verify with another + var tlSignature = Signer.SignWithPem(Kid, PrivateKey) + .Method("POST") + .Path("/test") + .Body("{}") + .Sign(); + + // Use a different public key + var wrongPublicKey = BugReproduction.LengthError.PublicKey; + + Action verify = () => Verifier.VerifyWithPem(wrongPublicKey) + .Method("POST") + .Path("/test") + .Body("{}") + .Verify(tlSignature); + + verify.Should().Throw(); + } + + [Fact] + public void ExtractKid_ValidSignature_ShouldSucceed() + { + var tlSignature = Signer.SignWithPem(Kid, PrivateKey) + .Method("POST") + .Path("/test") + .Body("{}") + .Sign(); + + var extractedKid = Verifier.ExtractKid(tlSignature); + extractedKid.Should().Be(Kid); + } + + [Fact] + public void ExtractKid_MissingKid_ShouldFail() + { + // Create a signature without kid + var headerDict = new Dictionary + { + ["alg"] = "ES512", + ["tl_version"] = "2", + ["tl_headers"] = "" + }; + var headerB64 = Base64Url.Encode(JsonSerializer.SerializeToUtf8Bytes(headerDict)); + + Action extract = () => Verifier.ExtractKid($"{headerB64}..signature"); + + extract.Should().Throw(); + } + + [Fact] + public void ExtractKid_MalformedJws_ShouldFail() + { + Action extract = () => Verifier.ExtractKid("not-a-valid-jws"); + + extract.Should().Throw(); + } + + #endregion + + #region Cross-cutting Security Tests + + [Fact] + public void VerifyJws_ReplayAttack_DifferentContext_ShouldFail() + { + // Sign a request for one endpoint + var tlSignature = Signer.SignWithPem(Kid, PrivateKey) + .Method("DELETE") + .Path("/users/123") + .Body("{}") + .Sign(); + + // Try to replay on a different endpoint + Action verify = () => Verifier.VerifyWithPem(PublicKey) + .Method("DELETE") + .Path("/users/456") // Different user ID + .Body("{}") + .Verify(tlSignature); + + verify.Should().Throw(); + } + + [Fact] + public void VerifyJws_NullByteInPayload_ShouldHandle() + { + // Test with null bytes in payload + var payloadWithNull = new byte[] { 0x7B, 0x00, 0x7D }; // {, null, } + + var tlSignature = Signer.SignWithPem(Kid, PrivateKey) + .Method("POST") + .Path("/test") + .Body(payloadWithNull) + .Sign(); + + Verifier.VerifyWithPem(PublicKey) + .Method("POST") + .Path("/test") + .Body(payloadWithNull) + .Verify(tlSignature); // Should not throw + } + + [Fact] + public void VerifyJws_ExtremelyLongHeader_ShouldHandle() + { + // Test with a very long header value + var longValue = new string('x', 10000); + + var tlSignature = Signer.SignWithPem(Kid, PrivateKey) + .Method("POST") + .Path("/test") + .Header("X-Long-Header", longValue) + .Body("{}") + .Sign(); + + Verifier.VerifyWithPem(PublicKey) + .Method("POST") + .Path("/test") + .Header("X-Long-Header", longValue) + .Body("{}") + .Verify(tlSignature); // Should not throw + } + + #endregion + } +} diff --git a/csharp/test/SigningFunction.cs b/csharp/test/SigningFunction.cs index 14d3390f..6dc6bc49 100644 --- a/csharp/test/SigningFunction.cs +++ b/csharp/test/SigningFunction.cs @@ -1,7 +1,6 @@ using System; using System.Security.Cryptography; using System.Threading.Tasks; -using Jose; namespace TrueLayer.Signing.Tests { diff --git a/csharp/test/UsageTest.cs b/csharp/test/UsageTest.cs index 4884c5dd..f030a25e 100644 --- a/csharp/test/UsageTest.cs +++ b/csharp/test/UsageTest.cs @@ -6,7 +6,6 @@ using System.Linq; using System.Text.Json; using System.Threading.Tasks; -using Jose; using Microsoft.Extensions.Primitives; using static TrueLayer.Signing.Tests.TestData;