From 9ca4bf3010be27205d7a44c7b8c50bfb2dcc2dbf Mon Sep 17 00:00:00 2001 From: monsieurleberre Date: Tue, 17 Feb 2026 07:10:29 -0500 Subject: [PATCH 1/3] Add Binance Travel Rule support with PII encryption (#328) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is sub production level, OK, and breaking the build, but this is just a trace, and next commit will remove all the 💩 --- .../Registration/DependencyInjection.cs | 3 + .../TravelRule/BinanceTravelRuleService.cs | 152 +++++++++++++++ .../TravelRule/IBinanceTravelRuleService.cs | 32 ++++ .../TravelRule/IRsaEncryptionService.cs | 24 +++ .../TravelRule/RsaEncryptionService.cs | 38 ++++ .../Base/FireblocksClientTestsBase.cs | 24 ++- .../ExchangeAccountsClientTests.cs | 149 ++++++++++++++- .../Integration/VaultClientTests.cs | 38 +++- .../Unit/DependencyInjectionTests.cs | 3 + .../BinanceTravelRuleServiceTests.cs | 179 ++++++++++++++++++ .../TravelRule/RsaEncryptionServiceTests.cs | 74 ++++++++ 11 files changed, 704 insertions(+), 12 deletions(-) create mode 100644 src/Trakx.Fireblocks.ApiClient/TravelRule/BinanceTravelRuleService.cs create mode 100644 src/Trakx.Fireblocks.ApiClient/TravelRule/IBinanceTravelRuleService.cs create mode 100644 src/Trakx.Fireblocks.ApiClient/TravelRule/IRsaEncryptionService.cs create mode 100644 src/Trakx.Fireblocks.ApiClient/TravelRule/RsaEncryptionService.cs create mode 100644 tests/Trakx.Fireblocks.ApiClient.Tests/Unit/TravelRule/BinanceTravelRuleServiceTests.cs create mode 100644 tests/Trakx.Fireblocks.ApiClient.Tests/Unit/TravelRule/RsaEncryptionServiceTests.cs diff --git a/src/Trakx.Fireblocks.ApiClient/Registration/DependencyInjection.cs b/src/Trakx.Fireblocks.ApiClient/Registration/DependencyInjection.cs index 9cc7e9e..07a3ec4 100644 --- a/src/Trakx.Fireblocks.ApiClient/Registration/DependencyInjection.cs +++ b/src/Trakx.Fireblocks.ApiClient/Registration/DependencyInjection.cs @@ -3,6 +3,7 @@ using Trakx.Common.ApiClient; using Trakx.Common.Configuration; using Trakx.Common.DateAndTime; +using Trakx.Fireblocks.ApiClient.TravelRule; using Trakx.Fireblocks.ApiClient.Utils; namespace Trakx.Fireblocks.ApiClient; @@ -30,6 +31,8 @@ public static IServiceCollection AddFireblocksClient(this IServiceCollection ser services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddApiClientsOfBaseTypeWithHttpClient( new ApiClientWithHttpClientRetryOptions diff --git a/src/Trakx.Fireblocks.ApiClient/TravelRule/BinanceTravelRuleService.cs b/src/Trakx.Fireblocks.ApiClient/TravelRule/BinanceTravelRuleService.cs new file mode 100644 index 0000000..6a9f3bd --- /dev/null +++ b/src/Trakx.Fireblocks.ApiClient/TravelRule/BinanceTravelRuleService.cs @@ -0,0 +1,152 @@ +namespace Trakx.Fireblocks.ApiClient.TravelRule; + +/// +internal class BinanceTravelRuleService( + IExchange_accountsClient exchangeAccountsClient, + ITransactionsClient transactionsClient, + IRsaEncryptionService encryptionService) : IBinanceTravelRuleService +{ + private string? _cachedPublicKeyPem; + + /// + public async Task BuildWithdrawalExtraParametersAsync(CancellationToken cancellationToken = default) + { + return await BuildExtraParametersAsync( + partyKey: "beneficiary", + transactionDirection: "withdraw", + vaspSection: new Dictionary + { + ["beneficiaryVASP"] = new Dictionary { ["vaspCode"] = "BINANCE" }, + ["originatingVASP"] = new Dictionary { ["vaspCountry"] = "FR" } + }, + cancellationToken); + } + + /// + public async Task BuildDepositExtraParametersAsync(CancellationToken cancellationToken = default) + { + return await BuildExtraParametersAsync( + partyKey: "originator", + transactionDirection: "deposit", + vaspSection: new Dictionary + { + ["originatingVASP"] = new Dictionary { ["vaspName"] = "Binance" }, + ["beneficiaryVASP"] = new Dictionary { ["vaspCountry"] = "FR" } + }, + cancellationToken); + } + + private async Task BuildExtraParametersAsync( + string partyKey, + string transactionDirection, + Dictionary vaspSection, + CancellationToken cancellationToken) + { + var pem = await GetPublicKeyAsync(cancellationToken); + + var data = new Dictionary + { + [partyKey] = new Dictionary + { + ["participantRelationshipType"] = encryptionService.Encrypt("FirstParty", pem), + ["entityType"] = encryptionService.Encrypt("Business", pem) + }, + ["transactionData"] = new Dictionary + { + [transactionDirection] = new Dictionary + { + ["isAddressVerified"] = encryptionService.Encrypt(true, pem) + } + } + }; + + foreach (var (key, value) in vaspSection) + { + var plainValues = (Dictionary)value; + var encrypted = new Dictionary(); + foreach (var (fieldKey, fieldValue) in plainValues) + encrypted[fieldKey] = encryptionService.Encrypt((string)fieldValue, pem); + data[key] = encrypted; + } + + var piiData = new Dictionary + { + ["type"] = "exchange-service-travel-rule", + ["typeVersion"] = "1.0.0", + ["data"] = data + }; + + var extraParameters = new ExtraParameters(); + extraParameters.AdditionalProperties["piiData"] = piiData; + return extraParameters; + } + + /// + public async Task WithdrawFromBinanceAsync( + string exchangeAccountId, string vaultAccountId, string assetId, string amount, + string? note = null, string? customerRefId = null, CancellationToken cancellationToken = default) + { + var extraParameters = await BuildWithdrawalExtraParametersAsync(cancellationToken); + + var request = new TransactionRequest + { + Operation = TransactionOperation.TRANSFER, + AssetId = assetId, + Amount = amount, + Note = note, + CustomerRefId = customerRefId, + Source = new TransferPeerPath + { + Type = TransferPeerPathType.EXCHANGE_ACCOUNT, + Id = exchangeAccountId, + }, + Destination = new DestinationTransferPeerPath + { + Type = TransferPeerPathType.VAULT_ACCOUNT, + Id = vaultAccountId, + }, + ExtraParameters = extraParameters, + }; + + var response = await transactionsClient.CreateTransactionAsync(body: request, cancellationToken: cancellationToken); + return response.Content; + } + + /// + public async Task DepositToBinanceAsync( + string exchangeAccountId, string vaultAccountId, string assetId, string amount, + string? note = null, string? customerRefId = null, CancellationToken cancellationToken = default) + { + var extraParameters = await BuildDepositExtraParametersAsync(cancellationToken); + + var request = new TransactionRequest + { + Operation = TransactionOperation.TRANSFER, + AssetId = assetId, + Amount = amount, + Note = note, + CustomerRefId = customerRefId, + Source = new TransferPeerPath + { + Type = TransferPeerPathType.VAULT_ACCOUNT, + Id = vaultAccountId, + }, + Destination = new DestinationTransferPeerPath + { + Type = TransferPeerPathType.EXCHANGE_ACCOUNT, + Id = exchangeAccountId, + }, + ExtraParameters = extraParameters, + }; + + var response = await transactionsClient.CreateTransactionAsync(body: request, cancellationToken: cancellationToken); + return response.Content; + } + + private async Task GetPublicKeyAsync(CancellationToken cancellationToken) + { + return _cachedPublicKeyPem ??= (await exchangeAccountsClient + .GetExchangeAccountsCredentialsPublicKeyAsync(cancellationToken)) + .Content.PublicKey; + } +} diff --git a/src/Trakx.Fireblocks.ApiClient/TravelRule/IBinanceTravelRuleService.cs b/src/Trakx.Fireblocks.ApiClient/TravelRule/IBinanceTravelRuleService.cs new file mode 100644 index 0000000..37ea43f --- /dev/null +++ b/src/Trakx.Fireblocks.ApiClient/TravelRule/IBinanceTravelRuleService.cs @@ -0,0 +1,32 @@ +namespace Trakx.Fireblocks.ApiClient.TravelRule; + +/// +/// Service for building encrypted PII data required by Binance travel rule compliance. +/// See: https://developers.fireblocks.com/docs/a-developers-guide-to-constructing-encrypted-pii-messages-for-binance-via-fireblocks +/// +public interface IBinanceTravelRuleService +{ + /// + /// Builds the travel rule extra parameters required for Binance withdrawals (exchange to custodian). + /// + Task BuildWithdrawalExtraParametersAsync(CancellationToken cancellationToken = default); + + /// + /// Builds the travel rule extra parameters required for Binance deposits (custodian to exchange). + /// + Task BuildDepositExtraParametersAsync(CancellationToken cancellationToken = default); + + /// + /// Withdraws from Binance exchange account to a vault account, with travel rule compliance. + /// + Task WithdrawFromBinanceAsync( + string exchangeAccountId, string vaultAccountId, string assetId, string amount, + string? note = null, string? customerRefId = null, CancellationToken cancellationToken = default); + + /// + /// Deposits from a vault account to Binance exchange account, with travel rule compliance. + /// + Task DepositToBinanceAsync( + string exchangeAccountId, string vaultAccountId, string assetId, string amount, + string? note = null, string? customerRefId = null, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/Trakx.Fireblocks.ApiClient/TravelRule/IRsaEncryptionService.cs b/src/Trakx.Fireblocks.ApiClient/TravelRule/IRsaEncryptionService.cs new file mode 100644 index 0000000..3a86a0f --- /dev/null +++ b/src/Trakx.Fireblocks.ApiClient/TravelRule/IRsaEncryptionService.cs @@ -0,0 +1,24 @@ +namespace Trakx.Fireblocks.ApiClient.TravelRule; + +/// +/// Service for encrypting PII data using RSA-OAEP with SHA-256. +/// +public interface IRsaEncryptionService +{ + /// + /// Encrypts a string value using RSA-OAEP with SHA-256. + /// + /// The plain text value to encrypt. + /// The RSA public key in PEM format. + /// Base64-encoded encrypted value. + string Encrypt(string plainText, string publicKeyPem); + + /// + /// Encrypts a boolean value using RSA-OAEP with SHA-256. + /// The boolean is converted to lowercase JSON representation ("true" or "false") before encryption. + /// + /// The boolean value to encrypt. + /// The RSA public key in PEM format. + /// Base64-encoded encrypted value. + string Encrypt(bool plainValue, string publicKeyPem); +} diff --git a/src/Trakx.Fireblocks.ApiClient/TravelRule/RsaEncryptionService.cs b/src/Trakx.Fireblocks.ApiClient/TravelRule/RsaEncryptionService.cs new file mode 100644 index 0000000..fa0dce9 --- /dev/null +++ b/src/Trakx.Fireblocks.ApiClient/TravelRule/RsaEncryptionService.cs @@ -0,0 +1,38 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Trakx.Fireblocks.ApiClient.TravelRule; + +/// +internal class RsaEncryptionService : IRsaEncryptionService +{ + /// + public string Encrypt(string plainText, string publicKeyPem) + { + ArgumentException.ThrowIfNullOrEmpty(plainText); + ArgumentException.ThrowIfNullOrEmpty(publicKeyPem); + + using var rsa = RSA.Create(); + rsa.ImportFromPem(publicKeyPem); + + var plainBytes = Encoding.UTF8.GetBytes(plainText); + var encryptedBytes = rsa.Encrypt(plainBytes, RSAEncryptionPadding.OaepSHA256); + + return Convert.ToBase64String(encryptedBytes); + } + + /// + public string Encrypt(bool plainValue, string publicKeyPem) + { + ArgumentException.ThrowIfNullOrEmpty(publicKeyPem); + + var boolBytes = BitConverter.GetBytes(plainValue); + + using var rsa = RSA.Create(); + rsa.ImportFromPem(publicKeyPem); + + var encryptedBytes = rsa.Encrypt(boolBytes, RSAEncryptionPadding.OaepSHA256); + + return Convert.ToBase64String(encryptedBytes); + } +} diff --git a/tests/Trakx.Fireblocks.ApiClient.Tests/Integration/Base/FireblocksClientTestsBase.cs b/tests/Trakx.Fireblocks.ApiClient.Tests/Integration/Base/FireblocksClientTestsBase.cs index 9d02afe..2dcf8d3 100644 --- a/tests/Trakx.Fireblocks.ApiClient.Tests/Integration/Base/FireblocksClientTestsBase.cs +++ b/tests/Trakx.Fireblocks.ApiClient.Tests/Integration/Base/FireblocksClientTestsBase.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.DependencyInjection; using Serilog; +using Trakx.Common.Infrastructure.Environment.Env; using Trakx.Common.Testing.Configuration; namespace Trakx.Fireblocks.ApiClient.Tests.Integration.Base; @@ -31,19 +32,26 @@ public class FireblocksApiFixture : IDisposable public FireblocksApiFixture() { - var apiConfiguration = AwsConfigurationHelper.GetConfigurationFromAws() - with - { - BaseUrl = new Uri("https://api.fireblocks.io/v1") - }; + var apiConfiguration = GetConfiguration(); var serviceCollection = new ServiceCollection(); - serviceCollection.AddFireblocksClient(apiConfiguration); - ServiceProvider = serviceCollection.BuildServiceProvider(); } + private static FireblocksApiConfiguration GetConfiguration() + { + try + { + return EnvConfigurationHelper.GetConfigurationFromEnv(); + } + catch + { + return AwsConfigurationHelper.GetConfigurationFromAws("Production") + with { BaseUrl = new Uri("https://api.fireblocks.io/v1") }; + } + } + protected virtual void Dispose(bool disposing) { if (!disposing) return; @@ -55,4 +63,4 @@ public void Dispose() Dispose(true); GC.SuppressFinalize(this); } -} \ No newline at end of file +} diff --git a/tests/Trakx.Fireblocks.ApiClient.Tests/Integration/ExchangeAccountsClientTests.cs b/tests/Trakx.Fireblocks.ApiClient.Tests/Integration/ExchangeAccountsClientTests.cs index 041d71b..193df8f 100644 --- a/tests/Trakx.Fireblocks.ApiClient.Tests/Integration/ExchangeAccountsClientTests.cs +++ b/tests/Trakx.Fireblocks.ApiClient.Tests/Integration/ExchangeAccountsClientTests.cs @@ -1,5 +1,7 @@ +using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using Trakx.Fireblocks.ApiClient.Tests.Integration.Base; +using Trakx.Fireblocks.ApiClient.TravelRule; namespace Trakx.Fireblocks.ApiClient.Tests.Integration; @@ -17,7 +19,7 @@ public ExchangeAccountsClientTests(FireblocksApiFixture apiFixture, ITestOutputH public async Task Exchange_accountsAllAsync_should_return_all_accounts() { var response = await _exchangeAccountsClient.GetPagedExchangeAccountsAsync(5); - response.Content.Exchanges.Should().BeEmpty(); + response.Content.Exchanges.Should().NotBeEmpty(); } [Fact] @@ -28,4 +30,147 @@ public async Task GetExchangeAccountsCredentialsPublicKeyAsync_should_return_pub response.Content.PublicKey.Should().NotBeNullOrEmpty(); response.Content.PublicKey.Should().StartWith("-----BEGIN PUBLIC KEY-----"); } -} \ No newline at end of file + + [Fact] + public async Task List_exchange_accounts_with_details() + { + string? after = null; + + do + { + var response = await _exchangeAccountsClient.GetPagedExchangeAccountsAsync(5, after: after); + var page = response.Content; + + foreach (var exchange in page.Exchanges) + { + _logger.Information("Exchange: Id={Id}, Name={Name}, Type={Type}, Status={Status}, IsSubaccount={IsSubaccount}", + exchange.Id, exchange.Name, exchange.Type, exchange.Status, exchange.IsSubaccount); + + if (exchange.Assets is { Count: > 0 }) + { + foreach (var asset in exchange.Assets) + { + _logger.Information(" Asset: Id={AssetId}, Balance={Balance}, Available={Available}, Total={Total}", + asset.Id, asset.Balance, asset.Available, asset.Total); + } + } + + if (exchange.TradingAccounts is not { Count: > 0 }) continue; + foreach (var tradingAccount in exchange.TradingAccounts) + { + _logger.Information(" TradingAccount: Name={Name}, Type={Type}", tradingAccount.Name, tradingAccount.Type); + } + } + + after = page.Paging?.After; + } while (!string.IsNullOrEmpty(after)); + } + + [Fact] + public async Task BuildWithdrawalExtraParameters_should_produce_valid_encrypted_pii_data() + { + var binanceTravelRuleService = _serviceProvider.GetRequiredService(); + + var result = await binanceTravelRuleService.BuildWithdrawalExtraParametersAsync(); + + result.Should().NotBeNull(); + result.AdditionalProperties.Should().ContainKey("piiData"); + + var piiData = result.AdditionalProperties["piiData"] as Dictionary; + piiData.Should().NotBeNull(); + piiData!["type"].Should().Be("exchange-service-travel-rule"); + piiData["typeVersion"].Should().Be("1.0.0"); + + var data = piiData["data"] as Dictionary; + data.Should().NotBeNull(); + data.Should().ContainKey("beneficiary"); + data.Should().ContainKey("transactionData"); + data.Should().ContainKey("beneficiaryVASP"); + data.Should().ContainKey("originatingVASP"); + + var beneficiary = data!["beneficiary"] as Dictionary; + AssertBase64Encoded(beneficiary!["participantRelationshipType"]); + AssertBase64Encoded(beneficiary["entityType"]); + + _logger.Information("Withdrawal piiData: {PiiData}", + JsonSerializer.Serialize(piiData, new JsonSerializerOptions { WriteIndented = true })); + } + + [Fact] + public async Task BuildDepositExtraParameters_should_produce_valid_encrypted_pii_data() + { + var binanceTravelRuleService = _serviceProvider.GetRequiredService(); + + var result = await binanceTravelRuleService.BuildDepositExtraParametersAsync(); + + result.Should().NotBeNull(); + result.AdditionalProperties.Should().ContainKey("piiData"); + + var piiData = result.AdditionalProperties["piiData"] as Dictionary; + piiData.Should().NotBeNull(); + piiData!["type"].Should().Be("exchange-service-travel-rule"); + piiData["typeVersion"].Should().Be("1.0.0"); + + var data = piiData["data"] as Dictionary; + data.Should().NotBeNull(); + data.Should().ContainKey("originator"); + data.Should().ContainKey("originatingVASP"); + data.Should().ContainKey("transactionData"); + data.Should().ContainKey("beneficiaryVASP"); + + var originator = data!["originator"] as Dictionary; + AssertBase64Encoded(originator!["participantRelationshipType"]); + AssertBase64Encoded(originator["entityType"]); + + _logger.Information("Deposit piiData: {PiiData}", + JsonSerializer.Serialize(piiData, new JsonSerializerOptions { WriteIndented = true })); + } + + [Fact] + public async Task WithdrawFromBinance_should_transfer_JUP_to_vault() + { + var binanceTravelRuleService = _serviceProvider.GetRequiredService(); + var transactionsClient = _serviceProvider.GetRequiredService(); + + var extraParameters = await binanceTravelRuleService.BuildWithdrawalExtraParametersAsync(); + + var request = new TransactionRequest + { + Operation = TransactionOperation.TRANSFER, + AssetId = "JUP_SOL", + Amount = "10", + Note = "test travel rule compliance", + CustomerRefId = Guid.NewGuid().ToString(), + Source = new TransferPeerPath + { + Type = TransferPeerPathType.EXCHANGE_ACCOUNT, + Id = "7b29fd51-6098-4c45-8471-4195cdcbdd70", + }, + Destination = new DestinationTransferPeerPath + { + Type = TransferPeerPathType.VAULT_ACCOUNT, + Id = "0", + }, + ExtraParameters = extraParameters, + }; + + var serialized = Newtonsoft.Json.JsonConvert.SerializeObject(request, Newtonsoft.Json.Formatting.Indented); + _logger.Information("TransactionRequest payload:\n{Payload}", serialized); + + var response = await transactionsClient.CreateTransactionAsync(body: request); + + response.Content.Should().NotBeNull(); + response.Content.Id.Should().NotBeNullOrEmpty(); + + _logger.Information("Withdrawal transaction created: Id={Id}, Status={Status}", + response.Content.Id, response.Content.Status); + } + + private static void AssertBase64Encoded(object value) + { + var str = value as string; + str.Should().NotBeNullOrEmpty(); + var action = () => Convert.FromBase64String(str); + action.Should().NotThrow(); + } +} diff --git a/tests/Trakx.Fireblocks.ApiClient.Tests/Integration/VaultClientTests.cs b/tests/Trakx.Fireblocks.ApiClient.Tests/Integration/VaultClientTests.cs index c041991..7edbecd 100644 --- a/tests/Trakx.Fireblocks.ApiClient.Tests/Integration/VaultClientTests.cs +++ b/tests/Trakx.Fireblocks.ApiClient.Tests/Integration/VaultClientTests.cs @@ -1,9 +1,10 @@ +using System.Text.RegularExpressions; using Microsoft.Extensions.DependencyInjection; using Trakx.Fireblocks.ApiClient.Tests.Integration.Base; namespace Trakx.Fireblocks.ApiClient.Tests.Integration; -public class VaultClientTests : FireblocksClientTestsBase +public partial class VaultClientTests : FireblocksClientTestsBase { private readonly IVaultsClient _vaultClient; @@ -23,6 +24,36 @@ public async Task GetVaultAccountsAsync_should_return_all_vault_accounts() accounts.Should().Contain(x => x.Assets.Any(x => x.Id == "BTC_TEST")); } + [Fact] + public async Task List_vault_accounts_with_details() + { + var userIdPattern = UserIdVaultNameRegex(); + string? after = null; + + do + { + var response = await _vaultClient.GetPagedVaultAccountsAsync(after: after, limit: 200); + var page = response.Content; + + foreach (var vault in page.Accounts) + { + if (userIdPattern.IsMatch(vault.Name ?? "")) continue; + + _logger.Information("Vault: Id={Id}, Name={Name}, AutoFuel={AutoFuel}, Hidden={Hidden}", + vault.Id, vault.Name, vault.AutoFuel, vault.HiddenOnUI); + + if (vault.Assets is not { Count: > 0 }) continue; + foreach (var asset in vault.Assets) + { + _logger.Information(" Asset: Id={AssetId}, Total={Total}, Available={Available}, Pending={Pending}, Frozen={Frozen}", + asset.Id, asset.Total, asset.Available, asset.Pending, asset.Frozen); + } + } + + after = page.Paging?.After; + } while (!string.IsNullOrEmpty(after)); + } + [Fact] public async Task GetVaultAccountsAsync_is_case_insensitive() { @@ -32,4 +63,7 @@ public async Task GetVaultAccountsAsync_is_case_insensitive() accounts.Should().NotBeNullOrEmpty(); accounts.Should().Contain(x => x.Name == "Exchange Warm Wallet"); } -} \ No newline at end of file + + [GeneratedRegex(@"^user__ID[0-9A-Fa-f]{10}$")] + private static partial Regex UserIdVaultNameRegex(); +} diff --git a/tests/Trakx.Fireblocks.ApiClient.Tests/Unit/DependencyInjectionTests.cs b/tests/Trakx.Fireblocks.ApiClient.Tests/Unit/DependencyInjectionTests.cs index decf46b..86f8fdc 100644 --- a/tests/Trakx.Fireblocks.ApiClient.Tests/Unit/DependencyInjectionTests.cs +++ b/tests/Trakx.Fireblocks.ApiClient.Tests/Unit/DependencyInjectionTests.cs @@ -2,6 +2,7 @@ using Trakx.Common.DateAndTime; using Trakx.Common.Testing.Configuration; using Trakx.Common.Testing.Extensions; +using Trakx.Fireblocks.ApiClient.TravelRule; using Trakx.Fireblocks.ApiClient.Utils; namespace Trakx.Fireblocks.ApiClient.Tests.Unit; @@ -26,6 +27,8 @@ public void Should_be_able_to_resolve_common_dependencies() _serviceProvider.CheckServiceRegistration(); _serviceProvider.CheckServiceRegistration(); _serviceProvider.CheckServiceRegistration(); + _serviceProvider.CheckServiceRegistration(); + _serviceProvider.CheckServiceRegistration(); } [Fact] diff --git a/tests/Trakx.Fireblocks.ApiClient.Tests/Unit/TravelRule/BinanceTravelRuleServiceTests.cs b/tests/Trakx.Fireblocks.ApiClient.Tests/Unit/TravelRule/BinanceTravelRuleServiceTests.cs new file mode 100644 index 0000000..2200efb --- /dev/null +++ b/tests/Trakx.Fireblocks.ApiClient.Tests/Unit/TravelRule/BinanceTravelRuleServiceTests.cs @@ -0,0 +1,179 @@ +using System.Security.Cryptography; +using System.Text; +using Trakx.Common.ApiClient; +using Trakx.Fireblocks.ApiClient.TravelRule; + +namespace Trakx.Fireblocks.ApiClient.Tests.Unit.TravelRule; + +public class BinanceTravelRuleServiceTests : IDisposable +{ + private readonly BinanceTravelRuleService _sut; + private readonly ITransactionsClient _transactionsClient; + private readonly RSA _rsa; + + public BinanceTravelRuleServiceTests() + { + _rsa = RSA.Create(2048); + var publicKeyPem = _rsa.ExportSubjectPublicKeyInfoPem(); + + var exchangeAccountsClient = Substitute.For(); + exchangeAccountsClient.GetExchangeAccountsCredentialsPublicKeyAsync(Arg.Any()) + .Returns(new Response( + 200, + new Dictionary>(), + new ExchangeCredentialsPublicKeyResponse { PublicKey = publicKeyPem })); + + _transactionsClient = Substitute.For(); + _transactionsClient.CreateTransactionAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(callInfo => new Response( + 200, + new Dictionary>(), + new CreateTransactionResponse { Id = "tx-123", Status = "SUBMITTED" })); + + var encryptionService = new RsaEncryptionService(); + _sut = new BinanceTravelRuleService(exchangeAccountsClient, _transactionsClient, encryptionService); + } + + [Fact] + public async Task BuildWithdrawalExtraParametersAsync_should_return_valid_pii_data_structure() + { + var result = await _sut.BuildWithdrawalExtraParametersAsync(); + + result.Should().NotBeNull(); + result.AdditionalProperties.Should().ContainKey("piiData"); + + var piiData = result.AdditionalProperties["piiData"] as Dictionary; + piiData.Should().NotBeNull(); + piiData!["type"].Should().Be("exchange-service-travel-rule"); + piiData["typeVersion"].Should().Be("1.0.0"); + + var data = piiData["data"] as Dictionary; + data.Should().NotBeNull(); + + var beneficiary = data!["beneficiary"] as Dictionary; + beneficiary.Should().NotBeNull(); + DecryptValue(beneficiary!["participantRelationshipType"]).Should().Be("FirstParty"); + DecryptValue(beneficiary["entityType"]).Should().Be("Business"); + + var transactionData = data["transactionData"] as Dictionary; + var withdraw = (transactionData!["withdraw"] as Dictionary)!; + DecryptBool(withdraw["isAddressVerified"]).Should().BeTrue(); + + var beneficiaryVasp = data["beneficiaryVASP"] as Dictionary; + DecryptValue(beneficiaryVasp!["vaspCode"]).Should().Be("BINANCE"); + + var originatingVasp = data["originatingVASP"] as Dictionary; + DecryptValue(originatingVasp!["vaspCountry"]).Should().Be("FR"); + } + + [Fact] + public async Task BuildDepositExtraParametersAsync_should_return_valid_pii_data_structure() + { + var result = await _sut.BuildDepositExtraParametersAsync(); + + result.Should().NotBeNull(); + result.AdditionalProperties.Should().ContainKey("piiData"); + + var piiData = result.AdditionalProperties["piiData"] as Dictionary; + piiData.Should().NotBeNull(); + piiData!["type"].Should().Be("exchange-service-travel-rule"); + piiData["typeVersion"].Should().Be("1.0.0"); + + var data = piiData["data"] as Dictionary; + data.Should().NotBeNull(); + + var originator = data!["originator"] as Dictionary; + originator.Should().NotBeNull(); + DecryptValue(originator!["participantRelationshipType"]).Should().Be("FirstParty"); + DecryptValue(originator["entityType"]).Should().Be("Business"); + + var originatingVasp = data["originatingVASP"] as Dictionary; + DecryptValue(originatingVasp!["vaspName"]).Should().Be("Binance"); + + var transactionData = data["transactionData"] as Dictionary; + var deposit = (transactionData!["deposit"] as Dictionary)!; + DecryptBool(deposit["isAddressVerified"]).Should().BeTrue(); + + var beneficiaryVasp = data["beneficiaryVASP"] as Dictionary; + DecryptValue(beneficiaryVasp!["vaspCountry"]).Should().Be("FR"); + } + + [Fact] + public async Task BuildWithdrawalExtraParametersAsync_should_produce_base64_encrypted_values() + { + var result = await _sut.BuildWithdrawalExtraParametersAsync(); + + var piiData = result.AdditionalProperties["piiData"] as Dictionary; + var data = piiData!["data"] as Dictionary; + var beneficiary = data!["beneficiary"] as Dictionary; + + // All encrypted values should be valid base64 strings + var encryptedValue = beneficiary!["participantRelationshipType"] as string; + encryptedValue.Should().NotBeNullOrEmpty(); + var action = () => Convert.FromBase64String(encryptedValue!); + action.Should().NotThrow(); + } + + [Fact] + public async Task WithdrawFromBinanceAsync_should_create_transaction_with_correct_peer_paths() + { + var result = await _sut.WithdrawFromBinanceAsync("exchange-1", "vault-2", "BTC", "0.5", "test note"); + + result.Id.Should().Be("tx-123"); + + await _transactionsClient.Received(1).CreateTransactionAsync( + Arg.Any(), + Arg.Any(), + Arg.Is(r => + r.Source.Type == TransferPeerPathType.EXCHANGE_ACCOUNT && + r.Source.Id == "exchange-1" && + r.Destination.Type == TransferPeerPathType.VAULT_ACCOUNT && + r.Destination.Id == "vault-2" && + r.AssetId == "BTC" && + r.Amount == "0.5" && + r.Note == "test note" && + r.ExtraParameters != null), + Arg.Any()); + } + + [Fact] + public async Task DepositToBinanceAsync_should_create_transaction_with_correct_peer_paths() + { + var result = await _sut.DepositToBinanceAsync("exchange-1", "vault-2", "ETH", "1.0"); + + result.Id.Should().Be("tx-123"); + + await _transactionsClient.Received(1).CreateTransactionAsync( + Arg.Any(), + Arg.Any(), + Arg.Is(r => + r.Source.Type == TransferPeerPathType.VAULT_ACCOUNT && + r.Source.Id == "vault-2" && + r.Destination.Type == TransferPeerPathType.EXCHANGE_ACCOUNT && + r.Destination.Id == "exchange-1" && + r.AssetId == "ETH" && + r.Amount == "1.0" && + r.ExtraParameters != null), + Arg.Any()); + } + + private string DecryptValue(object encryptedBase64) + { + var encryptedBytes = Convert.FromBase64String((string)encryptedBase64); + var decryptedBytes = _rsa.Decrypt(encryptedBytes, RSAEncryptionPadding.OaepSHA256); + return Encoding.UTF8.GetString(decryptedBytes); + } + + private bool DecryptBool(object encryptedBase64) + { + var encryptedBytes = Convert.FromBase64String((string)encryptedBase64); + var decryptedBytes = _rsa.Decrypt(encryptedBytes, RSAEncryptionPadding.OaepSHA256); + return BitConverter.ToBoolean(decryptedBytes); + } + + public void Dispose() + { + _rsa.Dispose(); + } +} diff --git a/tests/Trakx.Fireblocks.ApiClient.Tests/Unit/TravelRule/RsaEncryptionServiceTests.cs b/tests/Trakx.Fireblocks.ApiClient.Tests/Unit/TravelRule/RsaEncryptionServiceTests.cs new file mode 100644 index 0000000..17c45f7 --- /dev/null +++ b/tests/Trakx.Fireblocks.ApiClient.Tests/Unit/TravelRule/RsaEncryptionServiceTests.cs @@ -0,0 +1,74 @@ +using System.Security.Cryptography; +using System.Text; +using Trakx.Fireblocks.ApiClient.TravelRule; + +namespace Trakx.Fireblocks.ApiClient.Tests.Unit.TravelRule; + +public class RsaEncryptionServiceTests : IDisposable +{ + private readonly RsaEncryptionService _sut; + private readonly RSA _rsa; + private readonly string _publicKeyPem; + + public RsaEncryptionServiceTests() + { + _sut = new RsaEncryptionService(); + _rsa = RSA.Create(2048); + _publicKeyPem = _rsa.ExportSubjectPublicKeyInfoPem(); + } + + [Fact] + public void Encrypt_string_should_produce_base64_that_can_be_decrypted() + { + var plainText = "Hello World"; + + var encrypted = _sut.Encrypt(plainText, _publicKeyPem); + + encrypted.Should().NotBeNullOrEmpty(); + var encryptedBytes = Convert.FromBase64String(encrypted); + var decryptedBytes = _rsa.Decrypt(encryptedBytes, RSAEncryptionPadding.OaepSHA256); + var decrypted = Encoding.UTF8.GetString(decryptedBytes); + decrypted.Should().Be(plainText); + } + + [Fact] + public void Encrypt_bool_true_should_encrypt_as_raw_byte() + { + var encrypted = _sut.Encrypt(true, _publicKeyPem); + + encrypted.Should().NotBeNullOrEmpty(); + var encryptedBytes = Convert.FromBase64String(encrypted); + var decryptedBytes = _rsa.Decrypt(encryptedBytes, RSAEncryptionPadding.OaepSHA256); + BitConverter.ToBoolean(decryptedBytes).Should().BeTrue(); + } + + [Fact] + public void Encrypt_bool_false_should_encrypt_as_raw_byte() + { + var encrypted = _sut.Encrypt(false, _publicKeyPem); + + encrypted.Should().NotBeNullOrEmpty(); + var encryptedBytes = Convert.FromBase64String(encrypted); + var decryptedBytes = _rsa.Decrypt(encryptedBytes, RSAEncryptionPadding.OaepSHA256); + BitConverter.ToBoolean(decryptedBytes).Should().BeFalse(); + } + + [Fact] + public void Encrypt_should_throw_when_plainText_is_null_or_empty() + { + var action = () => _sut.Encrypt(string.Empty, _publicKeyPem); + action.Should().Throw(); + } + + [Fact] + public void Encrypt_should_throw_when_publicKeyPem_is_null_or_empty() + { + var action = () => _sut.Encrypt("test", string.Empty); + action.Should().Throw(); + } + + public void Dispose() + { + _rsa.Dispose(); + } +} From 79787dac5bc5f294d41587a13c01f72377adc211 Mon Sep 17 00:00:00 2001 From: monsieurleberre Date: Tue, 17 Feb 2026 11:37:25 -0500 Subject: [PATCH 2/3] refactor: remove RSA encryption from Binance travel rule (#329) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: remove RSA encryption from Binance travel rule * Unbreak the build 🤞 * OK this time we fix the build 🙈 * revert ExchangeAccountsClientTests to 5cddfcf6 --- .../Registration/DependencyInjection.cs | 1 - .../TravelRule/BinanceTravelRuleService.cs | 94 +++++------ .../TravelRule/IBinanceTravelRuleService.cs | 4 +- .../TravelRule/IRsaEncryptionService.cs | 24 --- .../TravelRule/RsaEncryptionService.cs | 38 ----- .../Base/FireblocksClientTestsBase.cs | 24 +-- .../ExchangeAccountsClientTests.cs | 149 +----------------- .../Unit/DependencyInjectionTests.cs | 1 - .../BinanceTravelRuleServiceTests.cs | 81 +++------- .../TravelRule/RsaEncryptionServiceTests.cs | 74 --------- 10 files changed, 73 insertions(+), 417 deletions(-) delete mode 100644 src/Trakx.Fireblocks.ApiClient/TravelRule/IRsaEncryptionService.cs delete mode 100644 src/Trakx.Fireblocks.ApiClient/TravelRule/RsaEncryptionService.cs delete mode 100644 tests/Trakx.Fireblocks.ApiClient.Tests/Unit/TravelRule/RsaEncryptionServiceTests.cs diff --git a/src/Trakx.Fireblocks.ApiClient/Registration/DependencyInjection.cs b/src/Trakx.Fireblocks.ApiClient/Registration/DependencyInjection.cs index 07a3ec4..ec02a1f 100644 --- a/src/Trakx.Fireblocks.ApiClient/Registration/DependencyInjection.cs +++ b/src/Trakx.Fireblocks.ApiClient/Registration/DependencyInjection.cs @@ -31,7 +31,6 @@ public static IServiceCollection AddFireblocksClient(this IServiceCollection ser services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); services.AddApiClientsOfBaseTypeWithHttpClient( diff --git a/src/Trakx.Fireblocks.ApiClient/TravelRule/BinanceTravelRuleService.cs b/src/Trakx.Fireblocks.ApiClient/TravelRule/BinanceTravelRuleService.cs index 6a9f3bd..52d9897 100644 --- a/src/Trakx.Fireblocks.ApiClient/TravelRule/BinanceTravelRuleService.cs +++ b/src/Trakx.Fireblocks.ApiClient/TravelRule/BinanceTravelRuleService.cs @@ -2,73 +2,66 @@ namespace Trakx.Fireblocks.ApiClient.TravelRule; /// internal class BinanceTravelRuleService( - IExchange_accountsClient exchangeAccountsClient, - ITransactionsClient transactionsClient, - IRsaEncryptionService encryptionService) : IBinanceTravelRuleService + ITransactionsClient transactionsClient) : IBinanceTravelRuleService { - private string? _cachedPublicKeyPem; - /// - public async Task BuildWithdrawalExtraParametersAsync(CancellationToken cancellationToken = default) + public Task BuildWithdrawalExtraParametersAsync(CancellationToken cancellationToken = default) { - return await BuildExtraParametersAsync( - partyKey: "beneficiary", - transactionDirection: "withdraw", - vaspSection: new Dictionary + var data = new Dictionary + { + ["beneficiary"] = new Dictionary { - ["beneficiaryVASP"] = new Dictionary { ["vaspCode"] = "BINANCE" }, - ["originatingVASP"] = new Dictionary { ["vaspCountry"] = "FR" } + ["participantRelationshipType"] = "FirstParty", + ["entityType"] = "Business" }, - cancellationToken); - } - - /// - public async Task BuildDepositExtraParametersAsync(CancellationToken cancellationToken = default) - { - return await BuildExtraParametersAsync( - partyKey: "originator", - transactionDirection: "deposit", - vaspSection: new Dictionary + ["transactionData"] = new Dictionary { - ["originatingVASP"] = new Dictionary { ["vaspName"] = "Binance" }, - ["beneficiaryVASP"] = new Dictionary { ["vaspCountry"] = "FR" } + ["withdraw"] = new Dictionary + { + ["isAddressVerified"] = true + } }, - cancellationToken); + ["originatingVASP"] = new Dictionary + { + ["vaspCountry"] = "FR" + } + }; + + var piiData = new Dictionary + { + ["type"] = "exchange-service-travel-rule", + ["typeVersion"] = "1.0.0", + ["data"] = data + }; + + var extraParameters = new ExtraParameters(); + extraParameters.AdditionalProperties["piiData"] = piiData; + return Task.FromResult(extraParameters); } - private async Task BuildExtraParametersAsync( - string partyKey, - string transactionDirection, - Dictionary vaspSection, - CancellationToken cancellationToken) + /// + public Task BuildDepositExtraParametersAsync(CancellationToken cancellationToken = default) { - var pem = await GetPublicKeyAsync(cancellationToken); - var data = new Dictionary { - [partyKey] = new Dictionary + ["originator"] = new Dictionary { - ["participantRelationshipType"] = encryptionService.Encrypt("FirstParty", pem), - ["entityType"] = encryptionService.Encrypt("Business", pem) + ["participantRelationshipType"] = "FirstParty", + ["entityType"] = "Business" }, ["transactionData"] = new Dictionary { - [transactionDirection] = new Dictionary + ["deposit"] = new Dictionary { - ["isAddressVerified"] = encryptionService.Encrypt(true, pem) + ["isAddressVerified"] = true } + }, + ["originatingVASP"] = new Dictionary + { + ["vaspName"] = "Binance" } }; - foreach (var (key, value) in vaspSection) - { - var plainValues = (Dictionary)value; - var encrypted = new Dictionary(); - foreach (var (fieldKey, fieldValue) in plainValues) - encrypted[fieldKey] = encryptionService.Encrypt((string)fieldValue, pem); - data[key] = encrypted; - } - var piiData = new Dictionary { ["type"] = "exchange-service-travel-rule", @@ -78,7 +71,7 @@ private async Task BuildExtraParametersAsync( var extraParameters = new ExtraParameters(); extraParameters.AdditionalProperties["piiData"] = piiData; - return extraParameters; + return Task.FromResult(extraParameters); } /// @@ -142,11 +135,4 @@ public async Task DepositToBinanceAsync( var response = await transactionsClient.CreateTransactionAsync(body: request, cancellationToken: cancellationToken); return response.Content; } - - private async Task GetPublicKeyAsync(CancellationToken cancellationToken) - { - return _cachedPublicKeyPem ??= (await exchangeAccountsClient - .GetExchangeAccountsCredentialsPublicKeyAsync(cancellationToken)) - .Content.PublicKey; - } } diff --git a/src/Trakx.Fireblocks.ApiClient/TravelRule/IBinanceTravelRuleService.cs b/src/Trakx.Fireblocks.ApiClient/TravelRule/IBinanceTravelRuleService.cs index 37ea43f..ece1b50 100644 --- a/src/Trakx.Fireblocks.ApiClient/TravelRule/IBinanceTravelRuleService.cs +++ b/src/Trakx.Fireblocks.ApiClient/TravelRule/IBinanceTravelRuleService.cs @@ -1,7 +1,7 @@ namespace Trakx.Fireblocks.ApiClient.TravelRule; /// -/// Service for building encrypted PII data required by Binance travel rule compliance. +/// Service for building PII data required by Binance travel rule compliance. /// See: https://developers.fireblocks.com/docs/a-developers-guide-to-constructing-encrypted-pii-messages-for-binance-via-fireblocks /// public interface IBinanceTravelRuleService @@ -29,4 +29,4 @@ Task WithdrawFromBinanceAsync( Task DepositToBinanceAsync( string exchangeAccountId, string vaultAccountId, string assetId, string amount, string? note = null, string? customerRefId = null, CancellationToken cancellationToken = default); -} \ No newline at end of file +} diff --git a/src/Trakx.Fireblocks.ApiClient/TravelRule/IRsaEncryptionService.cs b/src/Trakx.Fireblocks.ApiClient/TravelRule/IRsaEncryptionService.cs deleted file mode 100644 index 3a86a0f..0000000 --- a/src/Trakx.Fireblocks.ApiClient/TravelRule/IRsaEncryptionService.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace Trakx.Fireblocks.ApiClient.TravelRule; - -/// -/// Service for encrypting PII data using RSA-OAEP with SHA-256. -/// -public interface IRsaEncryptionService -{ - /// - /// Encrypts a string value using RSA-OAEP with SHA-256. - /// - /// The plain text value to encrypt. - /// The RSA public key in PEM format. - /// Base64-encoded encrypted value. - string Encrypt(string plainText, string publicKeyPem); - - /// - /// Encrypts a boolean value using RSA-OAEP with SHA-256. - /// The boolean is converted to lowercase JSON representation ("true" or "false") before encryption. - /// - /// The boolean value to encrypt. - /// The RSA public key in PEM format. - /// Base64-encoded encrypted value. - string Encrypt(bool plainValue, string publicKeyPem); -} diff --git a/src/Trakx.Fireblocks.ApiClient/TravelRule/RsaEncryptionService.cs b/src/Trakx.Fireblocks.ApiClient/TravelRule/RsaEncryptionService.cs deleted file mode 100644 index fa0dce9..0000000 --- a/src/Trakx.Fireblocks.ApiClient/TravelRule/RsaEncryptionService.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System.Security.Cryptography; -using System.Text; - -namespace Trakx.Fireblocks.ApiClient.TravelRule; - -/// -internal class RsaEncryptionService : IRsaEncryptionService -{ - /// - public string Encrypt(string plainText, string publicKeyPem) - { - ArgumentException.ThrowIfNullOrEmpty(plainText); - ArgumentException.ThrowIfNullOrEmpty(publicKeyPem); - - using var rsa = RSA.Create(); - rsa.ImportFromPem(publicKeyPem); - - var plainBytes = Encoding.UTF8.GetBytes(plainText); - var encryptedBytes = rsa.Encrypt(plainBytes, RSAEncryptionPadding.OaepSHA256); - - return Convert.ToBase64String(encryptedBytes); - } - - /// - public string Encrypt(bool plainValue, string publicKeyPem) - { - ArgumentException.ThrowIfNullOrEmpty(publicKeyPem); - - var boolBytes = BitConverter.GetBytes(plainValue); - - using var rsa = RSA.Create(); - rsa.ImportFromPem(publicKeyPem); - - var encryptedBytes = rsa.Encrypt(boolBytes, RSAEncryptionPadding.OaepSHA256); - - return Convert.ToBase64String(encryptedBytes); - } -} diff --git a/tests/Trakx.Fireblocks.ApiClient.Tests/Integration/Base/FireblocksClientTestsBase.cs b/tests/Trakx.Fireblocks.ApiClient.Tests/Integration/Base/FireblocksClientTestsBase.cs index 2dcf8d3..12966e5 100644 --- a/tests/Trakx.Fireblocks.ApiClient.Tests/Integration/Base/FireblocksClientTestsBase.cs +++ b/tests/Trakx.Fireblocks.ApiClient.Tests/Integration/Base/FireblocksClientTestsBase.cs @@ -1,6 +1,5 @@ using Microsoft.Extensions.DependencyInjection; using Serilog; -using Trakx.Common.Infrastructure.Environment.Env; using Trakx.Common.Testing.Configuration; namespace Trakx.Fireblocks.ApiClient.Tests.Integration.Base; @@ -32,24 +31,17 @@ public class FireblocksApiFixture : IDisposable public FireblocksApiFixture() { - var apiConfiguration = GetConfiguration(); + var apiConfiguration = AwsConfigurationHelper.GetConfigurationFromAws() + with + { + BaseUrl = new Uri("https://api.fireblocks.io/v1") + }; var serviceCollection = new ServiceCollection(); + serviceCollection.AddFireblocksClient(apiConfiguration); - ServiceProvider = serviceCollection.BuildServiceProvider(); - } - private static FireblocksApiConfiguration GetConfiguration() - { - try - { - return EnvConfigurationHelper.GetConfigurationFromEnv(); - } - catch - { - return AwsConfigurationHelper.GetConfigurationFromAws("Production") - with { BaseUrl = new Uri("https://api.fireblocks.io/v1") }; - } + ServiceProvider = serviceCollection.BuildServiceProvider(); } protected virtual void Dispose(bool disposing) @@ -63,4 +55,4 @@ public void Dispose() Dispose(true); GC.SuppressFinalize(this); } -} +} \ No newline at end of file diff --git a/tests/Trakx.Fireblocks.ApiClient.Tests/Integration/ExchangeAccountsClientTests.cs b/tests/Trakx.Fireblocks.ApiClient.Tests/Integration/ExchangeAccountsClientTests.cs index 193df8f..041d71b 100644 --- a/tests/Trakx.Fireblocks.ApiClient.Tests/Integration/ExchangeAccountsClientTests.cs +++ b/tests/Trakx.Fireblocks.ApiClient.Tests/Integration/ExchangeAccountsClientTests.cs @@ -1,7 +1,5 @@ -using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using Trakx.Fireblocks.ApiClient.Tests.Integration.Base; -using Trakx.Fireblocks.ApiClient.TravelRule; namespace Trakx.Fireblocks.ApiClient.Tests.Integration; @@ -19,7 +17,7 @@ public ExchangeAccountsClientTests(FireblocksApiFixture apiFixture, ITestOutputH public async Task Exchange_accountsAllAsync_should_return_all_accounts() { var response = await _exchangeAccountsClient.GetPagedExchangeAccountsAsync(5); - response.Content.Exchanges.Should().NotBeEmpty(); + response.Content.Exchanges.Should().BeEmpty(); } [Fact] @@ -30,147 +28,4 @@ public async Task GetExchangeAccountsCredentialsPublicKeyAsync_should_return_pub response.Content.PublicKey.Should().NotBeNullOrEmpty(); response.Content.PublicKey.Should().StartWith("-----BEGIN PUBLIC KEY-----"); } - - [Fact] - public async Task List_exchange_accounts_with_details() - { - string? after = null; - - do - { - var response = await _exchangeAccountsClient.GetPagedExchangeAccountsAsync(5, after: after); - var page = response.Content; - - foreach (var exchange in page.Exchanges) - { - _logger.Information("Exchange: Id={Id}, Name={Name}, Type={Type}, Status={Status}, IsSubaccount={IsSubaccount}", - exchange.Id, exchange.Name, exchange.Type, exchange.Status, exchange.IsSubaccount); - - if (exchange.Assets is { Count: > 0 }) - { - foreach (var asset in exchange.Assets) - { - _logger.Information(" Asset: Id={AssetId}, Balance={Balance}, Available={Available}, Total={Total}", - asset.Id, asset.Balance, asset.Available, asset.Total); - } - } - - if (exchange.TradingAccounts is not { Count: > 0 }) continue; - foreach (var tradingAccount in exchange.TradingAccounts) - { - _logger.Information(" TradingAccount: Name={Name}, Type={Type}", tradingAccount.Name, tradingAccount.Type); - } - } - - after = page.Paging?.After; - } while (!string.IsNullOrEmpty(after)); - } - - [Fact] - public async Task BuildWithdrawalExtraParameters_should_produce_valid_encrypted_pii_data() - { - var binanceTravelRuleService = _serviceProvider.GetRequiredService(); - - var result = await binanceTravelRuleService.BuildWithdrawalExtraParametersAsync(); - - result.Should().NotBeNull(); - result.AdditionalProperties.Should().ContainKey("piiData"); - - var piiData = result.AdditionalProperties["piiData"] as Dictionary; - piiData.Should().NotBeNull(); - piiData!["type"].Should().Be("exchange-service-travel-rule"); - piiData["typeVersion"].Should().Be("1.0.0"); - - var data = piiData["data"] as Dictionary; - data.Should().NotBeNull(); - data.Should().ContainKey("beneficiary"); - data.Should().ContainKey("transactionData"); - data.Should().ContainKey("beneficiaryVASP"); - data.Should().ContainKey("originatingVASP"); - - var beneficiary = data!["beneficiary"] as Dictionary; - AssertBase64Encoded(beneficiary!["participantRelationshipType"]); - AssertBase64Encoded(beneficiary["entityType"]); - - _logger.Information("Withdrawal piiData: {PiiData}", - JsonSerializer.Serialize(piiData, new JsonSerializerOptions { WriteIndented = true })); - } - - [Fact] - public async Task BuildDepositExtraParameters_should_produce_valid_encrypted_pii_data() - { - var binanceTravelRuleService = _serviceProvider.GetRequiredService(); - - var result = await binanceTravelRuleService.BuildDepositExtraParametersAsync(); - - result.Should().NotBeNull(); - result.AdditionalProperties.Should().ContainKey("piiData"); - - var piiData = result.AdditionalProperties["piiData"] as Dictionary; - piiData.Should().NotBeNull(); - piiData!["type"].Should().Be("exchange-service-travel-rule"); - piiData["typeVersion"].Should().Be("1.0.0"); - - var data = piiData["data"] as Dictionary; - data.Should().NotBeNull(); - data.Should().ContainKey("originator"); - data.Should().ContainKey("originatingVASP"); - data.Should().ContainKey("transactionData"); - data.Should().ContainKey("beneficiaryVASP"); - - var originator = data!["originator"] as Dictionary; - AssertBase64Encoded(originator!["participantRelationshipType"]); - AssertBase64Encoded(originator["entityType"]); - - _logger.Information("Deposit piiData: {PiiData}", - JsonSerializer.Serialize(piiData, new JsonSerializerOptions { WriteIndented = true })); - } - - [Fact] - public async Task WithdrawFromBinance_should_transfer_JUP_to_vault() - { - var binanceTravelRuleService = _serviceProvider.GetRequiredService(); - var transactionsClient = _serviceProvider.GetRequiredService(); - - var extraParameters = await binanceTravelRuleService.BuildWithdrawalExtraParametersAsync(); - - var request = new TransactionRequest - { - Operation = TransactionOperation.TRANSFER, - AssetId = "JUP_SOL", - Amount = "10", - Note = "test travel rule compliance", - CustomerRefId = Guid.NewGuid().ToString(), - Source = new TransferPeerPath - { - Type = TransferPeerPathType.EXCHANGE_ACCOUNT, - Id = "7b29fd51-6098-4c45-8471-4195cdcbdd70", - }, - Destination = new DestinationTransferPeerPath - { - Type = TransferPeerPathType.VAULT_ACCOUNT, - Id = "0", - }, - ExtraParameters = extraParameters, - }; - - var serialized = Newtonsoft.Json.JsonConvert.SerializeObject(request, Newtonsoft.Json.Formatting.Indented); - _logger.Information("TransactionRequest payload:\n{Payload}", serialized); - - var response = await transactionsClient.CreateTransactionAsync(body: request); - - response.Content.Should().NotBeNull(); - response.Content.Id.Should().NotBeNullOrEmpty(); - - _logger.Information("Withdrawal transaction created: Id={Id}, Status={Status}", - response.Content.Id, response.Content.Status); - } - - private static void AssertBase64Encoded(object value) - { - var str = value as string; - str.Should().NotBeNullOrEmpty(); - var action = () => Convert.FromBase64String(str); - action.Should().NotThrow(); - } -} +} \ No newline at end of file diff --git a/tests/Trakx.Fireblocks.ApiClient.Tests/Unit/DependencyInjectionTests.cs b/tests/Trakx.Fireblocks.ApiClient.Tests/Unit/DependencyInjectionTests.cs index 86f8fdc..1d7dbf3 100644 --- a/tests/Trakx.Fireblocks.ApiClient.Tests/Unit/DependencyInjectionTests.cs +++ b/tests/Trakx.Fireblocks.ApiClient.Tests/Unit/DependencyInjectionTests.cs @@ -27,7 +27,6 @@ public void Should_be_able_to_resolve_common_dependencies() _serviceProvider.CheckServiceRegistration(); _serviceProvider.CheckServiceRegistration(); _serviceProvider.CheckServiceRegistration(); - _serviceProvider.CheckServiceRegistration(); _serviceProvider.CheckServiceRegistration(); } diff --git a/tests/Trakx.Fireblocks.ApiClient.Tests/Unit/TravelRule/BinanceTravelRuleServiceTests.cs b/tests/Trakx.Fireblocks.ApiClient.Tests/Unit/TravelRule/BinanceTravelRuleServiceTests.cs index 2200efb..056e15a 100644 --- a/tests/Trakx.Fireblocks.ApiClient.Tests/Unit/TravelRule/BinanceTravelRuleServiceTests.cs +++ b/tests/Trakx.Fireblocks.ApiClient.Tests/Unit/TravelRule/BinanceTravelRuleServiceTests.cs @@ -1,28 +1,15 @@ -using System.Security.Cryptography; -using System.Text; using Trakx.Common.ApiClient; using Trakx.Fireblocks.ApiClient.TravelRule; namespace Trakx.Fireblocks.ApiClient.Tests.Unit.TravelRule; -public class BinanceTravelRuleServiceTests : IDisposable +public class BinanceTravelRuleServiceTests { private readonly BinanceTravelRuleService _sut; private readonly ITransactionsClient _transactionsClient; - private readonly RSA _rsa; public BinanceTravelRuleServiceTests() { - _rsa = RSA.Create(2048); - var publicKeyPem = _rsa.ExportSubjectPublicKeyInfoPem(); - - var exchangeAccountsClient = Substitute.For(); - exchangeAccountsClient.GetExchangeAccountsCredentialsPublicKeyAsync(Arg.Any()) - .Returns(new Response( - 200, - new Dictionary>(), - new ExchangeCredentialsPublicKeyResponse { PublicKey = publicKeyPem })); - _transactionsClient = Substitute.For(); _transactionsClient.CreateTransactionAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) @@ -31,8 +18,7 @@ public BinanceTravelRuleServiceTests() new Dictionary>(), new CreateTransactionResponse { Id = "tx-123", Status = "SUBMITTED" })); - var encryptionService = new RsaEncryptionService(); - _sut = new BinanceTravelRuleService(exchangeAccountsClient, _transactionsClient, encryptionService); + _sut = new BinanceTravelRuleService(_transactionsClient); } [Fact] @@ -53,18 +39,17 @@ public async Task BuildWithdrawalExtraParametersAsync_should_return_valid_pii_da var beneficiary = data!["beneficiary"] as Dictionary; beneficiary.Should().NotBeNull(); - DecryptValue(beneficiary!["participantRelationshipType"]).Should().Be("FirstParty"); - DecryptValue(beneficiary["entityType"]).Should().Be("Business"); + beneficiary!["participantRelationshipType"].Should().Be("FirstParty"); + beneficiary["entityType"].Should().Be("Business"); var transactionData = data["transactionData"] as Dictionary; var withdraw = (transactionData!["withdraw"] as Dictionary)!; - DecryptBool(withdraw["isAddressVerified"]).Should().BeTrue(); + withdraw["isAddressVerified"].Should().Be(true); - var beneficiaryVasp = data["beneficiaryVASP"] as Dictionary; - DecryptValue(beneficiaryVasp!["vaspCode"]).Should().Be("BINANCE"); + data.Should().NotContainKey("beneficiaryVASP"); var originatingVasp = data["originatingVASP"] as Dictionary; - DecryptValue(originatingVasp!["vaspCountry"]).Should().Be("FR"); + originatingVasp!["vaspCountry"].Should().Be("FR"); } [Fact] @@ -85,34 +70,17 @@ public async Task BuildDepositExtraParametersAsync_should_return_valid_pii_data_ var originator = data!["originator"] as Dictionary; originator.Should().NotBeNull(); - DecryptValue(originator!["participantRelationshipType"]).Should().Be("FirstParty"); - DecryptValue(originator["entityType"]).Should().Be("Business"); + originator!["participantRelationshipType"].Should().Be("FirstParty"); + originator["entityType"].Should().Be("Business"); var originatingVasp = data["originatingVASP"] as Dictionary; - DecryptValue(originatingVasp!["vaspName"]).Should().Be("Binance"); + originatingVasp!["vaspName"].Should().Be("Binance"); var transactionData = data["transactionData"] as Dictionary; var deposit = (transactionData!["deposit"] as Dictionary)!; - DecryptBool(deposit["isAddressVerified"]).Should().BeTrue(); + deposit["isAddressVerified"].Should().Be(true); - var beneficiaryVasp = data["beneficiaryVASP"] as Dictionary; - DecryptValue(beneficiaryVasp!["vaspCountry"]).Should().Be("FR"); - } - - [Fact] - public async Task BuildWithdrawalExtraParametersAsync_should_produce_base64_encrypted_values() - { - var result = await _sut.BuildWithdrawalExtraParametersAsync(); - - var piiData = result.AdditionalProperties["piiData"] as Dictionary; - var data = piiData!["data"] as Dictionary; - var beneficiary = data!["beneficiary"] as Dictionary; - - // All encrypted values should be valid base64 strings - var encryptedValue = beneficiary!["participantRelationshipType"] as string; - encryptedValue.Should().NotBeNullOrEmpty(); - var action = () => Convert.FromBase64String(encryptedValue!); - action.Should().NotThrow(); + data.Should().NotContainKey("beneficiaryVASP"); } [Fact] @@ -158,22 +126,15 @@ await _transactionsClient.Received(1).CreateTransactionAsync( Arg.Any()); } - private string DecryptValue(object encryptedBase64) - { - var encryptedBytes = Convert.FromBase64String((string)encryptedBase64); - var decryptedBytes = _rsa.Decrypt(encryptedBytes, RSAEncryptionPadding.OaepSHA256); - return Encoding.UTF8.GetString(decryptedBytes); - } - - private bool DecryptBool(object encryptedBase64) - { - var encryptedBytes = Convert.FromBase64String((string)encryptedBase64); - var decryptedBytes = _rsa.Decrypt(encryptedBytes, RSAEncryptionPadding.OaepSHA256); - return BitConverter.ToBoolean(decryptedBytes); - } - - public void Dispose() + [Fact(Skip = "Integration test — requires real Fireblocks credentials and Binance exchange account")] + public async Task WithdrawFromBinance_should_transfer_JUP_to_vault() { - _rsa.Dispose(); + await _sut.WithdrawFromBinanceAsync( + exchangeAccountId: "7b29fd51-6098-4c45-8471-4195cdcbdd70", + vaultAccountId: "0", + assetId: "JUP_SOL", + amount: "10", + note: "test travel rule compliance", + customerRefId: Guid.NewGuid().ToString()); } } diff --git a/tests/Trakx.Fireblocks.ApiClient.Tests/Unit/TravelRule/RsaEncryptionServiceTests.cs b/tests/Trakx.Fireblocks.ApiClient.Tests/Unit/TravelRule/RsaEncryptionServiceTests.cs deleted file mode 100644 index 17c45f7..0000000 --- a/tests/Trakx.Fireblocks.ApiClient.Tests/Unit/TravelRule/RsaEncryptionServiceTests.cs +++ /dev/null @@ -1,74 +0,0 @@ -using System.Security.Cryptography; -using System.Text; -using Trakx.Fireblocks.ApiClient.TravelRule; - -namespace Trakx.Fireblocks.ApiClient.Tests.Unit.TravelRule; - -public class RsaEncryptionServiceTests : IDisposable -{ - private readonly RsaEncryptionService _sut; - private readonly RSA _rsa; - private readonly string _publicKeyPem; - - public RsaEncryptionServiceTests() - { - _sut = new RsaEncryptionService(); - _rsa = RSA.Create(2048); - _publicKeyPem = _rsa.ExportSubjectPublicKeyInfoPem(); - } - - [Fact] - public void Encrypt_string_should_produce_base64_that_can_be_decrypted() - { - var plainText = "Hello World"; - - var encrypted = _sut.Encrypt(plainText, _publicKeyPem); - - encrypted.Should().NotBeNullOrEmpty(); - var encryptedBytes = Convert.FromBase64String(encrypted); - var decryptedBytes = _rsa.Decrypt(encryptedBytes, RSAEncryptionPadding.OaepSHA256); - var decrypted = Encoding.UTF8.GetString(decryptedBytes); - decrypted.Should().Be(plainText); - } - - [Fact] - public void Encrypt_bool_true_should_encrypt_as_raw_byte() - { - var encrypted = _sut.Encrypt(true, _publicKeyPem); - - encrypted.Should().NotBeNullOrEmpty(); - var encryptedBytes = Convert.FromBase64String(encrypted); - var decryptedBytes = _rsa.Decrypt(encryptedBytes, RSAEncryptionPadding.OaepSHA256); - BitConverter.ToBoolean(decryptedBytes).Should().BeTrue(); - } - - [Fact] - public void Encrypt_bool_false_should_encrypt_as_raw_byte() - { - var encrypted = _sut.Encrypt(false, _publicKeyPem); - - encrypted.Should().NotBeNullOrEmpty(); - var encryptedBytes = Convert.FromBase64String(encrypted); - var decryptedBytes = _rsa.Decrypt(encryptedBytes, RSAEncryptionPadding.OaepSHA256); - BitConverter.ToBoolean(decryptedBytes).Should().BeFalse(); - } - - [Fact] - public void Encrypt_should_throw_when_plainText_is_null_or_empty() - { - var action = () => _sut.Encrypt(string.Empty, _publicKeyPem); - action.Should().Throw(); - } - - [Fact] - public void Encrypt_should_throw_when_publicKeyPem_is_null_or_empty() - { - var action = () => _sut.Encrypt("test", string.Empty); - action.Should().Throw(); - } - - public void Dispose() - { - _rsa.Dispose(); - } -} From 4b90a6fd2d32adf907a2b55a21b4c25da55537ec Mon Sep 17 00:00:00 2001 From: monsieurleberre Date: Wed, 18 Feb 2026 08:31:05 -0500 Subject: [PATCH 3/3] Chore/just put the encryption back the fun never ends (#331) * add Binance Travel Rule support with PII encryption * refactor: remove beneficiaryVASP and consolidate travel rule tests * Sorry I missed this --- .../Registration/DependencyInjection.cs | 1 + .../TravelRule/BinanceTravelRuleService.cs | 92 +++++++++++-------- .../TravelRule/IBinanceTravelRuleService.cs | 4 +- .../TravelRule/IRsaEncryptionService.cs | 24 +++++ .../TravelRule/RsaEncryptionService.cs | 38 ++++++++ .../Integration/BinanceTravelRuleTests.cs | 88 ++++++++++++++++++ .../ExchangeAccountsClientTests.cs | 2 + .../Integration/TransactionsClientTests.cs | 2 +- .../Unit/DependencyInjectionTests.cs | 1 + .../BinanceTravelRuleServiceTests.cs | 63 +++++++++---- .../TravelRule/RsaEncryptionServiceTests.cs | 74 +++++++++++++++ 11 files changed, 326 insertions(+), 63 deletions(-) create mode 100644 src/Trakx.Fireblocks.ApiClient/TravelRule/IRsaEncryptionService.cs create mode 100644 src/Trakx.Fireblocks.ApiClient/TravelRule/RsaEncryptionService.cs create mode 100644 tests/Trakx.Fireblocks.ApiClient.Tests/Integration/BinanceTravelRuleTests.cs create mode 100644 tests/Trakx.Fireblocks.ApiClient.Tests/Unit/TravelRule/RsaEncryptionServiceTests.cs diff --git a/src/Trakx.Fireblocks.ApiClient/Registration/DependencyInjection.cs b/src/Trakx.Fireblocks.ApiClient/Registration/DependencyInjection.cs index ec02a1f..07a3ec4 100644 --- a/src/Trakx.Fireblocks.ApiClient/Registration/DependencyInjection.cs +++ b/src/Trakx.Fireblocks.ApiClient/Registration/DependencyInjection.cs @@ -31,6 +31,7 @@ public static IServiceCollection AddFireblocksClient(this IServiceCollection ser services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddApiClientsOfBaseTypeWithHttpClient( diff --git a/src/Trakx.Fireblocks.ApiClient/TravelRule/BinanceTravelRuleService.cs b/src/Trakx.Fireblocks.ApiClient/TravelRule/BinanceTravelRuleService.cs index 52d9897..b5a7e73 100644 --- a/src/Trakx.Fireblocks.ApiClient/TravelRule/BinanceTravelRuleService.cs +++ b/src/Trakx.Fireblocks.ApiClient/TravelRule/BinanceTravelRuleService.cs @@ -2,66 +2,71 @@ namespace Trakx.Fireblocks.ApiClient.TravelRule; /// internal class BinanceTravelRuleService( - ITransactionsClient transactionsClient) : IBinanceTravelRuleService + IExchange_accountsClient exchangeAccountsClient, + ITransactionsClient transactionsClient, + IRsaEncryptionService encryptionService) : IBinanceTravelRuleService { + private string? _cachedPublicKeyPem; + /// - public Task BuildWithdrawalExtraParametersAsync(CancellationToken cancellationToken = default) + public async Task BuildWithdrawalExtraParametersAsync(CancellationToken cancellationToken = default) { - var data = new Dictionary - { - ["beneficiary"] = new Dictionary + return await BuildExtraParametersAsync( + partyKey: "beneficiary", + transactionDirection: "withdraw", + vaspSection: new Dictionary { - ["participantRelationshipType"] = "FirstParty", - ["entityType"] = "Business" + ["originatingVASP"] = new Dictionary { ["vaspCountry"] = "FR" } }, - ["transactionData"] = new Dictionary + cancellationToken); + } + + /// + public async Task BuildDepositExtraParametersAsync(CancellationToken cancellationToken = default) + { + return await BuildExtraParametersAsync( + partyKey: "originator", + transactionDirection: "deposit", + vaspSection: new Dictionary { - ["withdraw"] = new Dictionary - { - ["isAddressVerified"] = true - } + ["originatingVASP"] = new Dictionary { ["vaspName"] = "Binance" } }, - ["originatingVASP"] = new Dictionary - { - ["vaspCountry"] = "FR" - } - }; - - var piiData = new Dictionary - { - ["type"] = "exchange-service-travel-rule", - ["typeVersion"] = "1.0.0", - ["data"] = data - }; - - var extraParameters = new ExtraParameters(); - extraParameters.AdditionalProperties["piiData"] = piiData; - return Task.FromResult(extraParameters); + cancellationToken); } - /// - public Task BuildDepositExtraParametersAsync(CancellationToken cancellationToken = default) + private async Task BuildExtraParametersAsync( + string partyKey, + string transactionDirection, + Dictionary vaspSection, + CancellationToken cancellationToken) { + var pem = await GetPublicKeyAsync(cancellationToken); + var data = new Dictionary { - ["originator"] = new Dictionary + [partyKey] = new Dictionary { - ["participantRelationshipType"] = "FirstParty", - ["entityType"] = "Business" + ["participantRelationshipType"] = encryptionService.Encrypt("FirstParty", pem), + ["entityType"] = encryptionService.Encrypt("Business", pem) }, ["transactionData"] = new Dictionary { - ["deposit"] = new Dictionary + [transactionDirection] = new Dictionary { - ["isAddressVerified"] = true + ["isAddressVerified"] = encryptionService.Encrypt(true, pem) } - }, - ["originatingVASP"] = new Dictionary - { - ["vaspName"] = "Binance" } }; + foreach (var (key, value) in vaspSection) + { + var plainValues = (Dictionary)value; + var encrypted = new Dictionary(); + foreach (var (fieldKey, fieldValue) in plainValues) + encrypted[fieldKey] = encryptionService.Encrypt((string)fieldValue, pem); + data[key] = encrypted; + } + var piiData = new Dictionary { ["type"] = "exchange-service-travel-rule", @@ -71,7 +76,7 @@ public Task BuildDepositExtraParametersAsync(CancellationToken var extraParameters = new ExtraParameters(); extraParameters.AdditionalProperties["piiData"] = piiData; - return Task.FromResult(extraParameters); + return extraParameters; } /// @@ -135,4 +140,11 @@ public async Task DepositToBinanceAsync( var response = await transactionsClient.CreateTransactionAsync(body: request, cancellationToken: cancellationToken); return response.Content; } + + private async Task GetPublicKeyAsync(CancellationToken cancellationToken) + { + return _cachedPublicKeyPem ??= (await exchangeAccountsClient + .GetExchangeAccountsCredentialsPublicKeyAsync(cancellationToken)) + .Content.PublicKey; + } } diff --git a/src/Trakx.Fireblocks.ApiClient/TravelRule/IBinanceTravelRuleService.cs b/src/Trakx.Fireblocks.ApiClient/TravelRule/IBinanceTravelRuleService.cs index ece1b50..37ea43f 100644 --- a/src/Trakx.Fireblocks.ApiClient/TravelRule/IBinanceTravelRuleService.cs +++ b/src/Trakx.Fireblocks.ApiClient/TravelRule/IBinanceTravelRuleService.cs @@ -1,7 +1,7 @@ namespace Trakx.Fireblocks.ApiClient.TravelRule; /// -/// Service for building PII data required by Binance travel rule compliance. +/// Service for building encrypted PII data required by Binance travel rule compliance. /// See: https://developers.fireblocks.com/docs/a-developers-guide-to-constructing-encrypted-pii-messages-for-binance-via-fireblocks /// public interface IBinanceTravelRuleService @@ -29,4 +29,4 @@ Task WithdrawFromBinanceAsync( Task DepositToBinanceAsync( string exchangeAccountId, string vaultAccountId, string assetId, string amount, string? note = null, string? customerRefId = null, CancellationToken cancellationToken = default); -} +} \ No newline at end of file diff --git a/src/Trakx.Fireblocks.ApiClient/TravelRule/IRsaEncryptionService.cs b/src/Trakx.Fireblocks.ApiClient/TravelRule/IRsaEncryptionService.cs new file mode 100644 index 0000000..3a86a0f --- /dev/null +++ b/src/Trakx.Fireblocks.ApiClient/TravelRule/IRsaEncryptionService.cs @@ -0,0 +1,24 @@ +namespace Trakx.Fireblocks.ApiClient.TravelRule; + +/// +/// Service for encrypting PII data using RSA-OAEP with SHA-256. +/// +public interface IRsaEncryptionService +{ + /// + /// Encrypts a string value using RSA-OAEP with SHA-256. + /// + /// The plain text value to encrypt. + /// The RSA public key in PEM format. + /// Base64-encoded encrypted value. + string Encrypt(string plainText, string publicKeyPem); + + /// + /// Encrypts a boolean value using RSA-OAEP with SHA-256. + /// The boolean is converted to lowercase JSON representation ("true" or "false") before encryption. + /// + /// The boolean value to encrypt. + /// The RSA public key in PEM format. + /// Base64-encoded encrypted value. + string Encrypt(bool plainValue, string publicKeyPem); +} diff --git a/src/Trakx.Fireblocks.ApiClient/TravelRule/RsaEncryptionService.cs b/src/Trakx.Fireblocks.ApiClient/TravelRule/RsaEncryptionService.cs new file mode 100644 index 0000000..fa0dce9 --- /dev/null +++ b/src/Trakx.Fireblocks.ApiClient/TravelRule/RsaEncryptionService.cs @@ -0,0 +1,38 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Trakx.Fireblocks.ApiClient.TravelRule; + +/// +internal class RsaEncryptionService : IRsaEncryptionService +{ + /// + public string Encrypt(string plainText, string publicKeyPem) + { + ArgumentException.ThrowIfNullOrEmpty(plainText); + ArgumentException.ThrowIfNullOrEmpty(publicKeyPem); + + using var rsa = RSA.Create(); + rsa.ImportFromPem(publicKeyPem); + + var plainBytes = Encoding.UTF8.GetBytes(plainText); + var encryptedBytes = rsa.Encrypt(plainBytes, RSAEncryptionPadding.OaepSHA256); + + return Convert.ToBase64String(encryptedBytes); + } + + /// + public string Encrypt(bool plainValue, string publicKeyPem) + { + ArgumentException.ThrowIfNullOrEmpty(publicKeyPem); + + var boolBytes = BitConverter.GetBytes(plainValue); + + using var rsa = RSA.Create(); + rsa.ImportFromPem(publicKeyPem); + + var encryptedBytes = rsa.Encrypt(boolBytes, RSAEncryptionPadding.OaepSHA256); + + return Convert.ToBase64String(encryptedBytes); + } +} diff --git a/tests/Trakx.Fireblocks.ApiClient.Tests/Integration/BinanceTravelRuleTests.cs b/tests/Trakx.Fireblocks.ApiClient.Tests/Integration/BinanceTravelRuleTests.cs new file mode 100644 index 0000000..153f5ba --- /dev/null +++ b/tests/Trakx.Fireblocks.ApiClient.Tests/Integration/BinanceTravelRuleTests.cs @@ -0,0 +1,88 @@ +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Trakx.Fireblocks.ApiClient.TravelRule; + +namespace Trakx.Fireblocks.ApiClient.Tests.Integration; + +public partial class TransactionsClientTests +{ + [Fact(Skip = "I hope we will never have to test this again 🙈")] + public async Task WithdrawFromBinance_should_transfer_JUP_to_vault() + { + var binanceTravelRuleService = _serviceProvider.GetRequiredService(); + var transactionsClient = _serviceProvider.GetRequiredService(); + + var extraParameters = await binanceTravelRuleService.BuildWithdrawalExtraParametersAsync(); + + var request = new TransactionRequest + { + Operation = TransactionOperation.TRANSFER, + AssetId = "JUP_SOL", + Amount = "10", + Note = "test travel rule compliance", + CustomerRefId = Guid.NewGuid().ToString(), + Source = new TransferPeerPath + { + Type = TransferPeerPathType.EXCHANGE_ACCOUNT, + Id = "7b29fd51-6098-4c45-8471-4195cdcbdd70", + }, + Destination = new DestinationTransferPeerPath + { + Type = TransferPeerPathType.VAULT_ACCOUNT, + Id = "0", + }, + ExtraParameters = extraParameters, + }; + + var serialized = Newtonsoft.Json.JsonConvert.SerializeObject(request, Newtonsoft.Json.Formatting.Indented); + _logger.Information("TransactionRequest payload:\n{Payload}", serialized); + + var response = await transactionsClient.CreateTransactionAsync(body: request); + + response.Content.Should().NotBeNull(); + response.Content.Id.Should().NotBeNullOrEmpty(); + + _logger.Information("Withdrawal transaction created: Id={Id}, Status={Status}", + response.Content.Id, response.Content.Status); + } + + [Fact(Skip = "I hope we will never have to test this again 🙈")] + public async Task DepositToBinance_should_transfer_JUP_from_vault() + { + var binanceTravelRuleService = _serviceProvider.GetRequiredService(); + var transactionsClient = _serviceProvider.GetRequiredService(); + + var extraParameters = await binanceTravelRuleService.BuildDepositExtraParametersAsync(); + + var request = new TransactionRequest + { + Operation = TransactionOperation.TRANSFER, + AssetId = "JUP_SOL", + Amount = "10", + Note = "test travel rule compliance", + CustomerRefId = Guid.NewGuid().ToString(), + Source = new TransferPeerPath + { + Type = TransferPeerPathType.VAULT_ACCOUNT, + Id = "0", + }, + Destination = new DestinationTransferPeerPath + { + Type = TransferPeerPathType.EXCHANGE_ACCOUNT, + Id = "7b29fd51-6098-4c45-8471-4195cdcbdd70", + }, + ExtraParameters = extraParameters, + }; + + var serialized = Newtonsoft.Json.JsonConvert.SerializeObject(request, Newtonsoft.Json.Formatting.Indented); + _logger.Information("TransactionRequest payload:\n{Payload}", serialized); + + var response = await transactionsClient.CreateTransactionAsync(body: request); + + response.Content.Should().NotBeNull(); + response.Content.Id.Should().NotBeNullOrEmpty(); + + _logger.Information("Deposit transaction created: Id={Id}, Status={Status}", + response.Content.Id, response.Content.Status); + } +} \ No newline at end of file diff --git a/tests/Trakx.Fireblocks.ApiClient.Tests/Integration/ExchangeAccountsClientTests.cs b/tests/Trakx.Fireblocks.ApiClient.Tests/Integration/ExchangeAccountsClientTests.cs index 041d71b..f90b21f 100644 --- a/tests/Trakx.Fireblocks.ApiClient.Tests/Integration/ExchangeAccountsClientTests.cs +++ b/tests/Trakx.Fireblocks.ApiClient.Tests/Integration/ExchangeAccountsClientTests.cs @@ -1,5 +1,7 @@ +using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using Trakx.Fireblocks.ApiClient.Tests.Integration.Base; +using Trakx.Fireblocks.ApiClient.TravelRule; namespace Trakx.Fireblocks.ApiClient.Tests.Integration; diff --git a/tests/Trakx.Fireblocks.ApiClient.Tests/Integration/TransactionsClientTests.cs b/tests/Trakx.Fireblocks.ApiClient.Tests/Integration/TransactionsClientTests.cs index 7c28b4d..d77bb79 100644 --- a/tests/Trakx.Fireblocks.ApiClient.Tests/Integration/TransactionsClientTests.cs +++ b/tests/Trakx.Fireblocks.ApiClient.Tests/Integration/TransactionsClientTests.cs @@ -4,7 +4,7 @@ namespace Trakx.Fireblocks.ApiClient.Tests.Integration; -public class TransactionsClientTests : FireblocksClientTestsBase +public partial class TransactionsClientTests : FireblocksClientTestsBase { private readonly ITransactionsClient _transactionsClient; diff --git a/tests/Trakx.Fireblocks.ApiClient.Tests/Unit/DependencyInjectionTests.cs b/tests/Trakx.Fireblocks.ApiClient.Tests/Unit/DependencyInjectionTests.cs index 1d7dbf3..86f8fdc 100644 --- a/tests/Trakx.Fireblocks.ApiClient.Tests/Unit/DependencyInjectionTests.cs +++ b/tests/Trakx.Fireblocks.ApiClient.Tests/Unit/DependencyInjectionTests.cs @@ -27,6 +27,7 @@ public void Should_be_able_to_resolve_common_dependencies() _serviceProvider.CheckServiceRegistration(); _serviceProvider.CheckServiceRegistration(); _serviceProvider.CheckServiceRegistration(); + _serviceProvider.CheckServiceRegistration(); _serviceProvider.CheckServiceRegistration(); } diff --git a/tests/Trakx.Fireblocks.ApiClient.Tests/Unit/TravelRule/BinanceTravelRuleServiceTests.cs b/tests/Trakx.Fireblocks.ApiClient.Tests/Unit/TravelRule/BinanceTravelRuleServiceTests.cs index 056e15a..555f792 100644 --- a/tests/Trakx.Fireblocks.ApiClient.Tests/Unit/TravelRule/BinanceTravelRuleServiceTests.cs +++ b/tests/Trakx.Fireblocks.ApiClient.Tests/Unit/TravelRule/BinanceTravelRuleServiceTests.cs @@ -1,15 +1,28 @@ +using System.Security.Cryptography; +using System.Text; using Trakx.Common.ApiClient; using Trakx.Fireblocks.ApiClient.TravelRule; namespace Trakx.Fireblocks.ApiClient.Tests.Unit.TravelRule; -public class BinanceTravelRuleServiceTests +public class BinanceTravelRuleServiceTests : IDisposable { private readonly BinanceTravelRuleService _sut; private readonly ITransactionsClient _transactionsClient; + private readonly RSA _rsa; public BinanceTravelRuleServiceTests() { + _rsa = RSA.Create(2048); + var publicKeyPem = _rsa.ExportSubjectPublicKeyInfoPem(); + + var exchangeAccountsClient = Substitute.For(); + exchangeAccountsClient.GetExchangeAccountsCredentialsPublicKeyAsync(Arg.Any()) + .Returns(new Response( + 200, + new Dictionary>(), + new ExchangeCredentialsPublicKeyResponse { PublicKey = publicKeyPem })); + _transactionsClient = Substitute.For(); _transactionsClient.CreateTransactionAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) @@ -18,7 +31,8 @@ public BinanceTravelRuleServiceTests() new Dictionary>(), new CreateTransactionResponse { Id = "tx-123", Status = "SUBMITTED" })); - _sut = new BinanceTravelRuleService(_transactionsClient); + var encryptionService = new RsaEncryptionService(); + _sut = new BinanceTravelRuleService(exchangeAccountsClient, _transactionsClient, encryptionService); } [Fact] @@ -39,17 +53,18 @@ public async Task BuildWithdrawalExtraParametersAsync_should_return_valid_pii_da var beneficiary = data!["beneficiary"] as Dictionary; beneficiary.Should().NotBeNull(); - beneficiary!["participantRelationshipType"].Should().Be("FirstParty"); - beneficiary["entityType"].Should().Be("Business"); + DecryptValue(beneficiary!["participantRelationshipType"]).Should().Be("FirstParty"); + DecryptValue(beneficiary["entityType"]).Should().Be("Business"); var transactionData = data["transactionData"] as Dictionary; var withdraw = (transactionData!["withdraw"] as Dictionary)!; - withdraw["isAddressVerified"].Should().Be(true); + DecryptBool(withdraw["isAddressVerified"]).Should().BeTrue(); + //explicitly diverge from Fireblocks documentation, the doc is wrong. data.Should().NotContainKey("beneficiaryVASP"); var originatingVasp = data["originatingVASP"] as Dictionary; - originatingVasp!["vaspCountry"].Should().Be("FR"); + DecryptValue(originatingVasp!["vaspCountry"]).Should().Be("FR"); } [Fact] @@ -70,16 +85,17 @@ public async Task BuildDepositExtraParametersAsync_should_return_valid_pii_data_ var originator = data!["originator"] as Dictionary; originator.Should().NotBeNull(); - originator!["participantRelationshipType"].Should().Be("FirstParty"); - originator["entityType"].Should().Be("Business"); + DecryptValue(originator!["participantRelationshipType"]).Should().Be("FirstParty"); + DecryptValue(originator["entityType"]).Should().Be("Business"); var originatingVasp = data["originatingVASP"] as Dictionary; - originatingVasp!["vaspName"].Should().Be("Binance"); + DecryptValue(originatingVasp!["vaspName"]).Should().Be("Binance"); var transactionData = data["transactionData"] as Dictionary; var deposit = (transactionData!["deposit"] as Dictionary)!; - deposit["isAddressVerified"].Should().Be(true); + DecryptBool(deposit["isAddressVerified"]).Should().BeTrue(); + //explicitly diverge from Fireblocks documentation, the doc is wrong. data.Should().NotContainKey("beneficiaryVASP"); } @@ -126,15 +142,22 @@ await _transactionsClient.Received(1).CreateTransactionAsync( Arg.Any()); } - [Fact(Skip = "Integration test — requires real Fireblocks credentials and Binance exchange account")] - public async Task WithdrawFromBinance_should_transfer_JUP_to_vault() + private string DecryptValue(object encryptedBase64) + { + var encryptedBytes = Convert.FromBase64String((string)encryptedBase64); + var decryptedBytes = _rsa.Decrypt(encryptedBytes, RSAEncryptionPadding.OaepSHA256); + return Encoding.UTF8.GetString(decryptedBytes); + } + + private bool DecryptBool(object encryptedBase64) + { + var encryptedBytes = Convert.FromBase64String((string)encryptedBase64); + var decryptedBytes = _rsa.Decrypt(encryptedBytes, RSAEncryptionPadding.OaepSHA256); + return BitConverter.ToBoolean(decryptedBytes); + } + + public void Dispose() { - await _sut.WithdrawFromBinanceAsync( - exchangeAccountId: "7b29fd51-6098-4c45-8471-4195cdcbdd70", - vaultAccountId: "0", - assetId: "JUP_SOL", - amount: "10", - note: "test travel rule compliance", - customerRefId: Guid.NewGuid().ToString()); + _rsa.Dispose(); } -} +} \ No newline at end of file diff --git a/tests/Trakx.Fireblocks.ApiClient.Tests/Unit/TravelRule/RsaEncryptionServiceTests.cs b/tests/Trakx.Fireblocks.ApiClient.Tests/Unit/TravelRule/RsaEncryptionServiceTests.cs new file mode 100644 index 0000000..17c45f7 --- /dev/null +++ b/tests/Trakx.Fireblocks.ApiClient.Tests/Unit/TravelRule/RsaEncryptionServiceTests.cs @@ -0,0 +1,74 @@ +using System.Security.Cryptography; +using System.Text; +using Trakx.Fireblocks.ApiClient.TravelRule; + +namespace Trakx.Fireblocks.ApiClient.Tests.Unit.TravelRule; + +public class RsaEncryptionServiceTests : IDisposable +{ + private readonly RsaEncryptionService _sut; + private readonly RSA _rsa; + private readonly string _publicKeyPem; + + public RsaEncryptionServiceTests() + { + _sut = new RsaEncryptionService(); + _rsa = RSA.Create(2048); + _publicKeyPem = _rsa.ExportSubjectPublicKeyInfoPem(); + } + + [Fact] + public void Encrypt_string_should_produce_base64_that_can_be_decrypted() + { + var plainText = "Hello World"; + + var encrypted = _sut.Encrypt(plainText, _publicKeyPem); + + encrypted.Should().NotBeNullOrEmpty(); + var encryptedBytes = Convert.FromBase64String(encrypted); + var decryptedBytes = _rsa.Decrypt(encryptedBytes, RSAEncryptionPadding.OaepSHA256); + var decrypted = Encoding.UTF8.GetString(decryptedBytes); + decrypted.Should().Be(plainText); + } + + [Fact] + public void Encrypt_bool_true_should_encrypt_as_raw_byte() + { + var encrypted = _sut.Encrypt(true, _publicKeyPem); + + encrypted.Should().NotBeNullOrEmpty(); + var encryptedBytes = Convert.FromBase64String(encrypted); + var decryptedBytes = _rsa.Decrypt(encryptedBytes, RSAEncryptionPadding.OaepSHA256); + BitConverter.ToBoolean(decryptedBytes).Should().BeTrue(); + } + + [Fact] + public void Encrypt_bool_false_should_encrypt_as_raw_byte() + { + var encrypted = _sut.Encrypt(false, _publicKeyPem); + + encrypted.Should().NotBeNullOrEmpty(); + var encryptedBytes = Convert.FromBase64String(encrypted); + var decryptedBytes = _rsa.Decrypt(encryptedBytes, RSAEncryptionPadding.OaepSHA256); + BitConverter.ToBoolean(decryptedBytes).Should().BeFalse(); + } + + [Fact] + public void Encrypt_should_throw_when_plainText_is_null_or_empty() + { + var action = () => _sut.Encrypt(string.Empty, _publicKeyPem); + action.Should().Throw(); + } + + [Fact] + public void Encrypt_should_throw_when_publicKeyPem_is_null_or_empty() + { + var action = () => _sut.Encrypt("test", string.Empty); + action.Should().Throw(); + } + + public void Dispose() + { + _rsa.Dispose(); + } +}