Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement latest route blinding spec updates #2408

Merged
merged 4 commits into from Sep 12, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Expand Up @@ -29,6 +29,7 @@ import fr.acinq.eclair.{Alias, BlockHeight, CltvExpiry, CltvExpiryDelta, Feature
import scodec.bits.ByteVector

import java.util.UUID
import scala.concurrent.duration.FiniteDuration

/**
* Created by PM on 20/05/2016.
Expand Down Expand Up @@ -183,7 +184,7 @@ final case class CMD_ADD_HTLC(replyTo: ActorRef, amount: MilliSatoshi, paymentHa
sealed trait HtlcSettlementCommand extends HasOptionalReplyToCommand { def id: Long }
final case class CMD_FULFILL_HTLC(id: Long, r: ByteVector32, commit: Boolean = false, replyTo_opt: Option[ActorRef] = None) extends HtlcSettlementCommand
final case class CMD_FAIL_HTLC(id: Long, reason: Either[ByteVector, FailureMessage], commit: Boolean = false, replyTo_opt: Option[ActorRef] = None) extends HtlcSettlementCommand
final case class CMD_FAIL_MALFORMED_HTLC(id: Long, onionHash: ByteVector32, failureCode: Int, commit: Boolean = false, replyTo_opt: Option[ActorRef] = None) extends HtlcSettlementCommand
final case class CMD_FAIL_MALFORMED_HTLC(id: Long, onionHash: ByteVector32, failureCode: Int, delay_opt: Option[FiniteDuration] = None, commit: Boolean = false, replyTo_opt: Option[ActorRef] = None) extends HtlcSettlementCommand
final case class CMD_UPDATE_FEE(feeratePerKw: FeeratePerKw, commit: Boolean = false, replyTo_opt: Option[ActorRef] = None) extends HasOptionalReplyToCommand
final case class CMD_SIGN(replyTo_opt: Option[ActorRef] = None) extends HasOptionalReplyToCommand

Expand Down
Expand Up @@ -399,14 +399,20 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, val
}

case Event(c: CMD_FAIL_MALFORMED_HTLC, d: DATA_NORMAL) =>
Commitments.sendFailMalformed(d.commitments, c) match {
case Right((commitments1, fail)) =>
if (c.commit) self ! CMD_SIGN()
context.system.eventStream.publish(AvailableBalanceChanged(self, d.channelId, d.shortIds, commitments1))
handleCommandSuccess(c, d.copy(commitments = commitments1)) sending fail
case Left(cause) =>
// we acknowledge the command right away in case of failure
handleCommandError(cause, c).acking(d.channelId, c)
c.delay_opt match {
case Some(delay) =>
log.debug("delaying CMD_FAIL_MALFORMED_HTLC with id={} for {}", c.id, delay)
context.system.scheduler.scheduleOnce(delay, self, c.copy(delay_opt = None))
stay()
case None => Commitments.sendFailMalformed(d.commitments, c) match {
case Right((commitments1, fail)) =>
if (c.commit) self ! CMD_SIGN()
context.system.eventStream.publish(AvailableBalanceChanged(self, d.channelId, d.shortIds, commitments1))
handleCommandSuccess(c, d.copy(commitments = commitments1)) sending fail
case Left(cause) =>
// we acknowledge the command right away in case of failure
handleCommandError(cause, c).acking(d.channelId, c)
}
}

case Event(fail: UpdateFailHtlc, d: DATA_NORMAL) =>
Expand Down
Expand Up @@ -77,24 +77,21 @@ object IncomingPaymentPacket {

private[payment] def decryptEncryptedRecipientData(add: UpdateAddHtlc, privateKey: PrivateKey, payload: TlvStream[OnionPaymentPayloadTlv], encryptedRecipientData: ByteVector): Either[FailureMessage, DecodedEncryptedRecipientData] = {
if (add.blinding_opt.isDefined && payload.get[OnionPaymentPayloadTlv.BlindingPoint].isDefined) {
// TODO: return an unparseable error
Left(InvalidOnionPayload(UInt64(12), 0))
Left(InvalidOnionBlinding(Sphinx.hash(add.onionRoutingPacket)))
} else {
add.blinding_opt.orElse(payload.get[OnionPaymentPayloadTlv.BlindingPoint].map(_.publicKey)) match {
case Some(blinding) => RouteBlindingEncryptedDataCodecs.decode(privateKey, blinding, encryptedRecipientData) match {
case Left(_) =>
// There are two possibilities in this case:
// - the blinding point is invalid: the sender or the previous node is buggy or malicious
// - the encrypted data is invalid: the sender, the previous node or the recipient must be buggy or malicious
// TODO: return an unparseable error
Left(InvalidOnionPayload(UInt64(12), 0))
Left(InvalidOnionBlinding(Sphinx.hash(add.onionRoutingPacket)))
case Right(decoded) => Right(DecodedEncryptedRecipientData(decoded.tlvs, decoded.nextBlinding))
}
case None =>
// The sender is trying to use route blinding, but we didn't receive the blinding point used to derive
// the decryption key. The sender or the previous peer is buggy or malicious.
// TODO: return an unparseable error
Left(InvalidOnionPayload(UInt64(12), 0))
Left(InvalidOnionBlinding(Sphinx.hash(add.onionRoutingPacket)))
}
}
}
Expand Down Expand Up @@ -124,7 +121,6 @@ object IncomingPaymentPacket {
case DecodedEncryptedRecipientData(blindedPayload, nextBlinding) =>
validateBlindedChannelRelayPayload(add, payload, blindedPayload, nextBlinding, nextPacket)
}
case None if add.blinding_opt.isDefined => Left(InvalidOnionPayload(UInt64(12), 0))
t-bast marked this conversation as resolved.
Show resolved Hide resolved
case None => IntermediatePayload.ChannelRelay.Standard.validate(payload).left.map(_.failureMessage).map {
payload => ChannelRelayPacket(add, payload, nextPacket)
}
Expand All @@ -135,9 +131,8 @@ object IncomingPaymentPacket {
decryptEncryptedRecipientData(add, privateKey, payload, encryptedRecipientData).flatMap {
case DecodedEncryptedRecipientData(blindedPayload, _) =>
// TODO: receiving through blinded routes is not supported yet.
FinalPayload.Blinded.validate(payload, blindedPayload).left.map(_.failureMessage).flatMap(_ => Left(InvalidOnionPayload(UInt64(12), 0)))
FinalPayload.Blinded.validate(payload, blindedPayload).left.map(_.failureMessage).flatMap(_ => Left(InvalidOnionBlinding(Sphinx.hash(add.onionRoutingPacket))))
}
case None if add.blinding_opt.isDefined => Left(InvalidOnionPayload(UInt64(12), 0))
t-bast marked this conversation as resolved.
Show resolved Hide resolved
case None =>
// We check if the payment is using trampoline: if it is, we may not be the final recipient.
payload.get[OnionPaymentPayloadTlv.TrampolineOnion] match {
Expand All @@ -156,10 +151,9 @@ object IncomingPaymentPacket {

private def validateBlindedChannelRelayPayload(add: UpdateAddHtlc, payload: TlvStream[OnionPaymentPayloadTlv], blindedPayload: TlvStream[RouteBlindingEncryptedDataTlv], nextBlinding: PublicKey, nextPacket: OnionRoutingPacket): Either[FailureMessage, ChannelRelayPacket] = {
IntermediatePayload.ChannelRelay.Blinded.validate(payload, blindedPayload, nextBlinding).left.map(_.failureMessage).flatMap {
// TODO: return an unparseable error
case payload if add.amountMsat < payload.paymentConstraints.minAmount => Left(InvalidOnionPayload(UInt64(12), 0))
case payload if add.cltvExpiry > payload.paymentConstraints.maxCltvExpiry => Left(InvalidOnionPayload(UInt64(12), 0))
case payload if !Features.areCompatible(Features.empty, payload.allowedFeatures) => Left(InvalidOnionPayload(UInt64(12), 0))
case payload if add.amountMsat < payload.paymentConstraints.minAmount => Left(InvalidOnionBlinding(Sphinx.hash(add.onionRoutingPacket)))
case payload if add.cltvExpiry > payload.paymentConstraints.maxCltvExpiry => Left(InvalidOnionBlinding(Sphinx.hash(add.onionRoutingPacket)))
case payload if !Features.areCompatible(Features.empty, payload.allowedFeatures) => Left(InvalidOnionBlinding(Sphinx.hash(add.onionRoutingPacket)))
case payload => Right(ChannelRelayPacket(add, payload, nextPacket))
}
}
Expand Down
Expand Up @@ -23,6 +23,7 @@ import akka.actor.typed.scaladsl.adapter.TypedActorRefOps
import akka.actor.typed.scaladsl.{ActorContext, Behaviors}
import fr.acinq.bitcoin.scalacompat.ByteVector32
import fr.acinq.eclair.channel._
import fr.acinq.eclair.crypto.Sphinx
import fr.acinq.eclair.db.PendingCommandsDb
import fr.acinq.eclair.payment.Monitoring.{Metrics, Tags}
import fr.acinq.eclair.payment.relay.Relayer.{OutgoingChannel, OutgoingChannelParams}
Expand All @@ -32,6 +33,8 @@ import fr.acinq.eclair.wire.protocol._
import fr.acinq.eclair.{Logs, NodeParams, TimestampSecond, channel, nodeFee}

import java.util.UUID
import scala.concurrent.duration.DurationLong
import scala.util.Random

object ChannelRelay {

Expand Down Expand Up @@ -77,10 +80,21 @@ object ChannelRelay {
}
}

def translateRelayFailure(originHtlcId: Long, fail: HtlcResult.Fail): channel.Command with channel.HtlcSettlementCommand = {
def translateRelayFailure(originHtlcId: Long, fail: HtlcResult.Fail, relayPacket_opt: Option[IncomingPaymentPacket.ChannelRelayPacket]): channel.Command with channel.HtlcSettlementCommand = {
fail match {
case f: HtlcResult.RemoteFail => CMD_FAIL_HTLC(originHtlcId, Left(f.fail.reason), commit = true)
case f: HtlcResult.RemoteFailMalformed => CMD_FAIL_MALFORMED_HTLC(originHtlcId, f.fail.onionHash, f.fail.failureCode, commit = true)
case f: HtlcResult.RemoteFailMalformed => relayPacket_opt match {
case Some(IncomingPaymentPacket.ChannelRelayPacket(add, payload: IntermediatePayload.ChannelRelay.Blinded, _)) =>
// Bolt 2:
// - if it is part of a blinded route:
// - MUST return an `update_fail_malformed_htlc` error using the `invalid_onion_blinding` failure code, with the `sha256_of_onion` of the onion it received.
// - If its onion payload contains `current_blinding_point`:
// - SHOULD add a random delay before sending `update_fail_malformed_htlc`.
val delay_opt = payload.records.get[OnionPaymentPayloadTlv.BlindingPoint].map(_ => Random.nextLong(1000).millis)
CMD_FAIL_MALFORMED_HTLC(originHtlcId, Sphinx.hash(add.onionRoutingPacket), InvalidOnionBlinding(ByteVector32.Zeroes).code, delay_opt, commit = true)
case _ =>
CMD_FAIL_MALFORMED_HTLC(originHtlcId, f.fail.onionHash, f.fail.failureCode, commit = true)
}
case _: HtlcResult.OnChainFail => CMD_FAIL_HTLC(originHtlcId, Right(PermanentChannelFailure), commit = true)
case HtlcResult.ChannelFailureBeforeSigned => CMD_FAIL_HTLC(originHtlcId, Right(PermanentChannelFailure), commit = true)
case f: HtlcResult.DisconnectedBeforeSigned => CMD_FAIL_HTLC(originHtlcId, Right(TemporaryChannelFailure(f.channelUpdate)), commit = true)
Expand Down Expand Up @@ -154,7 +168,7 @@ class ChannelRelay private(nodeParams: NodeParams,
case WrappedAddResponse(RES_ADD_SETTLED(o: Origin.ChannelRelayedHot, _, fail: HtlcResult.Fail)) =>
context.log.info("relaying fail to upstream")
Metrics.recordPaymentRelayFailed(Tags.FailureType.Remote, Tags.RelayType.Channel)
val cmd = translateRelayFailure(o.originHtlcId, fail)
val cmd = translateRelayFailure(o.originHtlcId, fail, Some(r))
safeSendAndStop(o.originChannelId, cmd)
}

Expand Down
Expand Up @@ -235,7 +235,7 @@ class PostRestartHtlcCleaner(nodeParams: NodeParams, register: ActorRef, initial
case Origin.ChannelRelayedCold(originChannelId, originHtlcId, _, _) =>
log.warning(s"payment failed for paymentHash=${failedHtlc.paymentHash}: failing 1 HTLC upstream")
Metrics.Resolved.withTag(Tags.Success, value = false).withTag(Metrics.Relayed, value = true).increment()
val cmd = ChannelRelay.translateRelayFailure(originHtlcId, fail)
val cmd = ChannelRelay.translateRelayFailure(originHtlcId, fail, None)
PendingCommandsDb.safeSend(register, nodeParams.db.pendingCommands, originChannelId, cmd)
case Origin.TrampolineRelayedCold(origins) =>
log.warning(s"payment failed for paymentHash=${failedHtlc.paymentHash}: failing ${origins.length} HTLCs upstream")
Expand Down
Expand Up @@ -33,7 +33,8 @@ import fr.acinq.eclair.{Logs, MilliSatoshi, NodeParams}
import grizzled.slf4j.Logging

import scala.concurrent.Promise
import scala.concurrent.duration.FiniteDuration
import scala.concurrent.duration.{DurationLong, FiniteDuration}
import scala.util.Random

/**
* Created by PM on 01/02/2017.
Expand Down Expand Up @@ -77,7 +78,13 @@ class Relayer(nodeParams: NodeParams, router: ActorRef, register: ActorRef, paym
}
case Left(badOnion: BadOnion) =>
log.warning(s"couldn't parse onion: reason=${badOnion.message}")
val cmdFail = CMD_FAIL_MALFORMED_HTLC(add.id, badOnion.onionHash, badOnion.code, commit = true)
val delay_opt = badOnion match {
// We are the introduction point of a blinded path: we add a non-negligible delay to make it look like it
// could come from a downstream node.
case InvalidOnionBlinding(_) if add.blinding_opt.isEmpty => Some(500.millis + Random.nextLong(1500).millis)
case _ => None
}
val cmdFail = CMD_FAIL_MALFORMED_HTLC(add.id, badOnion.onionHash, badOnion.code, delay_opt, commit = true)
log.warning(s"rejecting htlc #${add.id} from channelId=${add.channelId} reason=malformed onionHash=${cmdFail.onionHash} failureCode=${cmdFail.failureCode}")
PendingCommandsDb.safeSend(register, nodeParams.db.pendingCommands, add.channelId, cmdFail)
case Left(failure) =>
Expand Down
Expand Up @@ -23,6 +23,8 @@ import fr.acinq.eclair.wire.protocol.FailureMessageCodecs.failureMessageCodec
import scodec.Codec
import scodec.codecs._

import scala.concurrent.duration.FiniteDuration

object CommandCodecs {

val cmdFulfillCodec: Codec[CMD_FULFILL_HTLC] =
Expand All @@ -41,6 +43,8 @@ object CommandCodecs {
(("id" | int64) ::
("onionHash" | bytes32) ::
("failureCode" | uint16) ::
// No need to delay commands after a restart, we've been offline which already created a random delay.
("delay_opt" | provide(Option.empty[FiniteDuration])) ::
("commit" | provide(false)) ::
("replyTo_opt" | provide(Option.empty[ActorRef]))).as[CMD_FAIL_MALFORMED_HTLC]

Expand Down
Expand Up @@ -49,6 +49,7 @@ case object RequiredNodeFeatureMissing extends Perm with Node { def message = "p
case class InvalidOnionVersion(onionHash: ByteVector32) extends BadOnion with Perm { def message = "onion version was not understood by the processing node" }
case class InvalidOnionHmac(onionHash: ByteVector32) extends BadOnion with Perm { def message = "onion HMAC was incorrect when it reached the processing node" }
case class InvalidOnionKey(onionHash: ByteVector32) extends BadOnion with Perm { def message = "ephemeral key was unparsable by the processing node" }
case class InvalidOnionBlinding(onionHash: ByteVector32) extends BadOnion with Perm { def message = "the blinded onion didn't match the processing node's requirements" }
case class TemporaryChannelFailure(update: ChannelUpdate) extends Update { def message = s"channel ${update.shortChannelId} is currently unavailable" }
case object PermanentChannelFailure extends Perm { def message = "channel is permanently unavailable" }
case object RequiredChannelFeatureMissing extends Perm { def message = "channel requires features not present in the onion" }
Expand Down Expand Up @@ -120,6 +121,7 @@ object FailureMessageCodecs {
.typecase(21, provide(ExpiryTooFar))
.typecase(PERM | 22, (("tag" | varint) :: ("offset" | uint16)).as[InvalidOnionPayload])
.typecase(23, provide(PaymentTimeout))
.typecase(BADONION | PERM | 24, sha256.as[InvalidOnionBlinding])
// TODO: @t-bast: once fully spec-ed, these should probably include a NodeUpdate and use a different ID.
// We should update Phoenix and our nodes at the same time, or first update Phoenix to understand both new and old errors.
.typecase(NODE | 51, provide(TrampolineFeeInsufficient))
Expand Down
Expand Up @@ -1771,6 +1771,20 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with
localChanges = initialState.commitments.localChanges.copy(initialState.commitments.localChanges.proposed :+ fail))))
}

test("recv CMD_FAIL_MALFORMED_HTLC (with delay)") { f =>
import f._
val (_, htlc) = addHtlc(50000000 msat, alice, bob, alice2bob, bob2alice)
crossSign(alice, bob, alice2bob, bob2alice)

// actual test begins
val initialState = bob.stateData.asInstanceOf[DATA_NORMAL]
bob ! CMD_FAIL_MALFORMED_HTLC(htlc.id, Sphinx.hash(htlc.onionRoutingPacket), FailureMessageCodecs.BADONION | FailureMessageCodecs.PERM | 24, delay_opt = Some(50 millis))
val fail = bob2alice.expectMsgType[UpdateFailMalformedHtlc]
awaitCond(bob.stateData == initialState.copy(
commitments = initialState.commitments.copy(
localChanges = initialState.commitments.localChanges.copy(initialState.commitments.localChanges.proposed :+ fail))))
}

test("recv CMD_FAIL_MALFORMED_HTLC (unknown htlc id)") { f =>
import f._
val sender = TestProbe()
Expand Down
Expand Up @@ -49,30 +49,30 @@ class CommandCodecsSpec extends AnyFunSuite {
}

test("backward compatibility") {

val data32 = randomBytes32()
val data123 = randomBytes(123)

val legacyCmdFulfillCodec =
(("id" | int64) ::
("id" | int64) ::
("r" | bytes32) ::
("commit" | provide(false)))
("commit" | provide(false))
assert(CommandCodecs.cmdFulfillCodec.decode(legacyCmdFulfillCodec.encode(42 :: data32 :: true :: HNil).require).require ==
DecodeResult(CMD_FULFILL_HTLC(42, data32, commit = false, None), BitVector.empty))

val legacyCmdFailCodec =
(("id" | int64) ::
("id" | int64) ::
("reason" | either(bool, varsizebinarydata, failureMessageCodec)) ::
("commit" | provide(false)))
("commit" | provide(false))
assert(CommandCodecs.cmdFailCodec.decode(legacyCmdFailCodec.encode(42 :: Left(data123) :: true :: HNil).require).require ==
DecodeResult(CMD_FAIL_HTLC(42, Left(data123), commit = false, None), BitVector.empty))

val legacyCmdFailMalformedCodec =
(("id" | int64) ::
("id" | int64) ::
("onionHash" | bytes32) ::
("failureCode" | uint16) ::
("commit" | provide(false)))
("commit" | provide(false))
assert(CommandCodecs.cmdFailMalformedCodec.decode(legacyCmdFailMalformedCodec.encode(42 :: data32 :: 456 :: true :: HNil).require).require ==
DecodeResult(CMD_FAIL_MALFORMED_HTLC(42, data32, 456, commit = false, None), BitVector.empty))
DecodeResult(CMD_FAIL_MALFORMED_HTLC(42, data32, 456, None, commit = false, None), BitVector.empty))
}

}