What changed, and why it matters
This commit is a code refactor of how Eclair handles 'attribution data'—extra encrypted timing and accountability information attached to Lightning payment success and failure messages. It restructures the code to support future trampoline payments and blinded routes, and adds tests. The commit itself does not claim to fix a security bug; it is described as making later features easier to implement. However, the refactor changes how shared secrets are extracted and how attribution data is included or omitted for blinded and trampoline payments, which touches privacy-sensitive logic.
Treat as a normal code-review item rather than an urgent security patch. Reviewers should verify that the new shared-secret extraction correctly handles malformed trampoline onions, that blinded-path detection cannot be bypassed by a crafted payload, and that the HMAC verification changes do not weaken attribution accountability. Run the new tests and consider additional edge-case tests for partial/malformed attribution data.
Security signals we found
Refactor of cryptographic attribution-data creation/verification
New shared-secret extraction path parses trampoline onion and path-key/blinded-path state
Attribution data now explicitly suppressed for blinded-route intermediate nodes
Attribution HMAC verification now uses optional payload (None for success, Some failure packet for failures)
Adds tests for blinded-path and trampoline attribution behavior
Evidence from the diff
The commit refactors Sphinx.Attribution handling: it renames unwrap() to decrypt(), introduces HtlcSuccess/SuccessPacket, splits create() parameters into downstreamAttribution_opt and payload_opt, and adds HtlcSharedSecrets extraction that distinguishes outer onion, optional trampoline onion, and blinded-path detection. buildHtlcFailure and buildHtlcFulfill are updated to use the new extraction, ensuring no attribution data is emitted when inside a blinded path and adding support for nested trampoline attribution on fulfilled HTLCs. Tests are added/updated for blinded and trampoline failure/fulfill attribution flows. No CVE, advisory, or vendor security statement is present in the supplied materials.
Changed components
eclair-core/src/main/scala/fr/acinq/eclair/crypto/Sphinx.scalaeclair-core/src/main/scala/fr/acinq/eclair/payment/PaymentPacket.scalaeclair-core/src/main/scala/fr/acinq/eclair/payment/send/PaymentLifecycle.scalaeclair-core/src/main/scala/fr/acinq/eclair/payment/send/TrampolinePaymentLifecycle.scalaeclair-core/src/test/scala/fr/acinq/eclair/crypto/SphinxSpec.scalaeclair-core/src/test/scala/fr/acinq/eclair/payment/PaymentPacketSpec.scalaInspect captured patch +228 / −109
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/crypto/Sphinx.scala b/eclair-core/src/main/scala/fr/acinq/eclair/crypto/Sphinx.scala
index 97995c6..71efa78 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/crypto/Sphinx.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/crypto/Sphinx.scala
@@ -290,8 +290,9 @@ object Sphinx extends Logging {
case class HtlcFailure(holdTimes: Seq[HoldTime], failure: Either[CannotDecryptFailurePacket, DecryptedFailurePacket])
- object FailurePacket {
+ case class HtlcSuccess(holdTimes: Seq[HoldTime], remainingAttribution_opt: Option[ByteVector])
+ object FailurePacket {
/**
* Create a failure packet that needs to be wrapped before being returned to the sender.
* Each intermediate hop will add a layer of encryption and forward to the previous hop.
@@ -341,36 +342,70 @@ object Sphinx extends Logging {
case Nil => HtlcFailure(Nil, Left(CannotDecryptFailurePacket(packet, attribution_opt)))
case ss :: tail =>
val packet1 = wrap(packet, ss.secret)
- val attribution1_opt = attribution_opt.flatMap(Attribution.unwrap(_, packet1, ss.secret, sharedSecrets.length))
+ val attribution1_opt = attribution_opt.flatMap(attribution => Attribution.decrypt(attribution, Some(packet1), ss, sharedSecrets.length))
val um = generateKey("um", ss.secret)
- val HtlcFailure(downstreamHoldTimes, failure) = FailureMessageCodecs.failureOnionCodec(Hmac256(um)).decode(packet1.toBitVector) match {
+ val downstream = FailureMessageCodecs.failureOnionCodec(Hmac256(um)).decode(packet1.toBitVector) match {
+ // We've identified the failing node: no need to continue decrypting.
case Attempt.Successful(value) => HtlcFailure(Nil, Right(DecryptedFailurePacket(ss.remoteNodeId, index, value.value)))
- case _ => decrypt(packet1, attribution1_opt.map(_._2), tail, index + 1)
+ // The failing node may be downstream: we keep decrypting.
+ case _ => decrypt(packet1, attribution1_opt.map(_.downstreamAttribution), tail, index + 1)
+ }
+ HtlcFailure(attribution1_opt.map(_.holdTime).toSeq ++ downstream.holdTimes, downstream.failure)
+ }
+ }
+ }
+
+ object SuccessPacket {
+ /**
+ * Decrypt the attribution data provided in the HTLC-success case.
+ * Note that malicious nodes in the route may have altered the packet, triggering a decryption failure.
+ *
+ * @param attribution_opt attribution data for this success packet.
+ * @param sharedSecrets nodes shared secrets.
+ */
+ def decrypt(attribution_opt: Option[ByteVector], sharedSecrets: Seq[SharedSecret], index: Int = 1): HtlcSuccess = {
+ sharedSecrets match {
+ case Nil => HtlcSuccess(Nil, attribution_opt)
+ case ss :: tail =>
+ attribution_opt match {
+ case None => HtlcSuccess(Nil, None)
+ case Some(attribution) =>
+ Attribution.decrypt(attribution, None, ss, sharedSecrets.length) match {
+ case Some(perHopAttribution) =>
+ val downstream = decrypt(Some(perHopAttribution.downstreamAttribution), tail, index + 1)
+ HtlcSuccess(perHopAttribution.holdTime +: downstream.holdTimes, downstream.remainingAttribution_opt)
+ case None => HtlcSuccess(Nil, Some(attribution))
+ }
}
- 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
+ * Attribution data is included when resolving an HTLC, whether it's a failure or a success.
+ *
+ * In the HTLC failure case, nodes that relay attribution data can prove that they are not the origin of the failure.
+ * In case the failing node tries to hide (by returning garbage or tampering with a downstream failure), there will
+ * be at most two nodes that can be responsible: the last one to send attribution data and the one after it.
+ *
+ * Attribution data also contains timing information for each node on the payment path, which lets the sending node
+ * identify slow nodes and potentially prioritize faster nodes for future payments.
*/
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 val maxNumHops = 20
+ private val holdTimeLength = 4
+ private val hmacLength = 4 // HMACs are truncated to 4 bytes to save space
+ val totalLength: Int = maxNumHops * holdTimeLength + maxNumHops * (maxNumHops + 1) / 2 * hmacLength // = 920
+
+ /** Valid attribution data from one hop in the payment path. */
+ case class PerHopAttribution(holdTime: HoldTime, downstreamAttribution: ByteVector) {
+ val nodeId: PublicKey = holdTime.remoteNodeId
+ }
- private def cipher(bytes: ByteVector, sharedSecret: ByteVector32): ByteVector = {
+ private def wrap(attributionData: ByteVector, sharedSecret: ByteVector32): ByteVector = {
val key = generateKey("ammagext", sharedSecret)
val stream = generateStream(key, totalLength)
- bytes xor stream
+ attributionData xor stream
}
/**
@@ -391,67 +426,66 @@ object Sphinx extends Logging {
}))
/**
- * Computes the HMACs for the node that is `maxNumHops - remainingHops` hops away from us. Hence we only compute `remainingHops` 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.
+ * Computes the HMACs for the node that is `maxNumHops - remainingHops` hops away from us: we only need to compute
+ * `remainingHops` HMACs.
+ * HMACs are truncated to 4 bytes to save space: this should be enough since an attacker has only one shot at
+ * "forging" an HMAC, so they're unlikely to succeed.
*/
- private def computeHmacs(mac: Mac32, failurePacket: ByteVector, holdTimes: ByteVector, hmacs: Seq[Seq[ByteVector]], remainingHops: Int): Seq[ByteVector] = {
+ private def computeHmacs(mac: Mac32, payload: ByteVector, holdTimes: ByteVector, hmacs: Seq[Seq[ByteVector]], remainingHops: Int): Seq[ByteVector] = {
((maxNumHops - remainingHops) 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)
+ // We include the HMACs of downstream nodes in our own HMACs.
+ val downstreamMacs = ByteVector.concat((0 until y - 1).map(j => hmacs(j)(i)))
+ // We include the hold times of downstream nodes as well.
+ val downstreamHoldTimes = holdTimes.take(y * holdTimeLength)
+ mac.mac(payload ++ downstreamHoldTimes ++ downstreamMacs).bytes.take(hmacLength)
})
}
/**
* Create attribution data to send when settling an HTLC (in both failure and success cases).
*
- * @param failurePacket_opt the failure packet before being wrapped or `None` for fulfilled HTLCs.
+ * @param downstreamAttribution_opt attribution data received from downstream.
+ * @param payload_opt payload that should be covered by the attribution HMACs.
*/
- 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 / 100).require.bytes ++ previousAttribution.take((maxNumHops - 1) * holdTimeLength)
- val hmacs = computeHmacs(mac, failurePacket_opt.getOrElse(ByteVector.empty), holdTimes, previousHmacs, maxNumHops) +: previousHmacs
- cipher(holdTimes ++ ByteVector.concat(hmacs.map(ByteVector.concat(_))), sharedSecret)
+ def create(downstreamAttribution_opt: Option[ByteVector], payload_opt: Option[ByteVector], holdTime: FiniteDuration, sharedSecret: ByteVector32): ByteVector = {
+ val downstreamAttribution = downstreamAttribution_opt.getOrElse(zeroes(totalLength))
+ val downstreamHmacs = getHmacs(downstreamAttribution).dropRight(1).map(_.drop(1))
+ val downstreamHoldTimes = downstreamAttribution.take((maxNumHops - 1) * holdTimeLength)
+ val holdTimes = uint32.encode(holdTime.toMillis / 100).require.bytes ++ downstreamHoldTimes
+ val macKey = generateKey("um", sharedSecret)
+ val hmacs = computeHmacs(Hmac256(macKey), payload_opt.getOrElse(ByteVector.empty), holdTimes, downstreamHmacs, maxNumHops) +: downstreamHmacs
+ wrap(holdTimes ++ ByteVector.concat(hmacs.map(ByteVector.concat(_))), sharedSecret)
}
/**
- * Unwrap one hop of attribution data.
+ * Decrypt one hop of attribution data, or return [[None]] if we cannot extract attribution data (which happens if
+ * this node or the previous one is malicious, or if the node is inside a blinded path).
*
- * @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.
+ * @param attribution attribution data from this node.
+ * @param payload_opt (optional) payload that is also covered by this node's HMACs.
+ * @param sharedSecret shared secret with the node.
+ * @param remainingHops number of remaining downstream nodes.
*/
- def unwrap(encrypted: ByteVector, failurePacket: ByteVector, sharedSecret: ByteVector32, remainingHops: Int): Option[(FiniteDuration, ByteVector)] = {
- val bytes = cipher(encrypted, sharedSecret)
- val holdTime = (uint32.decode(bytes.take(holdTimeLength).bits).require.value * 100).milliseconds
- val hmacs = getHmacs(bytes)
- val mac = Hmac256(generateKey("um", sharedSecret))
- if (computeHmacs(mac, failurePacket, bytes.take(maxNumHops * holdTimeLength), hmacs.drop(1), remainingHops) == hmacs.head.drop(maxNumHops - remainingHops)) {
- 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)
+ def decrypt(attribution: ByteVector, payload_opt: Option[ByteVector], sharedSecret: SharedSecret, remainingHops: Int): Option[PerHopAttribution] = {
+ val decrypted = wrap(attribution, sharedSecret.secret)
+ val holdTime = (uint32.decode(decrypted.take(holdTimeLength).bits).require.value * 100).milliseconds
+ val holdTimes = decrypted.take(maxNumHops * holdTimeLength)
+ val hmacs = getHmacs(decrypted)
+ val macKey = generateKey("um", sharedSecret.secret)
+ val expectedHmacs = computeHmacs(Hmac256(macKey), payload_opt.getOrElse(ByteVector.empty), holdTimes, hmacs.drop(1), remainingHops)
+ if (expectedHmacs == hmacs.head.drop(maxNumHops - remainingHops)) {
+ // The attribution data from this node is valid: we shift it to access attribution data from the downstream nodes.
+ val downstreamHoldTimes = decrypted.slice(holdTimeLength, maxNumHops * holdTimeLength) ++ zeroes(holdTimeLength)
+ val downstreamHmacs = ByteVector.concat((hmacs.drop(1) :+ Seq()).map(s => zeroes(hmacLength) ++ ByteVector.concat(s)))
+ Some(PerHopAttribution(HoldTime(holdTime, sharedSecret.remoteNodeId), downstreamHoldTimes ++ downstreamHmacs))
} else {
+ // The attribution data from this node is invalid or missing. This doesn't necessarily mean that this node was
+ // malicious: they could be inside a blinded path, in which case they don't return any attribution data, which
+ // is fine (we only care about attribution outside the blinded path).
None
}
}
-
- case class UnwrappedAttribution(holdTimes: List[HoldTime], remaining_opt: Option[ByteVector])
-
- /**
- * Unwrap many hops of attribution data (e.g. used for fulfilled HTLCs).
- */
- def unwrap(attribution: ByteVector, sharedSecrets: Seq[SharedSecret]): UnwrappedAttribution = {
- sharedSecrets match {
- case Nil => UnwrappedAttribution(Nil, Some(attribution))
- case ss :: tail =>
- unwrap(attribution, ByteVector.empty, ss.secret, sharedSecrets.length) match {
- case Some((holdTime, nextAttribution)) =>
- val UnwrappedAttribution(holdTimes, remaining_opt) = unwrap(nextAttribution, tail)
- UnwrappedAttribution(HoldTime(holdTime, ss.remoteNodeId) :: holdTimes, remaining_opt)
- case None => UnwrappedAttribution(Nil, None)
- }
- }
- }
}
/**
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/PaymentPacket.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/PaymentPacket.scala
index 04d99fc..8046fb0 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/PaymentPacket.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/PaymentPacket.scala
@@ -327,7 +327,10 @@ object OutgoingPaymentPacket {
* In that case, packetPayloadLength_opt must be greater than the actual onion's content.
*/
def buildOnion(payloads: Seq[NodePayload], associatedData: ByteVector32, packetPayloadLength_opt: Option[Int]): Either[OutgoingPaymentError, Sphinx.PacketAndSecrets] = {
- val sessionKey = randomKey()
+ buildOnion(randomKey(), payloads, associatedData, packetPayloadLength_opt)
+ }
+
+ def buildOnion(sessionKey: PrivateKey, payloads: Seq[NodePayload], associatedData: ByteVector32, packetPayloadLength_opt: Option[Int]): Either[OutgoingPaymentError, Sphinx.PacketAndSecrets] = {
val nodeIds = payloads.map(_.nodeId)
val payloadsBin = payloads
.map(p => PaymentOnionCodecs.perHopPayloadCodec.encode(p.payload.records))
@@ -353,61 +356,92 @@ object OutgoingPaymentPacket {
}
}
- private def buildHtlcFailure(nodeSecret: PrivateKey, useAttributableFailures: Boolean, reason: FailureReason, add: UpdateAddHtlc, holdTime: FiniteDuration): Either[CannotExtractSharedSecret, (ByteVector, TlvStream[UpdateFailHtlcTlv])] = {
- extractSharedSecret(nodeSecret, add).map(sharedSecret => {
- val (packet, attribution) = reason match {
- case FailureReason.EncryptedDownstreamFailure(packet, attribution) => (packet, attribution)
- case FailureReason.LocalFailure(failure) => (Sphinx.FailurePacket.create(sharedSecret, failure), None)
- }
- val tlvs: TlvStream[UpdateFailHtlcTlv] = if (useAttributableFailures) {
- TlvStream(UpdateFailHtlcTlv.AttributionData(Sphinx.Attribution.create(attribution, Some(packet), holdTime, sharedSecret)))
- } else {
- TlvStream.empty
- }
- (Sphinx.FailurePacket.wrap(packet, sharedSecret), tlvs)
- })
- }
+ private case class HtlcSharedSecrets(outerOnionSecret: ByteVector32, trampolineOnionSecret_opt: Option[ByteVector32], blinded: Boolean)
/**
- * We decrypt the onion again to extract the shared secret used to encrypt onion failures.
- * We could avoid this by storing the shared secret after the initial onion decryption, but we would have to store it
- * in the database since we must be able to fail HTLCs after restarting our node.
+ * We decrypt the onion again to extract the shared secret(s) used to encrypt onion failures.
+ * We could avoid this by storing the shared secret(s) after the initial onion decryption, but we would have to store
+ * it in the database since we must be able to fail HTLCs after restarting our node.
* It's simpler to extract it again from the encrypted onion.
*/
- private def extractSharedSecret(nodeSecret: PrivateKey, add: UpdateAddHtlc): Either[CannotExtractSharedSecret, ByteVector32] = {
+ private def extractSharedSecret(nodeSecret: PrivateKey, add: UpdateAddHtlc): Either[CannotExtractSharedSecret, HtlcSharedSecrets] = {
Sphinx.peel(nodeSecret, Some(add.paymentHash), add.onionRoutingPacket) match {
- case Right(Sphinx.DecryptedPacket(_, _, sharedSecret)) => Right(sharedSecret)
+ case Right(Sphinx.DecryptedPacket(payload, _, outerOnionSecret)) =>
+ // Let's look at the onion payload to see if it contains a trampoline onion.
+ PaymentOnionCodecs.perHopPayloadCodec.decode(payload.bits) match {
+ case Attempt.Successful(DecodeResult(perHopPayload, _)) =>
+ // We try to extract the trampoline shared secret, if we can find one.
+ val trampolineOnionSecret_opt = perHopPayload.get[OnionPaymentPayloadTlv.TrampolineOnion].map(_.packet).flatMap(trampolinePacket => {
+ val trampolinePathKey_opt = perHopPayload.get[OnionPaymentPayloadTlv.PathKey].map(_.publicKey)
+ val trampolineOnionDecryptionKey = trampolinePathKey_opt.map(pathKey => Sphinx.RouteBlinding.derivePrivateKey(nodeSecret, pathKey)).getOrElse(nodeSecret)
+ Sphinx.peel(trampolineOnionDecryptionKey, Some(add.paymentHash), trampolinePacket).toOption.map(_.sharedSecret)
+ })
+ // We check if we are an intermediate node in a blinded (potentially trampoline) path.
+ val blinded = trampolineOnionSecret_opt match {
+ case Some(_) => perHopPayload.get[OnionPaymentPayloadTlv.PathKey].nonEmpty
+ case None => add.pathKey_opt.nonEmpty
+ }
+ Right(HtlcSharedSecrets(outerOnionSecret, trampolineOnionSecret_opt, blinded))
+ case Attempt.Failure(_) => Right(HtlcSharedSecrets(outerOnionSecret, None, blinded = add.pathKey_opt.nonEmpty))
+ }
case Left(_) => Left(CannotExtractSharedSecret(add.channelId, add))
}
}
+ private case class AttributableHtlcFailure(encryptedReason: ByteVector, attribution_opt: Option[ByteVector])
+
+ private def buildHtlcFailure(nodeSecret: PrivateKey, reason: FailureReason, add: UpdateAddHtlc, holdTime: FiniteDuration): Either[CannotExtractSharedSecret, AttributableHtlcFailure] = {
+ extractSharedSecret(nodeSecret, add).map(ss => {
+ reason match {
+ case FailureReason.EncryptedDownstreamFailure(packet, downstreamAttribution_opt) =>
+ val attribution = Sphinx.Attribution.create(downstreamAttribution_opt, Some(packet), holdTime, ss.outerOnionSecret)
+ AttributableHtlcFailure(Sphinx.FailurePacket.wrap(packet, ss.outerOnionSecret), Some(attribution))
+ case FailureReason.LocalFailure(failure) =>
+ val packet = Sphinx.FailurePacket.create(ss.outerOnionSecret, failure)
+ val attribution = Sphinx.Attribution.create(downstreamAttribution_opt = None, Some(packet), holdTime, ss.outerOnionSecret)
+ AttributableHtlcFailure(Sphinx.FailurePacket.wrap(packet, ss.outerOnionSecret), Some(attribution))
+ }
+ })
+ }
+
def buildHtlcFailure(nodeSecret: PrivateKey, useAttributableFailures: Boolean, cmd: CMD_FAIL_HTLC, add: UpdateAddHtlc, now: TimestampMilli = TimestampMilli.now()): Either[CannotExtractSharedSecret, HtlcFailureMessage] = {
add.pathKey_opt match {
case Some(_) =>
// We are part of a blinded route and we're not the introduction node.
+ // We return a standard error that doesn't disclose any information without any attribution data.
val failure = InvalidOnionBlinding(Sphinx.hash(add.onionRoutingPacket))
Right(UpdateFailMalformedHtlc(add.channelId, add.id, failure.onionHash, failure.code))
case None =>
- // If the htlcReceivedAt was lost (because the node restarted), we use a hold time of 0 which should be ignored by the payer.
- val holdTime = cmd.attribution_opt.map(now - _.htlcReceivedAt).getOrElse(0 millisecond)
- buildHtlcFailure(nodeSecret, useAttributableFailures, cmd.reason, add, holdTime).map {
- case (encryptedReason, tlvs) => UpdateFailHtlc(add.channelId, cmd.id, encryptedReason, tlvs)
+ // If the attribution was lost (because the node restarted), we use a hold time of 0 which should be ignored by the payer.
+ val holdTime = cmd.attribution_opt.map(a => now - a.htlcReceivedAt).getOrElse(0 millisecond)
+ buildHtlcFailure(nodeSecret, cmd.reason, add, holdTime).map { f =>
+ val tlvs: Set[UpdateFailHtlcTlv] = Set(
+ if (useAttributableFailures) f.attribution_opt.map(UpdateFailHtlcTlv.AttributionData(_)) else None
+ ).flatten
+ UpdateFailHtlc(add.channelId, cmd.id, f.encryptedReason, TlvStream(tlvs))
}
}
}
def buildHtlcFulfill(nodeSecret: PrivateKey, useAttributionData: Boolean, cmd: CMD_FULFILL_HTLC, add: UpdateAddHtlc, now: TimestampMilli = TimestampMilli.now()): UpdateFulfillHtlc = {
- // If we are part of a blinded route, we must not populate attribution data.
- val tlvs: TlvStream[UpdateFulfillHtlcTlv] = if (useAttributionData && add.pathKey_opt.isEmpty) {
- extractSharedSecret(nodeSecret, add) match {
- case Left(_) => TlvStream.empty
- case Right(sharedSecret) =>
- val holdTime = cmd.attribution_opt.map(now - _.htlcReceivedAt).getOrElse(0 millisecond)
- TlvStream(UpdateFulfillHtlcTlv.AttributionData(Sphinx.Attribution.create(cmd.attribution_opt.flatMap(_.downstreamAttribution_opt), None, holdTime, sharedSecret)))
- }
- } else {
- TlvStream.empty
+ // If we are part of a blinded route, we must not include any attribution data.
+ val attributionData_opt = add.pathKey_opt match {
+ case None if useAttributionData =>
+ val trampolineHoldTime = cmd.attribution_opt.flatMap(_.trampolineReceivedAt_opt).map(receivedAt => now - receivedAt).getOrElse(0 millisecond)
+ val holdTime = cmd.attribution_opt.map(a => now - a.htlcReceivedAt).getOrElse(0 millisecond)
+ extractSharedSecret(nodeSecret, add) match {
+ case Right(HtlcSharedSecrets(outerOnionSecret, None, _)) =>
+ Some(Sphinx.Attribution.create(cmd.attribution_opt.flatMap(_.downstreamAttribution_opt), None, holdTime, outerOnionSecret))
+ case Right(HtlcSharedSecrets(outerOnionSecret, Some(trampolineOnionSecret), blinded)) if !blinded =>
+ val trampolineAttribution = Sphinx.Attribution.create(cmd.attribution_opt.flatMap(_.downstreamAttribution_opt), None, trampolineHoldTime, trampolineOnionSecret)
+ Some(Sphinx.Attribution.create(Some(trampolineAttribution), None, holdTime, outerOnionSecret))
+ case _ => None
+ }
+ case _ => None
}
- UpdateFulfillHtlc(add.channelId, cmd.id, cmd.r, tlvs)
+ val tlvs: Set[UpdateFulfillHtlcTlv] = Set(
+ attributionData_opt.map(UpdateFulfillHtlcTlv.AttributionData(_))
+ ).flatten
+ UpdateFulfillHtlc(add.channelId, cmd.id, cmd.r, TlvStream(tlvs))
}
}
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/PaymentLifecycle.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/PaymentLifecycle.scala
index c165a46..e83c398 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/PaymentLifecycle.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/PaymentLifecycle.scala
@@ -119,11 +119,11 @@ class PaymentLifecycle(nodeParams: NodeParams, cfg: SendPaymentConfig, router: A
case HtlcResult.RemoteFulfill(updateFulfill) =>
updateFulfill.attribution_opt match {
case Some(attribution) =>
- val unwrapped = Sphinx.Attribution.unwrap(attribution, d.sharedSecrets)
- if (unwrapped.holdTimes.nonEmpty) {
- context.system.eventStream.publish(Router.ReportedHoldTimes(unwrapped.holdTimes))
+ val attributionDetails = Sphinx.SuccessPacket.decrypt(Some(attribution), d.sharedSecrets)
+ if (attributionDetails.holdTimes.nonEmpty) {
+ context.system.eventStream.publish(Router.ReportedHoldTimes(attributionDetails.holdTimes))
}
- unwrapped.remaining_opt
+ attributionDetails.remainingAttribution_opt
case None => None
}
case _: HtlcResult.OnChainFulfill => None
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/TrampolinePaymentLifecycle.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/TrampolinePaymentLifecycle.scala
index 0a31610..4f9c93b 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/TrampolinePaymentLifecycle.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/send/TrampolinePaymentLifecycle.scala
@@ -133,7 +133,7 @@ object TrampolinePaymentLifecycle {
val holdTimes = fulfill match {
case HtlcResult.RemoteFulfill(updateFulfill) =>
updateFulfill.attribution_opt match {
- case Some(attribution) => Sphinx.Attribution.unwrap(attribution, outerOnionSecrets).holdTimes
+ case Some(attribution) => Sphinx.SuccessPacket.decrypt(Some(attribution), outerOnionSecrets).holdTimes
case None => Nil
}
case _: HtlcResult.OnChainFulfill => Nil
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/crypto/SphinxSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/crypto/SphinxSpec.scala
index af9c8de..69c0f2d 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/crypto/SphinxSpec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/crypto/SphinxSpec.scala
@@ -361,8 +361,9 @@ class SphinxSpec extends AnyFunSuite {
val attribution4 = Attribution.create(Some(attribution3), None, 500 milliseconds, sharedSecret0)
assert(attribution4 == hex"84986c936d26bfd3bb2d34d3ec62cfdb63e0032fdb3d9d75f3e5d456f73dffa7e35aab1db4f1bd3b98ff585caf004f656c51037a3f4e810d275f3f6aea0c8e3a125ebee5f374b6440bcb9bb2955ebf70c06d64090f9f6cf098200305f7f4305ba9e1350a0c3f7dab4ccf35b8399b9650d8e363bf83d3a0a09706433f0adae6562eb338b21ea6f21329b3775905e59187c325c9cbf589f5da5e915d9e5ad1d21aa1431f9bdc587185ed8b5d4928e697e67cc96bee6d5354e3764cede3f385588fa665310356b2b1e68f8bd30c75d395405614a40a587031ebd6ace60dfb7c6dd188b572bd8e3e9a47b06c2187b528c5ed35c32da5130a21cd881138a5fcac806858ce6c596d810a7492eb261bcc91cead1dae75075b950c2e81cecf7e5fdb2b51df005d285803201ce914dfbf3218383829a0caa8f15486dd801133f1ed7edec436730b0ec98f48732547927229ac80269fcdc5e4f4db264274e940178732b429f9f0e582c559f994a7cdfb76c93ffc39de91ff936316726cc561a6520d47b2cd487299a96322dadc463ef06127fc63902ff9cc4f265e2fbd9de3fa5e48b7b51aa0850580ef9f3b5ebb60c6c3216c5a75a93e82936113d9cad57ae4a94dd6481954a9bd1b5cff4ab29ca221fa2bf9b28a362c9661206f896fc7cec563fb80aa5eaccb26c09fa4ef7a981e63028a9c4dac12f82ccb5bea090d56bbb1a4c431e315d9a169299224a8dbd099fb67ea61dfc604edf8a18ee742550b636836bb552dabb28820221bf8546331f32b0c143c1c89310c4fa2e1e0e895ce1a1eb0f43278fdb528131a3e32bfffe0c6de9006418f5309cba773ca38b6ad8507cc59445ccc0257506ebc16a4c01d4cd97e03fcf7a2049fea0db28447858f73b8e9fe98b391b136c9dc510288630a1f0af93b26a8891b857bfe4b818af99a1e011e6dbaa53982d29cf74ae7dffef45545279f19931708ed3eede5e82280eab908e8eb80abff3f1f023ab66869297b40da8496861dc455ac3abe1efa8a6f9e2c4eda48025d43a486a3f26f269743eaa30d6f0e1f48db6287751358a41f5b07aee0f098862e3493731fe2697acce734f004907c6f11eef189424fee52cd30ad708707eaf2e441f52bcf3d0c5440c1742458653c0c8a27b5ade784d9e09c8b47f1671901a29360e7e5e94946b9c75752a1a8d599d2a3e14ac81b84d42115cd688c8383a64fc6e7e1dc5568bb4837358ebe63207a4067af66b2027ad2ce8fb7ae3a452d40723a51fdf9f9c9913e8029a222cf81d12ad41e58860d75deb6de30ad")
- val Attribution.UnwrappedAttribution(holdTimes, Some(_)) = Attribution.unwrap(attribution4, sharedSecrets)
- assert(holdTimes == Seq(HoldTime(500 millisecond, publicKeys(0)), HoldTime(400 milliseconds, publicKeys(1)), HoldTime(300 milliseconds, publicKeys(2)), HoldTime(200 milliseconds, publicKeys(3)), HoldTime(100 milliseconds, publicKeys(4))))
+ val result = SuccessPacket.decrypt(Some(attribution4), sharedSecrets)
+ assert(result.remainingAttribution_opt.nonEmpty)
+ assert(result.holdTimes == Seq(HoldTime(500 millisecond, publicKeys(0)), HoldTime(400 milliseconds, publicKeys(1)), HoldTime(300 milliseconds, publicKeys(2)), HoldTime(200 milliseconds, publicKeys(3)), HoldTime(100 milliseconds, publicKeys(4))))
}
}
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/payment/PaymentPacketSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/payment/PaymentPacketSpec.scala
index 4c89c3a..38f3306 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/payment/PaymentPacketSpec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/payment/PaymentPacketSpec.scala
@@ -39,7 +39,7 @@ import fr.acinq.eclair.transactions.Transactions.ZeroFeeHtlcTxAnchorOutputsCommi
import fr.acinq.eclair.wire.protocol.OfferTypes.{InvoiceRequest, Offer, PaymentInfo}
import fr.acinq.eclair.wire.protocol.PaymentOnion.{FinalPayload, IntermediatePayload, OutgoingBlindedPerHopPayload}
import fr.acinq.eclair.wire.protocol._
-import fr.acinq.eclair.{BlockHeight, Bolt11Feature, Bolt12Feature, CltvExpiry, CltvExpiryDelta, EncodedNodeId, Features, MilliSatoshi, MilliSatoshiLong, ShortChannelId, TestConstants, TimestampMilli, TimestampSecondLong, UInt64, nodeFee, randomBytes32, randomKey}
+import fr.acinq.eclair.{BlockHeight, Bolt11Feature, Bolt12Feature, CltvExpiry, CltvExpiryDelta, EncodedNodeId, Features, MilliSatoshi, MilliSatoshiLong, ShortChannelId, TestConstants, TimestampMilli, TimestampMilliLong, TimestampSecondLong, UInt64, nodeFee, randomBytes32, randomKey}
import org.scalatest.BeforeAndAfterAll
import org.scalatest.funsuite.AnyFunSuite
import scodec.bits.{ByteVector, HexStringSyntax}
@@ -703,7 +703,7 @@ class PaymentPacketSpec extends AnyFunSuite with BeforeAndAfterAll {
assert(decryptedFailure == failure)
}
- test("build htlc failure onion (blinded payment)") {
+ test("build htlc failure onion with attribution data (blinded payment)") {
// a -> b -> c -> d -> e, blinded after c
val (_, route, recipient) = longBlindedHops(hex"0451")
val Right(payment) = buildOutgoingPayment(TestConstants.emptyOrigin, paymentHash, route, recipient, Reputation.Score.max(accountable = false))
@@ -720,21 +720,71 @@ class PaymentPacketSpec extends AnyFunSuite with BeforeAndAfterAll {
assert(payload_e.isInstanceOf[FinalPayload.Blinded])
// nodes after the introduction node cannot send `update_fail_htlc` messages
- val Right(fail_e: UpdateFailMalformedHtlc) = buildHtlcFailure(priv_e.privateKey, useAttributableFailures = false, CMD_FAIL_HTLC(add_e.id, FailureReason.LocalFailure(TemporaryNodeFailure()), None), add_e)
+ val Right(fail_e: UpdateFailMalformedHtlc) = buildHtlcFailure(priv_e.privateKey, useAttributableFailures = true, CMD_FAIL_HTLC(add_e.id, FailureReason.LocalFailure(TemporaryNodeFailure()), Some(FailureAttributionData(500 unixms, None))), add_e, now = 550 unixms)
assert(fail_e.id == add_e.id)
assert(fail_e.onionHash == Sphinx.hash(add_e.onionRoutingPacket))
assert(fail_e.failureCode == InvalidOnionBlinding(fail_e.onionHash).code)
- val Right(fail_d: UpdateFailMalformedHtlc) = buildHtlcFailure(priv_d.privateKey, useAttributableFailures = false, CMD_FAIL_HTLC(add_d.id, FailureReason.LocalFailure(UnknownNextPeer()), None), add_d)
+ val Right(fail_d: UpdateFailMalformedHtlc) = buildHtlcFailure(priv_d.privateKey, useAttributableFailures = true, CMD_FAIL_HTLC(add_d.id, FailureReason.LocalFailure(UnknownNextPeer()), Some(FailureAttributionData(500 unixms, None))), add_d, now = 560 unixms)
assert(fail_d.id == add_d.id)
assert(fail_d.onionHash == Sphinx.hash(add_d.onionRoutingPacket))
assert(fail_d.failureCode == InvalidOnionBlinding(fail_d.onionHash).code)
// only the introduction node is allowed to send an `update_fail_htlc` message
val failure = InvalidOnionBlinding(Sphinx.hash(add_c.onionRoutingPacket))
- val Right(fail_c: UpdateFailHtlc) = buildHtlcFailure(priv_c.privateKey, useAttributableFailures = false, CMD_FAIL_HTLC(add_c.id, FailureReason.LocalFailure(failure), None), add_c)
+ val Right(fail_c: UpdateFailHtlc) = buildHtlcFailure(priv_c.privateKey, useAttributableFailures = true, CMD_FAIL_HTLC(add_c.id, FailureReason.LocalFailure(failure), Some(FailureAttributionData(500 unixms, None))), add_c, now = 630 unixms)
assert(fail_c.id == add_c.id)
- val Right(fail_b: UpdateFailHtlc) = buildHtlcFailure(priv_b.privateKey, useAttributableFailures = false, CMD_FAIL_HTLC(add_b.id, FailureReason.EncryptedDownstreamFailure(fail_c.reason, None), None), add_b)
+ assert(fail_c.attribution_opt.nonEmpty)
+ val Right(fail_b: UpdateFailHtlc) = buildHtlcFailure(priv_b.privateKey, useAttributableFailures = true, CMD_FAIL_HTLC(add_b.id, FailureReason.EncryptedDownstreamFailure(fail_c.reason, fail_c.attribution_opt), Some(FailureAttributionData(380 unixms, None))), add_b, now = 650 unixms)
assert(fail_b.id == add_b.id)
- val Right(Sphinx.DecryptedFailurePacket(failingNode, 2, decryptedFailure)) = Sphinx.FailurePacket.decrypt(fail_b.reason, fail_b.attribution_opt, payment.sharedSecrets).failure
+ assert(fail_b.attribution_opt.nonEmpty)
+ val fail_a = Sphinx.FailurePacket.decrypt(fail_b.reason, fail_b.attribution_opt, payment.sharedSecrets)
+ assert(fail_a.failure.isRight)
+ assert(fail_a.failure.toOption.get.originNode == c)
+ assert(fail_a.failure.toOption.get.index == 2)
+ assert(fail_a.failure.toOption.get.failureMessage == failure)
+ assert(fail_a.holdTimes == Seq(HoldTime(200 millis, b), HoldTime(100 millis, c)))
+ }
+
+ test("build htlc failure onion with attribution data (trampoline payment)") {
+ // Create a trampoline payment to e:
+ // .--> d --.
+ // / \
+ // b -> c e
+ val invoiceFeatures = Features[Bolt11Feature](VariableLengthOnion -> Mandatory, PaymentSecret -> Mandatory, BasicMultiPartPayment -> Optional, Features.TrampolinePaymentPrototype -> Optional)
+ val invoice = Bolt11Invoice(Block.RegtestGenesisBlock.hash, Some(finalAmount), paymentHash, priv_e.privateKey, Left("invoice"), CltvExpiryDelta(12), paymentSecret = paymentSecret, features = invoiceFeatures)
+ val payment = TrampolinePayment.buildOutgoingPayment(c, invoice, finalExpiry)
+
+ val add_c = UpdateAddHtlc(randomBytes32(), 0, payment.trampolineAmount, paymentHash, payment.trampolineExpiry, payment.onion.packet, None, accountable = false, None)
+ val Right(RelayToTrampolinePacket(_, _, payload_c, trampolinePacket_e, _)) = decrypt(add_c, priv_c.privateKey, Features.empty)
+ val (add_d, sharedSecrets_c) = {
+ // c finds a path c->d->e
+ val payloads = Seq(
+ NodePayload(d, PaymentOnion.IntermediatePayload.ChannelRelay.Standard(channelUpdate_de.shortChannelId, payload_c.amountToForward, payload_c.outgoingCltv, upgradeAccountability = false)),
+ NodePayload(e, PaymentOnion.FinalPayload.Standard.createTrampolinePayload(payload_c.amountToForward, payload_c.amountToForward, payload_c.outgoingCltv, paymentSecret, trampolinePacket_e, upgradeAccountability = false))
+ )
+ val onion_d = OutgoingPaymentPacket.buildOnion(payloads, paymentHash, Some(PaymentOnionCodecs.paymentOnionPayloadLength)).toOption.get
+ val add_d = UpdateAddHtlc(randomBytes32(), 0, payload_c.amountToForward + 500.msat, paymentHash, payload_c.outgoingCltv + CltvExpiryDelta(36), onion_d.packet, None, accountable = false, None)
+ (add_d, onion_d.sharedSecrets)
+ }
+ val Right(ChannelRelayPacket(_, _, packet_e, _)) = decrypt(add_d, priv_d.privateKey, Features.empty)
+ val add_e = UpdateAddHtlc(randomBytes32(), 3, payload_c.amountToForward, paymentHash, payload_c.outgoingCltv, packet_e, None, accountable = false, None)
+ val Right(FinalPacket(_, payload_e, _)) = decrypt(add_e, priv_e.privateKey, Features.empty)
+ assert(payload_e.isInstanceOf[FinalPayload.Standard])
+
+ // e returns a failure
+ val failure = IncorrectOrUnknownPaymentDetails(finalAmount, BlockHeight(currentBlockCount))
+ val Right(fail_e: UpdateFailHtlc) = buildHtlcFailure(priv_e.privateKey, useAttributableFailures = true, CMD_FAIL_HTLC(add_e.id, FailureReason.LocalFailure(failure), Some(FailureAttributionData(TimestampMilli(500), Some(TimestampMilli(520))))), add_e, now = TimestampMilli(550))
+ assert(fail_e.id == add_e.id)
+ val Right(fail_d: UpdateFailHtlc) = buildHtlcFailure(priv_d.privateKey, useAttributableFailures = true, CMD_FAIL_HTLC(add_d.id, FailureReason.EncryptedDownstreamFailure(fail_e.reason, fail_e.attribution_opt), Some(FailureAttributionData(TimestampMilli(350), None))), add_d, now = TimestampMilli(560))
+ assert(fail_d.id == add_d.id)
+ // c is able to decrypt the failure and attribution data for the downstream path
+ val failureDetails_c = Sphinx.FailurePacket.decrypt(fail_d.reason, fail_d.attribution_opt, sharedSecrets_c)
+ assert(failureDetails_c.holdTimes == Seq(HoldTime(200 millis, d), HoldTime(0 millis, e)))
+ assert(failureDetails_c.failure.isRight)
+ // c creates a new error for the sender without downstream attribution data
+ val Right(fail_b: UpdateFailHtlc) = buildHtlcFailure(priv_c.privateKey, useAttributableFailures = true, CMD_FAIL_HTLC(add_c.id, FailureReason.LocalFailure(failure), Some(FailureAttributionData(TimestampMilli(250), Some(TimestampMilli(280))))), add_c, now = TimestampMilli(600))
+ assert(fail_b.attribution_opt.nonEmpty)
+ val Sphinx.HtlcFailure(holdTimes, Right(Sphinx.DecryptedFailurePacket(failingNode, _, decryptedFailure))) = Sphinx.FailurePacket.decrypt(fail_b.reason, fail_b.attribution_opt, payment.onion.sharedSecrets)
+ assert(holdTimes == Seq(HoldTime(300 millis, c)))
assert(failingNode == c)
assert(decryptedFailure == failure)
}
Why this scored 34/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.