diff --git a/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt b/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt index 6abd258e2..2004a97b5 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt @@ -240,7 +240,7 @@ data class NodeParams( * This offer will stay valid after restoring the seed on a different device. * @return the default offer and the private key that will sign invoices for this offer. */ - fun defaultOffer(trampolineNodeId: PublicKey): Pair { + fun defaultOffer(trampolineNodeId: PublicKey): OfferTypes.OfferAndKey { // We generate a deterministic blindingSecret based on: // - a custom tag indicating that this is used in the Bolt 12 context // - our trampoline node, which is used as an introduction node for the offer's blinded path diff --git a/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt b/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt index ce09b5001..8600d618d 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt @@ -730,7 +730,7 @@ class Peer( .first() .let { event -> replyTo.complete(event.address) } } - peerConnection?.send(DNSAddressRequest(nodeParams.chainHash, nodeParams.defaultOffer(walletParams.trampolineNode.id).first, languageSubtag)) + peerConnection?.send(DNSAddressRequest(nodeParams.chainHash, nodeParams.defaultOffer(walletParams.trampolineNode.id).offer, languageSubtag)) return replyTo.await() } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/payment/OfferManager.kt b/src/commonMain/kotlin/fr/acinq/lightning/payment/OfferManager.kt index 1d0aa5b2d..83744e0d9 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/payment/OfferManager.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/payment/OfferManager.kt @@ -48,7 +48,7 @@ class OfferManager(val nodeParams: NodeParams, val walletParams: WalletParams, v private val localOffers: HashMap = HashMap() init { - registerOffer(nodeParams.defaultOffer(walletParams.trampolineNode.id).first, null) + registerOffer(nodeParams.defaultOffer(walletParams.trampolineNode.id).offer, null) } fun registerOffer(offer: OfferTypes.Offer, pathId: ByteVector32?) { diff --git a/src/commonMain/kotlin/fr/acinq/lightning/payment/TrustedContact.kt b/src/commonMain/kotlin/fr/acinq/lightning/payment/TrustedContact.kt new file mode 100644 index 000000000..931aa8604 --- /dev/null +++ b/src/commonMain/kotlin/fr/acinq/lightning/payment/TrustedContact.kt @@ -0,0 +1,45 @@ +package fr.acinq.lightning.payment + +import fr.acinq.bitcoin.Crypto +import fr.acinq.bitcoin.PrivateKey +import fr.acinq.bitcoin.PublicKey +import fr.acinq.lightning.NodeParams +import fr.acinq.lightning.wire.OfferTypes +import io.ktor.utils.io.core.* + +/** + * We may want to reveal our identity when paying Bolt 12 offers from our trusted contacts. + * We also want to be able to identify payments from our trusted contacts if they choose to reveal themselves. + * + * @param offer Bolt 12 offer used by that contact. + * @param payerIds list of payer_id that this contact may use in their [OfferTypes.InvoiceRequest] when they want to reveal their identity. + */ +data class TrustedContact(val offer: OfferTypes.Offer, val payerIds: List) { + /** + * Derive a deterministic payer_key to pay our trusted contact's offer (used in our [OfferTypes.InvoiceRequest]). + * This payer_key is unique to this contact and only lets them identify us if they know our local offer. + * + * @param localOffer local offer that our contact may have stored in their contact list (see [NodeParams.defaultOffer]). + */ + fun deterministicPayerKey(localOffer: OfferTypes.OfferAndKey): PrivateKey = localOffer.privateKey * deriveTweak(offer) + + /** Return true if this payer_id matches this contact. */ + fun isPayer(payerId: PublicKey): Boolean = payerIds.contains(payerId) + + /** Return true if the [invoiceRequest] comes from this contact. */ + fun isPayer(invoiceRequest: OfferTypes.InvoiceRequest): Boolean = isPayer(invoiceRequest.payerId) + + companion object { + fun create(localOffer: OfferTypes.OfferAndKey, remoteOffer: OfferTypes.Offer): TrustedContact { + // We derive the payer_ids that this contact may use when paying our local offer. + // If they use one of those payer_ids, we'll be able to identify that the payment came from them. + val payerIds = remoteOffer.contactNodeIds.map { nodeId -> nodeId * deriveTweak(localOffer.offer) } + return TrustedContact(remoteOffer, payerIds) + } + + private fun deriveTweak(paidOffer: OfferTypes.Offer): PrivateKey { + // Note that we use a tagged hash to ensure this tweak only applies to the contact feature. + return PrivateKey(Crypto.sha256("blip42_bolt12_contacts".toByteArray() + paidOffer.offerId.toByteArray())) + } + } +} \ No newline at end of file diff --git a/src/commonMain/kotlin/fr/acinq/lightning/wire/OfferTypes.kt b/src/commonMain/kotlin/fr/acinq/lightning/wire/OfferTypes.kt index ad5fdfdc1..d11d22a24 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/wire/OfferTypes.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/wire/OfferTypes.kt @@ -709,6 +709,9 @@ object OfferTypes { } } + /** A bolt 12 offer and the private key used to sign invoices for that offer. */ + data class OfferAndKey(val offer: Offer, val privateKey: PrivateKey) + data class Offer(val records: TlvStream) { val chains: List = records.get()?.chains ?: listOf(Block.LivenetGenesisBlock.hash) val metadata: ByteVector? = records.get()?.data @@ -789,7 +792,7 @@ object OfferTypes { blindingSecret: PrivateKey, additionalTlvs: Set = setOf(), customTlvs: Set = setOf() - ): Pair { + ): OfferAndKey { if (description == null) require(amount == null) { "an offer description must be provided if the amount isn't null" } val blindedRouteDetails = OnionMessages.buildRouteToRecipient(blindingSecret, listOf(OnionMessages.IntermediateNode(EncodedNodeId.WithPublicKey.Plain(trampolineNodeId))), OnionMessages.Destination.Recipient(EncodedNodeId.WithPublicKey.Wallet(nodeParams.nodeId), null)) val tlvs: Set = setOfNotNull( @@ -800,7 +803,7 @@ object OfferTypes { // Note that we don't include an offer_node_id since we're using a blinded path. OfferPaths(listOf(ContactInfo.BlindedPath(blindedRouteDetails.route))), ) - return Pair(Offer(TlvStream(tlvs + additionalTlvs, customTlvs)), blindedRouteDetails.blindedPrivateKey(nodeParams.nodePrivateKey)) + return OfferAndKey(Offer(TlvStream(tlvs + additionalTlvs, customTlvs)), blindedRouteDetails.blindedPrivateKey(nodeParams.nodePrivateKey)) } fun validate(records: TlvStream): Either { diff --git a/src/commonTest/kotlin/fr/acinq/lightning/payment/TrustedContactTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/payment/TrustedContactTestsCommon.kt new file mode 100644 index 000000000..fe83d13e9 --- /dev/null +++ b/src/commonTest/kotlin/fr/acinq/lightning/payment/TrustedContactTestsCommon.kt @@ -0,0 +1,54 @@ +package fr.acinq.lightning.payment + +import fr.acinq.bitcoin.Block +import fr.acinq.lightning.Features +import fr.acinq.lightning.Lightning.randomKey +import fr.acinq.lightning.tests.TestConstants +import fr.acinq.lightning.tests.utils.LightningTestSuite +import fr.acinq.lightning.utils.msat +import fr.acinq.lightning.wire.OfferTypes +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class TrustedContactTestsCommon : LightningTestSuite() { + + @Test + fun `identify payments coming from trusted contacts`() { + val alice = TestConstants.Alice.nodeParams.defaultOffer(trampolineNodeId = randomKey().publicKey()) + val bob = TestConstants.Alice.nodeParams.defaultOffer(trampolineNodeId = randomKey().publicKey()) + val carol = run { + val priv = randomKey() + val offer = OfferTypes.Offer.createNonBlindedOffer(null, null, priv.publicKey(), Features.empty, Block.RegtestGenesisBlock.hash) + OfferTypes.OfferAndKey(offer, priv) + } + + // Alice has Bob and Carol in her contacts list. + val aliceContacts = listOf(bob, carol).map { TrustedContact.create(alice, it.offer) } + // Bob's only contact is Alice. + val bobContacts = listOf(alice).map { TrustedContact.create(bob, it.offer) } + // Carol's only contact is Bob. + val carolContacts = listOf(bob).map { TrustedContact.create(carol, it.offer) } + + // Alice pays Bob: they are both in each other's contact list. + val alicePayerKeyForBob = aliceContacts.first().deterministicPayerKey(alice) + val aliceInvoiceRequestForBob = OfferTypes.InvoiceRequest(bob.offer, 1105.msat, 1, Features.empty, alicePayerKeyForBob, null, Block.RegtestGenesisBlock.hash) + assertTrue(bobContacts.first().isPayer(aliceInvoiceRequestForBob)) + assertTrue(bobContacts.first().isPayer(alicePayerKeyForBob.publicKey())) + + // Alice pays Carol: but Carol's only contact is Bob, so she cannot identify the payer. + val alicePayerKeyForCarol = aliceContacts.last().deterministicPayerKey(alice) + assertNotEquals(alicePayerKeyForBob, alicePayerKeyForCarol) + val aliceInvoiceRequestForCarol = OfferTypes.InvoiceRequest(carol.offer, 1729.msat, 1, Features.empty, alicePayerKeyForCarol, null, Block.RegtestGenesisBlock.hash) + carolContacts.forEach { assertFalse(it.isPayer(aliceInvoiceRequestForCarol)) } + carolContacts.forEach { assertFalse(it.isPayer(alicePayerKeyForCarol.publicKey())) } + + // Alice pays Bob, with a different payer_key: Bob cannot identify the payer. + val aliceRandomPayerKey = randomKey() + val alicePrivateInvoiceRequestForBob = OfferTypes.InvoiceRequest(bob.offer, 2465.msat, 1, Features.empty, aliceRandomPayerKey, null, Block.RegtestGenesisBlock.hash) + bobContacts.forEach { assertFalse(it.isPayer(alicePrivateInvoiceRequestForBob)) } + bobContacts.forEach { assertFalse(it.isPayer(aliceRandomPayerKey.publicKey())) } + } + +} \ No newline at end of file