From 7e6d8f90291599763ba40a51c577d0ef2b544b41 Mon Sep 17 00:00:00 2001 From: Kyle McCullen Date: Sat, 29 Aug 2026 11:06:22 -0400 Subject: [PATCH] prevent mismatch between qr code address and encrypted parameter --- CHANGELOG.md | 5 + README.md | 236 ++++++++++++++++++++++++++++++++++++++---- branta/__init__.py | 3 +- branta/exceptions.py | 11 +- branta/v2/service.py | 43 ++++++-- tests/test_service.py | 103 +++++++++++++++++- 6 files changed, 367 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d56c106..7cb2c50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 +- `get_payments_by_qr_code` now verifies that the plaintext Bitcoin address parsed from a scanned QR code matches the address decrypted via `branta_id`/`branta_secret`, raising `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] - 2026-06-26 ### Added diff --git a/README.md b/README.md index 99d22a0..d36a354 100644 --- a/README.md +++ b/README.md @@ -2,15 +2,71 @@ Python SDK for the [Branta](https://branta.pro) V2 API — payment destination lookup and registration with zero-knowledge encryption support. -## Installation +# Requirements + +- Python 3.9 or higher + +# Installation ```bash pip install branta ``` -## Integration Guide +# Integration Guide + +If you are an AI agent implementing this SDK, follow the steps below exactly. If you are a developer, use this as a reference for the recommended integration patterns. + +**1. Ask which side(s) of a payment they are implementing:** +- **Send side** — the app is paying someone (e.g. a wallet). The user scans or pastes a destination and you verify it belongs to a known platform before funds are sent. +- **Receive side** — the app is receiving payment (e.g. a checkout, POS, invoicing platform). You post destinations to Branta so wallets can verify them. +- **Both** — some apps do both (e.g. an exchange or self-custodial wallet with invoice generation). Implement each side independently. + +If they are on the receive side, ask one follow-up: +- **Platform** — single-tenant, one API key. +- **Parent Platform** — multi-tenant, manages multiple child platforms. Two variants: + - **Shared key (Recommended)** — one API key for all children, no HMAC secret needed; tag the child per-payment with `set_child_platform()`. Default to this unless there's a specific reason for separate per-child keys. + - **Per-client keys** — each child has its own API key, and the parent signs every request with an HMAC secret to prove it originated from the parent; scope requests per-call. Use only if each child needs an independent, separately-revocable API key. + +**2. Follow the matching Quick Start section below.** + +**3. Apply these rules:** + +General (all types): +- Always use `PrivacyMode.Strict`. Never switch to `PrivacyMode.Loose` unless there is no QR scanner and ZK is impossible. +- Never call `BrantaClient` directly — always go through `BrantaService`. +- Never show an error or "not verified" message when a lookup returns empty or throws. An empty result means the destination is unknown to Branta, not that it is malicious. Show nothing. +- For `base_url`: use `BrantaServerBaseUrl.Production` only in production environments. Use `BrantaServerBaseUrl.Staging` everywhere else — including local development, CI, and staging/test environments. + +Send side (wallets): +- Prefer `get_payments_by_qr_code` over `get_payments` — it handles multi-value ZK QR payloads correctly. +- Only fall back to `get_payments` for copy/paste flows where there is no QR code. +- If `result.payments` is empty or an exception is thrown, render nothing. +- When `result.payments` is non-empty, display: the platform logo, the platform name (`payment.platform`), and the payment description (`payment.description`). Only render description when non-empty. Make the verification card a clickable link to `result.verify_url` — do not display the raw URL. +- For the platform logo: on dark backgrounds use `payment.platform_logo_url`. On light backgrounds prefer `payment.platform_logo_light_url` when available, falling back to `payment.platform_logo_url`. +- Optionally display `payment.parent_platform.logo_url` / `payment.parent_platform.logo_light_url` as a small secondary badge (e.g. corner icon). This is not required. + +Receive side (platforms): +- Always call `.set_zk()` on the `PaymentBuilder` before calling `add_payment`. Plain-text destinations are rejected in `Strict` mode. +- Store the `secret` returned by `add_payment` alongside the invoice — it is required to reconstruct the verify URL for the wallet. + +Receive side (parent platforms — per-client keys), in addition to the platform rules: +- Include `hmac_secret` in `BrantaClientOptions` but omit `default_api_key` at service construction. +- Pass per-call `BrantaClientOptions` with each child's API key to scope requests. + +Receive side (parent platforms — shared key), in addition to the platform rules: +- Include `default_api_key` in `BrantaClientOptions`. Do not include `hmac_secret`. +- Call `.set_child_platform(name, logo_url=..., logo_light_url=...)` on the builder to tag each payment with the child's branding. + +# Quick Start -### Quick start +## For Wallets + +Wallets should use `PrivacyMode.Strict`. Two flows are supported: + +- **Copy/paste**: call `get_payments` with the pasted text. Plain-text on-chain addresses will not return results in strict mode — they must be ZK-encoded. Hash-ZK destinations (bolt11, ark_address, silent_payment) work as plain text. +- **QR scan**: call `get_payments_by_qr_code` with the raw QR text. This handles both on-chain (when the QR includes `branta_id` / `branta_secret`) and hash-ZK destinations. + +Always catch errors and show nothing on not-found — a missing record just means the address was not posted to Branta. ```python import asyncio @@ -24,27 +80,24 @@ options = BrantaClientOptions( ) service = BrantaService(options) -async def main(): - result = await service.get_payments_by_qr_code("bitcoin:bc1q...") - if result.payments: - print(result.payments[0].get_default_value()) - -asyncio.run(main()) -``` - -### Privacy modes +async def lookup(input: str, is_qr_code: bool): + try: + result = ( + await service.get_payments_by_qr_code(input) + if is_qr_code + else await service.get_payments(input) + ) -| Mode | Behaviour | -|------|-----------| -| `PrivacyMode.Strict` (default) | Only ZK lookups. `get_payments()` raises `BrantaPaymentException` for plain addresses. `get_payments_by_qr_code()` returns empty for plain addresses. `add_payment()` raises if any destination has `is_zk=False`. | -| `PrivacyMode.Loose` | Both plain and ZK lookups are permitted. | + if not result.payments: + # Not found — show nothing. The address may simply not exist in Branta. + return -### Looking up a payment by QR code + # Render result.payments and result.verify_url + except Exception: + # Swallow errors — never surface a "not found" or lookup failure to the user. + pass -```python -result = await service.get_payments_by_qr_code(qr_text) -# result.payments — list of Payment objects (empty if not found) -# result.verify_url — always populated; share with the payer to verify +asyncio.run(lookup("bitcoin:bc1q...", False)) ``` Prefer `get_payments_by_qr_code` for QR-driven flows. It handles multi-destination payloads (`branta_id` / `branta_secret` fragments) automatically. @@ -62,7 +115,42 @@ result = await service.get_payments(encrypted_address, destination_encryption_ke result = await service.get_payments("lnbc...") ``` -### Registering a payment +### No-QR-Code Flows + +When QR scanning is not available, three options exist. Choose one based on how much control you want to give users over privacy: + +**Option 1 — Keep Strict mode (no code changes)** + +Only hash-ZK destinations (bolt11, ark_address, silent_payment) will return results. Plain-text on-chain address lookups silently return empty. This is the safest default and requires no additional work. + +**Option 2 — Opt-in Loose mode (Recommended)** + +Add a user-facing setting (e.g. "Enable on-chain address verification"). Only switch to `PrivacyMode.Loose` when the user explicitly opts in — this sends on-chain addresses in plain text, so the choice should be theirs. + +```python +options = ( + BrantaClientOptions(base_url=BrantaServerBaseUrl.Production, privacy=PrivacyMode.Loose) + if user_opted_in + else None +) + +result = await service.get_payments(input, options=options) +``` + +**Option 3 — Always Loose mode** + +Configure with `PrivacyMode.Loose` globally. All lookups including plain-text on-chain addresses are sent to Branta. Simplest, but gives users no privacy control. + +```python +service = BrantaService(BrantaClientOptions( + base_url=BrantaServerBaseUrl.Production, + privacy=PrivacyMode.Loose, +)) +``` + +## For Platforms + +Platforms post payments to Branta so wallets can verify them. Use `PrivacyMode.Strict` and mark each destination ZK via `.set_zk()` on the `PaymentBuilder`. ```python from branta.enums import DestinationType @@ -87,6 +175,71 @@ result = await service.add_payment(payment, BrantaClientOptions( # result.verify_url — share this URL to verify the payment ``` +## For Parent Platforms + +Choose a variant based on how API keys are structured. Only the per-client keys variant signs requests with HMAC — shared key needs none. + +
+Shared key — one API key covers all children (Recommended) + +Construct with a single API key; identify the child platform per-payment. + +```python +from branta.enums import BrantaServerBaseUrl, DestinationType, PrivacyMode +from branta.options import BrantaClientOptions +from branta.v2 import BrantaService + +service = BrantaService(BrantaClientOptions( + base_url=BrantaServerBaseUrl.Production, + default_api_key="", + privacy=PrivacyMode.Strict, +)) + +payment = ( + service.create_payment_builder() + .add_destination("bc1q...", DestinationType.BitcoinAddress).set_zk() + .set_child_platform("ChildBrand", logo_url="https://example.com/logo.png") + .set_ttl(600) + .build() +) + +result = await service.add_payment(payment) +``` + +
+ +
+Per-client keys — each child has its own API key + +Construct the service with the shared HMAC secret only; pass each child's API key per-call. + +```python +from branta.enums import BrantaServerBaseUrl, DestinationType, PrivacyMode +from branta.options import BrantaClientOptions +from branta.v2 import BrantaService + +service = BrantaService(BrantaClientOptions( + base_url=BrantaServerBaseUrl.Production, + hmac_secret="", + privacy=PrivacyMode.Strict, +)) + +payment = ( + service.create_payment_builder() + .add_destination("bc1q...", DestinationType.BitcoinAddress).set_zk() + .set_ttl(600) + .build() +) + +# Scope to the child platform's API key per-call +result = await service.add_payment(payment, BrantaClientOptions( + base_url=BrantaServerBaseUrl.Production, + default_api_key="", +)) +``` + +
+ ### Validating an API key ```python @@ -105,6 +258,15 @@ service = BrantaService(default_options) result = await service.get_payments("lnbc...", options=override_options) ``` +# Privacy + +`PrivacyMode` controls whether plain-text on-chain lookups are allowed. + +| Value | Behavior | +|-------|----------| +| `PrivacyMode.Strict` (default) | Only ZK lookups. `get_payments` raises `BrantaPaymentException` for plain addresses. `get_payments_by_qr_code` returns an empty `PaymentsResult` with a populated `verify_url`. `add_payment` raises if any destination has `is_zk=False`. | +| `PrivacyMode.Loose` | Both plain and ZK lookups are permitted. | + ## ZK destination types | Type | Encryption | @@ -114,7 +276,31 @@ result = await service.get_payments("lnbc...", options=override_options) | `ArkAddress` | Deterministic: SHA256 of lowercase address | | `SilentPayment` | Deterministic: SHA256 of lowercase address | -## Development +# BrantaService + +The primary service class. Always use `BrantaService` — never call `BrantaClient` directly. + +**Prefer `get_payments_by_qr_code` for integrations.** It parses the raw QR text and correctly resolves multiple ZK values in a single scan. `get_payments` only handles a single destination value and does not support multi-value ZK lookups. + +```python +async def get_payments_by_qr_code(qr_text: str, options: Optional[BrantaClientOptions] = None) -> PaymentsResult: ... +async def get_payments(destination_value: str, destination_encryption_key: Optional[str] = None, options: Optional[BrantaClientOptions] = None) -> PaymentsResult: ... +async def add_payment(payment: Payment, options: Optional[BrantaClientOptions] = None) -> AddPaymentResult: ... +async def is_api_key_valid(options: Optional[BrantaClientOptions] = None) -> bool: ... +``` + +`PaymentsResult` contains the list of matching `payments` and the `verify_url` to display to the user — `verify_url` is always returned, even when `payments` is empty. + +→ [`branta/v2/service.py`](branta/v2/service.py) + +# Release + +- Update `version` in `pyproject.toml` +- `pip install build twine` (one-time) +- `python -m build` +- `twine upload dist/*` + +# Development ```bash pip install -e ".[dev]" @@ -122,3 +308,7 @@ pytest tests/ --ignore=tests/test_integration.py # unit tests pytest tests/test_integration.py # integration (requires network) coverage run -m pytest tests/ --ignore=tests/test_integration.py && coverage report ``` + +# Responsible Disclosure + +Found critical bugs/vulnerabilities? Please email them to support@branta.pro. Thanks! diff --git a/branta/__init__.py b/branta/__init__.py index b305034..94453b2 100644 --- a/branta/__init__.py +++ b/branta/__init__.py @@ -1,5 +1,5 @@ from branta.enums import BrantaServerBaseUrl, DestinationType, PrivacyMode -from branta.exceptions import BrantaPaymentException, QRParseException +from branta.exceptions import BrantaPaymentException, BrantaPaymentExceptionReason, QRParseException from branta.extensions import ( get_api_key, get_base_url, @@ -22,6 +22,7 @@ "DestinationType", "PrivacyMode", "BrantaPaymentException", + "BrantaPaymentExceptionReason", "QRParseException", "BrantaClientOptions", "Payment", diff --git a/branta/exceptions.py b/branta/exceptions.py index 8be9de5..f557439 100644 --- a/branta/exceptions.py +++ b/branta/exceptions.py @@ -1,5 +1,14 @@ +from enum import Enum + + +class BrantaPaymentExceptionReason(Enum): + Tampered = "tampered" + + class BrantaPaymentException(Exception): - pass + def __init__(self, message: str, reason: "BrantaPaymentExceptionReason | None" = None) -> None: + super().__init__(message) + self.reason = reason class QRParseException(Exception): diff --git a/branta/v2/service.py b/branta/v2/service.py index 05b98e2..33142ce 100644 --- a/branta/v2/service.py +++ b/branta/v2/service.py @@ -4,7 +4,7 @@ from urllib.parse import quote from branta.enums import DestinationType, PrivacyMode -from branta.exceptions import BrantaPaymentException +from branta.exceptions import BrantaPaymentException, BrantaPaymentExceptionReason from branta.extensions import ( get_base_url, get_hash_zk_type, @@ -21,6 +21,15 @@ from branta.v2.secret_generator import GuidSecretGenerator +def _addresses_match(a: str, b: str) -> bool: + def is_bech32(v: str) -> bool: + return v.lower().startswith("bc1") + + if is_bech32(a) and is_bech32(b): + return a.lower() == b.lower() + return a == b + + class BrantaService: def __init__( self, @@ -51,10 +60,15 @@ async def get_payments_by_qr_code( for d in parser.destinations if get_hash_zk_type(d.value) is not None ] + on_chain_address = next( + (d.value for d in parser.destinations if d.type == DestinationType.BitcoinAddress), + None, + ) return await self._get_payments_for_zk( parser.on_chain_encryption_text, parser.on_chain_encryption_secret, additional_values, + on_chain_address, options, ) @@ -192,13 +206,16 @@ async def _get_payments_for_zk( lookup_value: str, encryption_key: Optional[str], additional_hash_values: List[str], + expected_on_chain_address: Optional[str], options: Optional[BrantaClientOptions], ) -> PaymentsResult: payments = await self._client.get_payments(lookup_value, options) keys: Dict[str, str] = {} for payment in payments: - await self._decrypt_destinations(payment, lookup_value, encryption_key, None, keys) + await self._decrypt_destinations( + payment, lookup_value, encryption_key, None, keys, expected_on_chain_address + ) for value in additional_hash_values: await self._decrypt_hash_zk_destinations(payment, value, keys) @@ -233,6 +250,7 @@ async def _decrypt_destinations( encryption_key: Optional[str], hash_zk_type: Optional[DestinationType], keys: Dict[str, str], + expected_on_chain_address: Optional[str] = None, ) -> None: for destination in payment.destinations: destination.is_encrypted = bool(destination.is_zk) @@ -243,13 +261,22 @@ async def _decrypt_destinations( if encryption_key is None: continue try: - destination.value = await self._aes_encryption.decrypt(destination.value, encryption_key) - destination.is_encrypted = False - if destination.zk_id is not None and destination.zk_id not in keys: - keys[destination.zk_id] = encryption_key - await self._try_decrypt_metadata(payment, destination, encryption_key) + decrypted = await self._aes_encryption.decrypt(destination.value, encryption_key) except Exception: - pass + continue + + if expected_on_chain_address is not None and not _addresses_match(decrypted, expected_on_chain_address): + raise 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.is_encrypted = False + if destination.zk_id is not None and destination.zk_id not in keys: + keys[destination.zk_id] = encryption_key + await self._try_decrypt_metadata(payment, destination, encryption_key) elif hash_zk_type is not None and destination.type == hash_zk_type: key = to_normalized_hash(destination_value) try: diff --git a/tests/test_service.py b/tests/test_service.py index 25438c7..031630d 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -5,7 +5,7 @@ import pytest from branta.enums import BrantaServerBaseUrl, DestinationType, PrivacyMode -from branta.exceptions import BrantaPaymentException +from branta.exceptions import BrantaPaymentException, BrantaPaymentExceptionReason from branta.extensions import to_normalized_hash from branta.models import Payment from branta.options import BrantaClientOptions @@ -237,6 +237,107 @@ async def get_side_effect(lookup, opts=None, signal=None): aes_mock.decrypt.assert_any_await(ENCRYPTED_BOLT11, BOLT11_HASH) +SWAPPED_ADDRESS = "1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2" +BECH32_ADDRESS = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4" +ENCRYPTED_BECH32_ADDRESS = "encrypted-bech32-address" + + +def zk_bech32_payment() -> Payment: + return PaymentBuilder().add_destination(ENCRYPTED_BECH32_ADDRESS, DestinationType.BitcoinAddress).set_zk().build() + + +# ===== get_payments_by_qr_code address binding ===== + +class TestGetPaymentsByQrCodeAddressBinding: + async def test_swapped_address_rejects(self, service, client_mock, aes_mock): + async def get_side_effect(lookup, opts=None, signal=None): + if lookup == ENCRYPTED_BITCOIN_ADDRESS: + return [zk_bitcoin_payment()] + return [] + client_mock.get_payments = AsyncMock(side_effect=get_side_effect) + + qr = f"bitcoin:{SWAPPED_ADDRESS}?branta_id={ENCRYPTED_BITCOIN_ADDRESS}&branta_secret={SECRET}" + with pytest.raises(BrantaPaymentException) as exc_info: + await service.get_payments_by_qr_code(qr) + assert exc_info.value.reason == BrantaPaymentExceptionReason.Tampered + + async def test_matching_address_does_not_throw(self, service, client_mock, aes_mock): + async def get_side_effect(lookup, opts=None, signal=None): + if lookup == ENCRYPTED_BITCOIN_ADDRESS: + return [zk_bitcoin_payment()] + return [] + client_mock.get_payments = AsyncMock(side_effect=get_side_effect) + + qr = f"bitcoin:{BITCOIN_ADDRESS}?branta_id={ENCRYPTED_BITCOIN_ADDRESS}&branta_secret={SECRET}" + result = await service.get_payments_by_qr_code(qr) + assert result.payments[0].destinations[0].value == BITCOIN_ADDRESS + + async def test_uppercase_bech32_qr_matches_lowercase_registered(self, service, client_mock, aes_mock): + async def decrypt_side_effect(encrypted_value, secret): + if encrypted_value == ENCRYPTED_BECH32_ADDRESS and secret == SECRET: + return BECH32_ADDRESS + return "" + aes_mock.decrypt = AsyncMock(side_effect=decrypt_side_effect) + + async def get_side_effect(lookup, opts=None, signal=None): + if lookup == ENCRYPTED_BECH32_ADDRESS: + return [zk_bech32_payment()] + return [] + client_mock.get_payments = AsyncMock(side_effect=get_side_effect) + + qr = f"bitcoin:{BECH32_ADDRESS.upper()}?branta_id={ENCRYPTED_BECH32_ADDRESS}&branta_secret={SECRET}" + result = await service.get_payments_by_qr_code(qr) + assert result.payments[0].destinations[0].value == BECH32_ADDRESS + + async def test_base58_case_mismatch_rejects(self, service, client_mock, aes_mock): + async def get_side_effect(lookup, opts=None, signal=None): + if lookup == ENCRYPTED_BITCOIN_ADDRESS: + return [zk_bitcoin_payment()] + return [] + client_mock.get_payments = AsyncMock(side_effect=get_side_effect) + + qr = f"bitcoin:{BITCOIN_ADDRESS.lower()}?branta_id={ENCRYPTED_BITCOIN_ADDRESS}&branta_secret={SECRET}" + with pytest.raises(BrantaPaymentException) as exc_info: + await service.get_payments_by_qr_code(qr) + assert exc_info.value.reason == BrantaPaymentExceptionReason.Tampered + + async def test_lightning_qr_with_zk_params_no_plain_address_decrypts_without_comparison( + self, service, client_mock, aes_mock + ): + async def get_side_effect(lookup, opts=None, signal=None): + if lookup == ENCRYPTED_BITCOIN_ADDRESS: + return [zk_bitcoin_payment()] + return [] + client_mock.get_payments = AsyncMock(side_effect=get_side_effect) + + qr = f"lightning:{BOLT11_INVOICE}?branta_id={ENCRYPTED_BITCOIN_ADDRESS}&branta_secret={SECRET}" + result = await service.get_payments_by_qr_code(qr) + assert result.payments[0].destinations[0].value == BITCOIN_ADDRESS + + async def test_combined_zk_qr_swapped_address_rejects(self, service, client_mock, aes_mock): + payment = ( + PaymentBuilder() + .add_destination(ENCRYPTED_BITCOIN_ADDRESS, DestinationType.BitcoinAddress).set_zk() + .add_destination(ENCRYPTED_BOLT11, DestinationType.Bolt11).set_zk() + .add_destination(ENCRYPTED_ARK_ADDRESS, DestinationType.ArkAddress).set_zk() + .build() + ) + + async def get_side_effect(lookup, opts=None, signal=None): + if lookup == ENCRYPTED_BITCOIN_ADDRESS: + return [payment] + return [] + client_mock.get_payments = AsyncMock(side_effect=get_side_effect) + + qr = ( + f"bitcoin:{SWAPPED_ADDRESS}?branta_id={ENCRYPTED_BITCOIN_ADDRESS}&branta_secret={SECRET}" + f"&lightning={BOLT11_INVOICE}&ark={ARK_ADDRESS}" + ) + with pytest.raises(BrantaPaymentException) as exc_info: + await service.get_payments_by_qr_code(qr) + assert exc_info.value.reason == BrantaPaymentExceptionReason.Tampered + + # ===== get_payments ===== class TestGetPayments: