Skip to content

Commit a5e3e16

Browse files
committed
Add support for official trampoline payments to blinded paths
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.
1 parent be42d28 commit a5e3e16

15 files changed

+889
-160
lines changed

src/commonMain/kotlin/fr/acinq/lightning/Features.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ sealed class Feature {
151151
object TrampolinePayment : Feature() {
152152
override val rfcName get() = "trampoline_routing"
153153
override val mandatory get() = 56
154-
override val scopes: Set<FeatureScope> get() = setOf(FeatureScope.Init, FeatureScope.Node, FeatureScope.Invoice)
154+
override val scopes: Set<FeatureScope> get() = setOf(FeatureScope.Init, FeatureScope.Node, FeatureScope.Invoice, FeatureScope.Bolt12)
155155
}
156156

157157
// The following features have not been standardised, hence the high feature bits to avoid conflicts.

src/commonMain/kotlin/fr/acinq/lightning/crypto/sphinx/Sphinx.kt

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -239,21 +239,22 @@ object Sphinx {
239239
/**
240240
* Create an encrypted onion packet that contains payloads for all nodes in the list.
241241
*
242-
* @param sessionKey session key.
243-
* @param publicKeys node public keys (one per node).
244-
* @param payloads payloads (one per node).
245-
* @param associatedData associated data.
246-
* @param packetLength length of the onion-encrypted payload (1300 for payment onions, variable for trampoline onions).
242+
* @param sessionKey session key.
243+
* @param publicKeys node public keys (one per node).
244+
* @param payloads (one per node).
245+
* @param associatedData (optional) associated data used in each hop's mac.
246+
* @param lastPacketAssociatedDataOverride (optional) distinct associated data to use for the last hops' mac.
247+
* @param packetLength length of the onion-encrypted payload (1300 for payment onions, variable for trampoline onions).
247248
* @return An onion packet with all shared secrets. The onion packet can be sent to the first node in the list, and
248249
* the shared secrets (one per node) can be used to parse returned failure messages if needed.
249250
*/
250-
fun create(sessionKey: PrivateKey, publicKeys: List<PublicKey>, payloads: List<ByteArray>, associatedData: ByteVector32?, packetLength: Int): PacketAndSecrets {
251+
fun create(sessionKey: PrivateKey, publicKeys: List<PublicKey>, payloads: List<ByteArray>, associatedData: ByteVector32?, lastPacketAssociatedDataOverride: ByteVector32?, packetLength: Int): PacketAndSecrets {
251252
val (ephemeralPublicKeys, sharedsecrets) = computeEphemeralPublicKeysAndSharedSecrets(sessionKey, publicKeys)
252253
val filler = generateFiller("rho", sharedsecrets.dropLast(1), payloads.dropLast(1), packetLength)
253254

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

258259
tailrec fun loop(hopPayloads: List<ByteArray>, ephKeys: List<PublicKey>, sharedSecrets: List<ByteVector32>, packet: OnionRoutingPacket): OnionRoutingPacket {
259260
return if (hopPayloads.isEmpty()) packet else {

src/commonMain/kotlin/fr/acinq/lightning/message/OnionMessages.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ object OnionMessages {
142142
route.blindedNodes.map { it.blindedPublicKey },
143143
payloads,
144144
associatedData = null,
145+
lastPacketAssociatedDataOverride = null,
145146
packetSize
146147
).packet
147148
return Either.Right(OnionMessage(route.blindingKey, packet))

src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,10 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: PaymentsDb, pri
497497
}
498498
}
499499
}
500+
is PaymentOnion.FinalPayload.TrampolineBlinded -> {
501+
// This should fail when decrypting the onion: this type of payload is only allowed in trampoline onions.
502+
return Either.Left(rejectPaymentPart(privateKey, paymentPart, null, currentBlockHeight))
503+
}
500504
}
501505
}
502506

@@ -570,6 +574,7 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: PaymentsDb, pri
570574
private fun rejectPaymentPart(privateKey: PrivateKey, paymentPart: PaymentPart, incomingPayment: IncomingPayment?, currentBlockHeight: Int): ProcessAddResult.Rejected {
571575
val failureMsg = when (paymentPart.finalPayload) {
572576
is PaymentOnion.FinalPayload.Blinded -> InvalidOnionBlinding(Sphinx.hash(paymentPart.onionPacket))
577+
is PaymentOnion.FinalPayload.TrampolineBlinded -> InvalidOnionBlinding(Sphinx.hash(paymentPart.onionPacket))
573578
is PaymentOnion.FinalPayload.Standard -> IncorrectOrUnknownPaymentDetails(paymentPart.totalAmount, currentBlockHeight.toLong())
574579
}
575580
val rejectedAction = when (paymentPart) {

src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentPacket.kt

Lines changed: 56 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
package fr.acinq.lightning.payment
22

3-
import fr.acinq.bitcoin.ByteVector
4-
import fr.acinq.bitcoin.ByteVector32
5-
import fr.acinq.bitcoin.PrivateKey
6-
import fr.acinq.bitcoin.PublicKey
3+
import fr.acinq.bitcoin.*
74
import fr.acinq.bitcoin.utils.Either
85
import fr.acinq.bitcoin.utils.flatMap
96
import fr.acinq.lightning.CltvExpiry
@@ -14,6 +11,7 @@ import fr.acinq.lightning.crypto.sphinx.Sphinx
1411
import fr.acinq.lightning.crypto.sphinx.Sphinx.hash
1512
import fr.acinq.lightning.utils.msat
1613
import fr.acinq.lightning.wire.*
14+
import io.ktor.utils.io.core.*
1715

1816
object IncomingPaymentPacket {
1917

@@ -44,7 +42,7 @@ object IncomingPaymentPacket {
4442
val onion = add.fold({ it.finalPacket }, { it.onionRoutingPacket })
4543
return decryptOnion(paymentHash, onion, privateKey, blinding).flatMap { outer ->
4644
when (outer) {
47-
is PaymentOnion.FinalPayload.Standard ->
45+
is PaymentOnion.FinalPayload.Standard -> {
4846
when (val trampolineOnion = outer.records.get<OnionPaymentPayloadTlv.TrampolineOnion>()) {
4947
null -> validate(htlcAmount, htlcExpiry, outer)
5048
else -> {
@@ -54,25 +52,49 @@ object IncomingPaymentPacket {
5452
is PaymentOnion.FinalPayload.Standard -> validate(htlcAmount, htlcExpiry, outer, innerPayload)
5553
// Blinded trampoline paths are not supported.
5654
is PaymentOnion.FinalPayload.Blinded -> Either.Left(InvalidOnionPayload(0, 0))
55+
is PaymentOnion.FinalPayload.TrampolineBlinded -> Either.Left(InvalidOnionPayload(0, 0))
5756
}
5857
}
5958
}
6059
}
61-
is PaymentOnion.FinalPayload.Blinded -> validate(htlcAmount, htlcExpiry, onion, outer)
60+
}
61+
is PaymentOnion.FinalPayload.Blinded -> {
62+
when (val trampolineOnion = outer.records.get<OnionPaymentPayloadTlv.TrampolineOnion>()) {
63+
null -> validate(htlcAmount, htlcExpiry, onion, outer, innerPayload = null)
64+
else -> {
65+
val associatedData = when (val metadata = OfferPaymentMetadata.fromPathId(privateKey.publicKey(), outer.pathId)) {
66+
null -> paymentHash
67+
else -> {
68+
val onionDecryptionKey = blinding?.let { RouteBlinding.derivePrivateKey(privateKey, it) } ?: privateKey
69+
blindedTrampolineAssociatedData(paymentHash, onionDecryptionKey, metadata.payerKey)
70+
}
71+
}
72+
when (val inner = decryptOnion(associatedData, trampolineOnion.packet, privateKey, blinding)) {
73+
is Either.Left -> Either.Left(inner.value)
74+
is Either.Right -> when (val innerPayload = inner.value) {
75+
is PaymentOnion.FinalPayload.TrampolineBlinded -> validate(htlcAmount, htlcExpiry, onion, outer, innerPayload)
76+
is PaymentOnion.FinalPayload.Blinded -> Either.Left(InvalidOnionPayload(0, 0))
77+
is PaymentOnion.FinalPayload.Standard -> Either.Left(InvalidOnionPayload(0, 0))
78+
}
79+
}
80+
}
81+
}
82+
}
83+
is PaymentOnion.FinalPayload.TrampolineBlinded -> Either.Left(InvalidOnionPayload(OnionPaymentPayloadTlv.PaymentData.tag, 0))
6284
}
6385
}
6486
}
6587

66-
private fun decryptOnion(paymentHash: ByteVector32, packet: OnionRoutingPacket, privateKey: PrivateKey, blinding: PublicKey?): Either<FailureMessage, PaymentOnion.FinalPayload> {
88+
private fun decryptOnion(associatedData: ByteVector32, packet: OnionRoutingPacket, privateKey: PrivateKey, blinding: PublicKey?): Either<FailureMessage, PaymentOnion.FinalPayload> {
6789
val onionDecryptionKey = blinding?.let { RouteBlinding.derivePrivateKey(privateKey, it) } ?: privateKey
68-
return Sphinx.peel(onionDecryptionKey, paymentHash, packet).flatMap { decrypted ->
90+
return Sphinx.peel(onionDecryptionKey, associatedData, packet).flatMap { decrypted ->
6991
when {
7092
!decrypted.isLastPacket -> Either.Left(UnknownNextPeer)
7193
else -> PaymentOnion.PerHopPayload.read(decrypted.payload.toByteArray()).flatMap { tlvs ->
7294
when (val encryptedRecipientData = tlvs.get<OnionPaymentPayloadTlv.EncryptedRecipientData>()?.data) {
7395
null -> when {
74-
blinding != null -> Either.Left(InvalidOnionBlinding(hash(packet)))
7596
tlvs.get<OnionPaymentPayloadTlv.BlindingPoint>() != null -> Either.Left(InvalidOnionBlinding(hash(packet)))
97+
blinding != null -> PaymentOnion.FinalPayload.TrampolineBlinded.read(decrypted.payload)
7698
else -> PaymentOnion.FinalPayload.Standard.read(decrypted.payload)
7799
}
78100
else -> when {
@@ -97,6 +119,16 @@ object IncomingPaymentPacket {
97119
.flatMap { blindedTlvs -> PaymentOnion.FinalPayload.Blinded.validate(tlvs, blindedTlvs) }
98120
}
99121

122+
/**
123+
* When we're using trampoline with Bolt 12, we expect the payer to include a trampoline payload.
124+
* However, the trampoline node could replace it with a trampoline onion they created.
125+
* To avoid that, we use a shared secret based on the [OfferTypes.InvoiceRequest] to authenticate the payload.
126+
*/
127+
private fun blindedTrampolineAssociatedData(paymentHash: ByteVector32, onionDecryptionKey: PrivateKey, payerId: PublicKey): ByteVector32 {
128+
val invReqSharedSecret = (payerId * onionDecryptionKey).value.toByteArray()
129+
return Crypto.sha256("blinded_trampoline_payment".toByteArray() + paymentHash.toByteArray() + invReqSharedSecret).byteVector32()
130+
}
131+
100132
private fun validate(htlcAmount: MilliSatoshi, htlcExpiry: CltvExpiry, payload: PaymentOnion.FinalPayload.Standard): Either<FailureMessage, PaymentOnion.FinalPayload> {
101133
return when {
102134
htlcAmount < payload.amount -> Either.Left(FinalIncorrectHtlcAmount(htlcAmount))
@@ -105,15 +137,23 @@ object IncomingPaymentPacket {
105137
}
106138
}
107139

108-
private fun validate(htlcAmount: MilliSatoshi, htlcExpiry: CltvExpiry, onion: OnionRoutingPacket, payload: PaymentOnion.FinalPayload.Blinded): Either<FailureMessage, PaymentOnion.FinalPayload> {
140+
private fun validate(
141+
htlcAmount: MilliSatoshi,
142+
htlcExpiry: CltvExpiry,
143+
onion: OnionRoutingPacket,
144+
outerPayload: PaymentOnion.FinalPayload.Blinded,
145+
innerPayload: PaymentOnion.FinalPayload.TrampolineBlinded?
146+
): Either<FailureMessage, PaymentOnion.FinalPayload> {
147+
val minAmount = listOfNotNull(outerPayload.amount, innerPayload?.amount).max()
148+
val minExpiry = listOfNotNull(outerPayload.expiry, innerPayload?.expiry).max()
109149
return when {
110-
payload.recipientData.paymentConstraints?.let { htlcAmount < it.minAmount } == true -> Either.Left(InvalidOnionBlinding(hash(onion)))
111-
payload.recipientData.paymentConstraints?.let { it.maxCltvExpiry < htlcExpiry } == true -> Either.Left(InvalidOnionBlinding(hash(onion)))
150+
outerPayload.recipientData.paymentConstraints?.let { htlcAmount < it.minAmount } == true -> Either.Left(InvalidOnionBlinding(hash(onion)))
151+
outerPayload.recipientData.paymentConstraints?.let { it.maxCltvExpiry < htlcExpiry } == true -> Either.Left(InvalidOnionBlinding(hash(onion)))
112152
// We currently don't set the allowed_features field in our invoices.
113-
!Features.areCompatible(Features.empty, payload.recipientData.allowedFeatures) -> Either.Left(InvalidOnionBlinding(hash(onion)))
114-
htlcAmount < payload.amount -> Either.Left(InvalidOnionBlinding(hash(onion)))
115-
htlcExpiry < payload.expiry -> Either.Left(InvalidOnionBlinding(hash(onion)))
116-
else -> Either.Right(payload)
153+
!Features.areCompatible(Features.empty, outerPayload.recipientData.allowedFeatures) -> Either.Left(InvalidOnionBlinding(hash(onion)))
154+
htlcAmount < minAmount -> Either.Left(InvalidOnionBlinding(hash(onion)))
155+
htlcExpiry < minExpiry -> Either.Left(InvalidOnionBlinding(hash(onion)))
156+
else -> Either.Right(outerPayload)
117157
}
118158
}
119159

src/commonMain/kotlin/fr/acinq/lightning/payment/OfferPaymentMetadata.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ sealed class OfferPaymentMetadata {
2828
abstract val offerId: ByteVector32
2929
abstract val amount: MilliSatoshi
3030
abstract val preimage: ByteVector32
31+
abstract val payerKey: PublicKey
3132
abstract val createdAtMillis: Long
3233
val paymentHash: ByteVector32 get() = preimage.sha256()
3334

@@ -55,7 +56,7 @@ sealed class OfferPaymentMetadata {
5556
override val offerId: ByteVector32,
5657
override val amount: MilliSatoshi,
5758
override val preimage: ByteVector32,
58-
val payerKey: PublicKey,
59+
override val payerKey: PublicKey,
5960
val payerNote: String?,
6061
val quantity: Long,
6162
override val createdAtMillis: Long

src/commonMain/kotlin/fr/acinq/lightning/payment/OutgoingPaymentHandler.kt

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -336,26 +336,27 @@ class OutgoingPaymentHandler(val nodeParams: NodeParams, val walletParams: Walle
336336
}
337337

338338
private fun createPaymentOnion(request: PayInvoice, hop: NodeHop, currentBlockHeight: Int): Triple<MilliSatoshi, CltvExpiry, PacketAndSecrets> {
339-
return when (val paymentRequest = request.paymentDetails.paymentRequest) {
340-
is Bolt11Invoice -> {
341-
val minFinalExpiryDelta = paymentRequest.minFinalExpiryDelta ?: Channel.MIN_CLTV_EXPIRY_DELTA
339+
return when (val details = request.paymentDetails) {
340+
is LightningOutgoingPayment.Details.Normal -> {
341+
val minFinalExpiryDelta = details.paymentRequest.minFinalExpiryDelta ?: Channel.MIN_CLTV_EXPIRY_DELTA
342342
val expiry = nodeParams.paymentRecipientExpiryParams.computeFinalExpiry(currentBlockHeight, minFinalExpiryDelta)
343-
val invoiceFeatures = paymentRequest.features
343+
val invoiceFeatures = details.paymentRequest.features
344344
if (request.recipient == walletParams.trampolineNode.id) {
345345
// We are directly paying our trampoline node.
346-
OutgoingPaymentPacket.buildPacketToTrampolinePeer(paymentRequest, request.amount, expiry)
346+
OutgoingPaymentPacket.buildPacketToTrampolinePeer(details.paymentRequest, request.amount, expiry)
347347
} else if (invoiceFeatures.hasFeature(Feature.TrampolinePayment)) {
348-
OutgoingPaymentPacket.buildPacketToTrampolineRecipient(paymentRequest, request.amount, expiry, hop)
348+
OutgoingPaymentPacket.buildPacketToTrampolineRecipient(details.paymentRequest, request.amount, expiry, hop)
349349
} else {
350-
OutgoingPaymentPacket.buildPacketToLegacyRecipient(paymentRequest, request.amount, expiry, hop)
350+
OutgoingPaymentPacket.buildPacketToLegacyRecipient(details.paymentRequest, request.amount, expiry, hop)
351351
}
352352
}
353-
is Bolt12Invoice -> {
353+
is LightningOutgoingPayment.Details.Blinded -> {
354354
// The recipient already included a final cltv-expiry-delta in their invoice blinded paths.
355355
val minFinalExpiryDelta = CltvExpiryDelta(0)
356356
val expiry = nodeParams.paymentRecipientExpiryParams.computeFinalExpiry(currentBlockHeight, minFinalExpiryDelta)
357-
OutgoingPaymentPacket.buildPacketToBlindedRecipient(paymentRequest, request.amount, expiry, hop)
357+
OutgoingPaymentPacket.buildPacketToBlindedRecipient(details.paymentRequest, details.payerKey, request.amount, expiry, hop)
358358
}
359+
is LightningOutgoingPayment.Details.SwapOut -> error("invalid lightning payment details (legacy swap out)")
359360
}
360361
}
361362

0 commit comments

Comments
 (0)