Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,7 @@ coveragereport/
**/Generated/AuthServerClient.cs*
**/Generated/WalletAddressClient.cs*
**/Generated/ResourceServerClient.cs*

.env
.idea/

15 changes: 15 additions & 0 deletions .idea/.idea.OpenPayments/.idea/.gitignore

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add a .gitignore for the .idea folder and exclude? Specific to Jetbrains.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions .idea/.idea.OpenPayments/.idea/.name

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions .idea/.idea.OpenPayments/.idea/encodings.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions .idea/.idea.OpenPayments/.idea/indexLayout.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions .idea/.idea.OpenPayments/.idea/vcs.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,21 @@

auth-server-generate:
npx swagger-cli bundle open-payments-specifications/openapi/auth-server.yaml -o OpenPayments.Sdk/tmp/auth-bundled.json -t json && \
nswag openapi2csclient /input:OpenPayments.Sdk/tmp/auth-bundled.json /output:OpenPayments.Sdk/Generated/AuthServerClient.cs /namespace:OpenPayments.Sdk.Generated.Auth /classname:AuthServerClient /injectHttpClient:true && \
nswag openapi2csclient /input:OpenPayments.Sdk/tmp/auth-bundled.json /output:OpenPayments.Sdk/Generated/Auth/AuthServerClient.g.cs /namespace:OpenPayments.Sdk.Generated.Auth /classname:AuthServerClient /injectHttpClient:true && \
rm -rf OpenPayments.Sdk/tmp/auth-bundled.json

as-models: auth-server-generate

resource-server-generate:
npx swagger-cli bundle open-payments-specifications/openapi/resource-server.yaml -o OpenPayments.Sdk/tmp/resource-bundled.json -t json && \
nswag openapi2csclient /input:OpenPayments.Sdk/tmp/resource-bundled.json /output:OpenPayments.Sdk/Generated/ResourceServerClient.cs /namespace:OpenPayments.Sdk.Generated.Resource /classname:ResourceServerClient /injectHttpClient:true && \
nswag openapi2csclient /input:OpenPayments.Sdk/tmp/resource-bundled.json /output:OpenPayments.Sdk/Generated/Resource/ResourceServerClient.g.cs /namespace:OpenPayments.Sdk.Generated.Resource /classname:ResourceServerClient /injectHttpClient:true && \
rm -rf OpenPayments.Sdk/tmp/resource-bundled.json

rs-models: resource-server-generate

wallet-address-models:
npx swagger-cli bundle open-payments-specifications/openapi/wallet-address-server.yaml -o OpenPayments.Sdk/tmp/wallet-bundled.json -t json && \
nswag openapi2csclient /input:OpenPayments.Sdk/tmp/wallet-bundled.json /output:OpenPayments.Sdk/Generated/WalletAddressClient.cs /namespace:OpenPayments.Sdk.Generated.Wallet /classname:WalletAddressClient /injectHttpClient:true && \
nswag openapi2csclient /input:OpenPayments.Sdk/tmp/wallet-bundled.json /output:OpenPayments.Sdk/Generated/Wallet/WalletAddressClient.g.cs /namespace:OpenPayments.Sdk.Generated.Wallet /classname:WalletAddressClient /injectHttpClient:true && \
rm -rf OpenPayments.Sdk/tmp/wallet-bundled.json

wa-models: wallet-address-models
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
using OpenPayments.Sdk.HttpSignatureUtils;

