From f928570f6f8fd3afcd6198c9724e3bc9e54ecb90 Mon Sep 17 00:00:00 2001 From: Kyle McCullen Date: Tue, 16 Jun 2026 12:54:03 -0400 Subject: [PATCH] add encrypted metadata support --- lib/src/v2/models/destination.dart | 4 + lib/src/v2/models/payment.dart | 4 + lib/src/v2/services/branta_service.dart | 45 ++++- test/branta_test.dart | 251 ++++++++++++++++++++++++ 4 files changed, 297 insertions(+), 7 deletions(-) diff --git a/lib/src/v2/models/destination.dart b/lib/src/v2/models/destination.dart index fffff02..43cae04 100644 --- a/lib/src/v2/models/destination.dart +++ b/lib/src/v2/models/destination.dart @@ -11,6 +11,7 @@ class Destination { DestinationType? type; String? zkId; + String? encryptedDek; Destination({ required this.value, @@ -19,6 +20,7 @@ class Destination { this.isEncrypted = false, this.type, this.zkId, + this.encryptedDek, }); factory Destination.fromJson(Map json) => Destination( @@ -27,6 +29,7 @@ class Destination { isZk: json['zk'] as bool? ?? false, type: DestinationType.fromJson(json['type'] as String?), zkId: json['zk_id'] as String?, + encryptedDek: json['encrypted_dek'] as String?, ); Map toJson() => { @@ -35,5 +38,6 @@ class Destination { 'zk': isZk, if (type != null) 'type': type!.jsonValue, if (zkId != null) 'zk_id': zkId, + if (encryptedDek != null) 'encrypted_dek': encryptedDek, }; } diff --git a/lib/src/v2/models/payment.dart b/lib/src/v2/models/payment.dart index eedef9c..fdb2d94 100644 --- a/lib/src/v2/models/payment.dart +++ b/lib/src/v2/models/payment.dart @@ -13,6 +13,10 @@ class Payment { Platform? parentPlatform; String? btcPayServerPluginVersion; + /// Runtime-only flag set by [BrantaService] after metadata decryption. + /// Not serialized to/from JSON. + bool isMetadataDecrypted = false; + Payment({ this.description, required this.destinations, diff --git a/lib/src/v2/services/branta_service.dart b/lib/src/v2/services/branta_service.dart index 33d2ec7..a02a21c 100644 --- a/lib/src/v2/services/branta_service.dart +++ b/lib/src/v2/services/branta_service.dart @@ -69,9 +69,9 @@ class BrantaService implements IBrantaService { final keys = {}; for (final payment in payments) { - await _decryptDestinations(payment.destinations, lookupValue, encryptionKey, null, keys); + await _decryptDestinations(payment, lookupValue, encryptionKey, null, keys); for (final value in additionalHashValues) { - await _decryptHashZkDestinations(payment.destinations, value, keys); + await _decryptHashZkDestinations(payment, value, keys); } } @@ -82,7 +82,7 @@ class BrantaService implements IBrantaService { } Future _decryptHashZkDestinations( - List destinations, + Payment payment, String plainValue, Map keys, ) async { @@ -90,13 +90,14 @@ class BrantaService implements IBrantaService { if (hashZkType == null) return; final key = plainValue.toNormalizedHash(); - for (final destination in destinations) { + for (final destination in payment.destinations) { if (!destination.isZk || destination.type != hashZkType) continue; try { destination.value = await _aesEncryption.decrypt(destination.value, key); destination.isEncrypted = false; final zkId = destination.zkId; if (zkId != null) keys.putIfAbsent(zkId, () => key); + await _tryDecryptMetadata(payment, destination, key); } catch (_) { // Key didn't match this destination — leave it encrypted. } @@ -136,7 +137,7 @@ class BrantaService implements IBrantaService { final keys = {}; for (final payment in payments) { - await _decryptDestinations(payment.destinations, normalizedDestination, destinationEncryptionKey, hashZkType, keys); + await _decryptDestinations(payment, normalizedDestination, destinationEncryptionKey, hashZkType, keys); } return PaymentsResult( @@ -146,13 +147,13 @@ class BrantaService implements IBrantaService { } Future _decryptDestinations( - List destinations, + Payment payment, String destinationValue, String? encryptionKey, DestinationType? hashZkType, Map keys, ) async { - for (final destination in destinations) { + for (final destination in payment.destinations) { destination.isEncrypted = destination.isZk; if (!destination.isZk) continue; @@ -163,6 +164,7 @@ class BrantaService implements IBrantaService { destination.isEncrypted = false; final zkId = destination.zkId; if (zkId != null) keys.putIfAbsent(zkId, () => encryptionKey); + await _tryDecryptMetadata(payment, destination, encryptionKey); } catch (_) { // Key didn't match — leave it encrypted. } @@ -173,6 +175,7 @@ class BrantaService implements IBrantaService { destination.isEncrypted = false; final zkId = destination.zkId; if (zkId != null) keys.putIfAbsent(zkId, () => key); + await _tryDecryptMetadata(payment, destination, key); } catch (_) { // Key didn't match — leave it encrypted. } @@ -180,6 +183,22 @@ class BrantaService implements IBrantaService { } } + Future _tryDecryptMetadata( + Payment payment, + Destination destination, + String keyUsed, + ) async { + final encryptedDek = destination.encryptedDek; + if (encryptedDek == null || payment.metadata == null || payment.isMetadataDecrypted) return; + try { + final dek = await _aesEncryption.decrypt(encryptedDek, keyUsed); + payment.metadata = await _aesEncryption.decrypt(payment.metadata!, dek); + payment.isMetadataDecrypted = true; + } catch (_) { + // DEK decryption failed — leave metadata as-is. + } + } + @override Future addPaymentAsync(Payment payment, {BrantaClientOptions? options}) async { final privacy = _resolvePrivacy(options); @@ -189,6 +208,12 @@ class BrantaService implements IBrantaService { ); } + String? dek; + if (payment.metadata != null && payment.destinations.any((d) => d.isZk)) { + dek = _secretGenerator.generate(); + payment.metadata = await _aesEncryption.encrypt(payment.metadata!, dek, deterministicNonce: false); + } + final secret = _secretGenerator.generate(); final encryptedToKey = {}; @@ -202,6 +227,9 @@ class BrantaService implements IBrantaService { deterministicNonce: _secretGenerator.deterministicNonce, ); encryptedToKey[destination.value] = secret; + if (dek != null) { + destination.encryptedDek = await _aesEncryption.encrypt(dek, secret, deterministicNonce: false); + } } else { final hashZkType = destination.value.getHashZkType(); if (hashZkType == null) { @@ -213,6 +241,9 @@ class BrantaService implements IBrantaService { final key = normalizedValue.toNormalizedHash(); destination.value = await _aesEncryption.encrypt(normalizedValue, key, deterministicNonce: true); encryptedToKey[destination.value] = key; + if (dek != null) { + destination.encryptedDek = await _aesEncryption.encrypt(dek, key, deterministicNonce: false); + } } } diff --git a/test/branta_test.dart b/test/branta_test.dart index a07e4d6..d75a699 100644 --- a/test/branta_test.dart +++ b/test/branta_test.dart @@ -157,6 +157,18 @@ class MockSecretGenerator implements ISecretGenerator { bool get deterministicNonce => false; } +class MockSequentialSecretGenerator implements ISecretGenerator { + final List _values; + int _index = 0; + MockSequentialSecretGenerator(this._values); + + @override + String generate() => _values[_index++]; + + @override + bool get deterministicNonce => false; +} + // --------------------------------------------------------------------------- // Test constants // --------------------------------------------------------------------------- @@ -1372,4 +1384,243 @@ void main() { expect(client.lastIsApiKeyValidOptions, equals(_looseOptions)); }); }); + + // ------------------------------------------------------------------------- + // BrantaService — Metadata Encryption (DEK Envelope) + // ------------------------------------------------------------------------- + group('BrantaService — metadata encryption (DEK envelope)', () { + const dek = 'test-dek'; + const encryptedDek = 'encrypted-dek-value'; + const metadata = '{"email":"alice@example.com"}'; + const encryptedMetadata = 'encrypted-metadata-value'; + + late MockBrantaClient client; + late MockAesEncryption aes; + + setUp(() { + client = MockBrantaClient(); + aes = MockAesEncryption(); + }); + + // addPayment + + test('addPaymentAsync_withMetadataAndZkBitcoinDestination_encryptsMetadataWithDekAndSetsDek', () async { + final payment = PaymentBuilder() + .addDestination(_bitcoinAddress, type: DestinationType.bitcoinAddress) + .setZk() + .build(); + payment.metadata = metadata; + final zkId = payment.destinations.first.zkId!; + + final responsePayment = PaymentBuilder() + .addDestination(_encryptedBitcoinAddress, type: DestinationType.bitcoinAddress) + .build(); + responsePayment.destinations.first.isZk = true; + responsePayment.destinations.first.zkId = zkId; + client.setupPostPayment(responsePayment); + + aes.setupEncrypt(metadata, dek, encryptedMetadata); + aes.setupEncrypt(_bitcoinAddress, _secret, _encryptedBitcoinAddress); + aes.setupEncrypt(dek, _secret, encryptedDek); + + final service = BrantaService( + client: client, + aesEncryption: aes, + defaultOptions: _looseOptions, + secretGenerator: MockSequentialSecretGenerator([dek, _secret]), + ); + + await service.addPaymentAsync(payment); + + expect(payment.metadata, equals(encryptedMetadata)); + expect(payment.destinations.first.encryptedDek, equals(encryptedDek)); + expect(aes.countEncryptCalls(metadata, dek), equals(1)); + expect(aes.countEncryptCalls(dek, _secret), equals(1)); + }); + + test('addPaymentAsync_withMetadataAndNoZkDestination_leavesMetadataPlain', () async { + final payment = PaymentBuilder() + .addDestination(_bitcoinAddress, type: DestinationType.bitcoinAddress) + .build(); + payment.metadata = metadata; + client.setupPostPayment(_plainBitcoinPayment); + + final service = _makeService(client, aes); + await service.addPaymentAsync(payment); + + expect(payment.metadata, equals(metadata)); + expect(aes.encryptCalls, isEmpty); + }); + + test('addPaymentAsync_withZkDestinationAndNoMetadata_doesNotGenerateDek', () async { + final payment = PaymentBuilder() + .addDestination(_bitcoinAddress, type: DestinationType.bitcoinAddress) + .setZk() + .build(); + final zkId = payment.destinations.first.zkId!; + + final responsePayment = PaymentBuilder() + .addDestination(_encryptedBitcoinAddress, type: DestinationType.bitcoinAddress) + .build(); + responsePayment.destinations.first.isZk = true; + responsePayment.destinations.first.zkId = zkId; + client.setupPostPayment(responsePayment); + + aes.setupEncrypt(_bitcoinAddress, _secret, _encryptedBitcoinAddress); + final service = _makeService(client, aes); + await service.addPaymentAsync(payment); + + // Only one encrypt call: the address. No DEK encrypt. + expect(aes.encryptCalls.length, equals(1)); + expect(payment.destinations.first.encryptedDek, isNull); + }); + + // decryptDestinations + + test('getPaymentsAsync_zkBitcoinWithEncryptedDek_decryptsMetadataViaDek', () async { + final payment = Payment( + destinations: [ + Destination( + value: _encryptedBitcoinAddress, + isZk: true, + type: DestinationType.bitcoinAddress, + encryptedDek: encryptedDek, + ), + ], + metadata: encryptedMetadata, + ); + client.setupGetPayments(_encryptedBitcoinAddress, [payment]); + aes.setupDecryptResult(_encryptedBitcoinAddress, _secret, _bitcoinAddress); + aes.setupDecryptResult(encryptedDek, _secret, dek); + aes.setupDecryptResult(encryptedMetadata, dek, metadata); + + final service = _makeService(client, aes); + final result = await service.getPaymentsAsync(_encryptedBitcoinAddress, destinationEncryptionKey: _secret); + + expect(result.payments.first.metadata, equals(metadata)); + expect(result.payments.first.isMetadataDecrypted, isTrue); + }); + + test('getPaymentsAsync_zkBolt11WithEncryptedDek_decryptsMetadataViaHashKey', () async { + final payment = Payment( + destinations: [ + Destination( + value: _encryptedBolt11, + isZk: true, + type: DestinationType.bolt11, + encryptedDek: encryptedDek, + ), + ], + metadata: encryptedMetadata, + ); + client.setupGetPayments(_encryptedBolt11, [payment]); + aes.setupEncrypt(_bolt11Invoice, _bolt11Hash, _encryptedBolt11, deterministicNonce: true); + aes.setupDecryptResult(_encryptedBolt11, _bolt11Hash, _decryptedBolt11); + aes.setupDecryptResult(encryptedDek, _bolt11Hash, dek); + aes.setupDecryptResult(encryptedMetadata, dek, metadata); + + final service = _makeService(client, aes); + final result = await service.getPaymentsAsync(_bolt11Invoice); + + expect(result.payments.first.metadata, equals(metadata)); + expect(result.payments.first.isMetadataDecrypted, isTrue); + }); + + test('getPaymentsAsync_wrongKey_encryptedDekDecryptFails_leavesMetadataEncrypted', () async { + final payment = Payment( + destinations: [ + Destination( + value: _encryptedBitcoinAddress, + isZk: true, + type: DestinationType.bitcoinAddress, + encryptedDek: encryptedDek, + ), + ], + metadata: encryptedMetadata, + ); + client.setupGetPayments(_encryptedBitcoinAddress, [payment]); + aes.setupDecryptResult(_encryptedBitcoinAddress, _secret, _bitcoinAddress); + aes.setupDecryptThrows(encryptedDek, _secret, Exception('Decryption failed: auth tag mismatch')); + + final service = _makeService(client, aes); + final result = await service.getPaymentsAsync(_encryptedBitcoinAddress, destinationEncryptionKey: _secret); + + expect(result.payments.first.metadata, equals(encryptedMetadata)); + expect(result.payments.first.isMetadataDecrypted, isFalse); + }); + + test('getPaymentsAsync_twoZkDestinationsWithEncryptedDek_decryptsMetadataOnce', () async { + const encryptedBitcoinAddress2 = 'encrypted-bitcoin-address-2'; + final payment = Payment( + destinations: [ + Destination( + value: _encryptedBitcoinAddress, + isZk: true, + type: DestinationType.bitcoinAddress, + encryptedDek: encryptedDek, + ), + Destination( + value: encryptedBitcoinAddress2, + isZk: true, + type: DestinationType.bitcoinAddress, + encryptedDek: encryptedDek, + ), + ], + metadata: encryptedMetadata, + ); + client.setupGetPayments(_encryptedBitcoinAddress, [payment]); + aes.setupDecryptResult(_encryptedBitcoinAddress, _secret, _bitcoinAddress); + aes.setupDecryptResult(encryptedBitcoinAddress2, _secret, _bitcoinAddress); + aes.setupDecryptResult(encryptedDek, _secret, dek); + aes.setupDecryptResult(encryptedMetadata, dek, metadata); + + final service = _makeService(client, aes); + final result = await service.getPaymentsAsync(_encryptedBitcoinAddress, destinationEncryptionKey: _secret); + + expect(result.payments.first.metadata, equals(metadata)); + expect(aes.countDecryptCalls(encryptedMetadata, dek), equals(1)); + }); + + test('getPaymentsAsync_noEncryptedDek_doesNotAttemptMetadataDecrypt', () async { + final payment = Payment( + destinations: [ + Destination( + value: _encryptedBitcoinAddress, + isZk: true, + type: DestinationType.bitcoinAddress, + ), + ], + metadata: encryptedMetadata, + ); + client.setupGetPayments(_encryptedBitcoinAddress, [payment]); + aes.setupDecryptResult(_encryptedBitcoinAddress, _secret, _bitcoinAddress); + + final service = _makeService(client, aes); + final result = await service.getPaymentsAsync(_encryptedBitcoinAddress, destinationEncryptionKey: _secret); + + expect(result.payments.first.metadata, equals(encryptedMetadata)); + expect(aes.countDecryptCalls(encryptedDek, _secret), equals(0)); + }); + + test('getPaymentsAsync_noMetadata_doesNotAttemptDekDecrypt', () async { + final payment = Payment( + destinations: [ + Destination( + value: _encryptedBitcoinAddress, + isZk: true, + type: DestinationType.bitcoinAddress, + encryptedDek: encryptedDek, + ), + ], + ); + client.setupGetPayments(_encryptedBitcoinAddress, [payment]); + aes.setupDecryptResult(_encryptedBitcoinAddress, _secret, _bitcoinAddress); + + final service = _makeService(client, aes); + final result = await service.getPaymentsAsync(_encryptedBitcoinAddress, destinationEncryptionKey: _secret); + + expect(result.payments.first.metadata, isNull); + expect(aes.countDecryptCalls(encryptedDek, _secret), equals(0)); + }); + }); }