Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions Branta.Tests/V2/Services/BrantaServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,107 @@ public async Task GetPaymentsByQrCodeAsync_CombinedZkQr_DecryptsBothAddressAndIn

#endregion

#region GetPaymentsByQrCodeAsync address binding

private const string SwappedAddress = "1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2";
private const string Bech32Address = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4";
private const string EncryptedBech32Address = "encrypted-bech32-address";

private static Payment ZkBech32Payment => new PaymentBuilder()
.AddDestination(EncryptedBech32Address, type: DestinationType.BitcoinAddress)
.SetZk()
.Build();

[Fact]
public async Task GetPaymentsByQrCodeAsync_SwappedAddress_Rejects()
{
_clientMock
.Setup(c => c.GetPaymentsAsync(EncryptedBitcoinAddress, It.IsAny<BrantaClientOptions?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([ZkBitcoinPayment]);

var qrText = $"bitcoin:{SwappedAddress}?branta_id={EncryptedBitcoinAddress}&branta_secret={Secret}";
var ex = await Assert.ThrowsAsync<BrantaPaymentException>(() => _service.GetPaymentsByQrCodeAsync(qrText));

Assert.Equal(BrantaPaymentExceptionReason.Tampered, ex.Reason);
}

[Fact]
public async Task GetPaymentsByQrCodeAsync_MatchingAddress_DoesNotThrow()
{
_clientMock
.Setup(c => c.GetPaymentsAsync(EncryptedBitcoinAddress, It.IsAny<BrantaClientOptions?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([ZkBitcoinPayment]);

var qrText = $"bitcoin:{BitcoinAddress}?branta_id={EncryptedBitcoinAddress}&branta_secret={Secret}";
var result = await _service.GetPaymentsByQrCodeAsync(qrText);

Assert.Equal(BitcoinAddress, result.Payments[0].Destinations[0].Value);
}

[Fact]
public async Task GetPaymentsByQrCodeAsync_UppercaseBech32Qr_MatchesLowercaseRegistered_DoesNotThrow()
{
_aesEncryptionMock.Setup(e => e.Decrypt(EncryptedBech32Address, Secret)).Returns(Bech32Address);
_clientMock
.Setup(c => c.GetPaymentsAsync(EncryptedBech32Address, It.IsAny<BrantaClientOptions?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([ZkBech32Payment]);

var qrText = $"bitcoin:{Bech32Address.ToUpperInvariant()}?branta_id={EncryptedBech32Address}&branta_secret={Secret}";
var result = await _service.GetPaymentsByQrCodeAsync(qrText);

Assert.Equal(Bech32Address, result.Payments[0].Destinations[0].Value);
}

[Fact]
public async Task GetPaymentsByQrCodeAsync_Base58CaseMismatch_Rejects()
{
_clientMock
.Setup(c => c.GetPaymentsAsync(EncryptedBitcoinAddress, It.IsAny<BrantaClientOptions?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([ZkBitcoinPayment]);

var qrText = $"bitcoin:{BitcoinAddress.ToLowerInvariant()}?branta_id={EncryptedBitcoinAddress}&branta_secret={Secret}";
var ex = await Assert.ThrowsAsync<BrantaPaymentException>(() => _service.GetPaymentsByQrCodeAsync(qrText));

Assert.Equal(BrantaPaymentExceptionReason.Tampered, ex.Reason);
}

[Fact]
public async Task GetPaymentsByQrCodeAsync_LightningQrWithZkParams_NoPlainOnChainAddress_DecryptsWithoutComparison()
{
_clientMock
.Setup(c => c.GetPaymentsAsync(EncryptedBitcoinAddress, It.IsAny<BrantaClientOptions?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([ZkBitcoinPayment]);

var qrText = $"lightning:{Bolt11Invoice}?branta_id={EncryptedBitcoinAddress}&branta_secret={Secret}";
var result = await _service.GetPaymentsByQrCodeAsync(qrText);

Assert.Equal(BitcoinAddress, result.Payments[0].Destinations[0].Value);
}

[Fact]
public async Task GetPaymentsByQrCodeAsync_CombinedZkQr_SwappedAddress_Rejects()
{
var payment = new PaymentBuilder()
.AddDestination(EncryptedBitcoinAddress, type: DestinationType.BitcoinAddress)
.SetZk()
.AddDestination(EncryptedBolt11, type: DestinationType.Bolt11)
.SetZk()
.AddDestination(EncryptedArkAddress, type: DestinationType.ArkAddress)
.SetZk()
.Build();

_clientMock
.Setup(c => c.GetPaymentsAsync(EncryptedBitcoinAddress, It.IsAny<BrantaClientOptions?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([payment]);

var qrText = $"bitcoin:{SwappedAddress}?branta_id={EncryptedBitcoinAddress}&branta_secret={Secret}&lightning={Bolt11Invoice}&ark={ArkAddress}";
var ex = await Assert.ThrowsAsync<BrantaPaymentException>(() => _service.GetPaymentsByQrCodeAsync(qrText));

Assert.Equal(BrantaPaymentExceptionReason.Tampered, ex.Reason);
}

#endregion

#region GetPaymentsAsync

[Fact]
Expand Down
10 changes: 8 additions & 2 deletions Branta/Exceptions/BrantaPaymentException.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
namespace Branta.Exceptions;
namespace Branta.Exceptions;

public class BrantaPaymentException(string message) : Exception(message)
public enum BrantaPaymentExceptionReason
{
Tampered
}

public class BrantaPaymentException(string message, BrantaPaymentExceptionReason? reason = null) : Exception(message)
{
public BrantaPaymentExceptionReason? Reason { get; } = reason;
}
35 changes: 27 additions & 8 deletions Branta/V2/Services/BrantaService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ public class BrantaService(IBrantaClient client, IAesEncryption aesEncryption, I
private readonly BrantaClientOptions _defaultOptions = defaultOptions.Value;
private readonly ISecretGenerator _secretGenerator = secretGenerator ?? new GuidSecretGenerator();

private static bool AddressesMatch(string a, string b)
{
bool IsBech32(string v) => v.StartsWith("bc1", StringComparison.OrdinalIgnoreCase);
if (IsBech32(a) && IsBech32(b)) return string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
return a == b;
}

public Task<PaymentsResult> GetPaymentsByQrCodeAsync(string qrText, BrantaClientOptions? options = null, CancellationToken ct = default)
{
var parser = new QRParser(qrText);
Expand All @@ -24,7 +31,8 @@ public Task<PaymentsResult> GetPaymentsByQrCodeAsync(string qrText, BrantaClient
.Where(d => d.Value.GetHashZkType().HasValue)
.Select(d => d.Value)
.ToList();
return GetPaymentsForZkAsync(parser.OnChainEncryptionText!, parser.OnChainEncryptionSecret, additionalValues, options, ct);
var onChainAddress = parser.Destinations.FirstOrDefault(d => d.Type == DestinationType.BitcoinAddress)?.Value;
return GetPaymentsForZkAsync(parser.OnChainEncryptionText!, parser.OnChainEncryptionSecret, additionalValues, onChainAddress, options, ct);
}

var destination = parser.Destination!;
Expand All @@ -34,14 +42,14 @@ public Task<PaymentsResult> GetPaymentsByQrCodeAsync(string qrText, BrantaClient
return GetPaymentsAsync(destination, null, options, ct);
}

private async Task<PaymentsResult> GetPaymentsForZkAsync(string lookupValue, string? encryptionKey, IReadOnlyList<string> additionalHashValues, BrantaClientOptions? options, CancellationToken ct)
private async Task<PaymentsResult> GetPaymentsForZkAsync(string lookupValue, string? encryptionKey, IReadOnlyList<string> additionalHashValues, string? expectedOnChainAddress, BrantaClientOptions? options, CancellationToken ct)
{
var payments = await client.GetPaymentsAsync(lookupValue, options, ct);

var keys = new Dictionary<string, string>();
foreach (var payment in payments)
{
DecryptDestinations(payment, lookupValue, encryptionKey, null, keys);
DecryptDestinations(payment, lookupValue, encryptionKey, null, keys, expectedOnChainAddress);
foreach (var value in additionalHashValues)
DecryptHashZkDestinations(payment, value, keys);
}
Expand Down Expand Up @@ -101,7 +109,7 @@ public async Task<PaymentsResult> GetPaymentsAsync(string destinationValue, stri
return new PaymentsResult { Payments = payments, VerifyUrl = BuildVerifyUrl(options, lookupValue, keys) };
}

private void DecryptDestinations(Payment payment, string destinationValue, string? encryptionKey, DestinationType? hashZkType, Dictionary<string, string> keys)
private void DecryptDestinations(Payment payment, string destinationValue, string? encryptionKey, DestinationType? hashZkType, Dictionary<string, string> keys, string? expectedOnChainAddress = null)
{
foreach (var destination in payment.Destinations)
{
Expand All @@ -111,17 +119,28 @@ private void DecryptDestinations(Payment payment, string destinationValue, strin
if (destination.Type == DestinationType.BitcoinAddress)
{
if (encryptionKey == null) continue;
string decrypted;
try
{
destination.Value = aesEncryption.Decrypt(destination.Value, encryptionKey);
destination.IsEncrypted = false;
keys.TryAdd(destination.ZkId!, encryptionKey);
TryDecryptMetadata(payment, destination, encryptionKey);
decrypted = aesEncryption.Decrypt(destination.Value, encryptionKey);
}
catch
{
// Key didn't match this destination — leave it encrypted.
continue;
}

if (expectedOnChainAddress != null && !AddressesMatch(decrypted, expectedOnChainAddress))
{
throw new BrantaPaymentException(
"The Bitcoin address in the QR code does not match the address verified by Branta. The QR code may have been tampered with.",
BrantaPaymentExceptionReason.Tampered);
}

destination.Value = decrypted;
destination.IsEncrypted = false;
keys.TryAdd(destination.ZkId!, encryptionKey);
TryDecryptMetadata(payment, destination, encryptionKey);
}
else if (hashZkType.HasValue && destination.Type == hashZkType.Value)
{
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixed
- `GetPaymentsByQrCodeAsync` now verifies that the plaintext Bitcoin address parsed from a scanned QR code matches the address decrypted via `branta_id`/`branta_secret`, throwing `BrantaPaymentException` with `Reason: BrantaPaymentExceptionReason.Tampered` on mismatch. Closes a gap where an attacker could swap the visible address in a `bitcoin:` URI while leaving a legitimate, verified `branta_id`/`branta_secret` pair untouched (ported from `branta-js` 3.2.1)

## [[3.2.0](https://github.com/BrantaOps/branta-dotnet/compare/3.1.6...3.2.0)] - 2026-07-17

### Added
Expand Down
Loading