diff --git a/payjoin-ffi/csharp/IntegrationTests.cs b/payjoin-ffi/csharp/IntegrationTests.cs index 8a7ef78cb..e3b4c806a 100644 --- a/payjoin-ffi/csharp/IntegrationTests.cs +++ b/payjoin-ffi/csharp/IntegrationTests.cs @@ -1,6 +1,6 @@ using Payjoin.Http; +using System.Security.Authentication; using System.Text.Json; -using Payjoin; using Xunit; namespace Payjoin.Tests @@ -311,6 +311,7 @@ public ValueTask InitializeAsync() { _httpClient = new HttpClient(); _services = TestServices.Initialize(); + _env = PayjoinMethods.InitBitcoindSenderReceiver(); return ValueTask.CompletedTask; } @@ -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( + () => ohttpClient.GetOhttpKeysAsync(new System.Uri(directory), cancellationToken)); + + Assert.IsType(ex.InnerException); + } + [Fact] public void TestFfiValidation() { @@ -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(); @@ -439,15 +460,8 @@ 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(); @@ -455,7 +469,8 @@ public async Task TestIntegrationV2ToV2() var receiverAddressJson = RpcCall(receiver, "getnewaddress"); var receiverAddress = JsonSerializer.Deserialize(receiverAddressJson)!; - var directory = _services!.DirectoryUrl(); + Assert.NotNull(_services); + var directory = _services.DirectoryUrl(); var ohttpRelay = _services.OhttpRelayUrl(); _services.WaitForServicesReady(); diff --git a/payjoin-ffi/csharp/Payjoin.Http.cs b/payjoin-ffi/csharp/Payjoin.Http.cs index 595f8efad..0ce5136ca 100644 --- a/payjoin-ffi/csharp/Payjoin.Http.cs +++ b/payjoin-ffi/csharp/Payjoin.Http.cs @@ -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; + /// - /// Fetches the OHTTP keys from the specified payjoin directory via proxy. + /// Initializes a new instance of configured to route + /// requests through an OHTTP relay proxy, ensuring the client IP address is never + /// revealed to the payjoin directory. /// + /// + /// The instance should be kept long-lived to allow the underlying + /// to reuse connections and avoid socket exhaustion. + /// /// - /// 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. /// + /// The DER-encoded certificate to use for local HTTPS connections. + 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); + } + + /// + /// Fetches the OHTTP keys from the specified payjoin directory. + /// /// /// The payjoin directory from which to fetch the OHTTP keys. This directory stores /// and forwards payjoin client payloads. /// - /// The DER-encoded certificate to use for local HTTPS connections. /// A token to cancel the asynchronous operation. /// The decoded from the payjoin directory. - internal static async Task GetOhttpKeysAsync(System.Uri ohttpRelayUrl, System.Uri directoryUrl, byte[] certificate, CancellationToken cancellationToken = default) + public async Task 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; + } } }