From 83f0f9727f17cecf6d2e3cde72c6a7b1e58a6228 Mon Sep 17 00:00:00 2001 From: Roberto Mancinelli Date: Sun, 23 Nov 2025 20:11:23 +0100 Subject: [PATCH 01/23] perf(PAYINS-1388): Enable AOT --- csharp/src/Signer.cs | 4 ++++ csharp/src/Util.cs | 14 ++++++++++++++ csharp/src/Verifier.cs | 15 +++++++++------ csharp/src/truelayer-signing.csproj | 3 ++- 4 files changed, 29 insertions(+), 7 deletions(-) diff --git a/csharp/src/Signer.cs b/csharp/src/Signer.cs index 78cb3aa1..a382fd26 100644 --- a/csharp/src/Signer.cs +++ b/csharp/src/Signer.cs @@ -233,7 +233,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..672c2305 100644 --- a/csharp/src/Util.cs +++ b/csharp/src/Util.cs @@ -3,6 +3,7 @@ using System.IO; using System.Security.Cryptography; using System.Text; +using System.Text.Json.Serialization; namespace TrueLayer.Signing { @@ -178,4 +179,17 @@ internal class Jwk public string X { get; set; } = ""; public string Y { get; set; } = ""; } + + /// AOT-compatible JSON serialization context for JWKS. +#if NET5_0_OR_GREATER + [JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.Never)] + [JsonSerializable(typeof(Jwks))] + [JsonSerializable(typeof(Jwk))] + [JsonSerializable(typeof(Dictionary))] + internal partial class SigningJsonContext : JsonSerializerContext + { + } +#endif } diff --git a/csharp/src/Verifier.cs b/csharp/src/Verifier.cs index e053f7c0..82dae588 100644 --- a/csharp/src/Verifier.cs +++ b/csharp/src/Verifier.cs @@ -14,11 +14,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 +44,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(); diff --git a/csharp/src/truelayer-signing.csproj b/csharp/src/truelayer-signing.csproj index 790d280f..96c3d8f4 100644 --- a/csharp/src/truelayer-signing.csproj +++ b/csharp/src/truelayer-signing.csproj @@ -23,6 +23,7 @@ true true TrueLayer.Signing + true @@ -36,7 +37,7 @@ - + From d0693d83c3bf5bdfcca9bc1b244480deb6505d3c Mon Sep 17 00:00:00 2001 From: Roberto Mancinelli Date: Sun, 23 Nov 2025 20:38:44 +0100 Subject: [PATCH 02/23] version --- csharp/src/truelayer-signing.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/csharp/src/truelayer-signing.csproj b/csharp/src/truelayer-signing.csproj index 96c3d8f4..8e03e813 100644 --- a/csharp/src/truelayer-signing.csproj +++ b/csharp/src/truelayer-signing.csproj @@ -1,6 +1,6 @@ - 0.2.5 + 0.2.6-rc2 net10.0;net9.0;net8.0;netstandard2.0;netstandard2.1 bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml enable From 9a9c4b361152e802d03df1c256d303a4b6aef538 Mon Sep 17 00:00:00 2001 From: Roberto Mancinelli Date: Sun, 23 Nov 2025 22:26:52 +0100 Subject: [PATCH 03/23] Replaced Jose.JWT.Headers --- csharp/src/Util.cs | 13 ++++++++++- csharp/src/Verifier.cs | 52 ++++++++++++++++++++++++++++-------------- 2 files changed, 47 insertions(+), 18 deletions(-) diff --git a/csharp/src/Util.cs b/csharp/src/Util.cs index 672c2305..123dfb1c 100644 --- a/csharp/src/Util.cs +++ b/csharp/src/Util.cs @@ -3,6 +3,7 @@ using System.IO; using System.Security.Cryptography; using System.Text; +using System.Text.Json; using System.Text.Json.Serialization; namespace TrueLayer.Signing @@ -143,7 +144,17 @@ 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) { - dict.TryGetValue(key, out var value); + if (!dict.TryGetValue(key, out var value)) + { + return null; + } + + // Handle JsonElement (from System.Text.Json deserialization) + if (value is JsonElement element && element.ValueKind == JsonValueKind.String) + { + return element.GetString(); + } + return value as string; } diff --git a/csharp/src/Verifier.cs b/csharp/src/Verifier.cs index 82dae588..961be928 100644 --- a/csharp/src/Verifier.cs +++ b/csharp/src/Verifier.cs @@ -71,21 +71,47 @@ 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 IDictionary 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); + var headerJson = Base64Url.Decode(headerB64); + +#if NET5_0_OR_GREATER + var headers = JsonSerializer.Deserialize(headerJson, SigningJsonContext.Default.DictionaryStringObject); +#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); } - var value = jwsHeaders.GetString(headerName); - if (value == null) - { - throw new SignatureException($"missing {headerName}"); - } - return value; } /// Extract kid from unverified jws Tl-Signature. @@ -233,15 +259,7 @@ 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); - } + var jwsHeaders = ParseJwsHeaders(tlSignature); if (_jwks is Jwks jwkeys) { // initialize public key using jwks data From b4f13b10035c1b2d14a76cac689dfdcbadf22bd8 Mon Sep 17 00:00:00 2001 From: Roberto Mancinelli Date: Sun, 23 Nov 2025 22:27:28 +0100 Subject: [PATCH 04/23] update version --- csharp/src/truelayer-signing.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/csharp/src/truelayer-signing.csproj b/csharp/src/truelayer-signing.csproj index 8e03e813..1623cf47 100644 --- a/csharp/src/truelayer-signing.csproj +++ b/csharp/src/truelayer-signing.csproj @@ -1,6 +1,6 @@ - 0.2.6-rc2 + 0.2.6-rc3 net10.0;net9.0;net8.0;netstandard2.0;netstandard2.1 bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml enable From 44fc9d10fb1b25649e6e1afa35e59ea51472b374 Mon Sep 17 00:00:00 2001 From: Roberto Mancinelli Date: Sun, 23 Nov 2025 23:04:28 +0100 Subject: [PATCH 05/23] remove Jose.JWT.DecodeBytes --- csharp/src/Util.cs | 2 +- csharp/src/Verifier.cs | 58 +++++++++++++++++++++++++++++++++++++++--- 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/csharp/src/Util.cs b/csharp/src/Util.cs index 123dfb1c..09df60e8 100644 --- a/csharp/src/Util.cs +++ b/csharp/src/Util.cs @@ -142,7 +142,7 @@ 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) { if (!dict.TryGetValue(key, out var value)) { diff --git a/csharp/src/Verifier.cs b/csharp/src/Verifier.cs index 961be928..ab8cc483 100644 --- a/csharp/src/Verifier.cs +++ b/csharp/src/Verifier.cs @@ -82,7 +82,7 @@ private static string ExtractJwsHeader(string tlSignature, string headerName) /// Parse JWS headers from a JWS token in an AOT-compatible way. /// Signature is invalid - private static IDictionary ParseJwsHeaders(string tlSignature) + private static Dictionary ParseJwsHeaders(string tlSignature) { try { @@ -298,20 +298,70 @@ public void Verify(string tlSignature) { try { - return Jose.JWT.DecodeBytes(tlSignature, _key, payload: signingPayload); + VerifyJwsSignature(tlSignature, 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(tlSignature, 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 jwsToken, byte[] payload, ECDsa key) + { + try + { + // JWS format for detached payload: base64url(header)..base64url(signature) + var parts = jwsToken.Split('.'); + if (parts.Length != 3 || !string.IsNullOrEmpty(parts[1])) + { + throw new SignatureException("invalid JWS format, expected detached signature"); + } + + var headerB64 = parts[0]; + var signatureB64 = parts[2]; + + // Decode the signature - ES512 signatures are IEEE P1363 format (raw r||s) + var signature = Base64Url.Decode(signatureB64); + + // Build the signing input: base64url(header).base64url(payload) + // For detached signatures, we reconstruct using the provided payload + var payloadB64 = Base64Url.Encode(payload); + var signingInput = Encoding.UTF8.GetBytes($"{headerB64}.{payloadB64}"); + + // Compute SHA-512 hash of the signing input (ES512 uses SHA-512) + byte[] hash; + using (var sha512 = SHA512.Create()) + { + hash = sha512.ComputeHash(signingInput); + } + + // 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) { From 0afb11fd843a50545f44fc9a205459dccc4aa1f6 Mon Sep 17 00:00:00 2001 From: Roberto Mancinelli Date: Sun, 23 Nov 2025 23:04:55 +0100 Subject: [PATCH 06/23] update version --- csharp/src/truelayer-signing.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/csharp/src/truelayer-signing.csproj b/csharp/src/truelayer-signing.csproj index 1623cf47..5dcc29bd 100644 --- a/csharp/src/truelayer-signing.csproj +++ b/csharp/src/truelayer-signing.csproj @@ -1,6 +1,6 @@ - 0.2.6-rc3 + 0.2.6-rc4 net10.0;net9.0;net8.0;netstandard2.0;netstandard2.1 bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml enable From cf2ae91ac6cf3128c1f93363babae31c8f477b62 Mon Sep 17 00:00:00 2001 From: Roberto Mancinelli Date: Sun, 23 Nov 2025 23:11:35 +0100 Subject: [PATCH 07/23] simplify code --- csharp/src/Verifier.cs | 45 +++++++++++++++++++++++++++++------------- 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/csharp/src/Verifier.cs b/csharp/src/Verifier.cs index ab8cc483..2458bb0d 100644 --- a/csharp/src/Verifier.cs +++ b/csharp/src/Verifier.cs @@ -95,6 +95,24 @@ private static Dictionary ParseJwsHeaders(string tlSignature) } 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); + } + } + + /// Parse JWS headers from base64url-encoded header in an AOT-compatible way. + /// Signature is invalid + private static Dictionary ParseJwsHeadersFromB64(string headerB64) + { + try + { var headerJson = Base64Url.Decode(headerB64); #if NET5_0_OR_GREATER @@ -259,7 +277,12 @@ public void Verify(string tlSignature) var dotCount = tlSignature.Count(c => c == '.'); SignatureException.Ensure(dotCount == 2, "invalid signature format, expected detached JWS (header..signature)"); - var jwsHeaders = ParseJwsHeaders(tlSignature); + // 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 @@ -298,7 +321,7 @@ public void Verify(string tlSignature) { try { - VerifyJwsSignature(tlSignature, signingPayload, _key); + VerifyJwsSignature(headerB64, signatureB64, signingPayload, _key); return true; } catch (SignatureException) @@ -308,7 +331,7 @@ public void Verify(string tlSignature) ? _path.Substring(0, _path.Length - 1) : _path + "/"; var alternatePayload = Util.BuildV2SigningPayload(_method, path2, signedHeaders, _body); - VerifyJwsSignature(tlSignature, alternatePayload, _key); + VerifyJwsSignature(headerB64, signatureB64, alternatePayload, _key); return true; } }, "Invalid signature"); @@ -316,20 +339,10 @@ public void Verify(string tlSignature) /// Verify JWS signature manually without using reflection-based deserialization (AOT-compatible). /// Signature is invalid - private static void VerifyJwsSignature(string jwsToken, byte[] payload, ECDsa key) + private static void VerifyJwsSignature(string headerB64, string signatureB64, byte[] payload, ECDsa key) { try { - // JWS format for detached payload: base64url(header)..base64url(signature) - var parts = jwsToken.Split('.'); - if (parts.Length != 3 || !string.IsNullOrEmpty(parts[1])) - { - throw new SignatureException("invalid JWS format, expected detached signature"); - } - - var headerB64 = parts[0]; - var signatureB64 = parts[2]; - // Decode the signature - ES512 signatures are IEEE P1363 format (raw r||s) var signature = Base64Url.Decode(signatureB64); @@ -339,11 +352,15 @@ private static void VerifyJwsSignature(string jwsToken, byte[] payload, ECDsa ke var signingInput = Encoding.UTF8.GetBytes($"{headerB64}.{payloadB64}"); // Compute SHA-512 hash of the signing input (ES512 uses SHA-512) +#if NET5_0_OR_GREATER + var hash = SHA512.HashData(signingInput); +#else byte[] hash; using (var sha512 = SHA512.Create()) { hash = sha512.ComputeHash(signingInput); } +#endif // Verify the signature using ECDSA // The signature is in IEEE P1363 format (concatenated r||s), which is what VerifyHash expects From e7f73b97bc9ac062e74abe94d9d20c2f6d16e823 Mon Sep 17 00:00:00 2001 From: Roberto Mancinelli Date: Sun, 23 Nov 2025 23:23:35 +0100 Subject: [PATCH 08/23] remove Jose-jwt --- csharp/src/Base64Url.cs | 23 +++++++++++++++++++ csharp/src/Signer.cs | 35 +++++++++++++++++++++++------ csharp/src/Verifier.cs | 1 - csharp/src/truelayer-signing.csproj | 5 ++++- csharp/test/ErrorTest.cs | 1 - csharp/test/SigningFunction.cs | 1 - csharp/test/UsageTest.cs | 1 - 7 files changed, 55 insertions(+), 12 deletions(-) create mode 100644 csharp/src/Base64Url.cs diff --git a/csharp/src/Base64Url.cs b/csharp/src/Base64Url.cs new file mode 100644 index 00000000..84176689 --- /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).Split('=')[0].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); + } + } +} \ No newline at end of file diff --git a/csharp/src/Signer.cs b/csharp/src/Signer.cs index a382fd26..6953d986 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 { @@ -209,15 +208,37 @@ 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); + + var signingMessage = $"{serializedJwsHeadersB64}.{signingPayloadB64}"; + var signingMessageBytes = Encoding.UTF8.GetBytes(signingMessage); + + // Compute SHA-512 hash (ES512 uses SHA-512) +#if NET5_0_OR_GREATER + var hash = SHA512.HashData(signingMessageBytes); +#else + byte[] hash; + using (var sha512 = SHA512.Create()) + { + hash = sha512.ComputeHash(signingMessageBytes); + } +#endif + + // 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}"; } } diff --git a/csharp/src/Verifier.cs b/csharp/src/Verifier.cs index 2458bb0d..c85afe3a 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 diff --git a/csharp/src/truelayer-signing.csproj b/csharp/src/truelayer-signing.csproj index 5dcc29bd..f6ecad4f 100644 --- a/csharp/src/truelayer-signing.csproj +++ b/csharp/src/truelayer-signing.csproj @@ -31,7 +31,6 @@ - @@ -40,6 +39,10 @@ + + + + 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/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; From 14f233b13eefb9c69048150aa17c24439ce4a9cb Mon Sep 17 00:00:00 2001 From: Roberto Mancinelli Date: Mon, 24 Nov 2025 10:51:04 +0100 Subject: [PATCH 09/23] minor --- csharp/src/truelayer-signing.csproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/csharp/src/truelayer-signing.csproj b/csharp/src/truelayer-signing.csproj index f6ecad4f..b496e1bd 100644 --- a/csharp/src/truelayer-signing.csproj +++ b/csharp/src/truelayer-signing.csproj @@ -24,6 +24,9 @@ true TrueLayer.Signing true + true + true + true @@ -31,9 +34,7 @@ - - - + @@ -43,7 +44,6 @@ - <_Parameter1>TrueLayer.Signing.Tests From ea3b7e48ba2d753ebfee3908ea8db65061fc54eb Mon Sep 17 00:00:00 2001 From: Roberto Mancinelli Date: Mon, 24 Nov 2025 10:51:35 +0100 Subject: [PATCH 10/23] version --- csharp/src/truelayer-signing.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/csharp/src/truelayer-signing.csproj b/csharp/src/truelayer-signing.csproj index b496e1bd..9bb14056 100644 --- a/csharp/src/truelayer-signing.csproj +++ b/csharp/src/truelayer-signing.csproj @@ -1,6 +1,6 @@ - 0.2.6-rc4 + 0.2.6-rc5 net10.0;net9.0;net8.0;netstandard2.0;netstandard2.1 bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml enable From 0feda466a43d687a8284b1be84c44ae3ecb92c8f Mon Sep 17 00:00:00 2001 From: Roberto Mancinelli Date: Fri, 28 Nov 2025 14:33:29 +0100 Subject: [PATCH 11/23] split deps based on TFM --- csharp/src/truelayer-signing.csproj | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/csharp/src/truelayer-signing.csproj b/csharp/src/truelayer-signing.csproj index 9bb14056..25ef650e 100644 --- a/csharp/src/truelayer-signing.csproj +++ b/csharp/src/truelayer-signing.csproj @@ -32,16 +32,33 @@ - - + + + + + + + + + + + + + + + + + + + From edb2ea43bf8f5bb2a8b1ccfa7e752b8e6623929e Mon Sep 17 00:00:00 2001 From: Roberto Mancinelli Date: Fri, 28 Nov 2025 14:36:04 +0100 Subject: [PATCH 12/23] fix warnings --- csharp/src/Signer.cs | 26 +++++++++++++++++++++++--- csharp/src/Util.cs | 2 +- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/csharp/src/Signer.cs b/csharp/src/Signer.cs index 6953d986..6f73b9d4 100644 --- a/csharp/src/Signer.cs +++ b/csharp/src/Signer.cs @@ -38,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) { } @@ -50,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; @@ -156,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) { @@ -164,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 @@ -188,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) { } diff --git a/csharp/src/Util.cs b/csharp/src/Util.cs index 09df60e8..dd6711ff 100644 --- a/csharp/src/Util.cs +++ b/csharp/src/Util.cs @@ -191,8 +191,8 @@ internal class Jwk public string Y { get; set; } = ""; } - /// AOT-compatible JSON serialization context for JWKS. #if NET5_0_OR_GREATER + /// AOT-compatible JSON serialization context for JWKS. [JsonSourceGenerationOptions( PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.Never)] From c5ec170900ea467b0c9df719fa4b5f910170aaf9 Mon Sep 17 00:00:00 2001 From: Roberto Mancinelli Date: Fri, 28 Nov 2025 14:46:02 +0100 Subject: [PATCH 13/23] update version --- csharp/src/truelayer-signing.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/csharp/src/truelayer-signing.csproj b/csharp/src/truelayer-signing.csproj index 25ef650e..ba3cd390 100644 --- a/csharp/src/truelayer-signing.csproj +++ b/csharp/src/truelayer-signing.csproj @@ -1,6 +1,6 @@ - 0.2.6-rc5 + 0.2.6-rc6 net10.0;net9.0;net8.0;netstandard2.0;netstandard2.1 bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml enable From 688373560ba302daaf8f2f6ac770a837bcc1af05 Mon Sep 17 00:00:00 2001 From: Roberto Mancinelli Date: Fri, 28 Nov 2025 14:54:05 +0100 Subject: [PATCH 14/23] add unit tests --- csharp/test/Base64UrlTest.cs | 656 ++++++++++++++++++++++++ csharp/test/JwsVerificationTest.cs | 787 +++++++++++++++++++++++++++++ 2 files changed, 1443 insertions(+) create mode 100644 csharp/test/Base64UrlTest.cs create mode 100644 csharp/test/JwsVerificationTest.cs 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/JwsVerificationTest.cs b/csharp/test/JwsVerificationTest.cs new file mode 100644 index 00000000..c327cf2a --- /dev/null +++ b/csharp/test/JwsVerificationTest.cs @@ -0,0 +1,787 @@ +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 + var headerDict = new Dictionary + { + ["alg"] = "ES512", + ["kid"] = "test-key-id", + ["tl_version"] = "2", + ["tl_headers"] = "Idempotency-Key" + }; + var headerJson = JsonSerializer.SerializeToUtf8Bytes(headerDict); + var headerB64 = Base64Url.Encode(headerJson); + + // Should not throw + var result = Verifier.VerifyWithPem(PublicKey) + .Method("POST") + .Path("/test") + .Body("{}"); + } + + [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 headerDict = new Dictionary + { + ["alg"] = "ES512", + ["kid"] = Kid, + ["tl_version"] = "2", + ["tl_headers"] = "" + }; + + 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 + } +} From 28937f50ee94a1496e3cdef28326cdba1c0f9943 Mon Sep 17 00:00:00 2001 From: Roberto Mancinelli Date: Fri, 28 Nov 2025 15:03:01 +0100 Subject: [PATCH 15/23] address pr comments --- csharp/src/Signer.cs | 7 +++++-- csharp/src/Util.cs | 10 +++++----- csharp/src/Verifier.cs | 15 +++++++++------ 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/csharp/src/Signer.cs b/csharp/src/Signer.cs index 6f73b9d4..95a2f006 100644 --- a/csharp/src/Signer.cs +++ b/csharp/src/Signer.cs @@ -239,8 +239,11 @@ public override string Sign() var signingPayload = Util.BuildV2SigningPayload(_method, _path, headerList, _body); var signingPayloadB64 = Base64Url.Encode(signingPayload); - var signingMessage = $"{serializedJwsHeadersB64}.{signingPayloadB64}"; - var signingMessageBytes = Encoding.UTF8.GetBytes(signingMessage); + // Efficiently build the signing message bytes without intermediate string allocation + var signingMessageBytes = new byte[serializedJwsHeadersB64.Length + 1 + signingPayloadB64.Length]; + Encoding.UTF8.GetBytes(serializedJwsHeadersB64, 0, serializedJwsHeadersB64.Length, signingMessageBytes, 0); + signingMessageBytes[serializedJwsHeadersB64.Length] = (byte)'.'; + Encoding.UTF8.GetBytes(signingPayloadB64, 0, signingPayloadB64.Length, signingMessageBytes, serializedJwsHeadersB64.Length + 1); // Compute SHA-512 hash (ES512 uses SHA-512) #if NET5_0_OR_GREATER diff --git a/csharp/src/Util.cs b/csharp/src/Util.cs index dd6711ff..2a23e357 100644 --- a/csharp/src/Util.cs +++ b/csharp/src/Util.cs @@ -142,20 +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 Dictionary dict, string key) + public static string? GetString(this Dictionary dict, string key) { - if (!dict.TryGetValue(key, out var value)) + if (!dict.TryGetValue(key, out var element)) { return null; } - // Handle JsonElement (from System.Text.Json deserialization) - if (value is JsonElement element && element.ValueKind == JsonValueKind.String) + if (element.ValueKind == JsonValueKind.String) { return element.GetString(); } - return value as string; + return null; } /// @@ -199,6 +198,7 @@ internal class Jwk [JsonSerializable(typeof(Jwks))] [JsonSerializable(typeof(Jwk))] [JsonSerializable(typeof(Dictionary))] + [JsonSerializable(typeof(Dictionary))] internal partial class SigningJsonContext : JsonSerializerContext { } diff --git a/csharp/src/Verifier.cs b/csharp/src/Verifier.cs index c85afe3a..60dbe049 100644 --- a/csharp/src/Verifier.cs +++ b/csharp/src/Verifier.cs @@ -81,7 +81,7 @@ private static string ExtractJwsHeader(string tlSignature, string headerName) /// Parse JWS headers from a JWS token in an AOT-compatible way. /// Signature is invalid - private static Dictionary ParseJwsHeaders(string tlSignature) + private static Dictionary ParseJwsHeaders(string tlSignature) { try { @@ -108,18 +108,18 @@ private static Dictionary ParseJwsHeaders(string tlSignature) /// Parse JWS headers from base64url-encoded header in an AOT-compatible way. /// Signature is invalid - private static Dictionary ParseJwsHeadersFromB64(string headerB64) + private static Dictionary ParseJwsHeadersFromB64(string headerB64) { try { var headerJson = Base64Url.Decode(headerB64); #if NET5_0_OR_GREATER - var headers = JsonSerializer.Deserialize(headerJson, SigningJsonContext.Default.DictionaryStringObject); + var headers = JsonSerializer.Deserialize(headerJson, SigningJsonContext.Default.DictionaryStringJsonElement); #else - var headers = JsonSerializer.Deserialize>(headerJson); + var headers = JsonSerializer.Deserialize>(headerJson); #endif - return headers ?? new Dictionary(); + return headers ?? new Dictionary(); } catch (SignatureException) { @@ -348,7 +348,10 @@ private static void VerifyJwsSignature(string headerB64, string signatureB64, by // Build the signing input: base64url(header).base64url(payload) // For detached signatures, we reconstruct using the provided payload var payloadB64 = Base64Url.Encode(payload); - var signingInput = Encoding.UTF8.GetBytes($"{headerB64}.{payloadB64}"); + var signingInput = new byte[headerB64.Length + 1 + payloadB64.Length]; + Encoding.UTF8.GetBytes(headerB64, 0, headerB64.Length, signingInput, 0); + signingInput[headerB64.Length] = (byte)'.'; + Encoding.UTF8.GetBytes(payloadB64, 0, payloadB64.Length, signingInput, headerB64.Length + 1); // Compute SHA-512 hash of the signing input (ES512 uses SHA-512) #if NET5_0_OR_GREATER From 49e4d2769f519d9cc2cceac75995579c7e3f3974 Mon Sep 17 00:00:00 2001 From: Roberto Mancinelli Date: Fri, 28 Nov 2025 15:08:52 +0100 Subject: [PATCH 16/23] pr comments --- csharp/src/Base64Url.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/csharp/src/Base64Url.cs b/csharp/src/Base64Url.cs index 84176689..eaae0874 100644 --- a/csharp/src/Base64Url.cs +++ b/csharp/src/Base64Url.cs @@ -6,7 +6,7 @@ namespace TrueLayer.Signing internal static class Base64Url { /// Encode bytes to base64url string. - public static string Encode(byte[] input) => Convert.ToBase64String(input).Split('=')[0].Replace('+', '-').Replace('/', '_'); + public static string Encode(byte[] input) => Convert.ToBase64String(input).TrimEnd('=').Replace('+', '-').Replace('/', '_'); /// Decode base64url string to bytes. public static byte[] Decode(string input) From 8a6951987bd6d880432aa143bc0593538fdc0244 Mon Sep 17 00:00:00 2001 From: Roberto Mancinelli Date: Fri, 28 Nov 2025 15:13:03 +0100 Subject: [PATCH 17/23] use Span-based API for better performance --- csharp/src/Base64Url.cs | 2 +- csharp/src/Signer.cs | 7 +++++++ csharp/src/Verifier.cs | 7 +++++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/csharp/src/Base64Url.cs b/csharp/src/Base64Url.cs index eaae0874..8689fdb5 100644 --- a/csharp/src/Base64Url.cs +++ b/csharp/src/Base64Url.cs @@ -20,4 +20,4 @@ public static byte[] Decode(string input) return Convert.FromBase64String(base64); } } -} \ No newline at end of file +} diff --git a/csharp/src/Signer.cs b/csharp/src/Signer.cs index 95a2f006..c09bfb03 100644 --- a/csharp/src/Signer.cs +++ b/csharp/src/Signer.cs @@ -241,9 +241,16 @@ public override string Sign() // Efficiently build the signing message bytes without intermediate string allocation var signingMessageBytes = new byte[serializedJwsHeadersB64.Length + 1 + signingPayloadB64.Length]; +#if NET5_0_OR_GREATER + // Use Span-based API for better performance on modern .NET + Encoding.ASCII.GetBytes(serializedJwsHeadersB64, signingMessageBytes.AsSpan(0, serializedJwsHeadersB64.Length)); + signingMessageBytes[serializedJwsHeadersB64.Length] = (byte)'.'; + Encoding.ASCII.GetBytes(signingPayloadB64, signingMessageBytes.AsSpan(serializedJwsHeadersB64.Length + 1)); +#else Encoding.UTF8.GetBytes(serializedJwsHeadersB64, 0, serializedJwsHeadersB64.Length, signingMessageBytes, 0); signingMessageBytes[serializedJwsHeadersB64.Length] = (byte)'.'; Encoding.UTF8.GetBytes(signingPayloadB64, 0, signingPayloadB64.Length, signingMessageBytes, serializedJwsHeadersB64.Length + 1); +#endif // Compute SHA-512 hash (ES512 uses SHA-512) #if NET5_0_OR_GREATER diff --git a/csharp/src/Verifier.cs b/csharp/src/Verifier.cs index 60dbe049..adc40c5a 100644 --- a/csharp/src/Verifier.cs +++ b/csharp/src/Verifier.cs @@ -349,9 +349,16 @@ private static void VerifyJwsSignature(string headerB64, string signatureB64, by // For detached signatures, we reconstruct using the provided payload var payloadB64 = Base64Url.Encode(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.UTF8.GetBytes(headerB64, 0, headerB64.Length, signingInput, 0); signingInput[headerB64.Length] = (byte)'.'; Encoding.UTF8.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 From 9341bc9620b21869ad67c98c7e9ea3d0ef791e94 Mon Sep 17 00:00:00 2001 From: Roberto Mancinelli Date: Fri, 28 Nov 2025 15:16:21 +0100 Subject: [PATCH 18/23] address PR comments --- csharp/test/JwsVerificationTest.cs | 31 +++++++++++------------------- 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/csharp/test/JwsVerificationTest.cs b/csharp/test/JwsVerificationTest.cs index c327cf2a..07a739a0 100644 --- a/csharp/test/JwsVerificationTest.cs +++ b/csharp/test/JwsVerificationTest.cs @@ -20,22 +20,21 @@ public class JwsVerificationTest [Fact] public void ParseJwsHeaders_ValidHeader_ShouldSucceed() { - // Valid JWS header with required fields - var headerDict = new Dictionary - { - ["alg"] = "ES512", - ["kid"] = "test-key-id", - ["tl_version"] = "2", - ["tl_headers"] = "Idempotency-Key" - }; - var headerJson = JsonSerializer.SerializeToUtf8Bytes(headerDict); - var headerB64 = Base64Url.Encode(headerJson); + // 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 - var result = Verifier.VerifyWithPem(PublicKey) + Verifier.VerifyWithPem(PublicKey) .Method("POST") .Path("/test") - .Body("{}"); + .Header("Idempotency-Key", "test-value") + .Body("{}") + .Verify(tlSignature); } [Fact] @@ -148,14 +147,6 @@ public void ParseJwsHeaders_JsonArray_ShouldFail() public void ParseJwsHeaders_Base64UrlPaddingVariations_ShouldSucceed() { // Test various padding scenarios in base64url encoding - var headerDict = new Dictionary - { - ["alg"] = "ES512", - ["kid"] = Kid, - ["tl_version"] = "2", - ["tl_headers"] = "" - }; - var tlSignature = Signer.SignWithPem(Kid, PrivateKey) .Method("POST") .Path("/test") From c5253526b7a177426e8d3bd1621b0425e8ec6222 Mon Sep 17 00:00:00 2001 From: Roberto Mancinelli Date: Fri, 28 Nov 2025 16:25:07 +0100 Subject: [PATCH 19/23] update benchmark --- .../InternalPerformanceBenchmarks.cs | 84 ------------------- csharp/benchmarks/Program.cs | 4 - .../TrueLayer.Signing.Benchmarks.csproj | 2 +- 3 files changed, 1 insertion(+), 89 deletions(-) delete mode 100644 csharp/benchmarks/InternalPerformanceBenchmarks.cs 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 From 02319719f721ecc6360edf4e8d09d8b551d07d46 Mon Sep 17 00:00:00 2001 From: Roberto Mancinelli Date: Fri, 28 Nov 2025 17:52:24 +0100 Subject: [PATCH 20/23] extract ComputeJwsSigningHash in util --- csharp/src/Signer.cs | 25 ++----------------------- csharp/src/Util.cs | 33 +++++++++++++++++++++++++++++++++ csharp/src/Verifier.cs | 26 ++------------------------ 3 files changed, 37 insertions(+), 47 deletions(-) diff --git a/csharp/src/Signer.cs b/csharp/src/Signer.cs index c09bfb03..23bf710d 100644 --- a/csharp/src/Signer.cs +++ b/csharp/src/Signer.cs @@ -239,29 +239,8 @@ public override string Sign() var signingPayload = Util.BuildV2SigningPayload(_method, _path, headerList, _body); var signingPayloadB64 = Base64Url.Encode(signingPayload); - // Efficiently build the signing message bytes without intermediate string allocation - var signingMessageBytes = new byte[serializedJwsHeadersB64.Length + 1 + signingPayloadB64.Length]; -#if NET5_0_OR_GREATER - // Use Span-based API for better performance on modern .NET - Encoding.ASCII.GetBytes(serializedJwsHeadersB64, signingMessageBytes.AsSpan(0, serializedJwsHeadersB64.Length)); - signingMessageBytes[serializedJwsHeadersB64.Length] = (byte)'.'; - Encoding.ASCII.GetBytes(signingPayloadB64, signingMessageBytes.AsSpan(serializedJwsHeadersB64.Length + 1)); -#else - Encoding.UTF8.GetBytes(serializedJwsHeadersB64, 0, serializedJwsHeadersB64.Length, signingMessageBytes, 0); - signingMessageBytes[serializedJwsHeadersB64.Length] = (byte)'.'; - Encoding.UTF8.GetBytes(signingPayloadB64, 0, signingPayloadB64.Length, signingMessageBytes, serializedJwsHeadersB64.Length + 1); -#endif - - // Compute SHA-512 hash (ES512 uses SHA-512) -#if NET5_0_OR_GREATER - var hash = SHA512.HashData(signingMessageBytes); -#else - byte[] hash; - using (var sha512 = SHA512.Create()) - { - hash = sha512.ComputeHash(signingMessageBytes); - } -#endif + // 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); diff --git a/csharp/src/Util.cs b/csharp/src/Util.cs index 2a23e357..86c7c6ae 100644 --- a/csharp/src/Util.cs +++ b/csharp/src/Util.cs @@ -171,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.UTF8.GetBytes(headerB64, 0, headerB64.Length, signingInput, 0); + signingInput[headerB64.Length] = (byte)'.'; + Encoding.UTF8.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 + } } diff --git a/csharp/src/Verifier.cs b/csharp/src/Verifier.cs index adc40c5a..f7d68720 100644 --- a/csharp/src/Verifier.cs +++ b/csharp/src/Verifier.cs @@ -345,31 +345,9 @@ private static void VerifyJwsSignature(string headerB64, string signatureB64, by // Decode the signature - ES512 signatures are IEEE P1363 format (raw r||s) var signature = Base64Url.Decode(signatureB64); - // Build the signing input: base64url(header).base64url(payload) - // For detached signatures, we reconstruct using the provided payload + // Compute SHA-512 hash of the JWS signing input var payloadB64 = Base64Url.Encode(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.UTF8.GetBytes(headerB64, 0, headerB64.Length, signingInput, 0); - signingInput[headerB64.Length] = (byte)'.'; - Encoding.UTF8.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 - var hash = SHA512.HashData(signingInput); -#else - byte[] hash; - using (var sha512 = SHA512.Create()) - { - hash = sha512.ComputeHash(signingInput); - } -#endif + 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 From a7b0ea6a492a977f3833ddf9eb595457ebda3aee Mon Sep 17 00:00:00 2001 From: Roberto Mancinelli Date: Fri, 28 Nov 2025 18:08:42 +0100 Subject: [PATCH 21/23] use ASCII in pre net5 path --- csharp/src/Util.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/csharp/src/Util.cs b/csharp/src/Util.cs index 86c7c6ae..597ca3a8 100644 --- a/csharp/src/Util.cs +++ b/csharp/src/Util.cs @@ -189,9 +189,9 @@ internal static byte[] ComputeJwsSigningHash(string headerB64, string payloadB64 signingInput[headerB64.Length] = (byte)'.'; Encoding.ASCII.GetBytes(payloadB64, signingInput.AsSpan(headerB64.Length + 1)); #else - Encoding.UTF8.GetBytes(headerB64, 0, headerB64.Length, signingInput, 0); + Encoding.ASCII.GetBytes(headerB64, 0, headerB64.Length, signingInput, 0); signingInput[headerB64.Length] = (byte)'.'; - Encoding.UTF8.GetBytes(payloadB64, 0, payloadB64.Length, signingInput, headerB64.Length + 1); + Encoding.ASCII.GetBytes(payloadB64, 0, payloadB64.Length, signingInput, headerB64.Length + 1); #endif // Compute SHA-512 hash of the signing input (ES512 uses SHA-512) From b629baa1e7be649faba9197daf19084af668af67 Mon Sep 17 00:00:00 2001 From: Roberto Mancinelli Date: Mon, 16 Mar 2026 10:52:30 +0100 Subject: [PATCH 22/23] update version --- csharp/src/truelayer-signing.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/csharp/src/truelayer-signing.csproj b/csharp/src/truelayer-signing.csproj index ba3cd390..2b72bfde 100644 --- a/csharp/src/truelayer-signing.csproj +++ b/csharp/src/truelayer-signing.csproj @@ -1,6 +1,6 @@ - 0.2.6-rc6 + 0.2.6-rc7 net10.0;net9.0;net8.0;netstandard2.0;netstandard2.1 bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml enable From 643ba65cbd4932f8fd27a1d2ee501da93349b40d Mon Sep 17 00:00:00 2001 From: Roberto Mancinelli Date: Mon, 16 Mar 2026 10:59:29 +0100 Subject: [PATCH 23/23] rc8 --- csharp/src/truelayer-signing.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/csharp/src/truelayer-signing.csproj b/csharp/src/truelayer-signing.csproj index 2b72bfde..732de725 100644 --- a/csharp/src/truelayer-signing.csproj +++ b/csharp/src/truelayer-signing.csproj @@ -1,6 +1,6 @@ - 0.2.6-rc7 + 0.2.6-rc8 net10.0;net9.0;net8.0;netstandard2.0;netstandard2.1 bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml enable