/// <summary>
/// Provides extension methods for configuring and adding signature-based authentication
/// to instances of <see cref="System.Net.Http.HttpClient"/>.
/// </summary>
public static class HttpClientSignatureExtensions
{
// public static async Task<bool> ValidateRequestSignatureAsync(
Expand Down
81 changes: 54 additions & 27 deletions OpenPayments.Sdk.HttpSignatureUtils/HttpRequestSigner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,39 @@

[assembly: InternalsVisibleTo("OpenPayments.Sdk.HttpSignatureUtils.Tests")]

internal class SignatureHeaders
/// <summary>
/// Signature headers returned by the HttpRequestSigner.
/// </summary>

public class SignatureHeaders
{
/// <summary>
/// Signature header value.
/// </summary>
public string Signature { get; set; } = string.Empty;
/// <summary>
/// Signature input header value.
/// </summary>
public string SignatureInput { get; set; } = string.Empty;
}

internal static class HttpRequestSigner
/// <summary>
/// Signs an HTTP request using the Ed25519 signature algorithm.
/// </summary>
public static class HttpRequestSigner
{
private static string BuildSignatureInput(List<string> components, string keyId, long created)
{
string fields = string.Join(" ", components.Select(h => $"\"{h}\""));
return $"({fields});created={created};keyid=\"{keyId}\"";
var fields = string.Join(" ", components.Select(h => $"\"{h}\""));
return $"({fields});created={created};keyid=\"{keyId}\";alg=\"ed25519\"";
}

private static string ComputeContentDigest(string body)
{
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(body));
var hash = SHA512.HashData(Encoding.UTF8.GetBytes(body));
return Convert.ToBase64String(hash);
}

private static async Task<string> TryGetHeaderValueAsync(HttpRequestMessage request, string name)
{
name = name.ToLowerInvariant();
Expand All @@ -41,14 +54,15 @@ private static async Task<string> TryGetHeaderValueAsync(HttpRequestMessage requ

if (name == "content-digest" && request.Content != null)
{
string body = await request.Content.ReadAsStringAsync();
return $"sha-256=:{ComputeContentDigest(body)}:";
var body = await request.Content.ReadAsStringAsync();
return $"sha-512=:{ComputeContentDigest(body)}:";
}

return "";
}

private static async Task<string> BuildSignatureBaseAsync(HttpRequestMessage request, List<string> components, long created, string keyId)
private static async Task<string> BuildSignatureBaseAsync(HttpRequestMessage request, List<string> components,
long created, string keyId)
{
var lines = new List<string>();

Expand All @@ -57,25 +71,34 @@ private static async Task<string> BuildSignatureBaseAsync(HttpRequestMessage req
switch (component)
{
case "@method":
lines.Add($"@method: {request.Method.Method.ToLower()}");
lines.Add($"\"@method\": {request.Method.Method.ToUpper()}");
break;
case "@target-uri":
lines.Add($"@target-uri: {request.RequestUri}");
lines.Add($"\"@target-uri\": {request.RequestUri}");
break;
default:
string value = await TryGetHeaderValueAsync(request, component);
lines.Add($"{component.ToLower()}: {value}");
var value = await TryGetHeaderValueAsync(request, component);
lines.Add($"\"{component.ToLower()}\": {value}");
break;
}
}

string fieldList = string.Join(" ", components.Select(c => $"\"{c}\""));
lines.Add($"\"@signature-params\": ({fieldList});created={created};keyid=\"{keyId}\"");
var fieldList = string.Join(" ", components.Select(c => $"\"{c}\""));
lines.Add($"\"@signature-params\": ({fieldList});created={created};keyid=\"{keyId}\";alg=\"ed25519\"");

return string.Join("\n", lines);
}

public static async Task<SignatureHeaders> SignHttpRequestAsync(HttpRequestMessage request, Key privateKey, string keyId)
/// <summary>
/// Signs an HTTP request using the Ed25519 signature algorithm.
/// </summary>
/// <param name="request"></param>
/// <param name="privateKey"></param>
/// <param name="keyId"></param>
/// <returns></returns>
/// <exception cref="ArgumentException"></exception>
public static async Task<SignatureHeaders> SignHttpRequestAsync(HttpRequestMessage request, Key privateKey,
string keyId)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(privateKey);
Expand All @@ -91,15 +114,20 @@ public static async Task<SignatureHeaders> SignHttpRequestAsync(HttpRequestMessa

if (request.Content != null)
{
string content = await request.Content.ReadAsStringAsync();
var content = await request.Content.ReadAsStringAsync();
if (!string.IsNullOrEmpty(content))
{
components.AddRange(["content-digest", "content-length", "content-type"]);

string digest = ComputeContentDigest(content);
var digest = ComputeContentDigest(content);

request.Content.Headers.TryAddWithoutValidation("Content-Digest", $"sha-256=:{digest}:");
request.Content.Headers.TryAddWithoutValidation("Content-Length", Encoding.UTF8.GetByteCount(content).ToString());
request.Content.Headers.TryAddWithoutValidation("Content-Digest", $"sha-512=:{digest}:");

if (!request.Content.Headers.Contains("Content-Length"))
{
request.Content.Headers.TryAddWithoutValidation("Content-Length",
Encoding.UTF8.GetByteCount(content).ToString());
}

if (!request.Content.Headers.Contains("Content-Type"))
{
Expand All @@ -108,12 +136,11 @@ public static async Task<SignatureHeaders> SignHttpRequestAsync(HttpRequestMessa
}
}

long created = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
string signatureInput = BuildSignatureInput(components, keyId, created);
string signatureBase = await BuildSignatureBaseAsync(request, components, created, keyId);

byte[] signatureBytes = SignatureAlgorithm.Ed25519.Sign(privateKey, Encoding.UTF8.GetBytes(signatureBase));
string base64Signature = Convert.ToBase64String(signatureBytes);
var created = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var signatureInput = BuildSignatureInput(components, keyId, created);
var signatureBase = await BuildSignatureBaseAsync(request, components, created, keyId);
var signatureBytes = SignatureAlgorithm.Ed25519.Sign(privateKey, Encoding.UTF8.GetBytes(signatureBase));
var base64Signature = Convert.ToBase64String(signatureBytes);

return new SignatureHeaders
{
Expand Down
61 changes: 55 additions & 6 deletions OpenPayments.Sdk.HttpSignatureUtils/KeyUtils.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
using System.Diagnostics;
using NSec.Cryptography;
using System.Security.Cryptography;
using Org.BouncyCastle.Asn1;
using Org.BouncyCastle.Asn1.Pkcs;
using Org.BouncyCastle.OpenSsl;

namespace OpenPayments.Sdk.HttpSignatureUtils;

Expand Down Expand Up @@ -29,7 +32,7 @@ public static class KeyUtils
/// </exception>
public static Key LoadBase64Key(string base64Key)
{
byte[] keyBytes = Convert.FromBase64String(base64Key);
var keyBytes = Convert.FromBase64String(base64Key);

if (keyBytes.Length != 32 && keyBytes.Length != 64)
{
Expand All @@ -41,6 +44,53 @@ public static Key LoadBase64Key(string base64Key)
return Key.Import(algorithm, seed, KeyBlobFormat.RawPrivateKey);
}

/// <summary>
/// Loads an Ed25519 private key from a PEM-encoded PKCS#8 string.
/// </summary>
/// <param name="pem"></param>
/// <returns></returns>
/// <exception cref="ArgumentException"></exception>
public static Key LoadPem(string pem)
{
// Read PEM -> DER object
using var sr = new StringReader(pem);
var pemObj = new PemReader(sr).ReadPemObject();
if (pemObj == null)
throw new ArgumentException("Invalid PEM");

// Parse PKCS#8 PrivateKeyInfo
var privInfo = PrivateKeyInfo.GetInstance(Asn1Object.FromByteArray(pemObj.Content));

// Ensure Ed25519 OID (1.3.101.112)
var oid = privInfo.PrivateKeyAlgorithm.Algorithm.Id;
if (oid != "1.3.101.112")
throw new ArgumentException($"Unexpected algorithm OID: {oid}. Expected Ed25519 (1.3.101.112).");

// Extract the inner OCTET STRING (seed). For Ed25519 in PKCS#8, this is 32 bytes.
var privateKeyOctets = Asn1OctetString.GetInstance(privInfo.ParsePrivateKey());
var privateKeyBytes = privateKeyOctets.GetOctets();

// Some toolchains wrap the seed in another OCTET STRING layer:
// If length is not 32, try one more unwrap.
if (privateKeyBytes.Length != 32)
{
try
{
var inner = Asn1OctetString.GetInstance(Asn1Object.FromByteArray(privateKeyBytes));
privateKeyBytes = inner.GetOctets();
}
catch { /* ignore */ }
}

if (privateKeyBytes.Length != 32)
throw new ArgumentException($"Ed25519 seed must be 32 bytes, got {privateKeyBytes.Length}.");

// Import into NSec as raw private key (seed)
var algorithm = SignatureAlgorithm.Ed25519;
var seed = privateKeyBytes.AsSpan(0, 32);
return Key.Import(algorithm, seed, KeyBlobFormat.RawPrivateKey);
}

/// <summary>
/// Generates a JSON Web Key (JWK) representing an Ed25519 public key.
/// </summary>
Expand Down Expand Up @@ -71,7 +121,7 @@ public static Jwk GenerateJwk(string keyId, Key? privateKey = null)
ExportPolicy = KeyExportPolicies.AllowPlaintextExport
});

byte[] publicKeyBytes = privateKey.PublicKey.Export(KeyBlobFormat.RawPublicKey);
var publicKeyBytes = privateKey.PublicKey.Export(KeyBlobFormat.RawPublicKey);

return new Jwk
{
Expand Down Expand Up @@ -105,7 +155,7 @@ public static Key LoadKey(string keyFilePath)
throw new ArgumentException("File was loaded, but key was not a valid Ed25519 private key (must be 32 or 64 bytes).");
}

byte[] seed = keyBytes.AsSpan(0, 32).ToArray();
var seed = keyBytes.AsSpan(0, 32).ToArray();

try
{
Expand All @@ -132,9 +182,8 @@ public static Key GenerateKey(GenerateKeyArgs? args = null)
if (args?.Dir is not null)
{
Directory.CreateDirectory(args.Dir);
string fileName = args.Filename ?? $"private-key-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}.pem";

string path = Path.Combine(args.Dir, fileName);
var fileName = args.Filename ?? $"private-key-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}.pem";
var path = Path.Combine(args.Dir, fileName);

key.ToPem(path);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace OpenPayments.Sdk.Tests.Clients;

[CollectionDefinition("AuthenticatedClient")]
public class AuthenticatedClientCollection : ICollectionFixture<AuthenticatedClientFixture> {

}
Loading
Loading