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
7 changes: 5 additions & 2 deletions src/classes/aesEncryption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,21 @@ type Bytes = Uint8Array<ArrayBuffer>;

type CryptoLike = Pick<Crypto, 'subtle' | 'getRandomValues'>;

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;
};

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));
};
Expand Down
7 changes: 6 additions & 1 deletion src/classes/nobleCryptoProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
10 changes: 9 additions & 1 deletion src/exceptions/brantaPaymentException.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
61 changes: 51 additions & 10 deletions src/v2/services/brantaService.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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!;
Expand All @@ -77,14 +92,15 @@ export class BrantaService implements IBrantaService {
lookupValue: string,
encryptionKey: string | undefined,
additionalHashValues: string[],
expectedOnChainAddress: string | undefined,
options: BrantaClientOptions | undefined,
signal: AbortSignal | undefined,
): Promise<PaymentsResult> {
const payments = await this.client.getPayments(lookupValue, options, signal);

const keys: Record<string, string> = {};
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);
}
Expand All @@ -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.
}
}
Expand Down Expand Up @@ -164,23 +186,42 @@ export class BrantaService implements IBrantaService {
encryptionKey: string | undefined,
hashZkType: DestinationType | undefined,
keys: Record<string, string>,
expectedOnChainAddress?: string,
): Promise<void> {
for (const destination of payment.destinations) {
destination.isEncrypted = !!destination.isZk;
if (!destination.isZk) continue;

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 {
Expand Down
188 changes: 187 additions & 1 deletion test/v2/services/brantaService.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading