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
41 changes: 28 additions & 13 deletions payjoin-ffi/csharp/IntegrationTests.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
using Payjoin.Http;
using System.Security.Authentication;
using System.Text.Json;
using Payjoin;
using Xunit;

namespace Payjoin.Tests
Expand Down Expand Up @@ -311,6 +311,7 @@ public ValueTask InitializeAsync()
{
_httpClient = new HttpClient();
_services = TestServices.Initialize();
_env = PayjoinMethods.InitBitcoindSenderReceiver();

return ValueTask.CompletedTask;
}
Expand All @@ -329,16 +330,35 @@ public async Task FetchAndDecodeOhttpKeysViaRelayProxy()
{
var cancellationToken = TestContext.Current.CancellationToken;

_services!.WaitForServicesReady();
Assert.NotNull(_services);
_services.WaitForServicesReady();
var ohttpRelay = _services.OhttpRelayUrl();
var directory = _services.DirectoryUrl();
var cert = _services.Cert();

using var keys = await OhttpKeysClient.GetOhttpKeysAsync(new System.Uri(ohttpRelay), new System.Uri(directory), cert, cancellationToken);
using var ohttpClient = new OhttpKeysClient(new System.Uri(ohttpRelay), cert);
using var keys = await ohttpClient.GetOhttpKeysAsync(new System.Uri(directory), cancellationToken);

Assert.NotNull(keys);
}

[Fact]
public async Task FetchOhttpKeysWithoutTestCertificateThrowsException()
{
var cancellationToken = TestContext.Current.CancellationToken;

Assert.NotNull(_services);
_services.WaitForServicesReady();
var ohttpRelay = _services.OhttpRelayUrl();
var directory = _services.DirectoryUrl();

using var ohttpClient = new OhttpKeysClient(new System.Uri(ohttpRelay));
var ex = await Assert.ThrowsAsync<HttpRequestException>(
() => ohttpClient.GetOhttpKeysAsync(new System.Uri(directory), cancellationToken));

Assert.IsType<AuthenticationException>(ex.InnerException);
}

[Fact]
public void TestFfiValidation()
{
Expand Down Expand Up @@ -412,7 +432,8 @@ public void TestFfiValidation()
new InputPair(txin, psbtIn, new PlainWeight(0));
});

var directory = _services!.DirectoryUrl();
Assert.NotNull(_services);
var directory = _services.DirectoryUrl();
_services.WaitForServicesReady();
var ohttpKeys = _services.FetchOhttpKeys();

Expand All @@ -439,23 +460,17 @@ public void TestFfiValidation()
public async Task TestIntegrationV2ToV2()
{
var cancellationToken = TestContext.Current.CancellationToken;
try
{
_env = PayjoinMethods.InitBitcoindSenderReceiver();
}
catch (Exception ex)
{
Assert.Skip($"test-utils are not available: {ex.GetType().Name}: {ex.Message}");
}

Assert.NotNull(_env);
using var bitcoind = _env.GetBitcoind();
using var receiver = _env.GetReceiver();
using var sender = _env.GetSender();

var receiverAddressJson = RpcCall(receiver, "getnewaddress");
var receiverAddress = JsonSerializer.Deserialize<string>(receiverAddressJson)!;

var directory = _services!.DirectoryUrl();
Assert.NotNull(_services);
var directory = _services.DirectoryUrl();
var ohttpRelay = _services.OhttpRelayUrl();
_services.WaitForServicesReady();

Expand Down
88 changes: 70 additions & 18 deletions payjoin-ffi/csharp/Payjoin.Http.cs
Original file line number Diff line number Diff line change
@@ -1,44 +1,96 @@
using Payjoin;
using System;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;

namespace Payjoin.Http
{
internal static class OhttpKeysClient
internal sealed class OhttpKeysClient : IDisposable
{
private readonly HttpClient _client;
private readonly HttpClientHandler _handler;
private bool _disposed;

/// <summary>
/// Fetches the OHTTP keys from the specified payjoin directory via proxy.
/// Initializes a new instance of <see cref="OhttpKeysClient"/> configured to route
/// requests through an OHTTP relay proxy, ensuring the client IP address is never
/// revealed to the payjoin directory.
/// </summary>
/// <remarks>
/// The instance should be kept long-lived to allow the underlying
/// <see cref="HttpClientHandler"/> to reuse connections and avoid socket exhaustion.
/// </remarks>
/// <param name="ohttpRelayUrl">
/// The HTTP CONNECT method proxy to request the OHTTP keys from a payjoin directory.
/// Proxying requests for OHTTP keys ensures a client IP address is never revealed to
/// the payjoin directory.
/// The HTTP CONNECT method proxy through which requests are routed.
/// </param>
/// <param name="certificate">The DER-encoded certificate to use for local HTTPS connections.</param>
public OhttpKeysClient(System.Uri ohttpRelayUrl, byte[]? certificate = null)
{
ArgumentNullException.ThrowIfNull(ohttpRelayUrl);

_handler = new HttpClientHandler
{
Proxy = new System.Net.WebProxy(ohttpRelayUrl),
UseProxy = true,
CheckCertificateRevocationList = true
};

if (certificate is { Length: > 0 })
{
_handler.ServerCertificateCustomValidationCallback = (_, serverCert, _, _) =>
serverCert is not null &&
certificate.SequenceEqual(serverCert.GetRawCertData());
}

_client = new HttpClient(_handler);
}

/// <summary>
/// Fetches the OHTTP keys from the specified payjoin directory.
/// </summary>
/// <param name="directoryUrl">
/// The payjoin directory from which to fetch the OHTTP keys. This directory stores
/// and forwards payjoin client payloads.
/// </param>
/// <param name="certificate">The DER-encoded certificate to use for local HTTPS connections.</param>
/// <param name="cancellationToken">A token to cancel the asynchronous operation.</param>
/// <returns>The decoded <see cref="OhttpKeys"/> from the payjoin directory.</returns>
internal static async Task<OhttpKeys> GetOhttpKeysAsync(System.Uri ohttpRelayUrl, System.Uri directoryUrl, byte[] certificate, CancellationToken cancellationToken = default)
public async Task<OhttpKeys> GetOhttpKeysAsync(System.Uri directoryUrl, CancellationToken cancellationToken = default)
{
var keysUrl = new System.Uri(directoryUrl, "/.well-known/ohttp-gateway");
ArgumentNullException.ThrowIfNull(directoryUrl);

using var handler = new HttpClientHandler
{
Proxy = new System.Net.WebProxy(ohttpRelayUrl),
UseProxy = true,
ServerCertificateCustomValidationCallback = (_, serverCert, _, _) => serverCert != null && serverCert.GetRawCertData().SequenceEqual(certificate)
};
var keysUrl = new System.Uri(directoryUrl, "/.well-known/ohttp-gateway");

using var client = new HttpClient(handler);
using var request = new HttpRequestMessage(HttpMethod.Get, keysUrl);
request.Headers.Accept.ParseAdd("application/ohttp-keys");

using var response = await client.SendAsync(request, cancellationToken);
using var response = await _client.SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();

var ohttpKeysBytes = await response.Content.ReadAsByteArrayAsync(cancellationToken);
var ohttpKeysBytes = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
return OhttpKeys.Decode(ohttpKeysBytes);
}

public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}

private void Dispose(bool disposing)
{
if (_disposed)
{
return;
}

if (disposing)
{
_client.Dispose();
_handler.Dispose();
}

_disposed = true;
}
}
}
Loading