Skip to content

Commit

Permalink
Add support for official trampoline payments to blinded paths
Browse files Browse the repository at this point in the history
We update the blinded path TLVs to use the official spec values.
We include blinded paths in the _outer_ onion instead of previously
including them in the _trampoline_ onion. This allows paying nodes
that support trampoline, for whom we can include a trampoline payload.
This lets the sender provide arbitrary TLVs to the recipient, even when
using trampoline.

To verify that the trampoline onion really comes from the intended
sender, the onion packet for the recipient uses an associated data with
a shared secret from the `invoice_request` in its HMAC. Without this
protection, the trampoline node could replace the trampoline onion with
one that it created.
  • Loading branch information
t-bast committed Aug 9, 2024
1 parent be42d28 commit a5e3e16
Show file tree
Hide file tree
Showing 15 changed files with 889 additions and 160 deletions.
2 changes: 1 addition & 1 deletion src/commonMain/kotlin/fr/acinq/lightning/Features.kt
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ sealed class Feature {
object TrampolinePayment : Feature() {
override val rfcName get() = "trampoline_routing"
override val mandatory get() = 56
override val scopes: Set<FeatureScope> get() = setOf(FeatureScope.Init, FeatureScope.Node, FeatureScope.Invoice)
override val scopes: Set<FeatureScope> get() = setOf(FeatureScope.Init, FeatureScope.Node, FeatureScope.Invoice, FeatureScope.Bolt12)
}

// The following features have not been standardised, hence the high feature bits to avoid conflicts.
Expand Down
15 changes: 8 additions & 7 deletions src/commonMain/kotlin/fr/acinq/lightning/crypto/sphinx/Sphinx.kt
Original file line number Diff line number Diff line change
Expand Up @@ -239,21 +239,22 @@ object Sphinx {
/**
* Create an encrypted onion packet that contains payloads for all nodes in the list.
*
* @param sessionKey session key.
* @param publicKeys node public keys (one per node).
* @param payloads payloads (one per node).
* @param associatedData associated data.
* @param packetLength length of the onion-encrypted payload (1300 for payment onions, variable for trampoline onions).
* @param sessionKey session key.
* @param publicKeys node public keys (one per node).
* @param payloads (one per node).
* @param associatedData (optional) associated data used in each hop's mac.
* @param lastPacketAssociatedDataOverride (optional) distinct associated data to use for the last hops' mac.
* @param packetLength length of the onion-encrypted payload (1300 for payment onions, variable for trampoline onions).
* @return An onion packet with all shared secrets. The onion packet can be sent to the first node in the list, and
* the shared secrets (one per node) can be used to parse returned failure messages if needed.
*/
fun create(sessionKey: PrivateKey, publicKeys: List<PublicKey>, payloads: List<ByteArray>, associatedData: ByteVector32?, packetLength: Int): PacketAndSecrets {
fun create(sessionKey: PrivateKey, publicKeys: List<PublicKey>, payloads: List<ByteArray>, associatedData: ByteVector32?, lastPacketAssociatedDataOverride: ByteVector32?, packetLength: Int): PacketAndSecrets {
val (ephemeralPublicKeys, sharedsecrets) = computeEphemeralPublicKeysAndSharedSecrets(sessionKey, publicKeys)
val filler = generateFiller("rho", sharedsecrets.dropLast(1), payloads.dropLast(1), packetLength)

// We deterministically-derive the initial payload bytes: see https://github.com/lightningnetwork/lightning-rfc/pull/697
val startingBytes = generateStream(generateKey("pad", sessionKey.value), packetLength)
val lastPacket = wrap(payloads.last(), associatedData, ephemeralPublicKeys.last(), sharedsecrets.last(), Either.Left(startingBytes.toByteVector()), filler.toByteVector())
val lastPacket = wrap(payloads.last(), lastPacketAssociatedDataOverride ?: associatedData, ephemeralPublicKeys.last(), sharedsecrets.last(), Either.Left(startingBytes.toByteVector()), filler.toByteVector())

tailrec fun loop(hopPayloads: List<ByteArray>, ephKeys: List<PublicKey>, sharedSecrets: List<ByteVector32>, packet: OnionRoutingPacket): OnionRoutingPacket {
return if (hopPayloads.isEmpty()) packet else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ object OnionMessages {
route.blindedNodes.map { it.blindedPublicKey },
payloads,
associatedData = null,
lastPacketAssociatedDataOverride = null,
packetSize
).packet
return Either.Right(OnionMessage(route.blindingKey, packet))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,10 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: PaymentsDb, pri
}
}
}
is PaymentOnion.FinalPayload.TrampolineBlinded -> {
// This should fail when decrypting the onion: this type of payload is only allowed in trampoline onions.
return Either.Left(rejectPaymentPart(privateKey, paymentPart, null, currentBlockHeight))
}
}
}

