Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 1 addition & 1 deletion src/commonMain/kotlin/fr/acinq/lightning/NodeParams.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<OfferTypes.Offer, PrivateKey> {
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
Expand Down
2 changes: 1 addition & 1 deletion src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ class OfferManager(val nodeParams: NodeParams, val walletParams: WalletParams, v
private val localOffers: HashMap<ByteVector32, OfferTypes.Offer> = 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?) {
Expand Down
45 changes: 45 additions & 0 deletions src/commonMain/kotlin/fr/acinq/lightning/payment/TrustedContact.kt
Original file line number Diff line number Diff line change
@@ -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<PublicKey>) {
/**
* 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 conact. */
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()))
}
}
}
7 changes: 5 additions & 2 deletions src/commonMain/kotlin/fr/acinq/lightning/wire/OfferTypes.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<OfferTlv>) {
val chains: List<BlockHash> = records.get<OfferChains>()?.chains ?: listOf(Block.LivenetGenesisBlock.hash)
val metadata: ByteVector? = records.get<OfferMetadata>()?.data
Expand Down Expand Up @@ -789,7 +792,7 @@ object OfferTypes {
blindingSecret: PrivateKey,
additionalTlvs: Set<OfferTlv> = setOf(),
customTlvs: Set<GenericTlv> = setOf()
): Pair<Offer, PrivateKey> {
): 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<OfferTlv> = setOfNotNull(
Expand All @@ -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<OfferTlv>): Either<InvalidTlvPayload, Offer> {
Expand Down
Original file line number Diff line number Diff line change
@@ -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())) }
}

}