Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[dotnet] Support providing tl_version + tl_headers via HTTP headers #276

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
3 changes: 3 additions & 0 deletions csharp/src/Properties.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;

[assembly:InternalsVisibleTo("TrueLayer.Signing.Tests")]
71 changes: 44 additions & 27 deletions csharp/src/Verifier.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ public static Verifier VerifyWithJwks(ReadOnlySpan<byte> jwksJson)
});
// ecdsa fully setup later once we know the jwk kid
var verifier = VerifyWith(ECDsa.Create());
verifier.jwks = jwks ?? new Jwks();
verifier._jwks = jwks ?? new Jwks();
return verifier;
}
catch (JsonException e)
Expand Down Expand Up @@ -106,22 +106,22 @@ public static string ExtractJku(string tlSignature)
return jku;
}

private ECDsa key;
private readonly ECDsa _key;
// Non-null when verifying using jwks data.
// This indicates we need to initialize `key` once we have the kid.
private Jwks? jwks;
private string method = "";
private string path = "";
private Dictionary<string, byte[]> headers = new Dictionary<string, byte[]>(new HeaderNameComparer());
private HashSet<string> requiredHeaders = new HashSet<string>(new HeaderNameComparer());
private byte[] body = new byte[0];
private Jwks? _jwks;
private string _method = "";
private string _path = "";
private readonly Dictionary<string, byte[]> _headers = new Dictionary<string, byte[]>(new HeaderNameComparer());
private readonly HashSet<string> _requiredHeaders = new HashSet<string>(new HeaderNameComparer());
private byte[] _body = Array.Empty<byte>();

private Verifier(ECDsa publicKey) => key = publicKey;
private Verifier(ECDsa publicKey) => _key = publicKey;

/// <summary>Add the request method.</summary>
public Verifier Method(string method)
{
this.method = method;
this._method = method;
return this;
}

Expand All @@ -134,7 +134,7 @@ public Verifier Path(string path)
{
throw new ArgumentException($"Invalid path \"{path}\" must start with '/'");
}
this.path = path;
this._path = path;
return this;
}

Expand All @@ -144,7 +144,7 @@ public Verifier Path(string path)
/// </summary>
public Verifier Header(string name, byte[] value)
{
this.headers.Add(name.Trim(), value);
this._headers.Add(name.Trim(), value);
return this;
}

Expand Down Expand Up @@ -198,14 +198,14 @@ public Verifier Headers(IEnumerable<KeyValuePair<string, IEnumerable<string>>> h
/// </summary>
public Verifier RequireHeader(string name)
{
requiredHeaders.Add(name);
_requiredHeaders.Add(name);
return this;
}

/// <summary>Add the full unmodified request body.</summary>
public Verifier Body(byte[] body)
{
this.body = body;
this._body = body;
return this;
}

Expand All @@ -229,47 +229,48 @@ public void Verify(string tlSignature)
{
throw new SignatureException($"Failed to parse JWS: {e.Message}", e);
}
if (jwks is Jwks jwkeys)
if (_jwks is Jwks jwkeys)
{
// initialize public key using jwks data
var kid = jwsHeaders.GetString("kid") ?? throw new SignatureException("missing kid");
FindAndImportJwk(jwkeys, kid);
}

SignatureException.Ensure(jwsHeaders.GetString("alg") == "ES512", "unsupported jws alg");
SignatureException.Ensure(jwsHeaders.GetString("tl_version") == "2", "unsupported jws tl_version");
var version = jwsHeaders.GetString("tl_version") ?? TryRequireHeaderString("Tl-Signature-Version");
SignatureException.Ensure(version == "2", "unsupported jws tl_version");
var signatureParts = tlSignature.Split('.');
SignatureException.Ensure(signatureParts.Length >= 3, "invalid signature format");

var signatureHeaderNames = (jwsHeaders.GetString("tl_headers") ?? "")
var signatureHeaderNames = (jwsHeaders.GetString("tl_headers") ?? TryRequireHeaderString("Tl-Signature-Headers") ?? "")
.Split(',')
.Select(h => h.Trim())
.Where(h => !string.IsNullOrEmpty(h))
.ToList();

var signatureHeaderNameSet = new HashSet<string>(signatureHeaderNames, new HeaderNameComparer());
var missingRequired = requiredHeaders.SingleOrDefault(h => !signatureHeaderNameSet.Contains(h));
SignatureException.Ensure(missingRequired == null, $"signature is missing required header {missingRequired}");
var missingRequired = _requiredHeaders.Where(h => !signatureHeaderNameSet.Contains(h)).ToList();
SignatureException.Ensure(missingRequired.Count == 0, $"signature is missing required headers {string.Join(",", missingRequired)}");

var signedHeaders = FilterOrderHeaders(signatureHeaderNames);

var signingPayload = Util.BuildV2SigningPayload(method, path, signedHeaders, body);
var signingPayload = Util.BuildV2SigningPayload(_method, _path, signedHeaders, _body);
var jws = $"{signatureParts[0]}.{Base64Url.Encode(signingPayload)}.{signatureParts[2]}";

SignatureException.Try(() =>
{
try
{
return Jose.JWT.Decode(jws, key);
return Jose.JWT.Decode(jws, _key);
}
catch (Jose.IntegrityException)
{
// try again with/without a trailing slash (#80)
var path2 = path + "/";
if (path.EndsWith("/")) path2 = path.Remove(path.Length - 1);
var signingPayload = Util.BuildV2SigningPayload(method, path2, signedHeaders, body);
var path2 = _path + "/";
if (_path.EndsWith("/")) path2 = _path.Remove(_path.Length - 1);
var signingPayload = Util.BuildV2SigningPayload(_method, path2, signedHeaders, _body);
var jws = $"{signatureParts[0]}.{Base64Url.Encode(signingPayload)}.{signatureParts[2]}";
return Jose.JWT.Decode(jws, key);
return Jose.JWT.Decode(jws, _key);
}
}, "Invalid signature");
}
Expand All @@ -282,7 +283,7 @@ private void FindAndImportJwk(Jwks jwks, string kid)
SignatureException.Ensure(jwk.Kty == "EC", "unsupported jwk.kty");
SignatureException.Ensure(jwk.Crv == "P-521", "unsupported jwk.crv");

