Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions docs/release-notes/eclair-vnext.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,17 @@ When using anchor outputs, allows propagating our local commitment transaction t

This removes the need for increasing the commitment feerate based on mempool conditions, which ensures that channels won't be force-closed anymore when nodes disagree on the current feerate.

### Attributable failures
### Attribution data

Eclair now supports attributable failures which allow nodes to prove they are not the source of the failure and provide timing data.
Eclair now supports attributable failures which allow nodes to prove they are not the source of the failure.
Previously a failing node could choose not to report the failure and we would penalize all nodes of the route.
If all nodes of the route support attributable failures, we only need to penalize two nodes (there is still some uncertainty as to which of the two nodes is the failing one).
See https://github.com/lightning/bolts/pull/1044 for more details.

Attribution data also provides hold times from payment relayers, both for fulfilled and failed HTLCs.

Support is disabled by default as the spec is not yet final.
It can be enabled by setting `eclair.features.option_attributable_failure = optional` at the risk of being incompatible with the final spec.
It can be enabled by setting `eclair.features.option_attribution_data = optional` at the risk of being incompatible with the final spec.

### API changes

Expand Down
2 changes: 1 addition & 1 deletion eclair-core/src/main/resources/reference.conf
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ eclair {
option_shutdown_anysegwit = optional
option_dual_fund = optional
option_quiesce = optional
option_attributable_failure = disabled
option_attribution_data = disabled
option_onion_messages = optional
// This feature should only be enabled when acting as an LSP for mobile wallets.
// When activating this feature, the peer-storage section should be customized to match desired SLAs.
Expand Down
6 changes: 3 additions & 3 deletions eclair-core/src/main/scala/fr/acinq/eclair/Features.scala
Original file line number Diff line number Diff line change
Expand Up @@ -270,8 +270,8 @@ object Features {
val mandatory = 34
}

case object AttributableFailures extends Feature with InitFeature with NodeFeature with Bolt11Feature {
val rfcName = "option_attributable_failure"
case object AttributionData extends Feature with InitFeature with NodeFeature with Bolt11Feature {
val rfcName = "option_attribution_data"
val mandatory = 36
}
case object OnionMessages extends Feature with InitFeature with NodeFeature {
Expand Down Expand Up @@ -377,7 +377,7 @@ object Features {
ShutdownAnySegwit,
DualFunding,
Quiescence,
AttributableFailures,
AttributionData,
OnionMessages,
ProvideStorage,
ChannelType,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ final case class CMD_ADD_HTLC(replyTo: ActorRef,
commit: Boolean = false) extends HasReplyToCommand with ForbiddenCommandDuringQuiescenceNegotiation with ForbiddenCommandWhenQuiescent

sealed trait HtlcSettlementCommand extends HasOptionalReplyToCommand with ForbiddenCommandDuringQuiescenceNegotiation with ForbiddenCommandWhenQuiescent { 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_FULFILL_HTLC(id: Long, r: ByteVector32, downstreamAttribution_opt: Option[ByteVector], htlcReceivedAt_opt: Option[TimestampMilli], commit: Boolean = false, replyTo_opt: Option[ActorRef] = None) extends HtlcSettlementCommand
final case class CMD_FAIL_HTLC(id: Long, reason: FailureReason, htlcReceivedAt_opt: Option[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 ForbiddenCommandDuringQuiescenceNegotiation with ForbiddenCommandWhenQuiescent
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -900,14 +900,14 @@ case class Commitments(params: ChannelParams,
.getOrElse(Right(copy(changes = changes1)))
}

def sendFulfill(cmd: CMD_FULFILL_HTLC): Either[ChannelException, (Commitments, UpdateFulfillHtlc)] =
def sendFulfill(cmd: CMD_FULFILL_HTLC, nodeSecret: PrivateKey, useAttributionData: Boolean): Either[ChannelException, (Commitments, UpdateFulfillHtlc)] =
getIncomingHtlcCrossSigned(cmd.id) match {
case Some(htlc) if CommitmentChanges.alreadyProposed(changes.localChanges.proposed, htlc.id) =>
// we have already sent a fail/fulfill for this htlc
Left(UnknownHtlcId(channelId, cmd.id))
case Some(htlc) if htlc.paymentHash == Crypto.sha256(cmd.r) =>
payment.Monitoring.Metrics.recordIncomingPaymentDistribution(params.remoteNodeId, htlc.amountMsat)
val fulfill = UpdateFulfillHtlc(channelId, cmd.id, cmd.r)
val fulfill = OutgoingPaymentPacket.buildHtlcFulfill(nodeSecret, useAttributionData, cmd, htlc)
Right((copy(changes = changes.addLocalProposal(fulfill)), fulfill))
case Some(_) => Left(InvalidHtlcPreimage(channelId, cmd.id))
case None => Left(UnknownHtlcId(channelId, cmd.id))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -481,7 +481,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
}

case Event(c: CMD_FULFILL_HTLC, d: DATA_NORMAL) =>
d.commitments.sendFulfill(c) match {
d.commitments.sendFulfill(c, nodeParams.privateKey, nodeParams.features.hasFeature(Features.AttributionData)) match {
case Right((commitments1, fulfill)) =>
if (c.commit) self ! CMD_SIGN()
context.system.eventStream.publish(AvailableBalanceChanged(self, d.channelId, d.aliases, commitments1, d.lastAnnouncement_opt))
Expand All @@ -506,7 +506,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
log.debug("delaying CMD_FAIL_HTLC with id={} for {}", c.id, delay)
context.system.scheduler.scheduleOnce(delay, self, c.copy(delay_opt = None))
stay()
case None => d.commitments.sendFail(c, nodeParams.privateKey, nodeParams.features.hasFeature(Features.AttributableFailures)) match {
case None => d.commitments.sendFail(c, nodeParams.privateKey, nodeParams.features.hasFeature(Features.AttributionData)) match {
case Right((commitments1, fail)) =>
if (c.commit) self ! CMD_SIGN()
context.system.eventStream.publish(AvailableBalanceChanged(self, d.channelId, d.aliases, commitments1, d.lastAnnouncement_opt))
Expand Down Expand Up @@ -1482,7 +1482,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall

when(SHUTDOWN)(handleExceptions {
case Event(c: CMD_FULFILL_HTLC, d: DATA_SHUTDOWN) =>
d.commitments.sendFulfill(c) match {
d.commitments.sendFulfill(c, nodeParams.privateKey, nodeParams.features.hasFeature(Features.AttributionData)) match {
case Right((commitments1, fulfill)) =>
if (c.commit) self ! CMD_SIGN()
handleCommandSuccess(c, d.copy(commitments = commitments1)) sending fulfill
Expand All @@ -1501,7 +1501,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
}

case Event(c: CMD_FAIL_HTLC, d: DATA_SHUTDOWN) =>
d.commitments.sendFail(c, nodeParams.privateKey, nodeParams.features.hasFeature(Features.AttributableFailures)) match {
d.commitments.sendFail(c, nodeParams.privateKey, nodeParams.features.hasFeature(Features.AttributionData)) match {
case Right((commitments1, fail)) =>
if (c.commit) self ! CMD_SIGN()
handleCommandSuccess(c, d.copy(commitments = commitments1)) sending fail
Expand Down Expand Up @@ -1859,8 +1859,8 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
when(CLOSING)(handleExceptions {
case Event(c: HtlcSettlementCommand, d: DATA_CLOSING) =>
(c match {
case c: CMD_FULFILL_HTLC => d.commitments.sendFulfill(c)
case c: CMD_FAIL_HTLC => d.commitments.sendFail(c, nodeParams.privateKey, nodeParams.features.hasFeature(Features.AttributableFailures))
case c: CMD_FULFILL_HTLC => d.commitments.sendFulfill(c, nodeParams.privateKey, nodeParams.features.hasFeature(Features.AttributionData))
case c: CMD_FAIL_HTLC => d.commitments.sendFail(c, nodeParams.privateKey, nodeParams.features.hasFeature(Features.AttributionData))
case c: CMD_FAIL_MALFORMED_HTLC => d.commitments.sendFailMalformed(c)
}) match {
case Right((commitments1, _)) =>
Expand Down
162 changes: 89 additions & 73 deletions eclair-core/src/main/scala/fr/acinq/eclair/crypto/Sphinx.scala
Original file line number Diff line number Diff line change
Expand Up @@ -348,86 +348,102 @@ object Sphinx extends Logging {
HtlcFailure(attribution1_opt.map(n => HoldTime(n._1, ss.remoteNodeId) +: downstreamHoldTimes).getOrElse(Nil), failure)
}
}
}

/**
* Attribution data is added to the failure packet and prevents a node from evading responsibility for its failures.
* Nodes that relay attribution data can prove that they are not the erring node and in case the erring node tries
* to hide, there will only be at most two nodes that can be the erring node (the last one to send attribution data
* and the one after it). It also adds timing data for each node on the path.
* Attribution data can also be added to fulfilled HTLCs to provide timing data and allow choosing fast nodes for
* future payments.
* https://github.com/lightning/bolts/pull/1044
*/
object Attribution {
val maxNumHops = 20
val holdTimeLength = 4
val hmacLength = 4 // HMACs are truncated to 4 bytes to save space
val totalLength = maxNumHops * holdTimeLength + maxNumHops * (maxNumHops + 1) / 2 * hmacLength // = 920

private def cipher(bytes: ByteVector, sharedSecret: ByteVector32): ByteVector = {
val key = generateKey("ammagext", sharedSecret)
val stream = generateStream(key, totalLength)
bytes xor stream
}

/**
* Attribution data is added to the failure packet and prevents a node from evading responsibility for its failures.
* Nodes that relay attribution data can prove that they are not the erring node and in case the erring node tries
* to hide, there will only be at most two nodes that can be the erring node (the last one to send attribution data
* and the one after it).
* It also adds timing data for each node on the path.
* https://github.com/lightning/bolts/pull/1044
* Get the HMACs from the attribution data.
* The layout of the attribution data is as follows (using maxNumHops = 3 for conciseness):
* holdTime(0) ++ holdTime(1) ++ holdTime(2) ++
* hmacs(0)(0) ++ hmacs(0)(1) ++ hmacs(0)(2) ++
* hmacs(1)(0) ++ hmacs(1)(1) ++
* hmacs(2)(0)
*
* Where `hmac(i)(j)` is the hmac added by node `i` (counted from the node that built the attribution data),
* assuming it is `maxNumHops - 1 - i - j` hops away from the erring node.
*/
object Attribution {
val maxNumHops = 20
val holdTimeLength = 4
val hmacLength = 4 // HMACs are truncated to 4 bytes to save space
val totalLength = maxNumHops * holdTimeLength + maxNumHops * (maxNumHops + 1) / 2 * hmacLength // = 920

private def cipher(bytes: ByteVector, sharedSecret: ByteVector32): ByteVector = {
val key = generateKey("ammagext", sharedSecret)
val stream = generateStream(key, totalLength)
bytes xor stream
}
private def getHmacs(bytes: ByteVector): Seq[Seq[ByteVector]] =
(0 until maxNumHops).map(i => (0 until (maxNumHops - i)).map(j => {
val start = maxNumHops * holdTimeLength + (maxNumHops * i - (i * (i - 1)) / 2 + j) * hmacLength
bytes.slice(start, start + hmacLength)
}))

/**
* Get the HMACs from the attribution data.
* The layout of the attribution data is as follows (using maxNumHops = 3 for conciseness):
* holdTime(0) ++ holdTime(1) ++ holdTime(2) ++
* hmacs(0)(0) ++ hmacs(0)(1) ++ hmacs(0)(2) ++
* hmacs(1)(0) ++ hmacs(1)(1) ++
* hmacs(2)(0)
*
* Where `hmac(i)(j)` is the hmac added by node `i` (counted from the node that built the attribution data),
* assuming it is `maxNumHops - 1 - i - j` hops away from the erring node.
*/
private def getHmacs(bytes: ByteVector): Seq[Seq[ByteVector]] =
(0 until maxNumHops).map(i => (0 until (maxNumHops - i)).map(j => {
val start = maxNumHops * holdTimeLength + (maxNumHops * i - (i * (i - 1)) / 2 + j) * hmacLength
bytes.slice(start, start + hmacLength)
}))

/**
* Computes the HMACs for the node that is `minNumHop` hops away from us. Hence we only compute `maxNumHops - minNumHop` HMACs.
* HMACs are truncated to 4 bytes to save space. An attacker has only one try to guess the HMAC so 4 bytes should be enough.
*/
private def computeHmacs(mac: Mac32, failurePacket: ByteVector, holdTimes: ByteVector, hmacs: Seq[Seq[ByteVector]], minNumHop: Int): Seq[ByteVector] = {
(minNumHop until maxNumHops).map(i => {
val y = maxNumHops - i
mac.mac(failurePacket ++
holdTimes.take(y * holdTimeLength) ++
ByteVector.concat((0 until y - 1).map(j => hmacs(j)(i)))).bytes.take(hmacLength)
})
}
/**
* Computes the HMACs for the node that is `minNumHop` hops away from us. Hence we only compute `maxNumHops - minNumHop` HMACs.
* HMACs are truncated to 4 bytes to save space. An attacker has only one try to guess the HMAC so 4 bytes should be enough.
*/
private def computeHmacs(mac: Mac32, failurePacket: ByteVector, holdTimes: ByteVector, hmacs: Seq[Seq[ByteVector]], minNumHop: Int): Seq[ByteVector] = {
(minNumHop until maxNumHops).map(i => {
val y = maxNumHops - i
mac.mac(failurePacket ++
holdTimes.take(y * holdTimeLength) ++
ByteVector.concat((0 until y - 1).map(j => hmacs(j)(i)))).bytes.take(hmacLength)
})
}

/**
* Create attribution data to send with the failure packet or with a fulfilled HTLC
*
* @param failurePacket_opt the failure packet before being wrapped or `None` for fulfilled HTLCs
*/
def create(previousAttribution_opt: Option[ByteVector], failurePacket_opt: Option[ByteVector], holdTime: FiniteDuration, sharedSecret: ByteVector32): ByteVector = {
val previousAttribution = previousAttribution_opt.getOrElse(ByteVector.low(totalLength))
val previousHmacs = getHmacs(previousAttribution).dropRight(1).map(_.drop(1))
val mac = Hmac256(generateKey("um", sharedSecret))
val holdTimes = uint32.encode(holdTime.toMillis).require.bytes ++ previousAttribution.take((maxNumHops - 1) * holdTimeLength)
val hmacs = computeHmacs(mac, failurePacket_opt.getOrElse(ByteVector.empty), holdTimes, previousHmacs, 0) +: previousHmacs
cipher(holdTimes ++ ByteVector.concat(hmacs.map(ByteVector.concat(_))), sharedSecret)
}

/**
* Create attribution data to send with the failure packet
*
* @param failurePacket the failure packet before being wrapped
*/
def create(previousAttribution_opt: Option[ByteVector], failurePacket: ByteVector, holdTime: FiniteDuration, sharedSecret: ByteVector32): ByteVector = {
val previousAttribution = previousAttribution_opt.getOrElse(ByteVector.low(totalLength))
val previousHmacs = getHmacs(previousAttribution).dropRight(1).map(_.drop(1))
val mac = Hmac256(generateKey("um", sharedSecret))
val holdTimes = uint32.encode(holdTime.toMillis).require.bytes ++ previousAttribution.take((maxNumHops - 1) * holdTimeLength)
val hmacs = computeHmacs(mac, failurePacket, holdTimes, previousHmacs, 0) +: previousHmacs
cipher(holdTimes ++ ByteVector.concat(hmacs.map(ByteVector.concat(_))), sharedSecret)
/**
* Unwrap one hop of attribution data
* @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
*/
def unwrap(encrypted: ByteVector, failurePacket: ByteVector, sharedSecret: ByteVector32, minNumHop: Int): Option[(FiniteDuration, ByteVector)] = {
val bytes = cipher(encrypted, sharedSecret)
val holdTime = uint32.decode(bytes.take(holdTimeLength).bits).require.value.milliseconds
val hmacs = getHmacs(bytes)
val mac = Hmac256(generateKey("um", sharedSecret))
if (computeHmacs(mac, failurePacket, bytes.take(maxNumHops * holdTimeLength), hmacs.drop(1), minNumHop) == hmacs.head.drop(minNumHop)) {
val unwrapped = bytes.slice(holdTimeLength, maxNumHops * holdTimeLength) ++ ByteVector.low(holdTimeLength) ++ ByteVector.concat((hmacs.drop(1) :+ Seq()).map(s => ByteVector.low(hmacLength) ++ ByteVector.concat(s)))
Some(holdTime, unwrapped)
} else {
None
}
}

/**
* Unwrap one hop of attribution data
* @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
*/
def unwrap(encrypted: ByteVector, failurePacket: ByteVector, sharedSecret: ByteVector32, minNumHop: Int): Option[(FiniteDuration, ByteVector)] = {
val bytes = cipher(encrypted, sharedSecret)
val holdTime = uint32.decode(bytes.take(holdTimeLength).bits).require.value.milliseconds
val hmacs = getHmacs(bytes)
val mac = Hmac256(generateKey("um", sharedSecret))
if (computeHmacs(mac, failurePacket, bytes.take(maxNumHops * holdTimeLength), hmacs.drop(1), minNumHop) == hmacs.head.drop(minNumHop)) {
val unwrapped = bytes.slice(holdTimeLength, maxNumHops * holdTimeLength) ++ ByteVector.low(holdTimeLength) ++ ByteVector.concat((hmacs.drop(1) :+ Seq()).map(s => ByteVector.low(hmacLength) ++ ByteVector.concat(s)))
Some(holdTime, unwrapped)
} else {
None
}
/**
* Decrypt the hold times from the attribution data of a fulfilled HTLC
*/
def fulfillHoldTimes(attribution: ByteVector, sharedSecrets: Seq[SharedSecret], hopIndex: Int = 0): List[HoldTime] = {
sharedSecrets match {
case Nil => Nil
case ss :: tail =>
unwrap(attribution, ByteVector.empty, ss.secret, hopIndex) match {
case Some((holdTime, nextAttribution)) =>
HoldTime(holdTime, ss.remoteNodeId) :: fulfillHoldTimes(nextAttribution, tail, hopIndex + 1)
case None => Nil
}
}
}
}
Expand Down
Loading