Expand Down Expand Up @@ -570,6 +574,7 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: PaymentsDb, pri
private fun rejectPaymentPart(privateKey: PrivateKey, paymentPart: PaymentPart, incomingPayment: IncomingPayment?, currentBlockHeight: Int): ProcessAddResult.Rejected {
val failureMsg = when (paymentPart.finalPayload) {
is PaymentOnion.FinalPayload.Blinded -> InvalidOnionBlinding(Sphinx.hash(paymentPart.onionPacket))
is PaymentOnion.FinalPayload.TrampolineBlinded -> InvalidOnionBlinding(Sphinx.hash(paymentPart.onionPacket))
is PaymentOnion.FinalPayload.Standard -> IncorrectOrUnknownPaymentDetails(paymentPart.totalAmount, currentBlockHeight.toLong())
}
val rejectedAction = when (paymentPart) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
package fr.acinq.lightning.payment

import fr.acinq.bitcoin.ByteVector
import fr.acinq.bitcoin.ByteVector32
import fr.acinq.bitcoin.PrivateKey
import fr.acinq.bitcoin.PublicKey
import fr.acinq.bitcoin.*
import fr.acinq.bitcoin.utils.Either
import fr.acinq.bitcoin.utils.flatMap
import fr.acinq.lightning.CltvExpiry
Expand All @@ -14,6 +11,7 @@ import fr.acinq.lightning.crypto.sphinx.Sphinx
import fr.acinq.lightning.crypto.sphinx.Sphinx.hash
import fr.acinq.lightning.utils.msat
import fr.acinq.lightning.wire.*
import io.ktor.utils.io.core.*

object IncomingPaymentPacket {

Expand Down Expand Up @@ -44,7 +42,7 @@ object IncomingPaymentPacket {
val onion = add.fold({ it.finalPacket }, { it.onionRoutingPacket })
return decryptOnion(paymentHash, onion, privateKey, blinding).flatMap { outer ->
when (outer) {
is PaymentOnion.FinalPayload.Standard ->
is PaymentOnion.FinalPayload.Standard -> {
when (val trampolineOnion = outer.records.get<OnionPaymentPayloadTlv.TrampolineOnion>()) {
null -> validate(htlcAmount, htlcExpiry, outer)
else -> {
Expand All @@ -54,25 +52,49 @@ object IncomingPaymentPacket {
is PaymentOnion.FinalPayload.Standard -> validate(htlcAmount, htlcExpiry, outer, innerPayload)
// Blinded trampoline paths are not supported.
is PaymentOnion.FinalPayload.Blinded -> Either.Left(InvalidOnionPayload(0, 0))
is PaymentOnion.FinalPayload.TrampolineBlinded -> Either.Left(InvalidOnionPayload(0, 0))
}
}
}
}
is PaymentOnion.FinalPayload.Blinded -> validate(htlcAmount, htlcExpiry, onion, outer)
}
is PaymentOnion.FinalPayload.Blinded -> {
when (val trampolineOnion = outer.records.get<OnionPaymentPayloadTlv.TrampolineOnion>()) {
null -> validate(htlcAmount, htlcExpiry, onion, outer, innerPayload = null)
else -> {
val associatedData = when (val metadata = OfferPaymentMetadata.fromPathId(privateKey.publicKey(), outer.pathId)) {
null -> paymentHash
else -> {
val onionDecryptionKey = blinding?.let { RouteBlinding.derivePrivateKey(privateKey, it) } ?: privateKey
blindedTrampolineAssociatedData(paymentHash, onionDecryptionKey, metadata.payerKey)
}
}
when (val inner = decryptOnion(associatedData, trampolineOnion.packet, privateKey, blinding)) {
is Either.Left -> Either.Left(inner.value)
is Either.Right -> when (val innerPayload = inner.value) {
is PaymentOnion.FinalPayload.TrampolineBlinded -> validate(htlcAmount, htlcExpiry, onion, outer, innerPayload)
is PaymentOnion.FinalPayload.Blinded -> Either.Left(InvalidOnionPayload(0, 0))
is PaymentOnion.FinalPayload.Standard -> Either.Left(InvalidOnionPayload(0, 0))
}
}
}
}
}
is PaymentOnion.FinalPayload.TrampolineBlinded -> Either.Left(InvalidOnionPayload(OnionPaymentPayloadTlv.PaymentData.tag, 0))
}
}
}

private fun decryptOnion(paymentHash: ByteVector32, packet: OnionRoutingPacket, privateKey: PrivateKey, blinding: PublicKey?): Either<FailureMessage, PaymentOnion.FinalPayload> {
private fun decryptOnion(associatedData: ByteVector32, packet: OnionRoutingPacket, privateKey: PrivateKey, blinding: PublicKey?): Either<FailureMessage, PaymentOnion.FinalPayload> {
val onionDecryptionKey = blinding?.let { RouteBlinding.derivePrivateKey(privateKey, it) } ?: privateKey
return Sphinx.peel(onionDecryptionKey, paymentHash, packet).flatMap { decrypted ->
return Sphinx.peel(onionDecryptionKey, associatedData, packet).flatMap { decrypted ->
when {
!decrypted.isLastPacket -> Either.Left(UnknownNextPeer)
else -> PaymentOnion.PerHopPayload.read(decrypted.payload.toByteArray()).flatMap { tlvs ->
when (val encryptedRecipientData = tlvs.get<OnionPaymentPayloadTlv.EncryptedRecipientData>()?.data) {
null -> when {
blinding != null -> Either.Left(InvalidOnionBlinding(hash(packet)))
tlvs.get<OnionPaymentPayloadTlv.BlindingPoint>() != null -> Either.Left(InvalidOnionBlinding(hash(packet)))
blinding != null -> PaymentOnion.FinalPayload.TrampolineBlinded.read(decrypted.payload)
else -> PaymentOnion.FinalPayload.Standard.read(decrypted.payload)
}
else -> when {
Expand All @@ -97,6 +119,16 @@ object IncomingPaymentPacket {
.flatMap { blindedTlvs -> PaymentOnion.FinalPayload.Blinded.validate(tlvs, blindedTlvs) }
}

/**
* When we're using trampoline with Bolt 12, we expect the payer to include a trampoline payload.
* However, the trampoline node could replace it with a trampoline onion they created.
* To avoid that, we use a shared secret based on the [OfferTypes.InvoiceRequest] to authenticate the payload.
*/
private fun blindedTrampolineAssociatedData(paymentHash: ByteVector32, onionDecryptionKey: PrivateKey, payerId: PublicKey): ByteVector32 {
val invReqSharedSecret = (payerId * onionDecryptionKey).value.toByteArray()
return Crypto.sha256("blinded_trampoline_payment".toByteArray() + paymentHash.toByteArray() + invReqSharedSecret).byteVector32()
}

private fun validate(htlcAmount: MilliSatoshi, htlcExpiry: CltvExpiry, payload: PaymentOnion.FinalPayload.Standard): Either<FailureMessage, PaymentOnion.FinalPayload> {
return when {
htlcAmount < payload.amount -> Either.Left(FinalIncorrectHtlcAmount(htlcAmount))
Expand All @@ -105,15 +137,23 @@ object IncomingPaymentPacket {
}
}

private fun validate(htlcAmount: MilliSatoshi, htlcExpiry: CltvExpiry, onion: OnionRoutingPacket, payload: PaymentOnion.FinalPayload.Blinded): Either<FailureMessage, PaymentOnion.FinalPayload> {
private fun validate(
htlcAmount: MilliSatoshi,
htlcExpiry: CltvExpiry,
onion: OnionRoutingPacket,
outerPayload: PaymentOnion.FinalPayload.Blinded,
innerPayload: PaymentOnion.FinalPayload.TrampolineBlinded?
): Either<FailureMessage, PaymentOnion.FinalPayload> {
val minAmount = listOfNotNull(outerPayload.amount, innerPayload?.amount).max()
val minExpiry = listOfNotNull(outerPayload.expiry, innerPayload?.expiry).max()
return when {
payload.recipientData.paymentConstraints?.let { htlcAmount < it.minAmount } == true -> Either.Left(InvalidOnionBlinding(hash(onion)))
payload.recipientData.paymentConstraints?.let { it.maxCltvExpiry < htlcExpiry } == true -> Either.Left(InvalidOnionBlinding(hash(onion)))
outerPayload.recipientData.paymentConstraints?.let { htlcAmount < it.minAmount } == true -> Either.Left(InvalidOnionBlinding(hash(onion)))
outerPayload.recipientData.paymentConstraints?.let { it.maxCltvExpiry < htlcExpiry } == true -> Either.Left(InvalidOnionBlinding(hash(onion)))
// We currently don't set the allowed_features field in our invoices.
!Features.areCompatible(Features.empty, payload.recipientData.allowedFeatures) -> Either.Left(InvalidOnionBlinding(hash(onion)))
htlcAmount < payload.amount -> Either.Left(InvalidOnionBlinding(hash(onion)))
htlcExpiry < payload.expiry -> Either.Left(InvalidOnionBlinding(hash(onion)))
else -> Either.Right(payload)
!Features.areCompatible(Features.empty, outerPayload.recipientData.allowedFeatures) -> Either.Left(InvalidOnionBlinding(hash(onion)))
htlcAmount < minAmount -> Either.Left(InvalidOnionBlinding(hash(onion)))
htlcExpiry < minExpiry -> Either.Left(InvalidOnionBlinding(hash(onion)))
else -> Either.Right(outerPayload)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ sealed class OfferPaymentMetadata {
abstract val offerId: ByteVector32
abstract val amount: MilliSatoshi
abstract val preimage: ByteVector32
abstract val payerKey: PublicKey
abstract val createdAtMillis: Long
val paymentHash: ByteVector32 get() = preimage.sha256()

Expand Down Expand Up @@ -55,7 +56,7 @@ sealed class OfferPaymentMetadata {
override val offerId: ByteVector32,
override val amount: MilliSatoshi,
override val preimage: ByteVector32,
val payerKey: PublicKey,
override val payerKey: PublicKey,
val payerNote: String?,
val quantity: Long,
override val createdAtMillis: Long
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -336,26 +336,27 @@ class OutgoingPaymentHandler(val nodeParams: NodeParams, val walletParams: Walle
}

private fun createPaymentOnion(request: PayInvoice, hop: NodeHop, currentBlockHeight: Int): Triple<MilliSatoshi, CltvExpiry, PacketAndSecrets> {
return when (val paymentRequest = request.paymentDetails.paymentRequest) {
is Bolt11Invoice -> {
val minFinalExpiryDelta = paymentRequest.minFinalExpiryDelta ?: Channel.MIN_CLTV_EXPIRY_DELTA
return when (val details = request.paymentDetails) {
is LightningOutgoingPayment.Details.Normal -> {
val minFinalExpiryDelta = details.paymentRequest.minFinalExpiryDelta ?: Channel.MIN_CLTV_EXPIRY_DELTA
val expiry = nodeParams.paymentRecipientExpiryParams.computeFinalExpiry(currentBlockHeight, minFinalExpiryDelta)
val invoiceFeatures = paymentRequest.features
val invoiceFeatures = details.paymentRequest.features
if (request.recipient == walletParams.trampolineNode.id) {
// We are directly paying our trampoline node.
OutgoingPaymentPacket.buildPacketToTrampolinePeer(paymentRequest, request.amount, expiry)
OutgoingPaymentPacket.buildPacketToTrampolinePeer(details.paymentRequest, request.amount, expiry)
} else if (invoiceFeatures.hasFeature(Feature.TrampolinePayment)) {
OutgoingPaymentPacket.buildPacketToTrampolineRecipient(paymentRequest, request.amount, expiry, hop)
OutgoingPaymentPacket.buildPacketToTrampolineRecipient(details.paymentRequest, request.amount, expiry, hop)
} else {
OutgoingPaymentPacket.buildPacketToLegacyRecipient(paymentRequest, request.amount, expiry, hop)
OutgoingPaymentPacket.buildPacketToLegacyRecipient(details.paymentRequest, request.amount, expiry, hop)
}
}
is Bolt12Invoice -> {
is LightningOutgoingPayment.Details.Blinded -> {
// The recipient already included a final cltv-expiry-delta in their invoice blinded paths.
val minFinalExpiryDelta = CltvExpiryDelta(0)
val expiry = nodeParams.paymentRecipientExpiryParams.computeFinalExpiry(currentBlockHeight, minFinalExpiryDelta)
OutgoingPaymentPacket.buildPacketToBlindedRecipient(paymentRequest, request.amount, expiry, hop)
OutgoingPaymentPacket.buildPacketToBlindedRecipient(details.paymentRequest, details.payerKey, request.amount, expiry, hop)
}
is LightningOutgoingPayment.Details.SwapOut -> error("invalid lightning payment details (legacy swap out)")
}
}

Expand Down
Loading

0 comments on commit a5e3e16

Please sign in to comment.