Skip to content
Open
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 @@ -143,7 +143,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 @@ -826,7 +826,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 @@ -837,7 +840,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
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 @@ -49,7 +49,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 { OfferTypes.InvoiceRequestPayerOffer(nodeParams.defaultOffer(walletParams.trampolineNode.id).offer) },
)
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 @@ -130,7 +136,14 @@ class OfferManager(val nodeParams: NodeParams, val walletParams: WalletParams, v
}
}

private fun receiveInvoiceRequest(request: OfferTypes.InvoiceRequest, pathId: ByteVector?, blindedPrivateKey: PrivateKey, replyPath: RouteBlinding.BlindedRoute?, remoteChannelUpdates: List<ChannelUpdate>, currentBlockHeight: Int): OnionMessageAction.SendMessage? {
private fun receiveInvoiceRequest(
request: OfferTypes.InvoiceRequest,
pathId: ByteVector?,
blindedPrivateKey: PrivateKey,
replyPath: RouteBlinding.BlindedRoute?,
remoteChannelUpdates: List<ChannelUpdate>,
currentBlockHeight: Int
): OnionMessageAction.SendMessage? {
// We must use the most restrictive minimum HTLC value between local and remote.
val minHtlc = (listOf(nodeParams.htlcMinimum) + remoteChannelUpdates.map { it.htlcMinimumMsat }).max()
return when {
Expand All @@ -155,7 +168,16 @@ class OfferManager(val nodeParams: NodeParams, val walletParams: WalletParams, v
val preimage = randomBytes32()
val (truncatedPayerNote, truncatedDescription) = OfferPaymentMetadata.truncateNotes(request.payerNote, request.offer.description)
val expirySeconds = request.offer.expirySeconds ?: nodeParams.bolt12InvoiceExpiry.inWholeSeconds
val metadata = OfferPaymentMetadata.V2(
// 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 metadata = OfferPaymentMetadata.V3(
offerId = request.offer.offerId,
amount = amount,
preimage = preimage,
Expand All @@ -164,7 +186,10 @@ class OfferManager(val nodeParams: NodeParams, val walletParams: WalletParams, v
description = truncatedDescription,
payerKey = request.payerId,
payerNote = truncatedPayerNote,
quantity = request.quantity_opt
quantity = request.quantity_opt,
contactSecret = request.contactSecret,
payerOffer = payerOffer,
payerAddress = request.payerAddress,
).toPathId(nodeParams.nodePrivateKey)
val recipientPayload = RouteBlindingEncryptedData(TlvStream(RouteBlindingEncryptedDataTlv.PathId(metadata))).write().toByteVector()
val cltvExpiryDelta = remoteChannelUpdates.maxOfOrNull { it.cltvExpiryDelta } ?: walletParams.invoiceDefaultRoutingFees.cltvExpiryDelta
Expand Down
Loading
Loading