From 13bfc922a215c1fe7e036685faab5ff6a033c83c Mon Sep 17 00:00:00 2001 From: t-bast Date: Wed, 17 Apr 2024 13:23:37 +0200 Subject: [PATCH] Use `invalid_onion_blinding` for failures We must use the `invalid_onion_blinding` failure code whenever we reject a blinded payment to protect against probing. We also need to send `update_fail_malformed_htlc` instead of `update_fail_htlc`, to avoid encrypting an error with our real `node_id`. When rejecting a blinded pay-to-open attempt, we also must avoid encrypting a normal error with our `node_id`: we instead return an empty error reason, that the introduction node will convert to an `invalid_onion_blinding` error. --- .../payment/IncomingPaymentHandler.kt | 25 +++--- .../payment/IncomingPaymentPacket.kt | 11 ++- .../IncomingPaymentHandlerTestsCommon.kt | 76 ++++++++++++++++--- 3 files changed, 92 insertions(+), 20 deletions(-) diff --git a/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt b/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt index 10a862592..4e6e2ca06 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandler.kt @@ -4,19 +4,20 @@ import fr.acinq.bitcoin.ByteVector import fr.acinq.bitcoin.ByteVector32 import fr.acinq.bitcoin.Crypto import fr.acinq.bitcoin.PrivateKey -import fr.acinq.bitcoin.io.ByteArrayInput import fr.acinq.bitcoin.utils.Either import fr.acinq.lightning.* import fr.acinq.lightning.Lightning.randomBytes32 import fr.acinq.lightning.channel.ChannelAction import fr.acinq.lightning.channel.ChannelCommand import fr.acinq.lightning.channel.Origin +import fr.acinq.lightning.crypto.sphinx.Sphinx import fr.acinq.lightning.db.IncomingPayment import fr.acinq.lightning.db.IncomingPaymentsDb import fr.acinq.lightning.io.PayToOpenResponseCommand import fr.acinq.lightning.io.PeerCommand import fr.acinq.lightning.io.WrappedChannelCommand -import fr.acinq.lightning.logging.* +import fr.acinq.lightning.logging.MDCLogger +import fr.acinq.lightning.logging.mdc import fr.acinq.lightning.utils.* import fr.acinq.lightning.wire.* @@ -25,12 +26,14 @@ sealed class PaymentPart { abstract val totalAmount: MilliSatoshi abstract val paymentHash: ByteVector32 abstract val finalPayload: PaymentOnion.FinalPayload + abstract val onionPacket: OnionRoutingPacket } data class HtlcPart(val htlc: UpdateAddHtlc, override val finalPayload: PaymentOnion.FinalPayload) : PaymentPart() { override val amount: MilliSatoshi = htlc.amountMsat override val totalAmount: MilliSatoshi = finalPayload.totalAmount override val paymentHash: ByteVector32 = htlc.paymentHash + override val onionPacket: OnionRoutingPacket = htlc.onionRoutingPacket override fun toString(): String = "htlc(channelId=${htlc.channelId},id=${htlc.id})" } @@ -38,6 +41,7 @@ data class PayToOpenPart(val payToOpenRequest: PayToOpenRequest, override val fi override val amount: MilliSatoshi = payToOpenRequest.amountMsat override val totalAmount: MilliSatoshi = finalPayload.totalAmount override val paymentHash: ByteVector32 = payToOpenRequest.paymentHash + override val onionPacket: OnionRoutingPacket = payToOpenRequest.finalPacket override fun toString(): String = "pay-to-open(amount=${payToOpenRequest.amountMsat})" } @@ -469,8 +473,7 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment // NB: IncomingPacket.decrypt does additional validation on top of IncomingPacket.decryptOnion return when (val decrypted = IncomingPaymentPacket.decrypt(htlc, privateKey)) { is Either.Left -> { // Unable to decrypt onion - val failureMsg = decrypted.value - val action = actionForFailureMessage(failureMsg, htlc) + val action = actionForFailureMessage(decrypted.value, htlc) Either.Left(ProcessAddResult.Rejected(listOf(action), null)) } is Either.Right -> Either.Right(HtlcPart(htlc, decrypted.value)) @@ -484,8 +487,7 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment private fun toPaymentPart(privateKey: PrivateKey, payToOpenRequest: PayToOpenRequest): Either { return when (val decrypted = IncomingPaymentPacket.decryptOnion(payToOpenRequest.paymentHash, payToOpenRequest.finalPacket, privateKey, payToOpenRequest.blinding)) { is Either.Left -> { - val failureMsg = decrypted.value - val action = actionForPayToOpenFailure(privateKey, failureMsg, payToOpenRequest) + val action = actionForPayToOpenFailure(privateKey, decrypted.value, payToOpenRequest) Either.Left(ProcessAddResult.Rejected(listOf(action), null)) } is Either.Right -> Either.Right(PayToOpenPart(payToOpenRequest, decrypted.value)) @@ -493,7 +495,10 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment } private fun rejectPaymentPart(privateKey: PrivateKey, paymentPart: PaymentPart, incomingPayment: IncomingPayment?, currentBlockHeight: Int): ProcessAddResult.Rejected { - val failureMsg = IncorrectOrUnknownPaymentDetails(paymentPart.totalAmount, currentBlockHeight.toLong()) + val failureMsg = when (paymentPart.finalPayload) { + is PaymentOnion.FinalPayload.Blinded -> InvalidOnionBlinding(Sphinx.hash(paymentPart.onionPacket)) + is PaymentOnion.FinalPayload.Standard -> IncorrectOrUnknownPaymentDetails(paymentPart.totalAmount, currentBlockHeight.toLong()) + } val rejectedAction = when (paymentPart) { is HtlcPart -> actionForFailureMessage(failureMsg, paymentPart.htlc) is PayToOpenPart -> actionForPayToOpenFailure(privateKey, failureMsg, paymentPart.payToOpenRequest) @@ -511,9 +516,9 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment fun actionForPayToOpenFailure(privateKey: PrivateKey, failure: FailureMessage, payToOpenRequest: PayToOpenRequest): PayToOpenResponseCommand { val reason = ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(failure) - val encryptedReason = when (val result = OutgoingPaymentPacket.buildHtlcFailure(privateKey, payToOpenRequest.paymentHash, payToOpenRequest.finalPacket, reason)) { - is Either.Right -> result.value - is Either.Left -> null + val encryptedReason = when (failure) { + is BadOnion -> null + else -> OutgoingPaymentPacket.buildHtlcFailure(privateKey, payToOpenRequest.paymentHash, payToOpenRequest.finalPacket, reason).right } return PayToOpenResponseCommand(PayToOpenResponse(payToOpenRequest.chainHash, payToOpenRequest.paymentHash, PayToOpenResponse.Result.Failure(encryptedReason))) } diff --git a/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentPacket.kt b/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentPacket.kt index b8aa221c2..c70950706 100644 --- a/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentPacket.kt +++ b/src/commonMain/kotlin/fr/acinq/lightning/payment/IncomingPaymentPacket.kt @@ -33,6 +33,7 @@ object IncomingPaymentPacket { is Either.Left -> Either.Left(inner.value) is Either.Right -> when (val innerPayload = inner.value) { is PaymentOnion.FinalPayload.Standard -> validate(add, outer, innerPayload) + // Blinded trampoline paths are not supported. is PaymentOnion.FinalPayload.Blinded -> Either.Left(InvalidOnionPayload(0U, 0)) } } @@ -86,7 +87,7 @@ object IncomingPaymentPacket { } } - private fun validate(add: UpdateAddHtlc, payload: PaymentOnion.FinalPayload): Either { + private fun validate(add: UpdateAddHtlc, payload: PaymentOnion.FinalPayload.Standard): Either { return when { add.amountMsat < payload.amount -> Either.Left(FinalIncorrectHtlcAmount(add.amountMsat)) add.cltvExpiry < payload.expiry -> Either.Left(FinalIncorrectCltvExpiry(add.cltvExpiry)) @@ -94,6 +95,14 @@ object IncomingPaymentPacket { } } + private fun validate(add: UpdateAddHtlc, payload: PaymentOnion.FinalPayload.Blinded): Either { + return when { + add.amountMsat < payload.amount -> Either.Left(InvalidOnionBlinding(hash(add.onionRoutingPacket))) + add.cltvExpiry < payload.expiry -> Either.Left(InvalidOnionBlinding(hash(add.onionRoutingPacket))) + else -> Either.Right(payload) + } + } + private fun validate(add: UpdateAddHtlc, outerPayload: PaymentOnion.FinalPayload.Standard, innerPayload: PaymentOnion.FinalPayload.Standard): Either { return when { add.amountMsat < outerPayload.amount -> Either.Left(FinalIncorrectHtlcAmount(add.amountMsat)) diff --git a/src/commonTest/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandlerTestsCommon.kt b/src/commonTest/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandlerTestsCommon.kt index ded141df3..b3b941f82 100644 --- a/src/commonTest/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandlerTestsCommon.kt +++ b/src/commonTest/kotlin/fr/acinq/lightning/payment/IncomingPaymentHandlerTestsCommon.kt @@ -819,7 +819,7 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { // 1. InvalidOnionKey // 2. InvalidOnionHmac // Since we used a valid pubKey, we should get an hmac failure. - val expectedErr = InvalidOnionHmac(Sphinx.hash(badOnion)) + val expectedErr = InvalidOnionHmac(hash(badOnion)) val expected = ChannelCommand.Htlc.Settlement.FailMalformed(add.id, expectedErr.onionHash, expectedErr.code, commit = true) assertEquals(setOf(WrappedChannelCommand(add.channelId, expected)), result.actions.toSet()) } @@ -1212,16 +1212,74 @@ class IncomingPaymentHandlerTestsCommon : LightningTestSuite() { @Test fun `reject blinded payment for Bolt11 invoice`() = runSuspendTest { - val (paymentHandler, incomingPayment, paymentSecret) = createFixture(defaultAmount) - checkDbPayment(incomingPayment, paymentHandler.db) + val (paymentHandler, incomingPayment, _) = createFixture(defaultAmount) + val cltvExpiry = TestConstants.Bob.nodeParams.minFinalCltvExpiryDelta.toCltvExpiry(TestConstants.defaultBlockHeight.toLong()) + val blindedPayload = makeBlindedPayload(TestConstants.Bob.nodeParams.nodeId, defaultAmount, defaultAmount, cltvExpiry, preimage = incomingPayment.preimage) + val add = makeUpdateAddHtlc(8, randomBytes32(), paymentHandler, incomingPayment.paymentHash, blindedPayload) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + + assertIs(result) + val expectedFailure = InvalidOnionBlinding(hash(add.onionRoutingPacket)) + val expected = ChannelCommand.Htlc.Settlement.FailMalformed(add.id, expectedFailure.onionHash, expectedFailure.code, commit = true) + assertEquals(setOf(WrappedChannelCommand(add.channelId, expected)), result.actions.toSet()) + } + + @Test + fun `reject non-blinded payment for Bol12 invoice`() = runSuspendTest { + val paymentHandler = IncomingPaymentHandler(TestConstants.Bob.nodeParams, InMemoryPaymentsDb()) val channelId = randomBytes32() + val (amount1, amount2) = Pair(100_000_000.msat, 50_000_000.msat) + val totalAmount = amount1 + amount2 + val preimage = randomBytes32() + val paymentHash = Crypto.sha256(preimage).toByteVector32() + val cltvExpiry = TestConstants.Bob.nodeParams.minFinalCltvExpiryDelta.toCltvExpiry(TestConstants.defaultBlockHeight.toLong()) + + // Step 1 of 2: + // - Alice sends first blinded multipart htlc to Bob + // - Bob doesn't accept the MPP set yet + run { + val add = makeUpdateAddHtlc(0, channelId, paymentHandler, paymentHash, makeBlindedPayload(TestConstants.Bob.nodeParams.nodeId, amount1, totalAmount, cltvExpiry, preimage = preimage)) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + assertIs(result) + assertNull(result.incomingPayment.received) + assertTrue(result.actions.isEmpty()) + } + + // Step 2 of 2: + // - Alice sends second multipart htlc to Bob without using blinded paths + // - Bob rejects that htlc (the first htlc will be rejected after the MPP timeout) + run { + val add = makeUpdateAddHtlc(1, channelId, paymentHandler, paymentHash, makeMppPayload(amount2, totalAmount, randomBytes32())) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + assertIs(result) + val expected = ChannelCommand.Htlc.Settlement.Fail(add.id, ChannelCommand.Htlc.Settlement.Fail.Reason.Failure(IncorrectOrUnknownPaymentDetails(totalAmount, TestConstants.defaultBlockHeight.toLong())), commit = true) + assertEquals(setOf(WrappedChannelCommand(add.channelId, expected)), result.actions.toSet()) + } + } + + @Test + fun `reject blinded payment with amount too low`() = runSuspendTest { + val paymentHandler = IncomingPaymentHandler(TestConstants.Bob.nodeParams, InMemoryPaymentsDb()) + val cltvExpiry = TestConstants.Bob.nodeParams.minFinalCltvExpiryDelta.toCltvExpiry(TestConstants.defaultBlockHeight.toLong()) + val metadata = OfferPaymentMetadata.V1(randomBytes32(), 100_000_000.msat, randomBytes32(), randomKey().publicKey(), 1, currentTimestampMillis()) + val pathId = metadata.toPathId(TestConstants.Bob.nodeParams.nodePrivateKey) + val amountTooLow = metadata.amount - 10_000_000.msat + val add = makeUpdateAddHtlc(8, randomBytes32(), paymentHandler, metadata.paymentHash, makeBlindedPayload(TestConstants.Bob.nodeParams.nodeId, amountTooLow, amountTooLow, cltvExpiry, pathId)) + val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) + + assertIs(result) + val expectedFailure = InvalidOnionBlinding(hash(add.onionRoutingPacket)) + val expected = ChannelCommand.Htlc.Settlement.FailMalformed(add.id, expectedFailure.onionHash, expectedFailure.code, commit = true) + assertEquals(setOf(WrappedChannelCommand(add.channelId, expected)), result.actions.toSet()) + } + + @Test + fun `reject blinded payment with payment_hash mismatch`() = runSuspendTest { + val paymentHandler = IncomingPaymentHandler(TestConstants.Bob.nodeParams, InMemoryPaymentsDb()) val cltvExpiry = TestConstants.Bob.nodeParams.minFinalCltvExpiryDelta.toCltvExpiry(TestConstants.defaultBlockHeight.toLong()) - val blindedPayload = makeBlindedPayload(TestConstants.Bob.nodeParams.nodeId, defaultAmount, defaultAmount, cltvExpiry, randomBytes32(), 1, randomBytes32()) - val standardPayload = PaymentOnion.FinalPayload.Standard(TlvStream( - makeMppPayload(defaultAmount, defaultAmount, paymentSecret).records.records + - blindedPayload.records.get()!! + - blindedPayload.records.get()!!)) - val add = makeUpdateAddHtlc(8, channelId, paymentHandler, incomingPayment.paymentHash, standardPayload) + val metadata = OfferPaymentMetadata.V1(randomBytes32(), 100_000_000.msat, randomBytes32(), randomKey().publicKey(), 1, currentTimestampMillis()) + val pathId = metadata.toPathId(TestConstants.Bob.nodeParams.nodePrivateKey) + val add = makeUpdateAddHtlc(8, randomBytes32(), paymentHandler, metadata.paymentHash.reversed(), makeBlindedPayload(TestConstants.Bob.nodeParams.nodeId, metadata.amount, metadata.amount, cltvExpiry, pathId)) val result = paymentHandler.process(add, TestConstants.defaultBlockHeight) assertIs(result)