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(); + } +}