Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for Bolt 12 contacts #719

Draft
wants to merge 2 commits into
base: master
Choose a base branch
from
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,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 session key 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
11 changes: 7 additions & 4 deletions modules/core/src/commonMain/kotlin/fr/acinq/lightning/io/Peer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ data class PayInvoice(override val paymentId: UUID, override val amount: MilliSa
val paymentHash: ByteVector32 = paymentDetails.paymentHash
val recipient: PublicKey = paymentDetails.paymentRequest.nodeId
}
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()
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()
// @formatter:on

data class PurgeExpiredPayments(val fromCreatedAt: Long, val toCreatedAt: Long) : PaymentCommand()
Expand Down Expand Up @@ -702,7 +702,10 @@ class Peer(
return res.await()
}

suspend fun payOffer(amount: MilliSatoshi, offer: OfferTypes.Offer, payerKey: PrivateKey, payerNote: String?, fetchInvoiceTimeout: Duration): SendPaymentResult {
/**
* @param contactSecret should only be provided if we'd like to reveal our identity to our contact.
*/
suspend fun payOffer(amount: MilliSatoshi, offer: OfferTypes.Offer, payerKey: PrivateKey, payerNote: String?, contactSecret: ByteVector32?, fetchInvoiceTimeout: Duration): SendPaymentResult {
val res = CompletableDeferred<SendPaymentResult>()
val paymentId = UUID.randomUUID()
this.launch {
Expand All @@ -712,7 +715,7 @@ class Peer(
.first()
)
}
send(PayOffer(paymentId, payerKey, payerNote, amount, offer, fetchInvoiceTimeout))
send(PayOffer(paymentId, payerKey, payerNote, amount, offer, contactSecret, fetchInvoiceTimeout))
return res.await()
}

Expand Down Expand Up @@ -763,7 +766,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
@@ -0,0 +1,112 @@
package fr.acinq.lightning.payment

import fr.acinq.bitcoin.ByteVector32
import fr.acinq.bitcoin.Crypto
import fr.acinq.bitcoin.PublicKey
import fr.acinq.bitcoin.byteVector32
import fr.acinq.lightning.wire.OfferTypes
import io.ktor.utils.io.core.*

/**
* BIP 353 human-readable address of a contact.
*/
data class ContactAddress(val name: String, val domain: String) {
init {
require(name.length < 256) { "bip353 name must be smaller than 256 characters" }
require(domain.length < 256) { "bip353 domain must be smaller than 256 characters" }
}

override fun toString(): String = "$name@$domain"

companion object {
fun fromString(address: String): ContactAddress? {
val parts = address.replace("₿", "").split('@')
return when {
parts.size != 2 -> null
parts.any { it.length > 255 } -> null
else -> ContactAddress(parts.first(), parts.last())
}
}
}
}

/**
* When we receive an invoice_request containing a contact address, we don't immediately fetch the offer from
* the BIP 353 address, because this could otherwise be used as a DoS vector since we haven't received a payment yet.
*
* After receiving the payment, we resolve the BIP 353 address to store the contact.
* In the invoice_request, they committed to the signing key used for their offer.
* We verify that the offer uses this signing key, otherwise the BIP 353 address most likely doesn't belong to them.
*/
data class UnverifiedContactAddress(val address: ContactAddress, val expectedOfferSigningKey: PublicKey) {
/**
* Verify that the offer obtained by resolving the BIP 353 address matches the invoice_request commitment.
* If this returns false, it means that either:
* - the contact address doesn't belong to the node
* - or they changed the signing key of the offer associated with their BIP 353 address
* Since the second case should be very infrequent, it's more likely that the remote node is malicious
* and we shouldn't store them in our contacts list.
*/
fun verify(offer: OfferTypes.Offer): Boolean = expectedOfferSigningKey == offer.issuerId || (offer.paths?.map { it.nodeId }?.toSet() ?: setOf()).contains(expectedOfferSigningKey)
}

/**
* Contact secrets are used to mutually authenticate payments.
*
* The first node to add the other to its contacts list will generate the [primarySecret] and send it when paying.
* If the second node adds the first node to its contacts list from the received payment, it will use the same
* [primarySecret] and both nodes are able to identify payments from each other.
*
* But if the second node independently added the first node to its contacts list, it may have generated a
* different [primarySecret]. Each node has a different [primarySecret], but they will store the other node's
* [primarySecret] in their [additionalRemoteSecrets], which lets them correctly identify payments.
*
* When sending a payment, we must always send the [primarySecret].
* When receiving payments, we must check if the received contact_secret matches either the [primarySecret]
* or any of the [additionalRemoteSecrets].
*/
data class ContactSecrets(val primarySecret: ByteVector32, val additionalRemoteSecrets: Set<ByteVector32>) {
/**
* This function should be used when we attribute an incoming payment to an existing contact.
* This can be necessary when:
* - our contact added us without using the contact_secret we initially sent them
* - our contact is using a different wallet from the one(s) we have already stored
*/
fun addRemoteSecret(remoteSecret: ByteVector32): ContactSecrets {
return this.copy(additionalRemoteSecrets = additionalRemoteSecrets + remoteSecret)
}
}

/**
* Contacts are trusted people to which we may want to reveal our identity when paying them.
* We're also able to figure out when incoming payments have been made by one of our contacts.
* See [bLIP 42](https://github.com/lightning/blips/blob/master/blip-0042.md) for more details.
*/
object Contacts {

/**
* We derive our contact secret deterministically based on our offer and our contact's offer.
* This provides a few interesting properties:
* - if we remove a contact and re-add it using the same offer, we will generate the same contact secret
* - if our contact is using the same deterministic algorithm with a single static offer, they will also generate the same contact secret
*
* Note that this function must only be used when adding a contact that hasn't paid us before.
* If we're adding a contact that paid us before, we must use the contact_secret they sent us,
* which ensures that when we pay them, they'll be able to know it was coming from us (see
* [fromRemoteSecret]).
*/
fun computeContactSecret(ourOffer: OfferTypes.OfferAndKey, theirOffer: OfferTypes.Offer): ContactSecrets {
// If their offer doesn't contain an issuerId, it must contain blinded paths.
val offerNodeId = theirOffer.issuerId ?: theirOffer.paths?.first()?.nodeId!!
val ecdh = offerNodeId.times(ourOffer.privateKey)
val primarySecret = Crypto.sha256("blip42_contact_secret".toByteArray() + ecdh.value.toByteArray()).byteVector32()
return ContactSecrets(primarySecret, setOf())
}

/**
* When adding a contact from which we've received a payment, we must use the contact_secret
* they sent us: this ensures that they'll be able to identify payments coming from us.
*/
fun fromRemoteSecret(remoteSecret: ByteVector32): ContactSecrets = ContactSecrets(remoteSecret, setOf())

}
Original file line number Diff line number Diff line change
Expand Up @@ -457,7 +457,7 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: PaymentsDb) {
}
is PaymentOnion.FinalPayload.Blinded -> {
// We encrypted the payment metadata for ourselves in the blinded path we included in the invoice.
return when (val metadata = OfferPaymentMetadata.fromPathId(nodeParams.nodeId, finalPayload.pathId)) {
return when (val metadata = OfferPaymentMetadata.fromPathId(nodeParams.nodePrivateKey, finalPayload.pathId, paymentPart.paymentHash)) {
null -> {
logger.warning { "invalid path_id: ${finalPayload.pathId.toHex()}" }
Either.Left(rejectPaymentPart(privateKey, paymentPart, null, currentBlockHeight))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,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 All @@ -58,7 +58,13 @@ class OfferManager(val nodeParams: NodeParams, val walletParams: WalletParams, v
* @return invoice requests that must be sent and the corresponding path_id that must be used in case of a timeout.
*/
fun requestInvoice(payOffer: PayOffer): Triple<ByteVector32, List<OnionMessage>, OfferTypes.InvoiceRequest> {
val request = OfferTypes.InvoiceRequest(payOffer.offer, payOffer.amount, 1, nodeParams.features.bolt12Features(), payOffer.payerKey, payOffer.payerNote, nodeParams.chainHash)
// If we're providing our contact secret, it means we're willing to reveal our identity to the recipient.
// We include our own offer to allow them to add us to their contacts list and pay us back.
val contactTlvs = setOfNotNull(
payOffer.contactSecret?.let { OfferTypes.InvoiceRequestContactSecret(it) },
payOffer.contactSecret?.let { localOffers[ByteVector32.Zeroes] }?.let { OfferTypes.InvoiceRequestPayerOffer(it) },
)
val request = OfferTypes.InvoiceRequest(payOffer.offer, payOffer.amount, 1, nodeParams.features.bolt12Features(), payOffer.payerKey, payOffer.payerNote, nodeParams.chainHash, contactTlvs)
val replyPathId = randomBytes32()
pendingInvoiceRequests[replyPathId] = PendingInvoiceRequest(payOffer, request)
// 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.
Expand Down Expand Up @@ -162,7 +168,27 @@ class OfferManager(val nodeParams: NodeParams, val walletParams: WalletParams, v
it.take(63) + "…"
}
}
val pathId = OfferPaymentMetadata.V1(ByteVector32(decrypted.pathId), amount, preimage, request.payerId, truncatedPayerNote, request.quantity, currentTimestampMillis()).toPathId(nodeParams.nodePrivateKey)
// 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.
// If the payer_address is provided, we don't include the payer_offer: we can retrieve it from the DNS.
// Otherwise, we want to include the payer_offer, but we must skip it if it's too large.
val payerOfferSize = request.payerOffer?.let { OfferTypes.Offer.tlvSerializer.write(it.records).size }
val payerOffer = when {
request.payerAddress != null -> null
payerOfferSize != null && payerOfferSize > 300 -> null
else -> request.payerOffer
}
val pathId = OfferPaymentMetadata.V2(
ByteVector32(decrypted.pathId),
amount,
preimage,
request.payerId,
truncatedPayerNote,
request.quantity,
request.contactSecret,
payerOffer,
request.payerAddress,
currentTimestampMillis()
).toPathId(nodeParams.nodePrivateKey)
val recipientPayload = RouteBlindingEncryptedData(TlvStream(RouteBlindingEncryptedDataTlv.PathId(pathId))).write().toByteVector()
val cltvExpiryDelta = remoteChannelUpdates.maxOfOrNull { it.cltvExpiryDelta } ?: walletParams.invoiceDefaultRoutingFees.cltvExpiryDelta
val paymentInfo = OfferTypes.PaymentInfo(
Expand Down
Loading
Loading