Skip to content
Open
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
83f0f97
perf(PAYINS-1388): Enable AOT
tl-Roberto-Mancinelli Nov 23, 2025
d0693d8
version
tl-Roberto-Mancinelli Nov 23, 2025
9a9c4b3
Replaced Jose.JWT.Headers
tl-Roberto-Mancinelli Nov 23, 2025
b4f13b1
update version
tl-Roberto-Mancinelli Nov 23, 2025
44fc9d1
remove Jose.JWT.DecodeBytes
tl-Roberto-Mancinelli Nov 23, 2025
0afb11f
update version
tl-Roberto-Mancinelli Nov 23, 2025
cf2ae91
simplify code
tl-Roberto-Mancinelli Nov 23, 2025
e7f73b9
remove Jose-jwt
tl-Roberto-Mancinelli Nov 23, 2025
14f233b
minor
tl-Roberto-Mancinelli Nov 24, 2025
ea3b7e4
version
tl-Roberto-Mancinelli Nov 24, 2025
0e3d6b7
Merge branch 'main' into aot
tl-Roberto-Mancinelli Nov 28, 2025
0feda46
split deps based on TFM
tl-Roberto-Mancinelli Nov 28, 2025
edb2ea4
fix warnings
tl-Roberto-Mancinelli Nov 28, 2025
c5ec170
update version
tl-Roberto-Mancinelli Nov 28, 2025
6883735
add unit tests
tl-Roberto-Mancinelli Nov 28, 2025
28937f5
address pr comments
tl-Roberto-Mancinelli Nov 28, 2025
49e4d27
pr comments
tl-Roberto-Mancinelli Nov 28, 2025
8a69519
use Span-based API for better performance
tl-Roberto-Mancinelli Nov 28, 2025
9341bc9
address PR comments
tl-Roberto-Mancinelli Nov 28, 2025
c525352
update benchmark
tl-Roberto-Mancinelli Nov 28, 2025
0231971
extract ComputeJwsSigningHash in util
tl-Roberto-Mancinelli Nov 28, 2025
a7b0ea6
use ASCII in pre net5 path
tl-Roberto-Mancinelli Nov 28, 2025
e2006e7
Merge branch 'main' into aot
tl-Roberto-Mancinelli Mar 16, 2026
b629baa
update version
tl-Roberto-Mancinelli Mar 16, 2026
643ba65
rc8
tl-Roberto-Mancinelli Mar 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 0 additions & 84 deletions csharp/benchmarks/InternalPerformanceBenchmarks.cs

This file was deleted.

4 changes: 0 additions & 4 deletions csharp/benchmarks/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,6 @@
case "verifier":
BenchmarkRunner.Run<VerifierBenchmarks>(config);
break;
case "jws":
case "jws-verify":
BenchmarkRunner.Run<JwsVerificationBenchmarks>(config);
break;
case "all":
default:
BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args, config);
Expand Down
2 changes: 1 addition & 1 deletion csharp/benchmarks/TrueLayer.Signing.Benchmarks.csproj
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<AssemblyName>TrueLayer.Signing.Benchmarks</AssemblyName>
<RootNamespace>TrueLayer.Signing.Benchmarks</RootNamespace>
Expand Down
23 changes: 23 additions & 0 deletions csharp/src/Base64Url.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using System;

namespace TrueLayer.Signing
{
/// <summary>Internal Base64Url encoding/decoding for AOT compatibility.</summary>
internal static class Base64Url
{
/// <summary>Encode bytes to base64url string.</summary>
public static string Encode(byte[] input) => Convert.ToBase64String(input).TrimEnd('=').Replace('+', '-').Replace('/', '_');

Comment thread
tl-Roberto-Mancinelli marked this conversation as resolved.
/// <summary>Decode base64url string to bytes.</summary>
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);
}
}
Comment thread
tl-Roberto-Mancinelli marked this conversation as resolved.
}
75 changes: 65 additions & 10 deletions csharp/src/Signer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Jose;

