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

Attributable errors #2519

Draft
wants to merge 6 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
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
1 change: 1 addition & 0 deletions eclair-core/src/main/resources/reference.conf
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ eclair {
option_route_blinding = disabled
option_shutdown_anysegwit = optional
option_dual_fund = disabled
option_attributable_error = optional
option_quiesce = disabled
option_onion_messages = optional
option_channel_type = optional
Expand Down
6 changes: 6 additions & 0 deletions eclair-core/src/main/scala/fr/acinq/eclair/Features.scala
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,11 @@ object Features {
val mandatory = 34
}

case object AttributableError extends Feature with InitFeature with NodeFeature with Bolt11Feature {
val rfcName = "option_attributable_error"
val mandatory = 36
}

case object OnionMessages extends Feature with InitFeature with NodeFeature {
val rfcName = "option_onion_messages"
val mandatory = 38
Expand Down Expand Up @@ -339,6 +344,7 @@ object Features {
RouteBlinding,
ShutdownAnySegwit,
DualFunding,
AttributableError,
Quiescence,
OnionMessages,
ChannelType,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import fr.acinq.eclair.payment.OutgoingPaymentPacket.Upstream
import fr.acinq.eclair.transactions.CommitmentSpec
import fr.acinq.eclair.transactions.Transactions._
import fr.acinq.eclair.wire.protocol.{ChannelAnnouncement, ChannelReady, ChannelReestablish, ChannelUpdate, ClosingSigned, CommitSig, FailureMessage, FundingCreated, FundingSigned, Init, OnionRoutingPacket, OpenChannel, OpenDualFundedChannel, Shutdown, SpliceInit, Stfu, TxSignatures, UpdateAddHtlc, UpdateFailHtlc, UpdateFailMalformedHtlc, UpdateFulfillHtlc}
import fr.acinq.eclair.{Alias, BlockHeight, CltvExpiry, CltvExpiryDelta, Features, InitFeature, MilliSatoshi, MilliSatoshiLong, RealShortChannelId, UInt64}
import fr.acinq.eclair.{Alias, BlockHeight, CltvExpiry, CltvExpiryDelta, Features, InitFeature, MilliSatoshi, MilliSatoshiLong, RealShortChannelId, TimestampMilli, UInt64}
import scodec.bits.ByteVector

import java.util.UUID
Expand Down Expand Up @@ -192,7 +192,7 @@ sealed trait ForbiddenCommandDuringQuiescence extends Command
final case class CMD_ADD_HTLC(replyTo: ActorRef, amount: MilliSatoshi, paymentHash: ByteVector32, cltvExpiry: CltvExpiry, onion: OnionRoutingPacket, nextBlindingKey_opt: Option[PublicKey], origin: Origin.Hot, commit: Boolean = false) extends HasReplyToCommand with ForbiddenCommandDuringSplice with ForbiddenCommandDuringQuiescence
sealed trait HtlcSettlementCommand extends HasOptionalReplyToCommand with ForbiddenCommandDuringSplice with ForbiddenCommandDuringQuiescence { 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], delay_opt: Option[FiniteDuration] = None, commit: Boolean = false, replyTo_opt: Option[ActorRef] = None) extends HtlcSettlementCommand
final case class CMD_FAIL_HTLC(id: Long, reason: Either[ByteVector, FailureMessage], useAttributableErrors: Boolean, startHoldTime: TimestampMilli, delay_opt: Option[FiniteDuration] = None, 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_UPDATE_FEE(feeratePerKw: FeeratePerKw, commit: Boolean = false, replyTo_opt: Option[ActorRef] = None) extends HasOptionalReplyToCommand with ForbiddenCommandDuringSplice with ForbiddenCommandDuringQuiescence
final case class CMD_SIGN(replyTo_opt: Option[ActorRef] = None) extends HasOptionalReplyToCommand with ForbiddenCommandDuringSplice
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -640,7 +640,8 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
case PostRevocationAction.RejectHtlc(add) =>
log.debug("rejecting incoming htlc {}", add)
// NB: we don't set commit = true, we will sign all updates at once afterwards.
self ! CMD_FAIL_HTLC(add.id, Right(TemporaryChannelFailure(d.channelUpdate)), commit = true)
// The HTLC is rejected without reading the onion, we default to legacy errors.
self ! CMD_FAIL_HTLC(add.id, Right(TemporaryChannelFailure(d.channelUpdate)), useAttributableErrors = false, TimestampMilli.now(), commit = true)
case PostRevocationAction.RelayFailure(result) =>
log.debug("forwarding {} to relayer", result)
relayer ! result
Expand Down Expand Up @@ -1341,11 +1342,13 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
case PostRevocationAction.RelayHtlc(add) =>
// BOLT 2: A sending node SHOULD fail to route any HTLC added after it sent shutdown.
log.debug("closing in progress: failing {}", add)
self ! CMD_FAIL_HTLC(add.id, Right(PermanentChannelFailure()), commit = true)
// The HTLC is rejected without reading the onion, we default to legacy errors.
self ! CMD_FAIL_HTLC(add.id, Right(PermanentChannelFailure()), useAttributableErrors = false, TimestampMilli.now(), commit = true)
case PostRevocationAction.RejectHtlc(add) =>
// BOLT 2: A sending node SHOULD fail to route any HTLC added after it sent shutdown.
log.debug("closing in progress: rejecting {}", add)
self ! CMD_FAIL_HTLC(add.id, Right(PermanentChannelFailure()), commit = true)
// The HTLC is rejected without reading the onion, we default to legacy errors.
self ! CMD_FAIL_HTLC(add.id, Right(PermanentChannelFailure()), useAttributableErrors = false, TimestampMilli.now(), commit = true)
case PostRevocationAction.RelayFailure(result) =>
log.debug("forwarding {} to relayer", result)
relayer ! result
Expand Down
86 changes: 85 additions & 1 deletion eclair-core/src/main/scala/fr/acinq/eclair/crypto/Sphinx.scala
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@ import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey}
import fr.acinq.bitcoin.scalacompat.{ByteVector32, Crypto}
import fr.acinq.eclair.wire.protocol._
import grizzled.slf4j.Logging
import scodec.Attempt
import scodec.{Attempt, DecodeResult}
import scodec.bits.ByteVector

import scala.annotation.tailrec
import scala.collection.mutable.ArrayBuffer
import scala.concurrent.duration.FiniteDuration
import scala.util.{Failure, Success, Try}

/**
Expand Down Expand Up @@ -332,6 +334,88 @@ object Sphinx extends Logging {

}

case class InvalidAttributableErrorPacket(hopPayloads: Seq[(PublicKey, AttributableError.HopPayload)], failingNode: PublicKey)

object AttributableErrorPacket {

import AttributableError._

private val payloadAndPadLength = 256
private val hopPayloadLength = 5
private val maxNumHop = 20
private val totalLength = 4 + payloadAndPadLength + maxNumHop * hopPayloadLength + (maxNumHop * (maxNumHop + 1)) / 2 * 4

def create(sharedSecret: ByteVector32, failure: FailureMessage, holdTime: FiniteDuration): ByteVector = {
val failurePayload = FailureMessageCodecs.failureOnionPayload(payloadAndPadLength).encode(failure).require.toByteVector
val zeroPayloads = Seq.fill(maxNumHop)(ByteVector.low(hopPayloadLength))
val zeroHmacs = maxNumHop.to(1, -1).map(Seq.fill(_)(ByteVector.low(4)))
val plainError = attributableErrorCodec(totalLength, hopPayloadLength, maxNumHop).encode(AttributableError(failurePayload, zeroPayloads, zeroHmacs)).require.bytes
wrap(plainError, sharedSecret, holdTime, isSource = true)
}

private def computeHmacs(mac: Mac32, failurePayload: ByteVector, hopPayloads: Seq[ByteVector], hmacs: Seq[Seq[ByteVector]], minNumHop: Int): Seq[ByteVector] = {
val newHmacs = (minNumHop until maxNumHop).map(i => {
val y = maxNumHop - i
mac.mac(failurePayload ++
ByteVector.concat(hopPayloads.take(y)) ++
ByteVector.concat((0 until y - 1).map(j => hmacs(j)(i)))).bytes.take(4)
})
newHmacs
}

def wrap(errorPacket: ByteVector, sharedSecret: ByteVector32, holdTime: FiniteDuration, isSource: Boolean): ByteVector = {
val um = generateKey("um", sharedSecret)
val error = attributableErrorCodec(errorPacket.length.toInt, hopPayloadLength, maxNumHop).decode(errorPacket.bits) match {
case Attempt.Successful(DecodeResult(value, _)) => value
case Attempt.Failure(_) => AttributableError.zero(payloadAndPadLength, hopPayloadLength, maxNumHop)
}
val hopPayloads = hopPayloadCodec.encode(HopPayload(isSource, holdTime)).require.bytes +: error.hopPayloads.dropRight(1)
val hmacs = computeHmacs(Hmac256(um), error.failurePayload, hopPayloads, error.hmacs.map(_.drop(1)), 0) +: error.hmacs.dropRight(1).map(_.drop(1))
val newError = attributableErrorCodec(errorPacket.length.toInt, hopPayloadLength, maxNumHop).encode(AttributableError(error.failurePayload, hopPayloads, hmacs)).require.bytes
val key = generateKey("ammag", sharedSecret)
val stream = generateStream(key, newError.length.toInt)
newError xor stream
}

private def unwrap(errorPacket: ByteVector, sharedSecret: ByteVector32, minNumHop: Int): Try[(ByteVector, HopPayload)] = Try {
val key = generateKey("ammag", sharedSecret)
val stream = generateStream(key, errorPacket.length.toInt)
val error = attributableErrorCodec(errorPacket.length.toInt, hopPayloadLength, maxNumHop).decode((errorPacket xor stream).bits).require.value
val um = generateKey("um", sharedSecret)
val shiftedHmacs = error.hmacs.tail.map(ByteVector.low(4) +: _) :+ Seq(ByteVector.low(4))
val hmacs = computeHmacs(Hmac256(um), error.failurePayload, error.hopPayloads, error.hmacs.tail, minNumHop)
require(hmacs == error.hmacs.head.drop(minNumHop), "Invalid HMAC")
val shiftedHopPayloads = error.hopPayloads.tail :+ ByteVector.fill(hopPayloadLength)(0)
val unwrapedError = AttributableError(error.failurePayload, shiftedHopPayloads, shiftedHmacs)
(attributableErrorCodec(errorPacket.length.toInt, hopPayloadLength, maxNumHop).encode(unwrapedError).require.bytes,
hopPayloadCodec.decode(error.hopPayloads.head.bits).require.value)
}

def decrypt(errorPacket: ByteVector, sharedSecrets: Seq[(ByteVector32, PublicKey)]): Either[InvalidAttributableErrorPacket, DecryptedFailurePacket] = {
var packet = errorPacket
var minNumHop = 0
val hopPayloads = ArrayBuffer.empty[(PublicKey, HopPayload)]
for ((sharedSecret, nodeId) <- sharedSecrets) {
unwrap(packet, sharedSecret, minNumHop) match {
case Failure(_) => return Left(InvalidAttributableErrorPacket(hopPayloads.toSeq, nodeId))
case Success((unwrapedPacket, hopPayload)) if hopPayload.isPayloadSource =>
val failurePayload = attributableErrorCodec(errorPacket.length.toInt, hopPayloadLength, maxNumHop).decode(unwrapedPacket.bits).require.value.failurePayload
FailureMessageCodecs.failureOnionPayload(payloadAndPadLength).decode(failurePayload.bits) match {
case Attempt.Successful(failureMessage) =>
return Right(DecryptedFailurePacket(nodeId, failureMessage.value))
case Attempt.Failure(_) =>
return Left(InvalidAttributableErrorPacket(hopPayloads.toSeq, nodeId))
}
case Success((unwrapedPacket, hopPayload)) =>
packet = unwrapedPacket
minNumHop += 1
hopPayloads += ((nodeId, hopPayload))
}
}
Left(InvalidAttributableErrorPacket(hopPayloads.toSeq, sharedSecrets.last._2))
}
}

/**
* Route blinding is a lightweight technique to provide recipient anonymity by blinding an arbitrary amount of hops at
* the end of an onion path. It can be used for payments or onion messages.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,15 @@ import fr.acinq.eclair.channel.{CMD_ADD_HTLC, CMD_FAIL_HTLC, CannotExtractShared
import fr.acinq.eclair.crypto.Sphinx
import fr.acinq.eclair.payment.send.Recipient
import fr.acinq.eclair.router.Router.{BlindedHop, ChannelHop, Route}
import fr.acinq.eclair.wire.protocol.OnionPaymentPayloadTlv.UseAttributableError
import fr.acinq.eclair.wire.protocol.PaymentOnion.{FinalPayload, IntermediatePayload, PerHopPayload}
import fr.acinq.eclair.wire.protocol._
import fr.acinq.eclair.{CltvExpiry, CltvExpiryDelta, Feature, Features, MilliSatoshi, ShortChannelId, TimestampMilli, UInt64, randomKey}
import fr.acinq.eclair.{CltvExpiry, CltvExpiryDelta, Feature, FeatureSupport, Features, MilliSatoshi, ShortChannelId, TimestampMilli, UInt64, randomKey}
import scodec.bits.ByteVector
import scodec.{Attempt, DecodeResult}

import java.util.UUID
import scala.concurrent.duration.FiniteDuration
import scala.util.{Failure, Success}

/**
Expand Down Expand Up @@ -114,6 +116,10 @@ object IncomingPaymentPacket {
case None => privateKey
}
decryptOnion(add.paymentHash, outerOnionDecryptionKey, add.onionRoutingPacket).flatMap {
case DecodedOnionPacket(payload, _) if payload.get[UseAttributableError].isDefined && !features.hasFeature(Features.AttributableError) =>
Left(InvalidOnionPayload(UInt64(20), 0))
case DecodedOnionPacket(payload, _) if payload.get[UseAttributableError].isEmpty && features.hasFeature(Features.AttributableError, Some(FeatureSupport.Mandatory)) =>
Left(InvalidOnionPayload(UInt64(20), 0))
case DecodedOnionPacket(payload, Some(nextPacket)) =>
payload.get[OnionPaymentPayloadTlv.EncryptedRecipientData] match {
case Some(_) if !features.hasFeature(Features.RouteBlinding) => Left(InvalidOnionPayload(UInt64(10), 0))
Expand Down Expand Up @@ -246,7 +252,7 @@ object OutgoingPaymentPacket {
val expiryIn: CltvExpiry = adds.map(_.add.cltvExpiry).min
}

case class ReceivedHtlc(add: UpdateAddHtlc, receivedAt: TimestampMilli)
case class ReceivedHtlc(add: UpdateAddHtlc, receivedAt: TimestampMilli, useAttributableErrors: Boolean)
}
// @formatter:on

Expand Down Expand Up @@ -304,10 +310,12 @@ object OutgoingPaymentPacket {
}
}

private def buildHtlcFailure(nodeSecret: PrivateKey, reason: Either[ByteVector, FailureMessage], add: UpdateAddHtlc): Either[CannotExtractSharedSecret, ByteVector] = {
private def buildHtlcFailure(nodeSecret: PrivateKey, reason: Either[ByteVector, FailureMessage], add: UpdateAddHtlc, useAttributableErrors: Boolean, holdTime: FiniteDuration): Either[CannotExtractSharedSecret, ByteVector] = {
Sphinx.peel(nodeSecret, Some(add.paymentHash), add.onionRoutingPacket) match {
case Right(Sphinx.DecryptedPacket(_, _, sharedSecret)) =>
val encryptedReason = reason match {
case Left(forwarded) if useAttributableErrors => Sphinx.AttributableErrorPacket.wrap(forwarded, sharedSecret, holdTime, isSource = false)
case Right(failure) if useAttributableErrors => Sphinx.AttributableErrorPacket.create(sharedSecret, failure, holdTime)
case Left(forwarded) => Sphinx.FailurePacket.wrap(forwarded, sharedSecret)
case Right(failure) => Sphinx.FailurePacket.create(sharedSecret, failure)
}
Expand All @@ -323,7 +331,7 @@ object OutgoingPaymentPacket {
val failure = InvalidOnionBlinding(Sphinx.hash(add.onionRoutingPacket))
Right(UpdateFailMalformedHtlc(add.channelId, add.id, failure.onionHash, failure.code))
case None =>
buildHtlcFailure(nodeSecret, cmd.reason, add).map(encryptedReason => UpdateFailHtlc(add.channelId, cmd.id, encryptedReason))
buildHtlcFailure(nodeSecret, cmd.reason, add, cmd.useAttributableErrors, TimestampMilli.now() - cmd.startHoldTime).map(encryptedReason => UpdateFailHtlc(add.channelId, cmd.id, encryptedReason))
}
}

Expand Down