SignatureException.TryAction(() => key.ImportParameters(new ECParameters
SignatureException.TryAction(() => _key.ImportParameters(new ECParameters
{
Curve = ECCurve.NamedCurves.nistP521,
Q = new ECPoint
Expand All @@ -301,7 +302,7 @@ private void FindAndImportJwk(Jwks jwks, string kid)
var orderedHeaders = new List<(string, byte[])>(signedHeaderNames.Count);
foreach (var name in signedHeaderNames)
{
if (headers.TryGetValue(name.ToLowerInvariant(), out var value))
if (_headers.TryGetValue(name.ToLowerInvariant(), out var value))
{
orderedHeaders.Add((name, value));
}
Expand All @@ -312,5 +313,21 @@ private void FindAndImportJwk(Jwks jwks, string kid)
}
return orderedHeaders;
}

private string? TryRequireHeaderString(string name)
{
if (GetHeaderString(name) is {} value)
{
_requiredHeaders.Add(name);
return value;
}

return null;
}

private string? GetHeaderString(string key) =>
_headers.TryGetValue(key, out var value)
? Encoding.UTF8.GetString(value)
: null;
}
}
2 changes: 1 addition & 1 deletion csharp/src/truelayer-signing.csproj
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<PackageVersion>0.1.15</PackageVersion>
<PackageVersion>0.1.16-rc1</PackageVersion>
<TargetFrameworks>net5.0;netstandard2.0;netstandard2.1</TargetFrameworks>
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile>
<Nullable>enable</Nullable>
Expand Down
24 changes: 24 additions & 0 deletions csharp/test/SigningFunction.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using System;
using System.Security.Cryptography;
using System.Threading.Tasks;
using Jose;

namespace TrueLayer.Signing.Tests
{
internal static class SigningFunction
{
public static Func<string, string> ForPrivateKey(string privateKeyPem) => payload =>
{
var privateKey = Util.ParsePem(privateKeyPem);
var payloadBytes = System.Text.Encoding.UTF8.GetBytes(payload);
var signatureBytes = privateKey.SignData(payloadBytes, HashAlgorithmName.SHA512);
return Base64Url.Encode(signatureBytes);
};

public static Func<string, Task<string>> ForPrivateKeyAsync(string privateKeyPem)
{
var func = ForPrivateKey(privateKeyPem);
return payload => Task.FromResult(func(payload));
}
}
}
83 changes: 75 additions & 8 deletions csharp/test/UsageTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
using FluentAssertions;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text.Json;
using System.Threading.Tasks;
using Jose;
using static TrueLayer.Signing.Tests.TestData;

namespace TrueLayer.Signing.Tests
Expand Down Expand Up @@ -408,13 +409,7 @@ public async Task SignAndVerify_AsyncFunction(TestCase testCase)
var idempotency_key = "idemp-2076717c-9005-4811-a321-9e0787fa0382";
var path = "/merchant_accounts/a61acaef-ee05-4077-92f3-25543a11bd8d/sweeping";

Func<string, Task<string>> signingFunction = payload =>
{
var privateKey = Util.ParsePem(testCase.PrivateKey);
var payloadBytes = System.Text.Encoding.UTF8.GetBytes(payload);
var signatureBytes = privateKey.SignData(payloadBytes, HashAlgorithmName.SHA512);
return Task.FromResult(Convert.ToBase64String(signatureBytes));
};
var signingFunction = SigningFunction.ForPrivateKeyAsync(testCase.PrivateKey);

var tlSignature = await Signer.SignWithFunction(testCase.Kid, signingFunction)
.Method("POST")
Expand All @@ -432,6 +427,78 @@ public async Task SignAndVerify_AsyncFunction(TestCase testCase)
.Verify(tlSignature); // should not throw
}

[Theory]
[MemberData(nameof(TestCases))]
public void Verify_CustomJoseHeadersSuppliedAsHttpHeaders_NotInPayload(TestCase testCase)
{
const string body = "{\"currency\":\"GBP\",\"max_amount_in_minor\":5000000}";
const string idempotencyKey = "idemp-2076717c-9005-4811-a321-9e0787fa0382";
const string path = "/merchant_accounts/a61acaef-ee05-4077-92f3-25543a11bd8d/sweeping";
var headers = new Dictionary<string, string>
{
["Idempotency-Key"] = idempotencyKey,
["Tl-Signature-Version"] = "2",
["Tl-Signature-Headers"] = "Idempotency-Key",
};

var signingFunction = SigningFunction.ForPrivateKey(testCase.PrivateKey);

var joseHeaders = Base64Url.Encode(JsonSerializer.SerializeToUtf8Bytes(new Dictionary<string, object>
{
["alg"] = "ES512",
["kid"] = testCase.Kid,
}));
var jwsPayload = Base64Url.Encode(Util.BuildV2SigningPayload("POST", path, new List<(string, byte[])>
{
("Idempotency-Key", idempotencyKey.ToUtf8())
}, body.ToUtf8()));
var signature = signingFunction($"{joseHeaders}.{jwsPayload}");
var tlSignature = $"{joseHeaders}..{signature}";

Action action = () => Verifier.VerifyWithPem(testCase.PublicKey)
.Method("POST")
.Path(path)
.Headers(headers)
.Body(body)
.Verify(tlSignature);

action.Should().Throw<SignatureException>();
}

[Theory]
[MemberData(nameof(TestCases))]
public void Verify_CustomJoseHeadersSuppliedAsHttpHeaders_Success(TestCase testCase)
{
const string body = "{\"currency\":\"GBP\",\"max_amount_in_minor\":5000000}";
const string idempotencyKey = "idemp-2076717c-9005-4811-a321-9e0787fa0382";
const string path = "/merchant_accounts/a61acaef-ee05-4077-92f3-25543a11bd8d/sweeping";
var headers = new Dictionary<string, string>
{
["Idempotency-Key"] = idempotencyKey,
["Tl-Signature-Version"] = "2",
["Tl-Signature-Headers"] = "Idempotency-Key,Tl-Signature-Version,Tl-Signature-Headers",
};

var signingFunction = SigningFunction.ForPrivateKey(testCase.PrivateKey);

var joseHeaders = Base64Url.Encode(JsonSerializer.SerializeToUtf8Bytes(new Dictionary<string, object>
{
["alg"] = "ES512",
["kid"] = testCase.Kid,
}));
var jwsPayload = Base64Url.Encode(Util.BuildV2SigningPayload("POST", path,
headers.Select(x => (x.Key, x.Value.ToUtf8())).ToList(), body.ToUtf8()));
var signature = signingFunction($"{joseHeaders}.{jwsPayload}");
var tlSignature = $"{joseHeaders}..{signature}";

Verifier.VerifyWithPem(testCase.PublicKey)
.Method("POST")
.Path(path)
.Headers(headers)
.Body(body)
.Verify(tlSignature); // should not throw
}

[Theory]
[MemberData(nameof(TestCases))]
public void SignAndVerify_WithJku(TestCase testCase)
Expand Down
Loading