namespace TrueLayer.Signing
{
Expand Down Expand Up @@ -39,10 +38,13 @@ public static Signer SignWithPem(string kid, ReadOnlySpan<byte> privateKeyPem)

/// <summary>
/// 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.
/// </summary>
public static AsyncSigner SignWithFunction(string kid, Func<string, Task<string>> signAsync) => new FunctionSigner(kid, signAsync);


/// <summary>
/// Initializes a new instance of the Signer class with the specified key ID.
/// </summary>
protected internal Signer(string kid) : base(kid)
{
}
Expand All @@ -51,16 +53,27 @@ protected internal Signer(string kid) : base(kid)
public abstract string Sign();
}

/// <summary>
/// Base class for signature builders with fluent API support.
/// </summary>
public abstract class Signer<TSigner> where TSigner : Signer<TSigner>
{
private readonly string _kid;
private readonly TSigner _this;
/// <summary>HTTP method for the request.</summary>
protected internal string _method = "POST";
/// <summary>Request path.</summary>
protected internal string _path = "";
/// <summary>Request headers to include in the signature.</summary>
protected internal readonly Dictionary<string, byte[]> _headers = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Request body.</summary>
protected internal byte[] _body = Array.Empty<byte>();
/// <summary>JSON Web Key Set URL.</summary>
protected internal string? _jku;

/// <summary>
/// Initializes a new instance of the Signer class with the specified key ID.
/// </summary>
protected internal Signer(string kid)
{
_kid = kid;
Expand Down Expand Up @@ -157,14 +170,17 @@ public TSigner Body(byte[] body)
public TSigner Body(string body) => Body(body.ToUtf8());

/// <summary>
/// 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.
/// </summary>
public TSigner Jku(string jku)
{
_jku = jku;
return _this;
}

/// <summary>
/// Creates the JWS headers for the signature.
/// </summary>
protected internal Dictionary<string, object> CreateJwsHeaders()
{
var jwsHeaders = new Dictionary<string, object>
Expand All @@ -189,6 +205,9 @@ protected internal Dictionary<string, object> CreateJwsHeaders()
/// </summary>
public abstract class AsyncSigner : Signer<AsyncSigner>
{
/// <summary>
/// Initializes a new instance of the AsyncSigner class with the specified key ID.
/// </summary>
protected internal AsyncSigner(string kid) : base(kid)
{
}
Expand All @@ -209,15 +228,47 @@ 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);

// 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

return JWT.EncodeBytes(
signingPayload,
_key,
JwsAlgorithm.ES512,
jwsHeaders,
options: new JwtOptions {DetachPayload = true});
// 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 detached JWS format: header..signature (empty payload)
return $"{serializedJwsHeadersB64}..{signatureB64}";
}
}

Expand All @@ -233,7 +284,11 @@ internal FunctionSigner(string kid, Func<string, Task<string>> signAsync) : base
public override async Task<string> 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));
Expand Down
31 changes: 28 additions & 3 deletions csharp/src/Util.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -140,10 +142,19 @@ private static void PreNet5ImportPem(this ECDsa key, ReadOnlySpan<char> pem)
}

/// <summary>Gets a value from the map as a string or null.</summary>
public static string? GetString(this IDictionary<string, object> dict, string key)
public static string? GetString(this Dictionary<string, JsonElement> 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;
}

/// <summary>
Expand Down Expand Up @@ -178,4 +189,18 @@ internal class Jwk
public string X { get; set; } = "";
public string Y { get; set; } = "";
}

#if NET5_0_OR_GREATER
/// <summary>AOT-compatible JSON serialization context for JWKS.</summary>
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.Never)]
[JsonSerializable(typeof(Jwks))]
[JsonSerializable(typeof(Jwk))]
[JsonSerializable(typeof(Dictionary<string, object>))]
Comment thread
tl-Roberto-Mancinelli marked this conversation as resolved.
[JsonSerializable(typeof(Dictionary<string, JsonElement>))]
internal partial class SigningJsonContext : JsonSerializerContext
{
}
#endif
}
Loading