1
0
Fork 0
mirror of https://github.com/ACINQ/eclair.git synced 2025-02-23 14:40:34 +01:00

Implement latest route blinding spec updates (#2408)

Add InvalidOnionBlinded error and translate downstream errors when
we're inside a blinded route, with a random delay when we're the
introduction point.

Add more restrictions to the tlvs that can be used inside blinded payloads.

Add route blinding feature bit and reject blinded payments when
the feature is disabled.
This commit is contained in:
Bastien Teinturier 2022-09-12 17:47:17 +02:00 committed by GitHub
parent 59f6cdad4c
commit 09f1940333
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 255 additions and 103 deletions

View file

@ -57,6 +57,7 @@ eclair {
// Do not enable option_anchor_outputs unless you really know what you're doing.
option_anchor_outputs = disabled
option_anchors_zero_fee_htlc_tx = optional
option_route_blinding = disabled
option_shutdown_anysegwit = optional
option_dual_fund = disabled
option_onion_messages = optional

View file

@ -221,6 +221,11 @@ object Features {
val mandatory = 22
}
case object RouteBlinding extends Feature with InitFeature with NodeFeature with InvoiceFeature {
val rfcName = "option_route_blinding"
val mandatory = 24
}
case object ShutdownAnySegwit extends Feature with InitFeature with NodeFeature {
val rfcName = "option_shutdown_anysegwit"
val mandatory = 26
@ -285,6 +290,7 @@ object Features {
StaticRemoteKey,
AnchorOutputs,
AnchorOutputsZeroFeeHtlcTx,
RouteBlinding,
ShutdownAnySegwit,
DualFunding,
OnionMessages,
@ -303,6 +309,7 @@ object Features {
BasicMultiPartPayment -> (PaymentSecret :: Nil),
AnchorOutputs -> (StaticRemoteKey :: Nil),
AnchorOutputsZeroFeeHtlcTx -> (StaticRemoteKey :: Nil),
RouteBlinding -> (VariableLengthOnion :: Nil),
TrampolinePaymentPrototype -> (PaymentSecret :: Nil),
KeySend -> (VariableLengthOnion :: Nil)
)

View file

@ -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.
@ -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

View file

@ -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) =>

View file

@ -25,7 +25,7 @@ import fr.acinq.eclair.crypto.Sphinx
import fr.acinq.eclair.router.Router.{ChannelHop, Hop, NodeHop}
import fr.acinq.eclair.wire.protocol.PaymentOnion.{FinalPayload, IntermediatePayload, PerHopPayload}
import fr.acinq.eclair.wire.protocol._
import fr.acinq.eclair.{CltvExpiry, CltvExpiryDelta, Features, MilliSatoshi, UInt64, randomBytes32, randomKey}
import fr.acinq.eclair.{CltvExpiry, CltvExpiryDelta, Feature, Features, MilliSatoshi, UInt64, randomBytes32, randomKey}
import scodec.bits.ByteVector
import scodec.{Attempt, DecodeResult}
@ -77,8 +77,7 @@ 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 {
@ -86,15 +85,13 @@ object IncomingPaymentPacket {
// 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)))
}
}
}
@ -110,7 +107,7 @@ object IncomingPaymentPacket {
* @param privateKey this node's private key
* @return whether the payment is to be relayed or if our node is the final recipient (or an error).
*/
def decrypt(add: UpdateAddHtlc, privateKey: PrivateKey)(implicit log: LoggingAdapter): Either[FailureMessage, IncomingPaymentPacket] = {
def decrypt(add: UpdateAddHtlc, privateKey: PrivateKey, features: Features[Feature])(implicit log: LoggingAdapter): Either[FailureMessage, IncomingPaymentPacket] = {
// We first derive the decryption key used to peel the onion.
val outerOnionDecryptionKey = add.blinding_opt match {
case Some(blinding) => Sphinx.RouteBlinding.derivePrivateKey(privateKey, blinding)
@ -119,25 +116,25 @@ object IncomingPaymentPacket {
decryptOnion(add.paymentHash, outerOnionDecryptionKey, add.onionRoutingPacket).flatMap {
case DecodedOnionPacket(payload, Some(nextPacket)) =>
payload.get[OnionPaymentPayloadTlv.EncryptedRecipientData] match {
case Some(OnionPaymentPayloadTlv.EncryptedRecipientData(encryptedRecipientData)) =>
decryptEncryptedRecipientData(add, privateKey, payload, encryptedRecipientData).flatMap {
case Some(_) if !features.hasFeature(Features.RouteBlinding) => Left(InvalidOnionPayload(UInt64(10), 0))
case Some(encrypted) =>
decryptEncryptedRecipientData(add, privateKey, payload, encrypted.data).flatMap {
case DecodedEncryptedRecipientData(blindedPayload, nextBlinding) =>
validateBlindedChannelRelayPayload(add, payload, blindedPayload, nextBlinding, nextPacket)
}
case None if add.blinding_opt.isDefined => Left(InvalidOnionPayload(UInt64(12), 0))
case None if add.blinding_opt.isDefined => Left(InvalidOnionBlinding(Sphinx.hash(add.onionRoutingPacket)))
case None => IntermediatePayload.ChannelRelay.Standard.validate(payload).left.map(_.failureMessage).map {
payload => ChannelRelayPacket(add, payload, nextPacket)
}
}
case DecodedOnionPacket(payload, None) =>
payload.get[OnionPaymentPayloadTlv.EncryptedRecipientData] match {
case Some(OnionPaymentPayloadTlv.EncryptedRecipientData(encryptedRecipientData)) =>
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)))
case Some(_) if !features.hasFeature(Features.RouteBlinding) => Left(InvalidOnionPayload(UInt64(10), 0))
case Some(encrypted) =>
decryptEncryptedRecipientData(add, privateKey, payload, encrypted.data).flatMap {
case DecodedEncryptedRecipientData(blindedPayload, _) => validateBlindedFinalPayload(add, payload, blindedPayload)
}
case None if add.blinding_opt.isDefined => Left(InvalidOnionPayload(UInt64(12), 0))
case None if add.blinding_opt.isDefined => Left(InvalidOnionBlinding(Sphinx.hash(add.onionRoutingPacket)))
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 {
@ -146,7 +143,7 @@ object IncomingPaymentPacket {
// blinding point and use it to derive the decryption key for the blinded trampoline onion.
decryptOnion(add.paymentHash, privateKey, trampolinePacket).flatMap {
case DecodedOnionPacket(innerPayload, Some(next)) => validateNodeRelay(add, payload, innerPayload, next)
case DecodedOnionPacket(innerPayload, None) => validateFinalPayload(add, payload, innerPayload)
case DecodedOnionPacket(innerPayload, None) => validateTrampolineFinalPayload(add, payload, innerPayload)
}
case None => validateFinalPayload(add, payload)
}
@ -156,10 +153,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))
}
}
@ -172,7 +168,17 @@ object IncomingPaymentPacket {
}
}
private def validateFinalPayload(add: UpdateAddHtlc, outerPayload: TlvStream[OnionPaymentPayloadTlv], innerPayload: TlvStream[OnionPaymentPayloadTlv]): Either[FailureMessage, FinalPacket] = {
private def validateBlindedFinalPayload(add: UpdateAddHtlc, payload: TlvStream[OnionPaymentPayloadTlv], blindedPayload: TlvStream[RouteBlindingEncryptedDataTlv]): Either[FailureMessage, FinalPacket] = {
FinalPayload.Blinded.validate(payload, blindedPayload).left.map(_.failureMessage).flatMap {
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)))
// TODO: receiving through blinded routes is not supported yet.
case _ => Left(InvalidOnionBlinding(Sphinx.hash(add.onionRoutingPacket)))
}
}
private def validateTrampolineFinalPayload(add: UpdateAddHtlc, outerPayload: TlvStream[OnionPaymentPayloadTlv], innerPayload: TlvStream[OnionPaymentPayloadTlv]): Either[FailureMessage, FinalPacket] = {
// The outer payload cannot use route blinding, but the inner payload may (but it's not supported yet).
FinalPayload.Standard.validate(outerPayload).left.map(_.failureMessage).flatMap { outerPayload =>
FinalPayload.Standard.validate(innerPayload).left.map(_.failureMessage).flatMap {

View file

@ -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}
@ -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 {
@ -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)
@ -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)
}

View file

@ -29,7 +29,7 @@ import fr.acinq.eclair.payment.Monitoring.Tags
import fr.acinq.eclair.payment.{ChannelPaymentRelayed, IncomingPaymentPacket, PaymentFailed, PaymentSent}
import fr.acinq.eclair.transactions.DirectedHtlc.outgoing
import fr.acinq.eclair.wire.protocol.{FailureMessage, TemporaryNodeFailure, UpdateAddHtlc}
import fr.acinq.eclair.{CustomCommitmentsPlugin, Logs, MilliSatoshiLong, NodeParams, TimestampMilli}
import fr.acinq.eclair.{CustomCommitmentsPlugin, Feature, Features, Logs, MilliSatoshiLong, NodeParams, TimestampMilli}
import scala.concurrent.Promise
import scala.util.Try
@ -67,7 +67,7 @@ class PostRestartHtlcCleaner(nodeParams: NodeParams, register: ActorRef, initial
val brokenHtlcs: BrokenHtlcs = {
val channels = listLocalChannels(nodeParams.db.channels)
val nonStandardIncomingHtlcs: Seq[IncomingHtlc] = nodeParams.pluginParams.collect { case p: CustomCommitmentsPlugin => p.getIncomingHtlcs(nodeParams, log) }.flatten
val htlcsIn: Seq[IncomingHtlc] = getIncomingHtlcs(channels, nodeParams.db.payments, nodeParams.privateKey) ++ nonStandardIncomingHtlcs
val htlcsIn: Seq[IncomingHtlc] = getIncomingHtlcs(channels, nodeParams.db.payments, nodeParams.privateKey, nodeParams.features) ++ nonStandardIncomingHtlcs
val nonStandardRelayedOutHtlcs: Map[Origin, Set[(ByteVector32, Long)]] = nodeParams.pluginParams.collect { case p: CustomCommitmentsPlugin => p.getHtlcsRelayedOut(htlcsIn, nodeParams, log) }.flatten.toMap
val relayedOut: Map[Origin, Set[(ByteVector32, Long)]] = getHtlcsRelayedOut(channels, htlcsIn) ++ nonStandardRelayedOutHtlcs
@ -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")
@ -334,14 +334,14 @@ object PostRestartHtlcCleaner {
}
/** @return incoming HTLCs that have been *cross-signed* (that potentially have been relayed). */
private def getIncomingHtlcs(channels: Seq[PersistentChannelData], paymentsDb: IncomingPaymentsDb, privateKey: PrivateKey)(implicit log: LoggingAdapter): Seq[IncomingHtlc] = {
private def getIncomingHtlcs(channels: Seq[PersistentChannelData], paymentsDb: IncomingPaymentsDb, privateKey: PrivateKey, features: Features[Feature])(implicit log: LoggingAdapter): Seq[IncomingHtlc] = {
// We are interested in incoming HTLCs, that have been *cross-signed* (otherwise they wouldn't have been relayed).
// They signed it first, so the HTLC will first appear in our commitment tx, and later on in their commitment when
// we subsequently sign it. That's why we need to look in *their* commitment with direction=OUT.
channels
.flatMap(_.commitments.remoteCommit.spec.htlcs)
.collect(outgoing)
.map(IncomingPaymentPacket.decrypt(_, privateKey))
.map(IncomingPaymentPacket.decrypt(_, privateKey, features))
.collect(decryptedIncomingHtlcs(paymentsDb))
}

View file

@ -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.
@ -62,7 +63,7 @@ class Relayer(nodeParams: NodeParams, router: ActorRef, register: ActorRef, paym
def receive: Receive = {
case RelayForward(add) =>
log.debug(s"received forwarding request for htlc #${add.id} from channelId=${add.channelId}")
IncomingPaymentPacket.decrypt(add, nodeParams.privateKey) match {
IncomingPaymentPacket.decrypt(add, nodeParams.privateKey, nodeParams.features) match {
case Right(p: IncomingPaymentPacket.FinalPacket) =>
log.debug(s"forwarding htlc #${add.id} to payment-handler")
paymentHandler forward p
@ -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) =>

View file

@ -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] =
@ -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]

View file

@ -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" }
@ -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))

View file

@ -145,6 +145,19 @@ object OnionPaymentPayloadTlv {
/** Id of the next node. */
case class OutgoingNodeId(nodeId: PublicKey) extends OnionPaymentPayloadTlv
/**
* Route blinding lets the recipient provide some encrypted data for each intermediate node in the blinded part of the
* route. This data cannot be decrypted or modified by the sender and usually contains information to locate the next
* node without revealing it to the sender.
*/
case class EncryptedRecipientData(data: ByteVector) extends OnionPaymentPayloadTlv
/** Blinding ephemeral public key for the introduction node of a blinded route. */
case class BlindingPoint(publicKey: PublicKey) extends OnionPaymentPayloadTlv
/** Total amount in blinded multi-part payments. */
case class TotalAmount(totalAmount: MilliSatoshi) extends OnionPaymentPayloadTlv
/**
* When payment metadata is included in a Bolt 11 invoice, we should send it as-is to the recipient.
* This lets recipients generate invoices without having to store anything on their side until the invoice is paid.
@ -168,16 +181,6 @@ object OnionPaymentPayloadTlv {
/** Pre-image included by the sender of a payment in case of a donation */
case class KeySend(paymentPreimage: ByteVector32) extends OnionPaymentPayloadTlv
/**
* Route blinding lets the recipient provide some encrypted data for each intermediate node in the blinded part of the
* route. This data cannot be decrypted or modified by the sender and usually contains information to locate the next
* node without revealing it to the sender.
*/
case class EncryptedRecipientData(data: ByteVector) extends OnionPaymentPayloadTlv
/** Blinding ephemeral public key for the introduction node of a blinded route. */
case class BlindingPoint(publicKey: PublicKey) extends OnionPaymentPayloadTlv
}
object PaymentOnion {
@ -264,9 +267,17 @@ object PaymentOnion {
object Blinded {
def validate(records: TlvStream[OnionPaymentPayloadTlv], blindedRecords: TlvStream[RouteBlindingEncryptedDataTlv], nextBlinding: PublicKey): Either[InvalidTlvPayload, Blinded] = {
if (records.get[AmountToForward].nonEmpty) return Left(ForbiddenTlv(UInt64(2)))
if (records.get[OutgoingCltv].nonEmpty) return Left(ForbiddenTlv(UInt64(4)))
if (records.get[EncryptedRecipientData].isEmpty) return Left(MissingRequiredTlv(UInt64(10)))
// Bolt 4: MUST return an error if the payload contains other tlv fields than `encrypted_recipient_data` and `current_blinding_point`.
if (records.unknown.nonEmpty) return Left(ForbiddenTlv(records.unknown.head.tag))
records.records.find {
case _: EncryptedRecipientData => false
case _: BlindingPoint => false
case _ => true
} match {
case Some(_) => return Left(ForbiddenTlv(UInt64(0)))
case None => // no forbidden tlv found
}
BlindedRouteData.validatePaymentRelayData(blindedRecords).map(blindedRecords => Blinded(records, blindedRecords, nextBlinding))
}
}
@ -388,7 +399,7 @@ object PaymentOnion {
*/
case class Blinded(records: TlvStream[OnionPaymentPayloadTlv], blindedRecords: TlvStream[RouteBlindingEncryptedDataTlv]) extends FinalPayload {
override val amount = records.get[AmountToForward].get.amount
override val totalAmount = amount // TODO: get from total_amount_msat tlv
override val totalAmount = records.get[TotalAmount].map(_.totalAmount).getOrElse(amount)
override val expiry = records.get[OutgoingCltv].get.cltv
val pathId_opt = blindedRecords.get[RouteBlindingEncryptedDataTlv.PathId].map(_.data)
val paymentConstraints = blindedRecords.get[RouteBlindingEncryptedDataTlv.PaymentConstraints].get
@ -403,6 +414,20 @@ object PaymentOnion {
def validate(records: TlvStream[OnionPaymentPayloadTlv], blindedRecords: TlvStream[RouteBlindingEncryptedDataTlv]): Either[InvalidTlvPayload, Blinded] = {
if (records.get[AmountToForward].isEmpty) return Left(MissingRequiredTlv(UInt64(2)))
if (records.get[OutgoingCltv].isEmpty) return Left(MissingRequiredTlv(UInt64(4)))
if (records.get[EncryptedRecipientData].isEmpty) return Left(MissingRequiredTlv(UInt64(10)))
// Bolt 4: MUST return an error if the payload contains other tlv fields than `encrypted_recipient_data`, `current_blinding_point`, `amt_to_forward`, `outgoing_cltv_value` and `total_amount_msat`.
if (records.unknown.nonEmpty) return Left(ForbiddenTlv(records.unknown.head.tag))
records.records.find {
case _: AmountToForward => false
case _: OutgoingCltv => false
case _: EncryptedRecipientData => false
case _: BlindingPoint => false
case _: TotalAmount => false
case _ => true
} match {
case Some(_) => return Left(ForbiddenTlv(UInt64(0)))
case None => // no forbidden tlv found
}
BlindedRouteData.validPaymentRecipientData(blindedRecords).map(blindedRecords => Blinded(records, blindedRecords))
}
}
@ -447,6 +472,8 @@ object PaymentOnionCodecs {
private val paymentMetadata: Codec[PaymentMetadata] = variableSizeBytesLong(varintoverflow, "payment_metadata" | bytes).as[PaymentMetadata]
private val totalAmount: Codec[TotalAmount] = ("total_amount_msat" | ltmillisatoshi).as[TotalAmount]
private val invoiceFeatures: Codec[InvoiceFeatures] = variableSizeBytesLong(varintoverflow, bytes).as[InvoiceFeatures]
private val invoiceRoutingInfo: Codec[InvoiceRoutingInfo] = variableSizeBytesLong(varintoverflow, list(listOfN(uint8, Bolt11Invoice.Codecs.extraHopCodec))).as[InvoiceRoutingInfo]
@ -463,6 +490,7 @@ object PaymentOnionCodecs {
.typecase(UInt64(10), encryptedRecipientData)
.typecase(UInt64(12), blindingPoint)
.typecase(UInt64(16), paymentMetadata)
.typecase(UInt64(18), totalAmount)
// Types below aren't specified - use cautiously when deploying (be careful with backwards-compatibility).
.typecase(UInt64(66097), invoiceFeatures)
.typecase(UInt64(66098), outgoingNodeId)

View file

@ -78,6 +78,7 @@ object BlindedRouteData {
if (records.get[PathId].isDefined) return Left(ForbiddenTlv(UInt64(6)))
if (records.get[PaymentRelay].isDefined) return Left(ForbiddenTlv(UInt64(10)))
if (records.get[PaymentConstraints].isDefined) return Left(ForbiddenTlv(UInt64(12)))
if (records.get[AllowedFeatures].exists(!_.features.isEmpty)) return Left(ForbiddenTlv(UInt64(14))) // we don't support custom blinded relay features yet
Right(records)
}
@ -92,6 +93,7 @@ object BlindedRouteData {
if (records.get[PaymentRelay].isEmpty) return Left(MissingRequiredTlv(UInt64(10)))
if (records.get[PaymentConstraints].isEmpty) return Left(MissingRequiredTlv(UInt64(12)))
if (records.get[PathId].nonEmpty) return Left(ForbiddenTlv(UInt64(6)))
if (records.get[AllowedFeatures].exists(!_.features.isEmpty)) return Left(ForbiddenTlv(UInt64(14))) // we don't support custom blinded relay features yet
Right(records)
}

View file

@ -99,8 +99,8 @@ class FeaturesSpec extends AnyFunSuite {
for ((testCase, valid) <- testCases) {
if (valid) {
assert(validateFeatureGraph(Features(testCase)) == None)
assert(validateFeatureGraph(Features(testCase.bytes)) == None)
assert(validateFeatureGraph(Features(testCase)).isEmpty)
assert(validateFeatureGraph(Features(testCase.bytes)).isEmpty)
} else {
assert(validateFeatureGraph(Features(testCase)).nonEmpty)
assert(validateFeatureGraph(Features(testCase.bytes)).nonEmpty)
@ -235,7 +235,7 @@ class FeaturesSpec extends AnyFunSuite {
hex"" -> Features.empty,
hex"0100" -> Features(VariableLengthOnion -> Mandatory),
hex"028a8a" -> Features(DataLossProtect -> Optional, InitialRoutingSync -> Optional, ChannelRangeQueries -> Optional, VariableLengthOnion -> Optional, ChannelRangeQueriesExtended -> Optional, PaymentSecret -> Optional, BasicMultiPartPayment -> Optional),
hex"09004200" -> Features(Map(VariableLengthOnion -> Optional, PaymentSecret -> Mandatory, ShutdownAnySegwit -> Optional), Set(UnknownFeature(24))),
hex"09004200" -> Features(Map(VariableLengthOnion -> Optional, PaymentSecret -> Mandatory, RouteBlinding -> Mandatory, ShutdownAnySegwit -> Optional)),
hex"80010080000000000000000000000000000000000000" -> Features(Map.empty[Feature, FeatureSupport], Set(UnknownFeature(151), UnknownFeature(160), UnknownFeature(175)))
)
@ -264,7 +264,7 @@ class FeaturesSpec extends AnyFunSuite {
val features = fromConfiguration(conf)
assert(features.toByteVector == hex"028a8a")
assert(Features(hex"028a8a") == features)
assert(validateFeatureGraph(features) == None)
assert(validateFeatureGraph(features).isEmpty)
assert(features.hasFeature(DataLossProtect, Some(Optional)))
assert(features.hasFeature(InitialRoutingSync, Some(Optional)))
assert(features.hasFeature(ChannelRangeQueries, Some(Optional)))
@ -287,7 +287,7 @@ class FeaturesSpec extends AnyFunSuite {
val features = fromConfiguration(conf)
assert(features.toByteVector == hex"068a")
assert(Features(hex"068a") == features)
assert(validateFeatureGraph(features) == None)
assert(validateFeatureGraph(features).isEmpty)
assert(features.hasFeature(DataLossProtect, Some(Optional)))
assert(features.hasFeature(InitialRoutingSync, Some(Optional)))
assert(!features.hasFeature(InitialRoutingSync, Some(Mandatory)))

View file

@ -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()

View file

@ -213,7 +213,7 @@ class Bolt11InvoiceSpec extends AnyFunSuite {
assert(invoice.prefix == "lntbs")
assert(invoice.amount_opt.contains(250000000 msat))
assert(invoice.paymentHash.bytes == hex"4ffb6e9eabe93a88eb927ead43ae74172d9fbc3d858cede1e80871a5eb8bd863")
assert(invoice.features == Features(VariableLengthOnion -> Mandatory, PaymentSecret -> Mandatory, BasicMultiPartPayment -> Optional, PaymentMetadata -> Optional ))
assert(invoice.features == Features(VariableLengthOnion -> Mandatory, PaymentSecret -> Mandatory, BasicMultiPartPayment -> Optional, PaymentMetadata -> Optional))
assert(invoice.createdAt == TimestampSecond(1660836433))
assert(invoice.nodeId == PublicKey(hex"02e899d99662f2e64ea0eeaecb53c4628fa40a22d7185076e42e8a3d67fcb7b8e6"))
assert(invoice.description == Left("yolo"))
@ -492,8 +492,8 @@ class Bolt11InvoiceSpec extends AnyFunSuite {
Features(bin" 0000110000101000100000000") -> Result(allowMultiPart = false, requirePaymentSecret = true, areSupported = true),
Features(bin" 0000100000101000100000000") -> Result(allowMultiPart = false, requirePaymentSecret = true, areSupported = true),
Features(bin" 0010000000101000100000000") -> Result(allowMultiPart = false, requirePaymentSecret = true, areSupported = true),
Features(bin" 000001000000000100000100000000") -> Result(allowMultiPart = false, requirePaymentSecret = true, areSupported = true),
// those are useful for nonreg testing of the areSupported method (which needs to be updated with every new supported mandatory bit)
Features(bin" 000001000000000100000100000000") -> Result(allowMultiPart = false, requirePaymentSecret = true, areSupported = false),
Features(bin" 000100000000000100000100000000") -> Result(allowMultiPart = false, requirePaymentSecret = true, areSupported = true),
Features(bin"00000010000000000000100000100000000") -> Result(allowMultiPart = false, requirePaymentSecret = true, areSupported = true),
Features(bin"00001000000000000000100000100000000") -> Result(allowMultiPart = false, requirePaymentSecret = true, areSupported = false)

View file

@ -75,7 +75,7 @@ class PaymentPacketSpec extends AnyFunSuite with BeforeAndAfterAll {
def testPeelOnion(packet_b: OnionRoutingPacket): Unit = {
val add_b = UpdateAddHtlc(randomBytes32(), 0, amount_ab, paymentHash, expiry_ab, packet_b, None)
val Right(relay_b@ChannelRelayPacket(add_b2, payload_b, packet_c)) = decrypt(add_b, priv_b.privateKey)
val Right(relay_b@ChannelRelayPacket(add_b2, payload_b, packet_c)) = decrypt(add_b, priv_b.privateKey, Features.empty)
assert(add_b2 == add_b)
assert(packet_c.payload.length == PaymentOnionCodecs.paymentOnionPayloadLength)
assert(relay_b.amountToForward == amount_bc)
@ -85,7 +85,7 @@ class PaymentPacketSpec extends AnyFunSuite with BeforeAndAfterAll {
assert(relay_b.expiryDelta == channelUpdate_bc.cltvExpiryDelta)
val add_c = UpdateAddHtlc(randomBytes32(), 1, amount_bc, paymentHash, expiry_bc, packet_c, None)
val Right(relay_c@ChannelRelayPacket(add_c2, payload_c, packet_d)) = decrypt(add_c, priv_c.privateKey)
val Right(relay_c@ChannelRelayPacket(add_c2, payload_c, packet_d)) = decrypt(add_c, priv_c.privateKey, Features.empty)
assert(add_c2 == add_c)
assert(packet_d.payload.length == PaymentOnionCodecs.paymentOnionPayloadLength)
assert(relay_c.amountToForward == amount_cd)
@ -95,7 +95,7 @@ class PaymentPacketSpec extends AnyFunSuite with BeforeAndAfterAll {
assert(relay_c.expiryDelta == channelUpdate_cd.cltvExpiryDelta)
val add_d = UpdateAddHtlc(randomBytes32(), 2, amount_cd, paymentHash, expiry_cd, packet_d, None)
val Right(relay_d@ChannelRelayPacket(add_d2, payload_d, packet_e)) = decrypt(add_d, priv_d.privateKey)
val Right(relay_d@ChannelRelayPacket(add_d2, payload_d, packet_e)) = decrypt(add_d, priv_d.privateKey, Features.empty)
assert(add_d2 == add_d)
assert(packet_e.payload.length == PaymentOnionCodecs.paymentOnionPayloadLength)
assert(relay_d.amountToForward == amount_de)
@ -105,7 +105,7 @@ class PaymentPacketSpec extends AnyFunSuite with BeforeAndAfterAll {
assert(relay_d.expiryDelta == channelUpdate_de.cltvExpiryDelta)
val add_e = UpdateAddHtlc(randomBytes32(), 2, amount_de, paymentHash, expiry_de, packet_e, None)
val Right(FinalPacket(add_e2, payload_e)) = decrypt(add_e, priv_e.privateKey)
val Right(FinalPacket(add_e2, payload_e)) = decrypt(add_e, priv_e.privateKey, Features.empty)
assert(add_e2 == add_e)
assert(payload_e.amount == finalAmount)
assert(payload_e.totalAmount == finalAmount)
@ -137,7 +137,7 @@ class PaymentPacketSpec extends AnyFunSuite with BeforeAndAfterAll {
// let's peel the onion
val add_b = UpdateAddHtlc(randomBytes32(), 0, finalAmount, paymentHash, finalExpiry, add.onion, None)
val Right(FinalPacket(add_b2, payload_b)) = decrypt(add_b, priv_b.privateKey)
val Right(FinalPacket(add_b2, payload_b)) = decrypt(add_b, priv_b.privateKey, Features.empty)
assert(add_b2 == add_b)
assert(payload_b.amount == finalAmount)
assert(payload_b.totalAmount == finalAmount)
@ -161,12 +161,12 @@ class PaymentPacketSpec extends AnyFunSuite with BeforeAndAfterAll {
assert(firstExpiry == expiry_ab)
val add_b = UpdateAddHtlc(randomBytes32(), 1, firstAmount, paymentHash, firstExpiry, onion.packet, None)
val Right(ChannelRelayPacket(add_b2, payload_b, packet_c)) = decrypt(add_b, priv_b.privateKey)
val Right(ChannelRelayPacket(add_b2, payload_b, packet_c)) = decrypt(add_b, priv_b.privateKey, Features.empty)
assert(add_b2 == add_b)
assert(payload_b == IntermediatePayload.ChannelRelay.Standard(channelUpdate_bc.shortChannelId, amount_bc, expiry_bc))
val add_c = UpdateAddHtlc(randomBytes32(), 2, amount_bc, paymentHash, expiry_bc, packet_c, None)
val Right(NodeRelayPacket(add_c2, outer_c, inner_c, packet_d)) = decrypt(add_c, priv_c.privateKey)
val Right(NodeRelayPacket(add_c2, outer_c, inner_c, packet_d)) = decrypt(add_c, priv_c.privateKey, Features.empty)
assert(add_c2 == add_c)
assert(outer_c.amount == amount_bc)
assert(outer_c.totalAmount == amount_bc)
@ -184,7 +184,7 @@ class PaymentPacketSpec extends AnyFunSuite with BeforeAndAfterAll {
assert(amount_d == amount_cd)
assert(expiry_d == expiry_cd)
val add_d = UpdateAddHtlc(randomBytes32(), 3, amount_d, paymentHash, expiry_d, onion_d.packet, None)
val Right(NodeRelayPacket(add_d2, outer_d, inner_d, packet_e)) = decrypt(add_d, priv_d.privateKey)
val Right(NodeRelayPacket(add_d2, outer_d, inner_d, packet_e)) = decrypt(add_d, priv_d.privateKey, Features.empty)
assert(add_d2 == add_d)
assert(outer_d.amount == amount_cd)
assert(outer_d.totalAmount == amount_cd)
@ -202,7 +202,7 @@ class PaymentPacketSpec extends AnyFunSuite with BeforeAndAfterAll {
assert(amount_e == amount_de)
assert(expiry_e == expiry_de)
val add_e = UpdateAddHtlc(randomBytes32(), 4, amount_e, paymentHash, expiry_e, onion_e.packet, None)
val Right(FinalPacket(add_e2, payload_e)) = decrypt(add_e, priv_e.privateKey)
val Right(FinalPacket(add_e2, payload_e)) = decrypt(add_e, priv_e.privateKey, Features.empty)
assert(add_e2 == add_e)
assert(payload_e == FinalPayload.Standard(TlvStream(AmountToForward(finalAmount), OutgoingCltv(finalExpiry), PaymentData(paymentSecret, finalAmount * 3), OnionPaymentPayloadTlv.PaymentMetadata(hex"010203"))))
}
@ -225,10 +225,10 @@ class PaymentPacketSpec extends AnyFunSuite with BeforeAndAfterAll {
assert(firstExpiry == expiry_ab)
val add_b = UpdateAddHtlc(randomBytes32(), 1, firstAmount, paymentHash, firstExpiry, onion.packet, None)
val Right(ChannelRelayPacket(_, _, packet_c)) = decrypt(add_b, priv_b.privateKey)
val Right(ChannelRelayPacket(_, _, packet_c)) = decrypt(add_b, priv_b.privateKey, Features.empty)
val add_c = UpdateAddHtlc(randomBytes32(), 2, amount_bc, paymentHash, expiry_bc, packet_c, None)
val Right(NodeRelayPacket(_, outer_c, inner_c, packet_d)) = decrypt(add_c, priv_c.privateKey)
val Right(NodeRelayPacket(_, outer_c, inner_c, packet_d)) = decrypt(add_c, priv_c.privateKey, Features.empty)
assert(outer_c.amount == amount_bc)
assert(outer_c.totalAmount == amount_bc)
assert(outer_c.expiry == expiry_bc)
@ -245,7 +245,7 @@ class PaymentPacketSpec extends AnyFunSuite with BeforeAndAfterAll {
assert(amount_d == amount_cd)
assert(expiry_d == expiry_cd)
val add_d = UpdateAddHtlc(randomBytes32(), 3, amount_d, paymentHash, expiry_d, onion_d.packet, None)
val Right(NodeRelayPacket(_, outer_d, inner_d, _)) = decrypt(add_d, priv_d.privateKey)
val Right(NodeRelayPacket(_, outer_d, inner_d, _)) = decrypt(add_d, priv_d.privateKey, Features.empty)
assert(outer_d.amount == amount_cd)
assert(outer_d.totalAmount == amount_cd)
assert(outer_d.expiry == expiry_cd)
@ -269,7 +269,7 @@ class PaymentPacketSpec extends AnyFunSuite with BeforeAndAfterAll {
test("fail to decrypt when the onion is invalid") {
val Success((firstAmount, firstExpiry, onion)) = buildPaymentPacket(paymentHash, hops, FinalPayload.Standard.createSinglePartPayload(finalAmount, finalExpiry, paymentSecret, None))
val add = UpdateAddHtlc(randomBytes32(), 1, firstAmount, paymentHash, firstExpiry, onion.packet.copy(payload = onion.packet.payload.reverse), None)
val Left(failure) = decrypt(add, priv_b.privateKey)
val Left(failure) = decrypt(add, priv_b.privateKey, Features.empty)
assert(failure.isInstanceOf[InvalidOnionHmac])
}
@ -277,78 +277,78 @@ class PaymentPacketSpec extends AnyFunSuite with BeforeAndAfterAll {
val Success((amount_ac, expiry_ac, trampolineOnion)) = buildTrampolinePacket(paymentHash, trampolineHops, FinalPayload.Standard.createMultiPartPayload(finalAmount, finalAmount * 2, finalExpiry, paymentSecret, None))
val Success((firstAmount, firstExpiry, onion)) = buildPaymentPacket(paymentHash, trampolineChannelHops, FinalPayload.Standard.createTrampolinePayload(amount_ac, amount_ac, expiry_ac, randomBytes32(), trampolineOnion.packet.copy(payload = trampolineOnion.packet.payload.reverse)))
val add_b = UpdateAddHtlc(randomBytes32(), 1, firstAmount, paymentHash, firstExpiry, onion.packet, None)
val Right(ChannelRelayPacket(_, _, packet_c)) = decrypt(add_b, priv_b.privateKey)
val Right(ChannelRelayPacket(_, _, packet_c)) = decrypt(add_b, priv_b.privateKey, Features.empty)
val add_c = UpdateAddHtlc(randomBytes32(), 2, amount_bc, paymentHash, expiry_bc, packet_c, None)
val Left(failure) = decrypt(add_c, priv_c.privateKey)
val Left(failure) = decrypt(add_c, priv_c.privateKey, Features.empty)
assert(failure.isInstanceOf[InvalidOnionHmac])
}
test("fail to decrypt when payment hash doesn't match associated data") {
val Success((firstAmount, firstExpiry, onion)) = buildPaymentPacket(paymentHash.reverse, hops, FinalPayload.Standard.createSinglePartPayload(finalAmount, finalExpiry, paymentSecret, None))
val add = UpdateAddHtlc(randomBytes32(), 1, firstAmount, paymentHash, firstExpiry, onion.packet, None)
val Left(failure) = decrypt(add, priv_b.privateKey)
val Left(failure) = decrypt(add, priv_b.privateKey, Features.empty)
assert(failure.isInstanceOf[InvalidOnionHmac])
}
test("fail to decrypt at the final node when amount has been modified by next-to-last node") {
val Success((firstAmount, firstExpiry, onion)) = buildPaymentPacket(paymentHash, hops.take(1), FinalPayload.Standard.createSinglePartPayload(finalAmount, finalExpiry, paymentSecret, None))
val add = UpdateAddHtlc(randomBytes32(), 1, firstAmount - 100.msat, paymentHash, firstExpiry, onion.packet, None)
val Left(failure) = decrypt(add, priv_b.privateKey)
val Left(failure) = decrypt(add, priv_b.privateKey, Features.empty)
assert(failure == FinalIncorrectHtlcAmount(firstAmount - 100.msat))
}
test("fail to decrypt at the final node when expiry has been modified by next-to-last node") {
val Success((firstAmount, firstExpiry, onion)) = buildPaymentPacket(paymentHash, hops.take(1), FinalPayload.Standard.createSinglePartPayload(finalAmount, finalExpiry, paymentSecret, None))
val add = UpdateAddHtlc(randomBytes32(), 1, firstAmount, paymentHash, firstExpiry - CltvExpiryDelta(12), onion.packet, None)
val Left(failure) = decrypt(add, priv_b.privateKey)
val Left(failure) = decrypt(add, priv_b.privateKey, Features.empty)
assert(failure == FinalIncorrectCltvExpiry(firstExpiry - CltvExpiryDelta(12)))
}
test("fail to decrypt at the final trampoline node when amount has been modified by next-to-last trampoline") {
val Success((amount_ac, expiry_ac, trampolineOnion)) = buildTrampolinePacket(paymentHash, trampolineHops, FinalPayload.Standard.createMultiPartPayload(finalAmount, finalAmount, finalExpiry, paymentSecret, None))
val Success((firstAmount, firstExpiry, onion)) = buildPaymentPacket(paymentHash, trampolineChannelHops, FinalPayload.Standard.createTrampolinePayload(amount_ac, amount_ac, expiry_ac, randomBytes32(), trampolineOnion.packet))
val Right(ChannelRelayPacket(_, _, packet_c)) = decrypt(UpdateAddHtlc(randomBytes32(), 1, firstAmount, paymentHash, firstExpiry, onion.packet, None), priv_b.privateKey)
val Right(NodeRelayPacket(_, _, _, packet_d)) = decrypt(UpdateAddHtlc(randomBytes32(), 2, amount_bc, paymentHash, expiry_bc, packet_c, None), priv_c.privateKey)
val Right(ChannelRelayPacket(_, _, packet_c)) = decrypt(UpdateAddHtlc(randomBytes32(), 1, firstAmount, paymentHash, firstExpiry, onion.packet, None), priv_b.privateKey, Features.empty)
val Right(NodeRelayPacket(_, _, _, packet_d)) = decrypt(UpdateAddHtlc(randomBytes32(), 2, amount_bc, paymentHash, expiry_bc, packet_c, None), priv_c.privateKey, Features.empty)
// c forwards the trampoline payment to d.
val Success((amount_d, expiry_d, onion_d)) = buildPaymentPacket(paymentHash, channelHopFromUpdate(c, d, channelUpdate_cd) :: Nil, FinalPayload.Standard.createTrampolinePayload(amount_cd, amount_cd, expiry_cd, randomBytes32(), packet_d))
val Right(NodeRelayPacket(_, _, _, packet_e)) = decrypt(UpdateAddHtlc(randomBytes32(), 3, amount_d, paymentHash, expiry_d, onion_d.packet, None), priv_d.privateKey)
val Right(NodeRelayPacket(_, _, _, packet_e)) = decrypt(UpdateAddHtlc(randomBytes32(), 3, amount_d, paymentHash, expiry_d, onion_d.packet, None), priv_d.privateKey, Features.empty)
// d forwards an invalid amount to e (the outer total amount doesn't match the inner amount).
val invalidTotalAmount = amount_de + 100.msat
val Success((amount_e, expiry_e, onion_e)) = buildPaymentPacket(paymentHash, channelHopFromUpdate(d, e, channelUpdate_de) :: Nil, FinalPayload.Standard.createTrampolinePayload(amount_de, invalidTotalAmount, expiry_de, randomBytes32(), packet_e))
val Left(failure) = decrypt(UpdateAddHtlc(randomBytes32(), 4, amount_e, paymentHash, expiry_e, onion_e.packet, None), priv_e.privateKey)
val Left(failure) = decrypt(UpdateAddHtlc(randomBytes32(), 4, amount_e, paymentHash, expiry_e, onion_e.packet, None), priv_e.privateKey, Features.empty)
assert(failure == FinalIncorrectHtlcAmount(invalidTotalAmount))
}
test("fail to decrypt at the final trampoline node when expiry has been modified by next-to-last trampoline") {
val Success((amount_ac, expiry_ac, trampolineOnion)) = buildTrampolinePacket(paymentHash, trampolineHops, FinalPayload.Standard.createMultiPartPayload(finalAmount, finalAmount, finalExpiry, paymentSecret, None))
val Success((firstAmount, firstExpiry, onion)) = buildPaymentPacket(paymentHash, trampolineChannelHops, FinalPayload.Standard.createTrampolinePayload(amount_ac, amount_ac, expiry_ac, randomBytes32(), trampolineOnion.packet))
val Right(ChannelRelayPacket(_, _, packet_c)) = decrypt(UpdateAddHtlc(randomBytes32(), 1, firstAmount, paymentHash, firstExpiry, onion.packet, None), priv_b.privateKey)
val Right(NodeRelayPacket(_, _, _, packet_d)) = decrypt(UpdateAddHtlc(randomBytes32(), 2, amount_bc, paymentHash, expiry_bc, packet_c, None), priv_c.privateKey)
val Right(ChannelRelayPacket(_, _, packet_c)) = decrypt(UpdateAddHtlc(randomBytes32(), 1, firstAmount, paymentHash, firstExpiry, onion.packet, None), priv_b.privateKey, Features.empty)
val Right(NodeRelayPacket(_, _, _, packet_d)) = decrypt(UpdateAddHtlc(randomBytes32(), 2, amount_bc, paymentHash, expiry_bc, packet_c, None), priv_c.privateKey, Features.empty)
// c forwards the trampoline payment to d.
val Success((amount_d, expiry_d, onion_d)) = buildPaymentPacket(paymentHash, channelHopFromUpdate(c, d, channelUpdate_cd) :: Nil, FinalPayload.Standard.createTrampolinePayload(amount_cd, amount_cd, expiry_cd, randomBytes32(), packet_d))
val Right(NodeRelayPacket(_, _, _, packet_e)) = decrypt(UpdateAddHtlc(randomBytes32(), 3, amount_d, paymentHash, expiry_d, onion_d.packet, None), priv_d.privateKey)
val Right(NodeRelayPacket(_, _, _, packet_e)) = decrypt(UpdateAddHtlc(randomBytes32(), 3, amount_d, paymentHash, expiry_d, onion_d.packet, None), priv_d.privateKey, Features.empty)
// d forwards an invalid expiry to e (the outer expiry doesn't match the inner expiry).
val invalidExpiry = expiry_de - CltvExpiryDelta(12)
val Success((amount_e, expiry_e, onion_e)) = buildPaymentPacket(paymentHash, channelHopFromUpdate(d, e, channelUpdate_de) :: Nil, FinalPayload.Standard.createTrampolinePayload(amount_de, amount_de, invalidExpiry, randomBytes32(), packet_e))
val Left(failure) = decrypt(UpdateAddHtlc(randomBytes32(), 4, amount_e, paymentHash, expiry_e, onion_e.packet, None), priv_e.privateKey)
val Left(failure) = decrypt(UpdateAddHtlc(randomBytes32(), 4, amount_e, paymentHash, expiry_e, onion_e.packet, None), priv_e.privateKey, Features.empty)
assert(failure == FinalIncorrectCltvExpiry(invalidExpiry))
}
test("fail to decrypt at intermediate trampoline node when amount is invalid") {
val Success((amount_ac, expiry_ac, trampolineOnion)) = buildTrampolinePacket(paymentHash, trampolineHops, FinalPayload.Standard.createSinglePartPayload(finalAmount, finalExpiry, paymentSecret, None))
val Success((firstAmount, firstExpiry, onion)) = buildPaymentPacket(paymentHash, trampolineChannelHops, FinalPayload.Standard.createTrampolinePayload(amount_ac, amount_ac, expiry_ac, randomBytes32(), trampolineOnion.packet))
val Right(ChannelRelayPacket(_, _, packet_c)) = decrypt(UpdateAddHtlc(randomBytes32(), 1, firstAmount, paymentHash, firstExpiry, onion.packet, None), priv_b.privateKey)
val Right(ChannelRelayPacket(_, _, packet_c)) = decrypt(UpdateAddHtlc(randomBytes32(), 1, firstAmount, paymentHash, firstExpiry, onion.packet, None), priv_b.privateKey, Features.empty)
// A trampoline relay is very similar to a final node: it can validate that the HTLC amount matches the onion outer amount.
val Left(failure) = decrypt(UpdateAddHtlc(randomBytes32(), 2, amount_bc - 100.msat, paymentHash, expiry_bc, packet_c, None), priv_c.privateKey)
val Left(failure) = decrypt(UpdateAddHtlc(randomBytes32(), 2, amount_bc - 100.msat, paymentHash, expiry_bc, packet_c, None), priv_c.privateKey, Features.empty)
assert(failure == FinalIncorrectHtlcAmount(amount_bc - 100.msat))
}
test("fail to decrypt at intermediate trampoline node when expiry is invalid") {
val Success((amount_ac, expiry_ac, trampolineOnion)) = buildTrampolinePacket(paymentHash, trampolineHops, FinalPayload.Standard.createSinglePartPayload(finalAmount, finalExpiry, paymentSecret, None))
val Success((firstAmount, firstExpiry, onion)) = buildPaymentPacket(paymentHash, trampolineChannelHops, FinalPayload.Standard.createTrampolinePayload(amount_ac, amount_ac, expiry_ac, randomBytes32(), trampolineOnion.packet))
val Right(ChannelRelayPacket(_, _, packet_c)) = decrypt(UpdateAddHtlc(randomBytes32(), 1, firstAmount, paymentHash, firstExpiry, onion.packet, None), priv_b.privateKey)
val Right(ChannelRelayPacket(_, _, packet_c)) = decrypt(UpdateAddHtlc(randomBytes32(), 1, firstAmount, paymentHash, firstExpiry, onion.packet, None), priv_b.privateKey, Features.empty)
// A trampoline relay is very similar to a final node: it can validate that the HTLC expiry matches the onion outer expiry.
val Left(failure) = decrypt(UpdateAddHtlc(randomBytes32(), 2, amount_bc, paymentHash, expiry_bc - CltvExpiryDelta(12), packet_c, None), priv_c.privateKey)
val Left(failure) = decrypt(UpdateAddHtlc(randomBytes32(), 2, amount_bc, paymentHash, expiry_bc - CltvExpiryDelta(12), packet_c, None), priv_c.privateKey, Features.empty)
assert(failure == FinalIncorrectCltvExpiry(expiry_bc - CltvExpiryDelta(12)))
}

View file

@ -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))
}
}

View file

@ -195,6 +195,29 @@ class PaymentOnionSpec extends AnyFunSuite {
assert(perHopPayloadCodec.decode(bin.bits).require.value == tlvs)
}
test("encode/decode final blinded per-hop payload") {
val blindedTlvs = TlvStream[RouteBlindingEncryptedDataTlv](
RouteBlindingEncryptedDataTlv.PathId(hex"2a2a2a2a"),
RouteBlindingEncryptedDataTlv.PaymentConstraints(CltvExpiry(1500), 1 msat),
)
val testCases = Map(
TlvStream[OnionPaymentPayloadTlv](AmountToForward(561 msat), OutgoingCltv(CltvExpiry(42)), EncryptedRecipientData(hex"deadbeef")) -> hex"0d 02020231 04012a 0a04deadbeef",
TlvStream[OnionPaymentPayloadTlv](AmountToForward(561 msat), OutgoingCltv(CltvExpiry(42)), EncryptedRecipientData(hex"deadbeef"), BlindingPoint(PublicKey(hex"036d6caac248af96f6afa7f904f550253a0f3ef3f5aa2fe6838a95b216691468e2"))) -> hex"30 02020231 04012a 0a04deadbeef 0c21036d6caac248af96f6afa7f904f550253a0f3ef3f5aa2fe6838a95b216691468e2",
TlvStream[OnionPaymentPayloadTlv](AmountToForward(561 msat), OutgoingCltv(CltvExpiry(42)), EncryptedRecipientData(hex"deadbeef"), BlindingPoint(PublicKey(hex"036d6caac248af96f6afa7f904f550253a0f3ef3f5aa2fe6838a95b216691468e2")), TotalAmount(1105 msat)) -> hex"34 02020231 04012a 0a04deadbeef 0c21036d6caac248af96f6afa7f904f550253a0f3ef3f5aa2fe6838a95b216691468e2 12020451",
)
for ((expected, bin) <- testCases) {
val decoded = perHopPayloadCodec.decode(bin.bits).require.value
assert(decoded == expected)
val Right(payload) = FinalPayload.Blinded.validate(decoded, blindedTlvs)
assert(payload.amount == 561.msat)
assert(payload.expiry == CltvExpiry(42))
assert(payload.pathId_opt.contains(hex"2a2a2a2a"))
val encoded = perHopPayloadCodec.encode(expected).require.bytes
assert(encoded == bin)
}
}
test("decode multi-part final per-hop payload") {
val Right(multiPart) = FinalPayload.Standard.validate(perHopPayloadCodec.decode(hex"2b 02020231 04012a 0822eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f2836866190451".bits).require.value)
assert(multiPart.amount == 561.msat)
@ -232,9 +255,13 @@ class PaymentOnionSpec extends AnyFunSuite {
val testCases = Seq(
// Forbidden non-encrypted amount.
TestCase(ForbiddenTlv(UInt64(2)), hex"0e 02020231 0a080123456789abcdef", validBlindedTlvs),
TestCase(ForbiddenTlv(UInt64(0)), hex"0e 02020231 0a080123456789abcdef", validBlindedTlvs),
// Forbidden non-encrypted expiry.
TestCase(ForbiddenTlv(UInt64(4)), hex"0d 04012a 0a080123456789abcdef", validBlindedTlvs),
TestCase(ForbiddenTlv(UInt64(0)), hex"0d 04012a 0a080123456789abcdef", validBlindedTlvs),
// Forbidden outgoing channel id.
TestCase(ForbiddenTlv(UInt64(0)), hex"14 06080000000000000451 0a080123456789abcdef", validBlindedTlvs),
// Forbidden unknown tlv.
TestCase(ForbiddenTlv(UInt64(51)), hex"0e 0a080123456789abcdef 33020102", validBlindedTlvs),
// Missing encrypted data.
TestCase(MissingRequiredTlv(UInt64(10)), hex"23 0c21036d6caac248af96f6afa7f904f550253a0f3ef3f5aa2fe6838a95b216691468e2", validBlindedTlvs),
// Missing encrypted outgoing channel.
@ -279,6 +306,26 @@ class PaymentOnionSpec extends AnyFunSuite {
}
}
test("decode invalid final blinded per-hop payload") {
val blindedTlvs = TlvStream[RouteBlindingEncryptedDataTlv](
RouteBlindingEncryptedDataTlv.PathId(hex"2a2a2a2a"),
RouteBlindingEncryptedDataTlv.PaymentConstraints(CltvExpiry(1500), 1 msat),
)
val testCases = Seq(
(MissingRequiredTlv(UInt64(2)), hex"0d 04012a 0a080123456789abcdef"), // missing amount
(MissingRequiredTlv(UInt64(4)), hex"0e 02020231 0a080123456789abcdef"), // missing expiry
(MissingRequiredTlv(UInt64(10)), hex"07 02020231 04012a"), // missing encrypted data
(ForbiddenTlv(UInt64(0)), hex"1b 02020231 04012a 06080000000000000451 0a080123456789abcdef"), // forbidden outgoing_channel_id
(ForbiddenTlv(UInt64(0)), hex"35 02020231 04012a 0822eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f2836866190451 0a080123456789abcdef"), // forbidden payment_data
(ForbiddenTlv(UInt64(0)), hex"17 02020231 04012a 0a080123456789abcdef 1004deadbeef"), // forbidden payment_metadata
(ForbiddenTlv(UInt64(65535)), hex"17 02020231 04012a 0a080123456789abcdef fdffff0206c1"), // forbidden unknown tlv
)
for ((expectedErr, bin) <- testCases) {
assert(FinalPayload.Blinded.validate(perHopPayloadCodec.decode(bin.bits).require.value, blindedTlvs) == Left(expectedErr))
}
}
test("decode invalid per-hop payload") {
val testCases = Seq(
// Invalid fixed-size (legacy) payload.

View file

@ -53,6 +53,19 @@ class RouteBlindingSpec extends AnyFunSuiteLike {
}
}
test("reject non-empty allowed features for intermediate nodes") {
{
val encoded = hex"02080000000000000231 0a060090000000fa 0c06000b699105dc 0e0101"
val decoded = blindedRouteDataCodec.decode(encoded.bits).require.value
assert(BlindedRouteData.validatePaymentRelayData(decoded) == Left(ForbiddenTlv(UInt64(14))))
}
{
val encoded = hex"01020000 042102edabbd16b41c8371b92ef2f04c1185b4f03b6dcd52ba9b78d9d7c89c8f221145 0e020100"
val decoded = blindedRouteDataCodec.decode(encoded.bits).require.value
assert(BlindedRouteData.validateMessageRelayData(decoded) == Left(ForbiddenTlv(UInt64(14))))
}
}
test("decode encrypted route blinding data") {
val sessionKey = randomKey()
val nodePrivKeys = Seq(randomKey(), randomKey(), randomKey(), randomKey(), randomKey())