Skip to content

Commit

Permalink
Use invalid_onion_blinding for failures
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
t-bast committed Apr 17, 2024
1 parent 6efbd9f commit 13bfc92
Show file tree
Hide file tree
Showing 3 changed files with 92 additions and 20 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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.*

Expand All @@ -25,19 +26,22 @@ 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})"
}

data class PayToOpenPart(val payToOpenRequest: PayToOpenRequest, override val finalPayload: PaymentOnion.FinalPayload) : PaymentPart() {
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})"
}

Expand Down Expand Up @@ -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))
Expand All @@ -484,16 +487,18 @@ class IncomingPaymentHandler(val nodeParams: NodeParams, val db: IncomingPayment
private fun toPaymentPart(privateKey: PrivateKey, payToOpenRequest: PayToOpenRequest): Either<ProcessAddResult.Rejected, PayToOpenPart> {
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))
}
}

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)
Expand All @@ -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)))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
Expand Down Expand Up @@ -86,14 +87,22 @@ object IncomingPaymentPacket {
}
}

private fun validate(add: UpdateAddHtlc, payload: PaymentOnion.FinalPayload): Either<FailureMessage, PaymentOnion.FinalPayload> {
private fun validate(add: UpdateAddHtlc, payload: PaymentOnion.FinalPayload.Standard): Either<FailureMessage, PaymentOnion.FinalPayload> {
return when {
add.amountMsat < payload.amount -> Either.Left(FinalIncorrectHtlcAmount(add.amountMsat))
add.cltvExpiry < payload.expiry -> Either.Left(FinalIncorrectCltvExpiry(add.cltvExpiry))
else -> Either.Right(payload)
}
}

private fun validate(add: UpdateAddHtlc, payload: PaymentOnion.FinalPayload.Blinded): Either<FailureMessage, PaymentOnion.FinalPayload> {
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<FailureMessage, PaymentOnion.FinalPayload> {
return when {
add.amountMsat < outerPayload.amount -> Either.Left(FinalIncorrectHtlcAmount(add.amountMsat))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
Expand Down Expand Up @@ -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<IncomingPaymentHandler.ProcessAddResult.Rejected>(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<IncomingPaymentHandler.ProcessAddResult.Pending>(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<IncomingPaymentHandler.ProcessAddResult.Rejected>(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<IncomingPaymentHandler.ProcessAddResult.Rejected>(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<OnionPaymentPayloadTlv.BlindingPoint>()!! +
blindedPayload.records.get<OnionPaymentPayloadTlv.EncryptedRecipientData>()!!))
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<IncomingPaymentHandler.ProcessAddResult.Rejected>(result)
Expand Down

0 comments on commit 13bfc92

Please sign in to comment.