Skip to content

Commit 1222f8e

Browse files
authored
Send normal failures for Bolt12 payments (#773)
Since we are always using 1-hop blinded paths from our LSP to ourselves, there are no public intermediate nodes that could be probed inside that blinded path. The requirement to always return malformed failure is thus unnecessary (and harmful for the payer who doesn't know why the payment failed). We now return normal failures in that case, which should be forwarded by our LSP to the upstream nodes. This also fixes an issue where non-trampoline blinded payments were not correctly failed because the extraction of the onion shared secret did not uses the `path_key` to derive the blinded onion decryption key.
1 parent ff0b210 commit 1222f8e

File tree

6 files changed

+147
-16
lines changed

6 files changed

+147
-16
lines changed

modules/core/src/commonMain/kotlin/fr/acinq/lightning/channel/Commitments.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -702,7 +702,7 @@ data class Commitments(
702702
// we have already sent a fail/fulfill for this htlc
703703
CommitmentChanges.alreadyProposed(changes.localChanges.proposed, htlc.id) -> Either.Left(UnknownHtlcId(channelId, cmd.id))
704704
else -> {
705-
when (val result = OutgoingPaymentPacket.buildHtlcFailure(nodeSecret, htlc.paymentHash, htlc.onionRoutingPacket, cmd.reason)) {
705+
when (val result = OutgoingPaymentPacket.buildHtlcFailure(nodeSecret, htlc.paymentHash, htlc.onionRoutingPacket, htlc.pathKey, cmd.reason)) {
706706
is Either.Right -> {
707707
val fail = UpdateFailHtlc(channelId, cmd.id, result.value)
708708
Either.Right(Pair(copy(changes = changes.addLocalProposal(fail)), fail))

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -575,10 +575,11 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: PaymentsDb) {
575575
}
576576

577577
private fun rejectPaymentPart(privateKey: PrivateKey, paymentPart: PaymentPart, incomingPayment: LightningIncomingPayment?, currentBlockHeight: Int): ProcessAddResult.Rejected {
578-
val failureMsg = when (paymentPart.finalPayload) {
579-
is PaymentOnion.FinalPayload.Blinded -> InvalidOnionBlinding(Sphinx.hash(paymentPart.onionPacket))
580-
is PaymentOnion.FinalPayload.Standard -> IncorrectOrUnknownPaymentDetails(paymentPart.totalAmount, currentBlockHeight.toLong())
581-
}
578+
// Note that for blinded payment, the BOLTs say we should always return InvalidOnionBlinding.
579+
// However, this is in order to protect against malicious nodes probing the blinded path.
580+
// But in our case, the blinded path is simply a private channel between our LSP and us, so there is nothing to probe!
581+
// We can thus return normal failures, which are more helpful for the payer than InvalidOnionBlinding.
582+
val failureMsg = IncorrectOrUnknownPaymentDetails(paymentPart.totalAmount, currentBlockHeight.toLong())
582583
val rejectedAction = when (paymentPart) {
583584
is HtlcPart -> actionForFailureMessage(failureMsg, paymentPart.htlc)
584585
is WillAddHtlcPart -> actionForWillAddHtlcFailure(privateKey, failureMsg, paymentPart.htlc)

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

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import fr.acinq.lightning.Feature
1010
import fr.acinq.lightning.Lightning
1111
import fr.acinq.lightning.MilliSatoshi
1212
import fr.acinq.lightning.channel.ChannelCommand
13+
import fr.acinq.lightning.crypto.RouteBlinding
1314
import fr.acinq.lightning.crypto.sphinx.FailurePacket
1415
import fr.acinq.lightning.crypto.sphinx.PacketAndSecrets
1516
import fr.acinq.lightning.crypto.sphinx.Sphinx
@@ -144,9 +145,10 @@ object OutgoingPaymentPacket {
144145
return Triple(trampolineAmount, trampolineExpiry, paymentOnion)
145146
}
146147

147-
fun buildHtlcFailure(nodeSecret: PrivateKey, paymentHash: ByteVector32, onion: OnionRoutingPacket, reason: ChannelCommand.Htlc.Settlement.Fail.Reason): Either<FailureMessage, ByteVector> {
148-
// we need to decrypt the payment onion to obtain the shared secret to build the error packet
149-
return when (val result = Sphinx.peel(nodeSecret, paymentHash, onion)) {
148+
fun buildHtlcFailure(nodeSecret: PrivateKey, paymentHash: ByteVector32, onion: OnionRoutingPacket, pathKey: PublicKey?, reason: ChannelCommand.Htlc.Settlement.Fail.Reason): Either<FailureMessage, ByteVector> {
149+
// We need to decrypt the payment onion to obtain the shared secret to build the error packet.
150+
val onionDecryptionKey = pathKey?.let { RouteBlinding.derivePrivateKey(nodeSecret, it) } ?: nodeSecret
151+
return when (val result = Sphinx.peel(onionDecryptionKey, paymentHash, onion)) {
150152
is Either.Right -> {
151153
val encryptedReason = when (reason) {
152154
is ChannelCommand.Htlc.Settlement.Fail.Reason.Bytes -> FailurePacket.wrap(reason.bytes.toByteArray(), result.value.sharedSecret)
@@ -160,7 +162,7 @@ object OutgoingPaymentPacket {
160162

161163
fun buildWillAddHtlcFailure(nodeSecret: PrivateKey, willAddHtlc: WillAddHtlc, failure: FailureMessage): OnTheFlyFundingMessage {
162164
val reason = ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(failure)
163-
return when (val f = buildHtlcFailure(nodeSecret, willAddHtlc.paymentHash, willAddHtlc.finalPacket, reason)) {
165+
return when (val f = buildHtlcFailure(nodeSecret, willAddHtlc.paymentHash, willAddHtlc.finalPacket, willAddHtlc.pathKey, reason)) {
164166
is Either.Right -> WillFailHtlc(willAddHtlc.id, willAddHtlc.paymentHash, f.value)
165167
is Either.Left -> WillFailMalformedHtlc(willAddHtlc.id, willAddHtlc.paymentHash, Sphinx.hash(willAddHtlc.finalPacket), f.value.code)
166168
}

modules/core/src/commonMain/kotlin/fr/acinq/lightning/wire/PaymentOnion.kt

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -401,7 +401,6 @@ object PaymentOnion {
401401
data class ChannelRelayPayload(val records: TlvStream<OnionPaymentPayloadTlv>) : PerHopPayload() {
402402
val amountToForward = records.get<OnionPaymentPayloadTlv.AmountToForward>()!!.amount
403403
val outgoingCltv = records.get<OnionPaymentPayloadTlv.OutgoingCltv>()!!.cltv
404-
val outgoingChannelId = records.get<OnionPaymentPayloadTlv.OutgoingChannelId>()!!.shortChannelId
405404

406405
override fun write(out: Output) = tlvSerializer.write(records, out)
407406

@@ -423,6 +422,16 @@ object PaymentOnion {
423422
}
424423
}
425424

425+
data class BlindedChannelRelayPayload(val records: TlvStream<OnionPaymentPayloadTlv>) : PerHopPayload() {
426+
override fun write(out: Output) = tlvSerializer.write(records, out)
427+
428+
companion object : PerHopPayloadReader<BlindedChannelRelayPayload> {
429+
override fun read(input: Input): Either<InvalidOnionPayload, BlindedChannelRelayPayload> {
430+
return PerHopPayload.read(input).map { BlindedChannelRelayPayload(it) }
431+
}
432+
}
433+
}
434+
426435
data class NodeRelayPayload(val records: TlvStream<OnionPaymentPayloadTlv>) : PerHopPayload() {
427436
val amountToForward = records.get<OnionPaymentPayloadTlv.AmountToForward>()!!.amount
428437
val outgoingCltv = records.get<OnionPaymentPayloadTlv.OutgoingCltv>()!!.cltv

modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandlerTestsCommon.kt

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1768,8 +1768,8 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() {
17681768
val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null)
17691769

17701770
assertIs<IncomingPaymentHandler.ProcessAddResult.Rejected>(result)
1771-
val expectedFailure = InvalidOnionBlinding(hash(add.onionRoutingPacket))
1772-
val expected = ChannelCommand.Htlc.Settlement.FailMalformed(add.id, expectedFailure.onionHash, expectedFailure.code, commit = true)
1771+
val expectedFailure = IncorrectOrUnknownPaymentDetails(defaultAmount, TestConstants.defaultBlockHeight.toLong())
1772+
val expected = ChannelCommand.Htlc.Settlement.Fail(add.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(expectedFailure), commit = true)
17731773
assertEquals(setOf(WrappedChannelCommand(add.channelId, expected)), result.actions.toSet())
17741774
}
17751775

@@ -1819,8 +1819,8 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() {
18191819
val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null)
18201820

18211821
assertIs<IncomingPaymentHandler.ProcessAddResult.Rejected>(result)
1822-
val expectedFailure = InvalidOnionBlinding(hash(add.onionRoutingPacket))
1823-
val expected = ChannelCommand.Htlc.Settlement.FailMalformed(add.id, expectedFailure.onionHash, expectedFailure.code, commit = true)
1822+
val expectedFailure = IncorrectOrUnknownPaymentDetails(amountTooLow, TestConstants.defaultBlockHeight.toLong())
1823+
val expected = ChannelCommand.Htlc.Settlement.Fail(add.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(expectedFailure), commit = true)
18241824
assertEquals(setOf(WrappedChannelCommand(add.channelId, expected)), result.actions.toSet())
18251825
}
18261826

@@ -1835,8 +1835,8 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() {
18351835
val result = paymentHandler.process(add, Features.empty, TestConstants.defaultBlockHeight, TestConstants.feeratePerKw, remoteFundingRates = null)
18361836

18371837
assertIs<IncomingPaymentHandler.ProcessAddResult.Rejected>(result)
1838-
val expectedFailure = InvalidOnionBlinding(hash(add.onionRoutingPacket))
1839-
val expected = ChannelCommand.Htlc.Settlement.FailMalformed(add.id, expectedFailure.onionHash, expectedFailure.code, commit = true)
1838+
val expectedFailure = IncorrectOrUnknownPaymentDetails(metadata.amount, TestConstants.defaultBlockHeight.toLong())
1839+
val expected = ChannelCommand.Htlc.Settlement.Fail(add.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(expectedFailure), commit = true)
18401840
assertEquals(setOf(WrappedChannelCommand(add.channelId, expected)), result.actions.toSet())
18411841
}
18421842

modules/core/src/commonTest/kotlin/fr/acinq/lightning/payment/PaymentPacketTestsCommon.kt

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ import fr.acinq.lightning.Lightning.randomBytes
88
import fr.acinq.lightning.Lightning.randomBytes32
99
import fr.acinq.lightning.Lightning.randomBytes64
1010
import fr.acinq.lightning.Lightning.randomKey
11+
import fr.acinq.lightning.channel.ChannelCommand
1112
import fr.acinq.lightning.channel.states.Channel
1213
import fr.acinq.lightning.crypto.RouteBlinding
14+
import fr.acinq.lightning.crypto.sphinx.FailurePacket
1315
import fr.acinq.lightning.crypto.sphinx.PacketAndSecrets
1416
import fr.acinq.lightning.crypto.sphinx.Sphinx
1517
import fr.acinq.lightning.crypto.sphinx.Sphinx.hash
@@ -123,6 +125,19 @@ class PaymentPacketTestsCommon : LightningTestSuite() {
123125
return Pair(outerPayload, innerPayload)
124126
}
125127

128+
// Wallets don't need to decrypt onions where they're the introduction node of a blinded path, but it's useful to test that encryption works correctly.
129+
fun decryptBlindedChannelRelay(add: UpdateAddHtlc, privateKey: PrivateKey): Pair<OnionRoutingPacket, PublicKey> {
130+
val decrypted = Sphinx.peel(privateKey, add.paymentHash, add.onionRoutingPacket).right!!
131+
assertFalse(decrypted.isLastPacket)
132+
val payload = PaymentOnion.PerHopPayload.read(decrypted.payload.toByteArray()).right!!
133+
val pathKey = payload.get<OnionPaymentPayloadTlv.PathKey>()?.publicKey ?: add.pathKey
134+
assertNotNull(pathKey)
135+
val encryptedData = payload.get<OnionPaymentPayloadTlv.EncryptedRecipientData>()!!.data
136+
val nextPathKey = RouteBlinding.decryptPayload(privateKey, pathKey, encryptedData).map { it.second }.right
137+
assertNotNull(nextPathKey)
138+
return Pair(decrypted.nextPacket, nextPathKey)
139+
}
140+
126141
// Wallets don't need to decrypt onions for intermediate nodes, but it's useful to test that encryption works correctly.
127142
fun decryptRelayToBlinded(add: UpdateAddHtlc, privateKey: PrivateKey): Pair<PaymentOnion.FinalPayload, PaymentOnion.RelayToBlindedPayload> {
128143
val decrypted = Sphinx.peel(privateKey, add.paymentHash, add.onionRoutingPacket).right!!
@@ -504,6 +519,110 @@ class PaymentPacketTestsCommon : LightningTestSuite() {
504519
assertEquals(InvalidOnionBlinding(hash(addD.onionRoutingPacket)), failure)
505520
}
506521

522+
@Test
523+
fun `build htlc failure onion`() {
524+
// B sends a payment B -> C -> D.
525+
val (amountC, expiryC, onionC) = run {
526+
val payloadD = PaymentOnion.FinalPayload.Standard.createMultiPartPayload(finalAmount, finalAmount, finalExpiry, paymentSecret, paymentMetadata = null)
527+
encryptChannelRelay(paymentHash, listOf(ChannelHop(b, c, channelUpdateBC), ChannelHop(c, d, channelUpdateCD)), payloadD)
528+
}
529+
assertEquals(amountBC, amountC)
530+
assertEquals(expiryBC, expiryC)
531+
532+
// C relays the payment to D.
533+
val addC = UpdateAddHtlc(randomBytes32(), 2, amountC, paymentHash, expiryC, onionC.packet)
534+
val (payloadC, packetD) = decryptChannelRelay(addC, privC)
535+
val addD = UpdateAddHtlc(randomBytes32(), 3, payloadC.amountToForward, paymentHash, payloadC.outgoingCltv, packetD)
536+
assertNotNull(IncomingPaymentPacket.decrypt(addD, privD).right)
537+
val willAddD = WillAddHtlc(Block.RegtestGenesisBlock.hash, randomBytes32(), payloadC.amountToForward, paymentHash, payloadC.outgoingCltv, packetD)
538+
assertNotNull(IncomingPaymentPacket.decrypt(willAddD, privD))
539+
540+
// D returns a failure.
541+
val failure = IncorrectOrUnknownPaymentDetails(finalAmount, currentBlockCount)
542+
val encryptedFailuresD = run {
543+
val encryptedFailureD = OutgoingPaymentPacket.buildHtlcFailure(privD, paymentHash, addD.onionRoutingPacket, addD.pathKey, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(failure)).right
544+
assertNotNull(encryptedFailureD)
545+
val willFailD = OutgoingPaymentPacket.buildWillAddHtlcFailure(privD, willAddD, failure)
546+
assertIs<WillFailHtlc>(willFailD)
547+
listOf(encryptedFailureD, willFailD.reason)
548+
}
549+
encryptedFailuresD.forEach { encryptedFailureD ->
550+
// C cannot decrypt the failure: it re-encrypts and relays that failure to B.
551+
val encryptedFailureC = OutgoingPaymentPacket.buildHtlcFailure(privC, paymentHash, addC.onionRoutingPacket, addC.pathKey, ChannelCommand.Htlc.Settlement.Fail.Reason.Bytes(encryptedFailureD)).right
552+
assertNotNull(encryptedFailureC)
553+
// B decrypts the failure.
554+
val decrypted = FailurePacket.decrypt(encryptedFailureC.toByteArray(), onionC.sharedSecrets)
555+
assertTrue(decrypted.isSuccess)
556+
assertEquals(d, decrypted.get().originNode)
557+
assertEquals(failure, decrypted.get().failureMessage)
558+
}
559+
}
560+
561+
@Test
562+
fun `build htlc failure onion -- blinded payment`() {
563+
// D creates a blinded path C -> D.
564+
val offer = OfferTypes.Offer.createNonBlindedOffer(finalAmount, "test offer", d, Features(Feature.BasicMultiPartPayment to FeatureSupport.Optional), Block.RegtestGenesisBlock.hash)
565+
val pathId = OfferPaymentMetadata.V1(offer.offerId, finalAmount, paymentPreimage, randomKey().publicKey(), "hello", 1, currentTimestampMillis()).toPathId(privD)
566+
val blindedPayloadD = RouteBlindingEncryptedData(TlvStream(RouteBlindingEncryptedDataTlv.PathId(pathId)))
567+
val blindedPayloadC = RouteBlindingEncryptedData(
568+
TlvStream(
569+
RouteBlindingEncryptedDataTlv.OutgoingChannelId(channelUpdateCD.shortChannelId),
570+
RouteBlindingEncryptedDataTlv.PaymentRelay(channelUpdateCD.cltvExpiryDelta, channelUpdateCD.feeProportionalMillionths, channelUpdateCD.feeBaseMsat),
571+
RouteBlindingEncryptedDataTlv.PaymentConstraints(finalExpiry, 1.msat),
572+
)
573+
)
574+
val blindedRoute = RouteBlinding.create(randomKey(), listOf(c, d), listOf(blindedPayloadC, blindedPayloadD).map { it.write().byteVector() }).route
575+
576+
// B sends a blinded payment B -> C -> D using this blinded path.
577+
val onionC = run {
578+
val payloadD = PaymentOnion.FinalPayload.Blinded(
579+
TlvStream(
580+
OnionPaymentPayloadTlv.AmountToForward(finalAmount),
581+
OnionPaymentPayloadTlv.TotalAmount(finalAmount),
582+
OnionPaymentPayloadTlv.OutgoingCltv(finalExpiry),
583+
OnionPaymentPayloadTlv.EncryptedRecipientData(blindedRoute.encryptedPayloads.last()),
584+
),
585+
blindedPayloadD
586+
)
587+
val payloadC = PaymentOnion.BlindedChannelRelayPayload(
588+
TlvStream(
589+
OnionPaymentPayloadTlv.EncryptedRecipientData(blindedRoute.encryptedPayloads.first()),
590+
OnionPaymentPayloadTlv.PathKey(blindedRoute.firstPathKey),
591+
)
592+
)
593+
OutgoingPaymentPacket.buildOnion(listOf(c, blindedRoute.blindedNodeIds.last()), listOf(payloadC, payloadD), paymentHash, OnionRoutingPacket.PaymentPacketLength)
594+
}
595+
596+
// C relays the payment to D.
597+
val addC = UpdateAddHtlc(randomBytes32(), 2, amountBC, paymentHash, expiryBC, onionC.packet)
598+
val (packetD, pathKeyD) = decryptBlindedChannelRelay(addC, privC)
599+
val addD = UpdateAddHtlc(randomBytes32(), 3, amountCD, paymentHash, expiryCD, packetD, pathKeyD, fundingFee = null)
600+
assertNotNull(IncomingPaymentPacket.decrypt(addD, privD).right)
601+
val willAddD = WillAddHtlc(Block.RegtestGenesisBlock.hash, randomBytes32(), amountCD, paymentHash, expiryCD, packetD, pathKeyD)
602+
assertNotNull(IncomingPaymentPacket.decrypt(willAddD, privD).right)
603+
604+
// D returns a failure: note that it is not a blinded failure, since there is no need to protect the blinded path against probing.
605+
// This ensures that payers get a meaningful error from wallet recipients.
606+
val failure = IncorrectOrUnknownPaymentDetails(finalAmount, currentBlockCount)
607+
val encryptedFailuresD = run {
608+
val encryptedFailureD = OutgoingPaymentPacket.buildHtlcFailure(privD, paymentHash, addD.onionRoutingPacket, addD.pathKey, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(failure)).right
609+
assertNotNull(encryptedFailureD)
610+
val willFailD = OutgoingPaymentPacket.buildWillAddHtlcFailure(privD, willAddD, failure)
611+
assertIs<WillFailHtlc>(willFailD)
612+
listOf(encryptedFailureD, willFailD.reason)
613+
}
614+
encryptedFailuresD.forEach { encryptedFailureD ->
615+
// C cannot decrypt the failure: it re-encrypts and relays that failure to B.
616+
val encryptedFailureC = OutgoingPaymentPacket.buildHtlcFailure(privC, paymentHash, addC.onionRoutingPacket, addC.pathKey, ChannelCommand.Htlc.Settlement.Fail.Reason.Bytes(encryptedFailureD)).right
617+
assertNotNull(encryptedFailureC)
618+
// B decrypts the failure.
619+
val decrypted = FailurePacket.decrypt(encryptedFailureC.toByteArray(), onionC.sharedSecrets)
620+
assertTrue(decrypted.isSuccess)
621+
assertEquals(blindedRoute.blindedNodeIds.last(), decrypted.get().originNode)
622+
assertEquals(failure, decrypted.get().failureMessage)
623+
}
624+
}
625+
507626
@Test
508627
fun `prune outgoing blinded paths`() {
509628
// We create an invoice with a large number of blinded paths.

0 commit comments

Comments
 (0)