Skip to content

Commit 8837d91

Browse files
committed
Add support for Bolt 12 contacts
Add support for contacts as specified in bLIP 42. Contacts are mutually authenticated using a 32-bytes random secret generated when first adding a node to our contacts. When paying contacts, we include our own payment information to allow them to pay us back and us to their contacts. The benefit of this design is that offers stay private by default (they don't include any contact information). It's only when we pay someone we trust that we reveal contact information (which they are free to ignore). The drawback of this design is that if when both nodes independently add each other to their contacts list, they generate a different contact secret: users must manually associate incoming payments to an existing contact to correctly identify incoming payments (by storing multiple secrets for such contacts). This also happens when contacts use multiple wallets, which will all use different contact secrets. I think this is an acceptable trade-off to preserve privacy by default. More details in the bLIP: lightning/blips#42
1 parent 4b2ffbc commit 8837d91

File tree

9 files changed

+580
-58
lines changed

9 files changed

+580
-58
lines changed

modules/core/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ data class PayInvoice(override val paymentId: UUID, override val amount: MilliSa
142142
val paymentHash: ByteVector32 = paymentDetails.paymentHash
143143
val recipient: PublicKey = paymentDetails.paymentRequest.nodeId
144144
}
145-
data class PayOffer(override val paymentId: UUID, val payerKey: PrivateKey, val payerNote: String?, override val amount: MilliSatoshi, val offer: OfferTypes.Offer, val fetchInvoiceTimeout: Duration, val trampolineFeesOverride: List<TrampolineFees>? = null) : SendPayment()
145+
data class PayOffer(override val paymentId: UUID, val payerKey: PrivateKey, val payerNote: String?, override val amount: MilliSatoshi, val offer: OfferTypes.Offer, val contactSecret: ByteVector32?, val fetchInvoiceTimeout: Duration, val trampolineFeesOverride: List<TrampolineFees>? = null) : SendPayment()
146146
// @formatter:on
147147

148148
data class PurgeExpiredPayments(val fromCreatedAt: Long, val toCreatedAt: Long) : PaymentCommand()
@@ -798,7 +798,10 @@ class Peer(
798798
return res.await()
799799
}
800800

801-
suspend fun payOffer(amount: MilliSatoshi, offer: OfferTypes.Offer, payerKey: PrivateKey, payerNote: String?, fetchInvoiceTimeout: Duration): SendPaymentResult {
801+
/**
802+
* @param contactSecret should only be provided if we'd like to reveal our identity to our contact.
803+
*/
804+
suspend fun payOffer(amount: MilliSatoshi, offer: OfferTypes.Offer, payerKey: PrivateKey, payerNote: String?, contactSecret: ByteVector32?, fetchInvoiceTimeout: Duration): SendPaymentResult {
802805
val res = CompletableDeferred<SendPaymentResult>()
803806
val paymentId = UUID.randomUUID()
804807
this.launch {
@@ -809,7 +812,7 @@ class Peer(
809812
.first()
810813
)
811814
}
812-
send(PayOffer(paymentId, payerKey, payerNote, amount, offer, fetchInvoiceTimeout))
815+
send(PayOffer(paymentId, payerKey, payerNote, amount, offer, contactSecret, fetchInvoiceTimeout))
813816
return res.await()
814817
}
815818

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package fr.acinq.lightning.payment
2+
3+
import fr.acinq.bitcoin.ByteVector32
4+
import fr.acinq.bitcoin.Crypto
5+
import fr.acinq.bitcoin.byteVector32
6+
import fr.acinq.lightning.wire.OfferTypes
7+
import io.ktor.utils.io.core.*
8+
9+
/**
10+
* BIP 353 human-readable address of a contact.
11+
*/
12+
data class ContactAddress(val name: String, val domain: String) {
13+
init {
14+
require(name.length < 256) { "bip353 name must be smaller than 256 characters" }
15+
require(domain.length < 256) { "bip353 domain must be smaller than 256 characters" }
16+
}
17+
18+
override fun toString(): String = "$name@$domain"
19+
20+
companion object {
21+
fun fromString(address: String): ContactAddress? {
22+
val parts = address.replace("", "").split('@')
23+
return when {
24+
parts.size != 2 -> null
25+
parts.any { it.length > 255 } -> null
26+
else -> ContactAddress(parts.first(), parts.last())
27+
}
28+
}
29+
}
30+
}
31+
32+
/**
33+
* Contact secrets are used to mutually authenticate payments.
34+
*
35+
* The first node to add the other to its contacts list will generate the [primarySecret] and send it when paying.
36+
* If the second node adds the first node to its contacts list from the received payment, it will use the same
37+
* [primarySecret] and both nodes are able to identify payments from each other.
38+
*
39+
* But if the second node independently added the first node to its contacts list, it may have generated a
40+
* different [primarySecret]. Each node has a different [primarySecret], but they will store the other node's
41+
* [primarySecret] in their [additionalRemoteSecrets], which lets them correctly identify payments.
42+
*
43+
* When sending a payment, we must always send the [primarySecret].
44+
* When receiving payments, we must check if the received contact_secret matches either the [primarySecret]
45+
* or any of the [additionalRemoteSecrets].
46+
*/
47+
data class ContactSecrets(val primarySecret: ByteVector32, val additionalRemoteSecrets: Set<ByteVector32>) {
48+
/**
49+
* This function should be used when we attribute an incoming payment to an existing contact.
50+
* This can be necessary when:
51+
* - our contact added us without using the contact_secret we initially sent them
52+
* - our contact is using a different wallet from the one(s) we have already stored
53+
*/
54+
fun addRemoteSecret(remoteSecret: ByteVector32): ContactSecrets {
55+
return this.copy(additionalRemoteSecrets = additionalRemoteSecrets + remoteSecret)
56+
}
57+
}
58+
59+
/**
60+
* Contacts are trusted people to which we may want to reveal our identity when paying them.
61+
* We're also able to figure out when incoming payments have been made by one of our contacts.
62+
* See [bLIP 42](https://github.com/lightning/blips/blob/master/blip-0042.md) for more details.
63+
*/
64+
object Contacts {
65+
66+
/**
67+
* We derive our contact secret deterministically based on our offer and our contact's offer.
68+
* This provides a few interesting properties:
69+
* - if we remove a contact and re-add it using the same offer, we will generate the same contact secret
70+
* - if our contact is using the same deterministic algorithm with a single static offer, they will also generate the same contact secret
71+
*
72+
* Note that this function must only be used when adding a contact that hasn't paid us before.
73+
* If we're adding a contact that paid us before, we must use the contact_secret they sent us,
74+
* which ensures that when we pay them, they'll be able to know it was coming from us (see
75+
* [fromRemoteSecret]).
76+
*/
77+
fun computeContactSecret(ourOffer: OfferTypes.OfferAndKey, theirOffer: OfferTypes.Offer): ContactSecrets {
78+
// If their offer doesn't contain an issuerId, it must contain blinded paths.
79+
val offerNodeId = theirOffer.issuerId ?: theirOffer.paths?.first()?.nodeId!!
80+
val ecdh = offerNodeId.times(ourOffer.privateKey)
81+
val primarySecret = Crypto.sha256("blip42_contact_secret".toByteArray() + ecdh.value.toByteArray()).byteVector32()
82+
return ContactSecrets(primarySecret, setOf())
83+
}
84+
85+
/**
86+
* When adding a contact from which we've received a payment, we must use the contact_secret
87+
* they sent us: this ensures that they'll be able to identify payments coming from us.
88+
*/
89+
fun fromRemoteSecret(remoteSecret: ByteVector32): ContactSecrets = ContactSecrets(remoteSecret, setOf())
90+
91+
}

modules/core/src/commonMain/kotlin/fr/acinq/lightning/payment/OfferManager.kt

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,13 @@ class OfferManager(val nodeParams: NodeParams, val walletParams: WalletParams, v
4949
* @return invoice requests that must be sent and the corresponding path_id that must be used in case of a timeout.
5050
*/
5151
fun requestInvoice(payOffer: PayOffer): Triple<ByteVector32, List<OnionMessage>, OfferTypes.InvoiceRequest> {
52-
val request = OfferTypes.InvoiceRequest(payOffer.offer, payOffer.amount, 1, nodeParams.features.bolt12Features(), payOffer.payerKey, payOffer.payerNote, nodeParams.chainHash)
52+
// If we're providing our contact secret, it means we're willing to reveal our identity to the recipient.
53+
// We include our own offer to allow them to add us to their contacts list and pay us back.
54+
val contactTlvs = setOfNotNull(
55+
payOffer.contactSecret?.let { OfferTypes.InvoiceRequestContactSecret(it) },
56+
payOffer.contactSecret?.let { OfferTypes.InvoiceRequestPayerOffer(nodeParams.defaultOffer(walletParams.trampolineNode.id).offer) },
57+
)
58+
val request = OfferTypes.InvoiceRequest(payOffer.offer, payOffer.amount, 1, nodeParams.features.bolt12Features(), payOffer.payerKey, payOffer.payerNote, nodeParams.chainHash, contactTlvs)
5359
val replyPathId = randomBytes32()
5460
pendingInvoiceRequests[replyPathId] = PendingInvoiceRequest(payOffer, request)
5561
// We add dummy hops to the reply path: this way the receiver only learns that we're at most 3 hops away from our peer.
@@ -130,7 +136,14 @@ class OfferManager(val nodeParams: NodeParams, val walletParams: WalletParams, v
130136
}
131137
}
132138

133-
private fun receiveInvoiceRequest(request: OfferTypes.InvoiceRequest, pathId: ByteVector?, blindedPrivateKey: PrivateKey, replyPath: RouteBlinding.BlindedRoute?, remoteChannelUpdates: List<ChannelUpdate>, currentBlockHeight: Int): OnionMessageAction.SendMessage? {
139+
private fun receiveInvoiceRequest(
140+
request: OfferTypes.InvoiceRequest,
141+
pathId: ByteVector?,
142+
blindedPrivateKey: PrivateKey,
143+
replyPath: RouteBlinding.BlindedRoute?,
144+
remoteChannelUpdates: List<ChannelUpdate>,
145+
currentBlockHeight: Int
146+
): OnionMessageAction.SendMessage? {
134147
// We must use the most restrictive minimum HTLC value between local and remote.
135148
val minHtlc = (listOf(nodeParams.htlcMinimum) + remoteChannelUpdates.map { it.htlcMinimumMsat }).max()
136149
return when {
@@ -155,7 +168,16 @@ class OfferManager(val nodeParams: NodeParams, val walletParams: WalletParams, v
155168
val preimage = randomBytes32()
156169
val (truncatedPayerNote, truncatedDescription) = OfferPaymentMetadata.truncateNotes(request.payerNote, request.offer.description)
157170
val expirySeconds = request.offer.expirySeconds ?: nodeParams.bolt12InvoiceExpiry.inWholeSeconds
158-
val metadata = OfferPaymentMetadata.V2(
171+
// We mustn't use too much space in the path_id, otherwise the sender won't be able to include it in its payment onion.
172+
// If the payer_address is provided, we don't include the payer_offer: we can retrieve it from the DNS.
173+
// Otherwise, we want to include the payer_offer, but we must skip it if it's too large.
174+
val payerOfferSize = request.payerOffer?.let { OfferTypes.Offer.tlvSerializer.write(it.records).size }
175+
val payerOffer = when {
176+
request.payerAddress != null -> null
177+
payerOfferSize != null && payerOfferSize > 300 -> null
178+
else -> request.payerOffer
179+
}
180+
val metadata = OfferPaymentMetadata.V3(
159181
offerId = request.offer.offerId,
160182
amount = amount,
161183
preimage = preimage,
@@ -164,7 +186,10 @@ class OfferManager(val nodeParams: NodeParams, val walletParams: WalletParams, v
164186
description = truncatedDescription,
165187
payerKey = request.payerId,
166188
payerNote = truncatedPayerNote,
167-
quantity = request.quantity_opt
189+
quantity = request.quantity_opt,
190+
contactSecret = request.contactSecret,
191+
payerOffer = payerOffer,
192+
payerAddress = request.payerAddress,
168193
).toPathId(nodeParams.nodePrivateKey)
169194
val recipientPayload = RouteBlindingEncryptedData(TlvStream(RouteBlindingEncryptedDataTlv.PathId(metadata))).write().toByteVector()
170195
val cltvExpiryDelta = remoteChannelUpdates.maxOfOrNull { it.cltvExpiryDelta } ?: walletParams.invoiceDefaultRoutingFees.cltvExpiryDelta

0 commit comments

Comments
 (0)