Skip to content

Commit 1d57546

Browse files
committed
Attributable failures
1 parent ecd4634 commit 1d57546

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+390
-250
lines changed

eclair-core/src/main/resources/reference.conf

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ eclair {
7272
option_shutdown_anysegwit = optional
7373
option_dual_fund = optional
7474
option_quiesce = optional
75+
option_attributable_failure = optional
7576
option_onion_messages = optional
7677
// This feature should only be enabled when acting as an LSP for mobile wallets.
7778
// When activating this feature, the peer-storage section should be customized to match desired SLAs.

eclair-core/src/main/scala/fr/acinq/eclair/Features.scala

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,10 @@ object Features {
270270
val mandatory = 34
271271
}
272272

273+
case object AttributableFailures extends Feature with InitFeature with NodeFeature with Bolt11Feature {
274+
val rfcName = "option_attributable_failure"
275+
val mandatory = 36
276+
}
273277
case object OnionMessages extends Feature with InitFeature with NodeFeature {
274278
val rfcName = "option_onion_messages"
275279
val mandatory = 38
@@ -373,6 +377,7 @@ object Features {
373377
ShutdownAnySegwit,
374378
DualFunding,
375379
Quiescence,
380+
AttributableFailures,
376381
OnionMessages,
377382
ProvideStorage,
378383
ChannelType,

eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,7 @@ final case class CMD_ADD_HTLC(replyTo: ActorRef,
215215

216216
sealed trait HtlcSettlementCommand extends HasOptionalReplyToCommand with ForbiddenCommandDuringQuiescenceNegotiation with ForbiddenCommandWhenQuiescent { def id: Long }
217217
final case class CMD_FULFILL_HTLC(id: Long, r: ByteVector32, commit: Boolean = false, replyTo_opt: Option[ActorRef] = None) extends HtlcSettlementCommand
218-
final case class CMD_FAIL_HTLC(id: Long, reason: FailureReason, delay_opt: Option[FiniteDuration] = None, commit: Boolean = false, replyTo_opt: Option[ActorRef] = None) extends HtlcSettlementCommand
218+
final case class CMD_FAIL_HTLC(id: Long, reason: FailureReason, startHoldTime: TimestampMilli, delay_opt: Option[FiniteDuration] = None, commit: Boolean = false, replyTo_opt: Option[ActorRef] = None) extends HtlcSettlementCommand
219219
final case class CMD_FAIL_MALFORMED_HTLC(id: Long, onionHash: ByteVector32, failureCode: Int, commit: Boolean = false, replyTo_opt: Option[ActorRef] = None) extends HtlcSettlementCommand
220220
final case class CMD_UPDATE_FEE(feeratePerKw: FeeratePerKw, commit: Boolean = false, replyTo_opt: Option[ActorRef] = None) extends HasOptionalReplyToCommand with ForbiddenCommandDuringQuiescenceNegotiation with ForbiddenCommandWhenQuiescent
221221
final case class CMD_SIGN(replyTo_opt: Option[ActorRef] = None) extends HasOptionalReplyToCommand with ForbiddenCommandWhenQuiescent

eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -896,14 +896,14 @@ case class Commitments(params: ChannelParams,
896896
case None => Left(UnknownHtlcId(channelId, fulfill.id))
897897
}
898898

899-
def sendFail(cmd: CMD_FAIL_HTLC, nodeSecret: PrivateKey): Either[ChannelException, (Commitments, HtlcFailureMessage)] =
899+
def sendFail(cmd: CMD_FAIL_HTLC, nodeSecret: PrivateKey, useAttributableFailures: Boolean): Either[ChannelException, (Commitments, HtlcFailureMessage)] =
900900
getIncomingHtlcCrossSigned(cmd.id) match {
901901
case Some(htlc) if CommitmentChanges.alreadyProposed(changes.localChanges.proposed, htlc.id) =>
902902
// we have already sent a fail/fulfill for this htlc
903903
Left(UnknownHtlcId(channelId, cmd.id))
904904
case Some(htlc) =>
905905
// we need the shared secret to build the error packet
906-
OutgoingPaymentPacket.buildHtlcFailure(nodeSecret, cmd, htlc).map(fail => (copy(changes = changes.addLocalProposal(fail)), fail))
906+
OutgoingPaymentPacket.buildHtlcFailure(nodeSecret, useAttributableFailures, cmd, htlc).map(fail => (copy(changes = changes.addLocalProposal(fail)), fail))
907907
case None => Left(UnknownHtlcId(channelId, cmd.id))
908908
}
909909

eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -501,7 +501,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
501501
log.debug("delaying CMD_FAIL_HTLC with id={} for {}", c.id, delay)
502502
context.system.scheduler.scheduleOnce(delay, self, c.copy(delay_opt = None))
503503
stay()
504-
case None => d.commitments.sendFail(c, nodeParams.privateKey) match {
504+
case None => d.commitments.sendFail(c, nodeParams.privateKey, nodeParams.features.hasFeature(Features.AttributableFailures)) match {
505505
case Right((commitments1, fail)) =>
506506
if (c.commit) self ! CMD_SIGN()
507507
context.system.eventStream.publish(AvailableBalanceChanged(self, d.channelId, d.aliases, commitments1, d.lastAnnouncement_opt))
@@ -668,7 +668,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
668668
case PostRevocationAction.RejectHtlc(add) =>
669669
log.debug("rejecting incoming htlc {}", add)
670670
// NB: we don't set commit = true, we will sign all updates at once afterwards.
671-
self ! CMD_FAIL_HTLC(add.id, FailureReason.LocalFailure(TemporaryChannelFailure(Some(d.channelUpdate))), commit = true)
671+
self ! CMD_FAIL_HTLC(add.id, FailureReason.LocalFailure(TemporaryChannelFailure(Some(d.channelUpdate))), TimestampMilli.now(), commit = true)
672672
case PostRevocationAction.RelayFailure(result) =>
673673
log.debug("forwarding {} to relayer", result)
674674
relayer ! result
@@ -1498,7 +1498,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
14981498
}
14991499

15001500
case Event(c: CMD_FAIL_HTLC, d: DATA_SHUTDOWN) =>
1501-
d.commitments.sendFail(c, nodeParams.privateKey) match {
1501+
d.commitments.sendFail(c, nodeParams.privateKey, nodeParams.features.hasFeature(Features.AttributableFailures)) match {
15021502
case Right((commitments1, fail)) =>
15031503
if (c.commit) self ! CMD_SIGN()
15041504
handleCommandSuccess(c, d.copy(commitments = commitments1)) sending fail
@@ -1617,11 +1617,11 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
16171617
case PostRevocationAction.RelayHtlc(add) =>
16181618
// BOLT 2: A sending node SHOULD fail to route any HTLC added after it sent shutdown.
16191619
log.debug("closing in progress: failing {}", add)
1620-
self ! CMD_FAIL_HTLC(add.id, FailureReason.LocalFailure(PermanentChannelFailure()), commit = true)
1620+
self ! CMD_FAIL_HTLC(add.id, FailureReason.LocalFailure(PermanentChannelFailure()), TimestampMilli.now(), commit = true)
16211621
case PostRevocationAction.RejectHtlc(add) =>
16221622
// BOLT 2: A sending node SHOULD fail to route any HTLC added after it sent shutdown.
16231623
log.debug("closing in progress: rejecting {}", add)
1624-
self ! CMD_FAIL_HTLC(add.id, FailureReason.LocalFailure(PermanentChannelFailure()), commit = true)
1624+
self ! CMD_FAIL_HTLC(add.id, FailureReason.LocalFailure(PermanentChannelFailure()), TimestampMilli.now(), commit = true)
16251625
case PostRevocationAction.RelayFailure(result) =>
16261626
log.debug("forwarding {} to relayer", result)
16271627
relayer ! result
@@ -1861,7 +1861,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
18611861
case Event(c: HtlcSettlementCommand, d: DATA_CLOSING) =>
18621862
(c match {
18631863
case c: CMD_FULFILL_HTLC => d.commitments.sendFulfill(c)
1864-
case c: CMD_FAIL_HTLC => d.commitments.sendFail(c, nodeParams.privateKey)
1864+
case c: CMD_FAIL_HTLC => d.commitments.sendFail(c, nodeParams.privateKey, nodeParams.features.hasFeature(Features.AttributableFailures))
18651865
case c: CMD_FAIL_MALFORMED_HTLC => d.commitments.sendFailMalformed(c)
18661866
}) match {
18671867
case Right((commitments1, _)) =>

eclair-core/src/main/scala/fr/acinq/eclair/crypto/Sphinx.scala

Lines changed: 75 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,10 @@ import fr.acinq.eclair.wire.protocol._
2323
import grizzled.slf4j.Logging
2424
import scodec.Attempt
2525
import scodec.bits.ByteVector
26+
import scodec.codecs.uint32
2627

2728
import scala.annotation.tailrec
29+
import scala.concurrent.duration.{DurationLong, FiniteDuration}
2830
import scala.util.{Failure, Success, Try}
2931

3032
/**
@@ -282,6 +284,10 @@ object Sphinx extends Logging {
282284
*/
283285
case class CannotDecryptFailurePacket(unwrapped: ByteVector)
284286

287+
case class HoldTime(duration: FiniteDuration, remoteNodeId: PublicKey)
288+
289+
case class HtlcFailure(holdTimes: Seq[HoldTime], failure: Either[CannotDecryptFailurePacket, DecryptedFailurePacket])
290+
285291
object FailurePacket {
286292

287293
/**
@@ -294,12 +300,19 @@ object Sphinx extends Logging {
294300
* @param failure failure message.
295301
* @return a failure packet that can be sent to the destination node.
296302
*/
303+
def createAndWrap(sharedSecret: ByteVector32, failure: FailureMessage): ByteVector = {
304+
wrap(create(sharedSecret, failure), sharedSecret)
305+
}
306+
307+
/**
308+
* Create a failure packet that needs to be wrapped before being returned to the sender.
309+
*/
297310
def create(sharedSecret: ByteVector32, failure: FailureMessage): ByteVector = {
298311
val um = generateKey("um", sharedSecret)
299312
val packet = FailureMessageCodecs.failureOnionCodec(Hmac256(um)).encode(failure).require.toByteVector
300313
logger.debug(s"um key: $um")
301314
logger.debug(s"raw error packet: ${packet.toHex}")
302-
wrap(packet, sharedSecret)
315+
packet
303316
}
304317

305318
/**
@@ -322,25 +335,78 @@ object Sphinx extends Logging {
322335
* it was sent by the corresponding node.
323336
* Note that malicious nodes in the route may have altered the packet, triggering a decryption failure.
324337
*
325-
* @param packet failure packet.
326-
* @param sharedSecrets nodes shared secrets.
338+
* @param packet failure packet.
339+
* @param attribution_opt attribution data for this failure packet.
340+
* @param sharedSecrets nodes shared secrets.
327341
* @return failure message if the origin of the packet could be identified and the packet decrypted, the unwrapped
328342
* failure packet otherwise.
329343
*/
330-
@tailrec
331-
def decrypt(packet: ByteVector, sharedSecrets: Seq[SharedSecret]): Either[CannotDecryptFailurePacket, DecryptedFailurePacket] = {
344+
def decrypt(packet: ByteVector, attribution_opt: Option[ByteVector], sharedSecrets: Seq[SharedSecret], hopIndex: Int = 0): HtlcFailure = {
332345
sharedSecrets match {
333-
case Nil => Left(CannotDecryptFailurePacket(packet))
346+
case Nil => HtlcFailure(Nil, Left(CannotDecryptFailurePacket(packet)))
334347
case ss :: tail =>
335348
val packet1 = wrap(packet, ss.secret)
349+
val attribution1_opt = attribution_opt.flatMap(Attribution.unwrap(_, packet1, ss.secret, hopIndex))
336350
val um = generateKey("um", ss.secret)
337-
FailureMessageCodecs.failureOnionCodec(Hmac256(um)).decode(packet1.toBitVector) match {
338-
case Attempt.Successful(value) => Right(DecryptedFailurePacket(ss.remoteNodeId, value.value))
339-
case _ => decrypt(packet1, tail)
351+
val HtlcFailure(holdTimes, failure) = FailureMessageCodecs.failureOnionCodec(Hmac256(um)).decode(packet1.toBitVector) match {
352+
case Attempt.Successful(value) => HtlcFailure(Nil, Right(DecryptedFailurePacket(ss.remoteNodeId, value.value)))
353+
case _ => decrypt(packet1, attribution1_opt.map(_._2), tail, hopIndex + 1)
340354
}
355+
HtlcFailure(attribution1_opt.map(n => HoldTime(n._1, ss.remoteNodeId) +: holdTimes).getOrElse(Nil), failure)
341356
}
342357
}
343358

359+
object Attribution {
360+
private def cipher(bytes: ByteVector, sharedSecret: ByteVector32): ByteVector = {
361+
val key = generateKey("ammagext", sharedSecret)
362+
val stream = generateStream(key, 920)
363+
bytes xor stream
364+
}
365+
366+
private def getHmacs(bytes: ByteVector): Seq[Seq[ByteVector]] =
367+
(0 until 20).map(i => (0 until (20 - i)).map(j => {
368+
val start = (20 + 20 * i - (i * (i - 1)) / 2 + j) * 4
369+
bytes.slice(start, start + 4)
370+
}))
371+
372+
private def computeHmacs(mac: Mac32, reason: ByteVector, holdTimes: ByteVector, hmacs: Seq[Seq[ByteVector]], minNumHop: Int): Seq[ByteVector] = {
373+
(minNumHop until 20).map(i => {
374+
val y = 20 - i
375+
mac.mac(reason ++
376+
holdTimes.take(y * 4) ++
377+
ByteVector.concat((0 until y - 1).map(j => hmacs(j)(i)))).bytes.take(4)
378+
})
379+
}
380+
381+
/**
382+
* Create attribution data to send with the failure packet
383+
*/
384+
def create(previousAttribution_opt: Option[ByteVector], reason: ByteVector, holdTime: FiniteDuration, sharedSecret: ByteVector32): ByteVector = {
385+
val previousAttribution = previousAttribution_opt.getOrElse(ByteVector.low(920))
386+
val previousHmacs = getHmacs(previousAttribution).dropRight(1).map(_.drop(1))
387+
val mac = Hmac256(generateKey("um", sharedSecret))
388+
val holdTimes = uint32.encode(holdTime.toMillis).require.bytes ++ previousAttribution.take(19 * 4)
389+
val hmacs = computeHmacs(mac, reason, holdTimes, previousHmacs, 0) +: previousHmacs
390+
cipher(holdTimes ++ ByteVector.concat(hmacs.map(ByteVector.concat(_))), sharedSecret)
391+
}
392+
393+
/**
394+
* Unwrap one hop of attribution data
395+
* @return a pair with the hold time for this hop and the attribution data for the next hop, or None if the attribution data was invalid
396+
*/
397+
def unwrap(encrypted: ByteVector, reason: ByteVector, sharedSecret: ByteVector32, minNumHop: Int): Option[(FiniteDuration, ByteVector)] = {
398+
val bytes = cipher(encrypted, sharedSecret)
399+
val holdTime = uint32.decode(bytes.take(4).bits).require.value.milliseconds
400+
val hmacs = getHmacs(bytes)
401+
val mac = Hmac256(generateKey("um", sharedSecret))
402+
if (computeHmacs(mac, reason, bytes.take(20 * 4), hmacs.drop(1), minNumHop) == hmacs.head.drop(minNumHop)) {
403+
val unwraped = bytes.slice(4, 20 * 4) ++ ByteVector.low(4) ++ ByteVector.concat((hmacs.drop(1) :+ Seq()).map(s => ByteVector.low(4) ++ ByteVector.concat(s)))
404+
Some(holdTime, unwraped)
405+
} else {
406+
None
407+
}
408+
}
409+
}
344410
}
345411

346412
/**

eclair-core/src/main/scala/fr/acinq/eclair/db/PaymentsDb.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ object FailureSummary {
250250
def apply(f: PaymentFailure): FailureSummary = f match {
251251
case LocalFailure(_, route, t) => FailureSummary(FailureType.LOCAL, t.getMessage, route.map(h => HopSummary(h)).toList, route.headOption.map(_.nodeId))
252252
case RemoteFailure(_, route, e) => FailureSummary(FailureType.REMOTE, e.failureMessage.message, route.map(h => HopSummary(h)).toList, Some(e.originNode))
253-
case UnreadableRemoteFailure(_, route, _) => FailureSummary(FailureType.UNREADABLE_REMOTE, "could not decrypt failure onion", route.map(h => HopSummary(h)).toList, None)
253+
case UnreadableRemoteFailure(_, route, _, _) => FailureSummary(FailureType.UNREADABLE_REMOTE, "could not decrypt failure onion", route.map(h => HopSummary(h)).toList, None)
254254
}
255255
}
256256

eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,7 @@ class Peer(val nodeParams: NodeParams,
339339
pending.proposed.find(_.htlc.id == msg.id) match {
340340
case Some(htlc) =>
341341
val failure = msg match {
342-
case msg: WillFailHtlc => FailureReason.EncryptedDownstreamFailure(msg.reason)
342+
case msg: WillFailHtlc => FailureReason.EncryptedDownstreamFailure(msg.reason, msg.attribution_opt)
343343
case msg: WillFailMalformedHtlc => FailureReason.LocalFailure(createBadOnionFailure(msg.onionHash, msg.failureCode))
344344
}
345345
htlc.createFailureCommands(Some(failure))(log).foreach { case (channelId, cmd) => PendingCommandsDb.safeSend(register, nodeParams.db.pendingCommands, channelId, cmd) }

eclair-core/src/main/scala/fr/acinq/eclair/payment/PaymentEvents.scala

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package fr.acinq.eclair.payment
1919
import fr.acinq.bitcoin.scalacompat.ByteVector32
2020
import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey
2121
import fr.acinq.eclair.crypto.Sphinx
22+
import fr.acinq.eclair.crypto.Sphinx.HoldTime
2223
import fr.acinq.eclair.payment.Invoice.ExtraEdge
2324
import fr.acinq.eclair.payment.send.PaymentError.RetryExhausted
2425
import fr.acinq.eclair.payment.send.PaymentInitiator.SendPaymentConfig
@@ -150,7 +151,7 @@ case class LocalFailure(amount: MilliSatoshi, route: Seq[Hop], t: Throwable) ext
150151
case class RemoteFailure(amount: MilliSatoshi, route: Seq[Hop], e: Sphinx.DecryptedFailurePacket) extends PaymentFailure
151152

152153
/** A remote node failed the payment but we couldn't decrypt the failure (e.g. a malicious node tampered with the message). */
153-
case class UnreadableRemoteFailure(amount: MilliSatoshi, route: Seq[Hop], failurePacket: ByteVector) extends PaymentFailure
154+
case class UnreadableRemoteFailure(amount: MilliSatoshi, route: Seq[Hop], failurePacket: ByteVector, holdTimes: Seq[HoldTime]) extends PaymentFailure
154155

155156
object PaymentFailure {
156157

@@ -235,13 +236,14 @@ object PaymentFailure {
235236
}
236237
case RemoteFailure(_, hops, Sphinx.DecryptedFailurePacket(nodeId, _)) =>
237238
ignoreNodeOutgoingEdge(nodeId, hops, ignore)
238-
case UnreadableRemoteFailure(_, hops, _) =>
239+
case UnreadableRemoteFailure(_, hops, _, holdTimes) =>
239240
// We don't know which node is sending garbage, let's blacklist all nodes except:
241+
// - the nodes that returned attribution data (except the last one)
240242
// - the one we are directly connected to: it would be too restrictive for retries
241243
// - the final recipient: they have no incentive to send garbage since they want that payment
242244
// - the introduction point of a blinded route: we don't want a node before the blinded path to force us to ignore that blinded path
243245
// - the trampoline node: we don't want a node before the trampoline node to force us to ignore that trampoline node
244-
val blacklist = hops.collect { case hop: ChannelHop => hop }.map(_.nextNodeId).drop(1).dropRight(1).toSet
246+
val blacklist = hops.collect { case hop: ChannelHop => hop }.map(_.nextNodeId).drop(1 max (holdTimes.length - 1)).dropRight(1).toSet
245247
ignore ++ blacklist
246248
case LocalFailure(_, hops, _) => hops.headOption match {
247249
case Some(hop: ChannelHop) =>

0 commit comments

Comments
 (0)