Skip to content

Commit 0e7d664

Browse files
committed
Add support for trampoline failure encryption
When returning trampoline failures for the payer (the creator of the trampoline onion), they must be encrypted using the sphinx shared secret of the trampoline onion. When relaying a trampoline payment, we re-wrap the (peeled) trampoline onion inside a payment onion: if we receive a failure for the outgoing payment, it can be either coming from before the next trampoline node or after them. If it's coming from before, we can decrypt that error using the shared secrets we created for the payment onion: depending on the error, we can then return our own error to the payer. If it's coming from after the next trampoline onion, it will be encrypted for the payer, so we cannot decrypt it. We must peel the shared secrets of our payment onion, and then re-encrypted with the shared secret of the incoming trampoline onion. This way only the payer will be able to decrypt the failure, which is relayed back through each intermediate trampoline node.
1 parent 32d92a5 commit 0e7d664

File tree

15 files changed

+724
-104
lines changed

15 files changed

+724
-104
lines changed

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ object Monitoring {
130130
def apply(cmdFail: CMD_FAIL_HTLC): String = cmdFail.reason match {
131131
case _: FailureReason.EncryptedDownstreamFailure => Remote
132132
case FailureReason.LocalFailure(f) => f.getClass.getSimpleName
133+
case FailureReason.LocalTrampolineFailure(f) => f.getClass.getSimpleName
133134
}
134135

135136
def apply(pf: PaymentFailure): String = pf match {

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

Lines changed: 86 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ package fr.acinq.eclair.payment
1818

1919
import fr.acinq.bitcoin.scalacompat.ByteVector32
2020
import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey}
21-
import fr.acinq.eclair.channel.{CMD_ADD_HTLC, CMD_FAIL_HTLC, CMD_FULFILL_HTLC, CannotExtractSharedSecret, Origin}
21+
import fr.acinq.eclair.channel._
2222
import fr.acinq.eclair.crypto.Sphinx
2323
import fr.acinq.eclair.payment.send.Recipient
2424
import fr.acinq.eclair.router.Router.Route
@@ -378,30 +378,81 @@ object OutgoingPaymentPacket {
378378
}
379379
}
380380

