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;