diff --git a/src/classes/aesEncryption.ts b/src/classes/aesEncryption.ts index 6ec9f5b..f6c7ba7 100644 --- a/src/classes/aesEncryption.ts +++ b/src/classes/aesEncryption.ts @@ -4,10 +4,13 @@ type Bytes = Uint8Array; type CryptoLike = Pick; +export const WEB_CRYPTO_UNAVAILABLE_MESSAGE = + 'Web Crypto API is not available. Pass a crypto provider via BrantaServiceOptions or see README for setup instructions.'; + const resolveSubtle = (crypto?: BrantaCryptoProvider): SubtleCrypto => { const c = crypto ?? (globalThis as { crypto?: CryptoLike }).crypto; if (!c?.subtle) { - throw new Error('Web Crypto API is not available. Pass a crypto provider via BrantaServiceOptions or see README for setup instructions.'); + throw new Error(WEB_CRYPTO_UNAVAILABLE_MESSAGE); } return c.subtle; }; @@ -15,7 +18,7 @@ const resolveSubtle = (crypto?: BrantaCryptoProvider): SubtleCrypto => { const getRandomBytes = (length: number, crypto?: BrantaCryptoProvider): Bytes => { const c = crypto ?? (globalThis as { crypto?: CryptoLike }).crypto; if (!c?.getRandomValues) { - throw new Error('Web Crypto API is not available. Pass a crypto provider via BrantaServiceOptions or see README for setup instructions.'); + throw new Error(WEB_CRYPTO_UNAVAILABLE_MESSAGE); } return c.getRandomValues(new Uint8Array(length)); }; diff --git a/src/classes/nobleCryptoProvider.ts b/src/classes/nobleCryptoProvider.ts index 45394c1..94a8e28 100644 --- a/src/classes/nobleCryptoProvider.ts +++ b/src/classes/nobleCryptoProvider.ts @@ -4,7 +4,12 @@ type HashFn = { (data: Uint8Array): Uint8Array } & object; export interface NobleDeps { sha256: HashFn; - hmac: (hash: HashFn, key: Uint8Array, msg: Uint8Array) => Uint8Array; + // `hash` here is whatever @noble/hashes' `sha256` actually exports (a CHash: callable, + // plus internal metadata like outputLen/blockLen/canXOF). The SDK has no dependency on + // @noble/hashes and can't mirror that shape exactly (or track it across versions), so + // this is intentionally typed loosely — `hmac` just forwards it straight through to the + // real `hmac` function below, which does its own runtime validation. + hmac: (hash: any, key: Uint8Array, msg: Uint8Array) => Uint8Array; gcm: (key: Uint8Array, nonce: Uint8Array) => { encrypt(data: Uint8Array): Uint8Array; decrypt(data: Uint8Array): Uint8Array }; randomBytes: (length: number) => Uint8Array; } diff --git a/src/exceptions/brantaPaymentException.ts b/src/exceptions/brantaPaymentException.ts index ef2fd2e..fdf2b2f 100644 --- a/src/exceptions/brantaPaymentException.ts +++ b/src/exceptions/brantaPaymentException.ts @@ -1,7 +1,15 @@ +export enum BrantaPaymentExceptionReason { + Tampered = 'tampered', + CryptoUnavailable = 'crypto_unavailable', +} + export class BrantaPaymentException extends Error { - constructor(message: string) { + readonly reason?: BrantaPaymentExceptionReason; + + constructor(message: string, reason?: BrantaPaymentExceptionReason) { super(message); this.name = 'BrantaPaymentException'; + this.reason = reason; Object.setPrototypeOf(this, BrantaPaymentException.prototype); } } diff --git a/src/index.ts b/src/index.ts index 4664069..7daa01a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,7 @@ export { BrantaServerBaseUrl, BrantaServerBaseUrls } from './enums/brantaServerB export { DestinationType } from './enums/destinationType.js'; export { PrivacyMode } from './enums/privacyMode.js'; export type { BrantaClientOptions } from './classes/brantaClientOptions.js'; -export { BrantaPaymentException } from './exceptions/brantaPaymentException.js'; +export { BrantaPaymentException, BrantaPaymentExceptionReason } from './exceptions/brantaPaymentException.js'; export { QRParseException } from './exceptions/qrParseException.js'; export { AesEncryption } from './classes/aesEncryption.js'; export { AesEncryptionService } from './classes/aesEncryptionService.js'; diff --git a/src/v2/services/brantaService.ts b/src/v2/services/brantaService.ts index e097c50..88959c5 100644 --- a/src/v2/services/brantaService.ts +++ b/src/v2/services/brantaService.ts @@ -1,9 +1,10 @@ import type { BrantaCryptoProvider } from '../../index.js'; +import { WEB_CRYPTO_UNAVAILABLE_MESSAGE } from '../../classes/aesEncryption.js'; import { AesEncryptionService } from '../../classes/aesEncryptionService.js'; import { BrantaClientOptions } from '../../classes/brantaClientOptions.js'; import { DestinationType } from '../../enums/destinationType.js'; import { PrivacyMode } from '../../enums/privacyMode.js'; -import { BrantaPaymentException } from '../../exceptions/brantaPaymentException.js'; +import { BrantaPaymentException, BrantaPaymentExceptionReason } from '../../exceptions/brantaPaymentException.js'; import { getBaseUrl, getHashZkType, @@ -23,6 +24,12 @@ import { Payment } from '../models/payment.js'; import { PaymentsResult } from '../models/paymentsResult.js'; import { BrantaClient } from './brantaClient.js'; +const addressesMatch = (a: string, b: string): boolean => { + const isBech32 = (v: string): boolean => v.toLowerCase().startsWith('bc1'); + if (isBech32(a) && isBech32(b)) return a.toLowerCase() === b.toLowerCase(); + return a === b; +}; + export interface BrantaServiceOptions { defaultOptions?: BrantaClientOptions; client?: IBrantaClient; @@ -62,7 +69,15 @@ export class BrantaService implements IBrantaService { const additionalValues = parser.destinations .filter((d) => getHashZkType(d.value) !== undefined) .map((d) => d.value); - return this.getPaymentsForZk(parser.onChainEncryptionText!, parser.onChainEncryptionSecret, additionalValues, options, signal); + const onChainAddress = parser.destinations.find((d) => d.type === DestinationType.BitcoinAddress)?.value; + return this.getPaymentsForZk( + parser.onChainEncryptionText!, + parser.onChainEncryptionSecret, + additionalValues, + onChainAddress, + options, + signal, + ); } const destination = parser.destination!; @@ -77,6 +92,7 @@ export class BrantaService implements IBrantaService { lookupValue: string, encryptionKey: string | undefined, additionalHashValues: string[], + expectedOnChainAddress: string | undefined, options: BrantaClientOptions | undefined, signal: AbortSignal | undefined, ): Promise { @@ -84,7 +100,7 @@ export class BrantaService implements IBrantaService { const keys: Record = {}; for (const payment of payments) { - await this.decryptDestinations(payment, lookupValue, encryptionKey, undefined, keys); + await this.decryptDestinations(payment, lookupValue, encryptionKey, undefined, keys, expectedOnChainAddress); for (const value of additionalHashValues) { await this.decryptHashZkDestinations(payment, value, keys); } @@ -111,7 +127,13 @@ export class BrantaService implements IBrantaService { keys[destination.zkId] = key; } await this.tryDecryptMetadata(payment, destination, key); - } catch { + } catch (err) { + if (err instanceof Error && err.message.includes(WEB_CRYPTO_UNAVAILABLE_MESSAGE)) { + throw new BrantaPaymentException( + 'Unable to verify this payment: encryption is not available in this environment.', + BrantaPaymentExceptionReason.CryptoUnavailable, + ); + } // Key didn't match this destination — leave it encrypted. } } @@ -164,6 +186,7 @@ export class BrantaService implements IBrantaService { encryptionKey: string | undefined, hashZkType: DestinationType | undefined, keys: Record, + expectedOnChainAddress?: string, ): Promise { for (const destination of payment.destinations) { destination.isEncrypted = !!destination.isZk; @@ -171,16 +194,34 @@ export class BrantaService implements IBrantaService { if (destination.type === DestinationType.BitcoinAddress) { if (encryptionKey === undefined) continue; + let decrypted: string; try { - destination.value = await this.aesEncryption.decrypt(destination.value, encryptionKey); - destination.isEncrypted = false; - if (destination.zkId !== undefined && !(destination.zkId in keys)) { - keys[destination.zkId] = encryptionKey; + decrypted = await this.aesEncryption.decrypt(destination.value, encryptionKey); + } catch (err) { + if (err instanceof Error && err.message.includes(WEB_CRYPTO_UNAVAILABLE_MESSAGE)) { + throw new BrantaPaymentException( + 'Unable to verify this payment: encryption is not available in this environment.', + BrantaPaymentExceptionReason.CryptoUnavailable, + ); } - await this.tryDecryptMetadata(payment, destination, encryptionKey); - } catch { // Key didn't match this destination — leave it encrypted. + continue; + } + if (expectedOnChainAddress !== undefined && !addressesMatch(decrypted, expectedOnChainAddress)) { + console.log( + `[branta] address mismatch — QR: ${expectedOnChainAddress}, verified: ${decrypted}`, + ); + 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; + if (destination.zkId !== undefined && !(destination.zkId in keys)) { + keys[destination.zkId] = encryptionKey; } + await this.tryDecryptMetadata(payment, destination, encryptionKey); } else if (hashZkType !== undefined && destination.type === hashZkType) { const key = await toNormalizedHash(destinationValue, this.crypto); try { diff --git a/test/v2/services/brantaService.test.ts b/test/v2/services/brantaService.test.ts index 2bce8d9..b64475f 100644 --- a/test/v2/services/brantaService.test.ts +++ b/test/v2/services/brantaService.test.ts @@ -1,10 +1,11 @@ import { beforeAll, beforeEach, describe, expect, jest, test } from '@jest/globals'; +import { WEB_CRYPTO_UNAVAILABLE_MESSAGE } from '../../../src/classes/aesEncryption.js'; import { BrantaClientOptions } from '../../../src/classes/brantaClientOptions.js'; import { BrantaServerBaseUrl } from '../../../src/enums/brantaServerBaseUrl.js'; import { DestinationType } from '../../../src/enums/destinationType.js'; import { PrivacyMode } from '../../../src/enums/privacyMode.js'; -import { BrantaPaymentException } from '../../../src/exceptions/brantaPaymentException.js'; +import { BrantaPaymentException, BrantaPaymentExceptionReason } from '../../../src/exceptions/brantaPaymentException.js'; import { toNormalizedHash } from '../../../src/extensions/brantaExtensions.js'; import { PaymentBuilder } from '../../../src/v2/classes/paymentBuilder.js'; import { IAesEncryption } from '../../../src/v2/interfaces/iAesEncryption.js'; @@ -233,6 +234,191 @@ describe('BrantaService', () => { }); }); + // ===== getPaymentsByQrCode: QR address / decrypted address binding ===== + // An attacker can swap the plaintext bitcoin: address in a QR code while leaving + // branta_id/branta_secret untouched. The SDK must refuse to report a payment as + // verified when the address it decrypts differs from the address the QR displays. + + describe('getPaymentsByQrCode address binding', () => { + const SwappedBitcoinAddress = '1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2'; + const RegisteredBech32Address = 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq'; + const UppercaseBech32Qr = RegisteredBech32Address.toUpperCase(); + const EncryptedBech32Address = 'encrypted-bech32-address'; + const BaseAddressLower = '1a1zp1ep5qgefi2dmptftl5slmv7divfna'; + + const zkBech32Payment = (): Payment => + new PaymentBuilder().addDestination(EncryptedBech32Address, DestinationType.BitcoinAddress).setZk().build(); + + test('getPaymentsByQrCode_zkBitcoinUri_swappedAddress_rejects', async () => { + clientMock.getPayments.mockImplementation(async (lookup: string) => { + if (lookup === EncryptedBitcoinAddress) return [zkBitcoinPayment()]; + return []; + }); + + const qrText = `bitcoin:${SwappedBitcoinAddress}?branta_id=${EncryptedBitcoinAddress}&branta_secret=${Secret}`; + const promise = service.getPaymentsByQrCode(qrText); + + await expect(promise).rejects.toThrow(BrantaPaymentException); + await expect(promise).rejects.toMatchObject({ reason: BrantaPaymentExceptionReason.Tampered }); + }); + + test('getPaymentsByQrCode_zkBitcoinUri_matchingAddress_doesNotThrow', async () => { + clientMock.getPayments.mockImplementation(async (lookup: string) => { + if (lookup === EncryptedBitcoinAddress) return [zkBitcoinPayment()]; + return []; + }); + + const qrText = `bitcoin:${BitcoinAddress}?branta_id=${EncryptedBitcoinAddress}&branta_secret=${Secret}`; + + await expect(service.getPaymentsByQrCode(qrText)).resolves.toMatchObject({ + payments: [{ destinations: [{ value: BitcoinAddress }] }], + }); + }); + + test('getPaymentsByQrCode_zkBitcoinUri_uppercaseBech32Qr_matchesLowercaseRegistered_doesNotThrow', async () => { + aesMock.decrypt.mockImplementation(async (encryptedValue, secret) => { + if (encryptedValue === EncryptedBech32Address && secret === Secret) return RegisteredBech32Address; + return ''; + }); + clientMock.getPayments.mockImplementation(async (lookup: string) => { + if (lookup === EncryptedBech32Address) return [zkBech32Payment()]; + return []; + }); + + const qrText = `bitcoin:${UppercaseBech32Qr}?branta_id=${EncryptedBech32Address}&branta_secret=${Secret}`; + + await expect(service.getPaymentsByQrCode(qrText)).resolves.toMatchObject({ + payments: [{ destinations: [{ value: RegisteredBech32Address }] }], + }); + }); + + test('getPaymentsByQrCode_zkBitcoinUri_base58CaseMismatch_rejects', async () => { + aesMock.decrypt.mockImplementation(async (encryptedValue, secret) => { + if (encryptedValue === EncryptedBitcoinAddress && secret === Secret) return BitcoinAddress; + return ''; + }); + clientMock.getPayments.mockImplementation(async (lookup: string) => { + if (lookup === EncryptedBitcoinAddress) return [zkBitcoinPayment()]; + return []; + }); + + // Base58 is case-sensitive: a lowercased QR address must NOT be treated as a match. + const qrText = `bitcoin:${BaseAddressLower}?branta_id=${EncryptedBitcoinAddress}&branta_secret=${Secret}`; + const promise = service.getPaymentsByQrCode(qrText); + + await expect(promise).rejects.toThrow(BrantaPaymentException); + await expect(promise).rejects.toMatchObject({ reason: BrantaPaymentExceptionReason.Tampered }); + }); + + test('getPaymentsByQrCode_lightningQrWithZkParams_noPlainOnChainAddress_decryptsWithoutComparison', async () => { + const payment = new PaymentBuilder() + .addDestination(EncryptedBolt11, DestinationType.Bolt11) + .setZk() + .addDestination(EncryptedBitcoinAddress, DestinationType.BitcoinAddress) + .setZk() + .build(); + + clientMock.getPayments.mockImplementation(async (lookup: string) => { + if (lookup === EncryptedBolt11) return [payment]; + return []; + }); + + // branta_id/branta_secret here refer to the bolt11 lookup itself (lookupValue), and are + // reused as the "encryptionKey" for any BitcoinAddress destination on the returned + // payment too, matching existing getPaymentsForZk behavior — so the bitcoin destination + // still decrypts successfully. No plaintext on-chain address is present in this QR + // though, so there's nothing to compare the decrypted value against, and it must not throw. + const qrText = `lightning:${Bolt11Invoice}?branta_id=${EncryptedBolt11}&branta_secret=${Secret}`; + + await expect(service.getPaymentsByQrCode(qrText)).resolves.toMatchObject({ + payments: [ + { + destinations: [{ value: DecryptedBolt11 }, { value: BitcoinAddress, isEncrypted: false }], + }, + ], + }); + }); + + test('getPaymentsByQrCode_combinedZkQr_swappedAddress_rejects', async () => { + const payment = new PaymentBuilder() + .addDestination(EncryptedBitcoinAddress, DestinationType.BitcoinAddress) + .setZk() + .addDestination(EncryptedBolt11, DestinationType.Bolt11) + .setZk() + .addDestination(EncryptedArkAddress, DestinationType.ArkAddress) + .setZk() + .build(); + + clientMock.getPayments.mockImplementation(async (lookup: string) => { + if (lookup === EncryptedBitcoinAddress) return [payment]; + return []; + }); + + const qrText = `bitcoin:${SwappedBitcoinAddress}?branta_id=${EncryptedBitcoinAddress}&branta_secret=${Secret}&lightning=${Bolt11Invoice}&ark=${ArkAddress}`; + const promise = service.getPaymentsByQrCode(qrText); + + await expect(promise).rejects.toThrow(BrantaPaymentException); + await expect(promise).rejects.toMatchObject({ reason: BrantaPaymentExceptionReason.Tampered }); + }); + }); + + describe('getPaymentsByQrCode crypto unavailable', () => { + test('getPaymentsByQrCode_zkBitcoinUri_cryptoUnavailable_throwsCryptoUnavailableException', async () => { + aesMock.decrypt.mockImplementation(async () => { + throw new Error(WEB_CRYPTO_UNAVAILABLE_MESSAGE); + }); + clientMock.getPayments.mockImplementation(async (lookup: string) => { + if (lookup === EncryptedBitcoinAddress) return [zkBitcoinPayment()]; + return []; + }); + + const qrText = `bitcoin:${BitcoinAddress}?branta_id=${EncryptedBitcoinAddress}&branta_secret=${Secret}`; + const promise = service.getPaymentsByQrCode(qrText); + + await expect(promise).rejects.toThrow(BrantaPaymentException); + await expect(promise).rejects.toMatchObject({ reason: BrantaPaymentExceptionReason.CryptoUnavailable }); + }); + + test('getPaymentsByQrCode_hashZkDestination_cryptoUnavailable_throwsCryptoUnavailableException', async () => { + const payment = new PaymentBuilder().addDestination(EncryptedBolt11, DestinationType.Bolt11).setZk().build(); + + aesMock.decrypt.mockImplementation(async (encryptedValue, secret) => { + if (encryptedValue === EncryptedBolt11 && secret === Bolt11Hash) { + throw new Error(WEB_CRYPTO_UNAVAILABLE_MESSAGE); + } + return ''; + }); + clientMock.getPayments.mockImplementation(async (lookup: string) => { + if (lookup === EncryptedBolt11) return [payment]; + return []; + }); + + const qrText = `lightning:${Bolt11Invoice}?branta_id=${EncryptedBolt11}&branta_secret=${Secret}`; + const promise = service.getPaymentsByQrCode(qrText); + + await expect(promise).rejects.toThrow(BrantaPaymentException); + await expect(promise).rejects.toMatchObject({ reason: BrantaPaymentExceptionReason.CryptoUnavailable }); + }); + + test('getPaymentsByQrCode_zkBitcoinUri_wrongKey_leavesDestinationEncrypted_regression', async () => { + // A wrong-key / GCM-auth-tag-mismatch failure is the normal privacy-by-design case and + // must keep resolving silently — only the crypto-unavailable message is special-cased. + aesMock.decrypt.mockImplementation(async () => { + throw new Error('OperationError: the operation failed for an operation-specific reason.'); + }); + clientMock.getPayments.mockImplementation(async (lookup: string) => { + if (lookup === EncryptedBitcoinAddress) return [zkBitcoinPayment()]; + return []; + }); + + const qrText = `bitcoin:${BitcoinAddress}?branta_id=${EncryptedBitcoinAddress}&branta_secret=${Secret}`; + + await expect(service.getPaymentsByQrCode(qrText)).resolves.toMatchObject({ + payments: [{ destinations: [{ value: EncryptedBitcoinAddress, isEncrypted: true }] }], + }); + }); + }); + // ===== getPayments ===== describe('getPayments', () => {