381-
private def buildHtlcFailure(nodeSecret: PrivateKey, useAttributableFailures: Boolean, reason: FailureReason, add: UpdateAddHtlc, holdTime: FiniteDuration): Either[CannotExtractSharedSecret, (ByteVector, TlvStream[UpdateFailHtlcTlv])] = {
382-
extractSharedSecret(nodeSecret, add).map(sharedSecret => {
383-
val (packet, attribution) = reason match {
384-
case FailureReason.EncryptedDownstreamFailure(packet, attribution) => (packet, attribution)
385-
case FailureReason.LocalFailure(failure) => (Sphinx.FailurePacket.create(sharedSecret, failure), None)
381+
private def buildHtlcFailure(nodeSecret: PrivateKey, reason: FailureReason, add: UpdateAddHtlc, holdTime: FiniteDuration): Either[CannotExtractSharedSecret, (ByteVector, Option[ByteVector])] = {
382+
extractSharedSecret(nodeSecret, add).map(ss => {
383+
reason match {
384+
case FailureReason.EncryptedDownstreamFailure(packet, previousAttribution_opt) =>
385+
ss.trampolineOnionSecret_opt match {
386+
case Some(trampolineOnionSecret) if !ss.blinded =>
387+
// If we are unable to decrypt the downstream failure and the payment is using trampoline, the failure is
388+
// intended for the payer. We encrypt it with the trampoline secret first and then the outer secret.
389+
val trampolinePacket = Sphinx.FailurePacket.wrap(packet, trampolineOnionSecret)
390+
val attributionInner = Sphinx.Attribution.create(previousAttribution_opt, Some(packet), holdTime, trampolineOnionSecret)
391+
val attributionOuter = Sphinx.Attribution.create(Some(attributionInner), Some(trampolinePacket), holdTime, ss.outerOnionSecret)
392+
(Sphinx.FailurePacket.wrap(trampolinePacket, ss.outerOnionSecret), Some(attributionOuter))
393+
case Some(trampolineOnionSecret) =>
394+
// When we're inside a blinded path, we don't report our attribution data.
395+
val trampolinePacket = Sphinx.FailurePacket.wrap(packet, trampolineOnionSecret)
396+
(Sphinx.FailurePacket.wrap(trampolinePacket, ss.outerOnionSecret), None)
397+
case None =>
398+
val attribution = Sphinx.Attribution.create(previousAttribution_opt, Some(packet), holdTime, ss.outerOnionSecret)
399+
(Sphinx.FailurePacket.wrap(packet, ss.outerOnionSecret), Some(attribution))
400+
}
401+
case FailureReason.LocalFailure(failure) =>
402+
// This isn't a trampoline failure, so we only encrypt it for the node who created the outer onion.
403+
val packet = Sphinx.FailurePacket.create(ss.outerOnionSecret, failure)
404+
val attribution = Sphinx.Attribution.create(previousAttribution_opt = None, Some(packet), holdTime, ss.outerOnionSecret)
405+
(Sphinx.FailurePacket.wrap(packet, ss.outerOnionSecret), Some(attribution))
406+
case FailureReason.LocalTrampolineFailure(failure) =>
407+
// This is a trampoline failure: we try to encrypt it to the node who created the trampoline onion.
408+
ss.trampolineOnionSecret_opt match {
409+
case Some(trampolineOnionSecret) if !ss.blinded =>
410+
val packet = Sphinx.FailurePacket.create(trampolineOnionSecret, failure)
411+
val trampolinePacket = Sphinx.FailurePacket.wrap(packet, trampolineOnionSecret)
412+
val attributionInner = Sphinx.Attribution.create(previousAttribution_opt = None, Some(packet), holdTime, trampolineOnionSecret)
413+
val attributionOuter = Sphinx.Attribution.create(Some(attributionInner), Some(trampolinePacket), holdTime, ss.outerOnionSecret)
414+
(Sphinx.FailurePacket.wrap(trampolinePacket, ss.outerOnionSecret), Some(attributionOuter))
415+
case Some(trampolineOnionSecret) =>
416+
val packet = Sphinx.FailurePacket.create(trampolineOnionSecret, failure)
417+
val trampolinePacket = Sphinx.FailurePacket.wrap(packet, trampolineOnionSecret)
418+
(Sphinx.FailurePacket.wrap(trampolinePacket, ss.outerOnionSecret), None)
419+
case None =>
420+
// This shouldn't happen, we only generate trampoline failures when there was a trampoline onion.
421+
val packet = Sphinx.FailurePacket.create(ss.outerOnionSecret, failure)
422+
(Sphinx.FailurePacket.wrap(packet, ss.outerOnionSecret), None)
423+
}
386424
}
387-
val tlvs: TlvStream[UpdateFailHtlcTlv] = if (useAttributableFailures) {
388-
TlvStream(UpdateFailHtlcTlv.AttributionData(Sphinx.Attribution.create(attribution, Some(packet), holdTime, sharedSecret)))
389-
} else {
390-
TlvStream.empty
391-
}
392-
(Sphinx.FailurePacket.wrap(packet, sharedSecret), tlvs)
393425
})
394426
}
395427

428+
private case class HtlcSharedSecrets(outerOnionSecret: ByteVector32, trampolineOnionSecret_opt: Option[ByteVector32], blinded: Boolean)
429+
396430
/**
397431
* We decrypt the onion again to extract the shared secret used to encrypt onion failures.
398432
* We could avoid this by storing the shared secret after the initial onion decryption, but we would have to store it
399433
* in the database since we must be able to fail HTLCs after restarting our node.
400434
* It's simpler to extract it again from the encrypted onion.
401435
*/
402-
private def extractSharedSecret(nodeSecret: PrivateKey, add: UpdateAddHtlc): Either[CannotExtractSharedSecret, ByteVector32] = {
436+
private def extractSharedSecret(nodeSecret: PrivateKey, add: UpdateAddHtlc): Either[CannotExtractSharedSecret, HtlcSharedSecrets] = {
403437
Sphinx.peel(nodeSecret, Some(add.paymentHash), add.onionRoutingPacket) match {
404-
case Right(Sphinx.DecryptedPacket(_, _, sharedSecret)) => Right(sharedSecret)
438+
case Right(Sphinx.DecryptedPacket(payload, _, outerOnionSecret)) =>
439+
// Let's look at the onion payload to see if it contains a trampoline onion.
440+
PaymentOnionCodecs.perHopPayloadCodec.decode(payload.bits) match {
441+
case Attempt.Successful(DecodeResult(perHopPayload, _)) =>
442+
// We try to extract the trampoline shared secret, if we can find one.
443+
val trampolineOnionSecret_opt = perHopPayload.get[OnionPaymentPayloadTlv.TrampolineOnion].map(_.packet).flatMap(trampolinePacket => {
444+
val trampolinePathKey_opt = perHopPayload.get[OnionPaymentPayloadTlv.PathKey].map(_.publicKey)
445+
val trampolineOnionDecryptionKey = trampolinePathKey_opt.map(pathKey => Sphinx.RouteBlinding.derivePrivateKey(nodeSecret, pathKey)).getOrElse(nodeSecret)
446+
Sphinx.peel(trampolineOnionDecryptionKey, Some(add.paymentHash), trampolinePacket).toOption.map(_.sharedSecret)
447+
})
448+
// We check if we are an intermediate node in a blinded (potentially trampoline) path.
449+
val blinded = trampolineOnionSecret_opt match {
450+
case Some(_) => perHopPayload.get[OnionPaymentPayloadTlv.PathKey].nonEmpty
451+
case None => add.pathKey_opt.nonEmpty
452+
}
453+
Right(HtlcSharedSecrets(outerOnionSecret, trampolineOnionSecret_opt, blinded))
454+
case Attempt.Failure(_) => Right(HtlcSharedSecrets(outerOnionSecret, None, blinded = false))
455+
}
405456
case Left(_) => Left(CannotExtractSharedSecret(add.channelId, add))
406457
}
407458
}
@@ -415,24 +466,33 @@ object OutgoingPaymentPacket {
415466
case None =>
416467
// If the htlcReceivedAt was lost (because the node restarted), we use a hold time of 0 which should be ignored by the payer.
417468
val holdTime = cmd.htlcReceivedAt_opt.map(now - _).getOrElse(0 millisecond)
418-
buildHtlcFailure(nodeSecret, useAttributableFailures, cmd.reason, add, holdTime).map {
419-
case (encryptedReason, tlvs) => UpdateFailHtlc(add.channelId, cmd.id, encryptedReason, tlvs)
469+
buildHtlcFailure(nodeSecret, cmd.reason, add, holdTime).map {
470+
case (encryptedReason, attributionData_opt) =>
471+
val tlvs: Set[UpdateFailHtlcTlv] = Set(
472+
if (useAttributableFailures) attributionData_opt.map(UpdateFailHtlcTlv.AttributionData(_)) else None
473+
).flatten
474+
UpdateFailHtlc(add.channelId, cmd.id, encryptedReason, TlvStream(tlvs))
420475
}
421476
}
422477
}
423478

424479
def buildHtlcFulfill(nodeSecret: PrivateKey, useAttributionData: Boolean, cmd: CMD_FULFILL_HTLC, add: UpdateAddHtlc, now: TimestampMilli = TimestampMilli.now()): UpdateFulfillHtlc = {
425480
// If we are part of a blinded route, we must not populate attribution data.
426-
val tlvs: TlvStream[UpdateFulfillHtlcTlv] = if (useAttributionData && add.pathKey_opt.isEmpty) {
427-
extractSharedSecret(nodeSecret, add) match {
428-
case Left(_) => TlvStream.empty
429-
case Right(sharedSecret) =>
430-
val holdTime = cmd.htlcReceivedAt_opt.map(now - _).getOrElse(0 millisecond)
431-
TlvStream(UpdateFulfillHtlcTlv.AttributionData(Sphinx.Attribution.create(cmd.downstreamAttribution_opt, None, holdTime, sharedSecret)))
432-
}
433-
} else {
434-
TlvStream.empty
481+
val attributionData_opt = add.pathKey_opt match {
482+
case None if useAttributionData =>
483+
extractSharedSecret(nodeSecret, add) match {
484+
case Right(sharedSecret) =>
485+
// Note that we use the outer shared secret: we report our hold time to the previous trampoline node, not
486+
// necessarily to the payer.
487+
val holdTime = cmd.htlcReceivedAt_opt.map(now - _).getOrElse(0 millisecond)
488+
Some(Sphinx.Attribution.create(cmd.downstreamAttribution_opt, None, holdTime, sharedSecret.outerOnionSecret))
489+
case Left(_) => None
490+
}
491+
case _ => None
435492
}
436-
UpdateFulfillHtlc(add.channelId, cmd.id, cmd.r, tlvs)
493+
val tlvs: Set[UpdateFulfillHtlcTlv] = Set(
494+
attributionData_opt.map(UpdateFulfillHtlcTlv.AttributionData(_))
495+
).flatten
496+
UpdateFulfillHtlc(add.channelId, cmd.id, cmd.r, TlvStream(tlvs))
437497
}
438498
}

eclair-core/src/main/scala/fr/acinq/eclair/payment/receive/MultiPartHandler.scala

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -461,7 +461,12 @@ object MultiPartHandler {
461461

462462
private def validateStandardPayment(nodeParams: NodeParams, add: UpdateAddHtlc, payload: FinalPayload.Standard, record: IncomingStandardPayment, receivedAt: TimestampMilli)(implicit log: LoggingAdapter): Option[CMD_FAIL_HTLC] = {
463463
// We send the same error regardless of the failure to avoid probing attacks.
464-
val cmdFail = CMD_FAIL_HTLC(add.id, FailureReason.LocalFailure(IncorrectOrUnknownPaymentDetails(payload.totalAmount, nodeParams.currentBlockHeight)), Some(receivedAt), commit = true)
464+
val failure = if (payload.isTrampoline) {
465+
FailureReason.LocalTrampolineFailure(IncorrectOrUnknownPaymentDetails(payload.totalAmount, nodeParams.currentBlockHeight))
466+
} else {
467+
FailureReason.LocalFailure(IncorrectOrUnknownPaymentDetails(payload.totalAmount, nodeParams.currentBlockHeight))
468+
}
469+
val cmdFail = CMD_FAIL_HTLC(add.id, failure, Some(receivedAt), commit = true)
465470
val commonOk = validateCommon(nodeParams, add, payload, record)
466471
val secretOk = validatePaymentSecret(add, payload, record.invoice)
467472
if (commonOk && secretOk) None else Some(cmdFail)

0 commit comments

Comments
 (0)