Skip to content

Commit d7739eb

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 7a901c6 commit d7739eb

19 files changed

+797
-130
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: 89 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -379,30 +379,81 @@ object OutgoingPaymentPacket {
379379
}
380380
}
381381

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

429+
private case class HtlcSharedSecrets(outerOnionSecret: ByteVector32, trampolineOnionSecret_opt: Option[ByteVector32], blinded: Boolean)
430+
397431
/**
398432
* We decrypt the onion again to extract the shared secret used to encrypt onion failures.
399433
* We could avoid this by storing the shared secret after the initial onion decryption, but we would have to store it
400434
* in the database since we must be able to fail HTLCs after restarting our node.
401435
* It's simpler to extract it again from the encrypted onion.
402436
*/
403-
private def extractSharedSecret(nodeSecret: PrivateKey, add: UpdateAddHtlc): Either[CannotExtractSharedSecret, ByteVector32] = {
437+
private def extractSharedSecret(nodeSecret: PrivateKey, add: UpdateAddHtlc): Either[CannotExtractSharedSecret, HtlcSharedSecrets] = {
404438
Sphinx.peel(nodeSecret, Some(add.paymentHash), add.onionRoutingPacket) match {
405-
case Right(Sphinx.DecryptedPacket(_, _, sharedSecret)) => Right(sharedSecret)
439+
case Right(Sphinx.DecryptedPacket(payload, _, outerOnionSecret)) =>
440+
// Let's look at the onion payload to see if it contains a trampoline onion.
441+
PaymentOnionCodecs.perHopPayloadCodec.decode(payload.bits) match {
442+
case Attempt.Successful(DecodeResult(perHopPayload, _)) =>
443+
// We try to extract the trampoline shared secret, if we can find one.
444+
val trampolineOnionSecret_opt = perHopPayload.get[OnionPaymentPayloadTlv.TrampolineOnion].map(_.packet).flatMap(trampolinePacket => {
445+
val trampolinePathKey_opt = perHopPayload.get[OnionPaymentPayloadTlv.PathKey].map(_.publicKey)
446+
val trampolineOnionDecryptionKey = trampolinePathKey_opt.map(pathKey => Sphinx.RouteBlinding.derivePrivateKey(nodeSecret, pathKey)).getOrElse(nodeSecret)
447+
Sphinx.peel(trampolineOnionDecryptionKey, Some(add.paymentHash), trampolinePacket).toOption.map(_.sharedSecret)
448+
})
449+
// We check if we are an intermediate node in a blinded (potentially trampoline) path.
450+
val blinded = trampolineOnionSecret_opt match {
451+
case Some(_) => perHopPayload.get[OnionPaymentPayloadTlv.PathKey].nonEmpty
452+
case None => add.pathKey_opt.nonEmpty
453+
}
454+
Right(HtlcSharedSecrets(outerOnionSecret, trampolineOnionSecret_opt, blinded))
455+
case Attempt.Failure(_) => Right(HtlcSharedSecrets(outerOnionSecret, None, blinded = false))
456+
}
406457
case Left(_) => Left(CannotExtractSharedSecret(add.channelId, add))
407458
}
408459
}
@@ -414,26 +465,38 @@ object OutgoingPaymentPacket {
414465
val failure = InvalidOnionBlinding(Sphinx.hash(add.onionRoutingPacket))
415466
Right(UpdateFailMalformedHtlc(add.channelId, add.id, failure.onionHash, failure.code))
416467
case None =>
417-
// If the htlcReceivedAt was lost (because the node restarted), we use a hold time of 0 which should be ignored by the payer.
468+
// If the attribution data was lost (because the node restarted), we use a hold time of 0 which should be ignored by the payer.
469+
val trampolineHoldTime = cmd.attribution_opt.flatMap(_.trampolineReceivedAt_opt).map(now - _).getOrElse(0 millisecond)
418470
val holdTime = cmd.attribution_opt.map(now - _.htlcReceivedAt).getOrElse(0 millisecond)
419-
buildHtlcFailure(nodeSecret, useAttributableFailures, cmd.reason, add, holdTime).map {
420-
case (encryptedReason, tlvs) => UpdateFailHtlc(add.channelId, cmd.id, encryptedReason, tlvs)
471+
buildHtlcFailure(nodeSecret, cmd.reason, add, holdTime, trampolineHoldTime).map {
472+
case (encryptedReason, attributionData_opt) =>
473+
val tlvs: Set[UpdateFailHtlcTlv] = Set(
474+
if (useAttributableFailures) attributionData_opt.map(UpdateFailHtlcTlv.AttributionData(_)) else None
475+
).flatten
476+
UpdateFailHtlc(add.channelId, cmd.id, encryptedReason, TlvStream(tlvs))
421477
}
422478
}
423479
}
424480

425481
def buildHtlcFulfill(nodeSecret: PrivateKey, useAttributionData: Boolean, cmd: CMD_FULFILL_HTLC, add: UpdateAddHtlc, now: TimestampMilli = TimestampMilli.now()): UpdateFulfillHtlc = {
426482
// If we are part of a blinded route, we must not populate attribution data.
427-
val tlvs: TlvStream[UpdateFulfillHtlcTlv] = if (useAttributionData && add.pathKey_opt.isEmpty) {
428-
extractSharedSecret(nodeSecret, add) match {
429-
case Left(_) => TlvStream.empty
430-
case Right(sharedSecret) =>
431-
val holdTime = cmd.attribution_opt.map(now - _.htlcReceivedAt).getOrElse(0 millisecond)
432-
TlvStream(UpdateFulfillHtlcTlv.AttributionData(Sphinx.Attribution.create(cmd.attribution_opt.flatMap(_.downstreamAttribution_opt), None, holdTime, sharedSecret)))
433-
}
434-
} else {
435-
TlvStream.empty
483+
val attributionData_opt = add.pathKey_opt match {
484+
case None if useAttributionData =>
485+
val trampolineHoldTime = cmd.attribution_opt.flatMap(_.trampolineReceivedAt_opt).map(now - _).getOrElse(0 millisecond)
486+
val holdTime = cmd.attribution_opt.map(now - _.htlcReceivedAt).getOrElse(0 millisecond)
487+
extractSharedSecret(nodeSecret, add) match {
488+
case Right(HtlcSharedSecrets(outerOnionSecret, None, _)) =>
489+
Some(Sphinx.Attribution.create(cmd.attribution_opt.flatMap(_.downstreamAttribution_opt), None, holdTime, outerOnionSecret))
490+
case Right(HtlcSharedSecrets(outerOnionSecret, Some(trampolineOnionSecret), blinded)) if !blinded =>
491+
val trampolineAttribution = Sphinx.Attribution.create(cmd.attribution_opt.flatMap(_.downstreamAttribution_opt), None, trampolineHoldTime, trampolineOnionSecret)
492+
Some(Sphinx.Attribution.create(Some(trampolineAttribution), None, holdTime, outerOnionSecret))
493+
case _ => None
494+
}
495+
case _ => None
436496
}
437-
UpdateFulfillHtlc(add.channelId, cmd.id, cmd.r, tlvs)
497+
val tlvs: Set[UpdateFulfillHtlcTlv] = Set(
498+
attributionData_opt.map(UpdateFulfillHtlcTlv.AttributionData(_))
499+
).flatten
500+
UpdateFulfillHtlc(add.channelId, cmd.id, cmd.r, TlvStream(tlvs))
438501
}
439502
}

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
@@ -473,7 +473,12 @@ object MultiPartHandler {
473473
private def validateStandardPayment(nodeParams: NodeParams, add: UpdateAddHtlc, payload: FinalPayload.Standard, record: IncomingStandardPayment, receivedAt: TimestampMilli)(implicit log: LoggingAdapter): Option[CMD_FAIL_HTLC] = {
474474
// We send the same error regardless of the failure to avoid probing attacks.
475475
val attribution = FailureAttributionData(htlcReceivedAt = receivedAt, trampolineReceivedAt_opt = None)
476-
val cmdFail = CMD_FAIL_HTLC(add.id, FailureReason.LocalFailure(IncorrectOrUnknownPaymentDetails(payload.totalAmount, nodeParams.currentBlockHeight)), Some(attribution), commit = true)
476+
val failure = if (payload.isTrampoline) {
477+
FailureReason.LocalTrampolineFailure(IncorrectOrUnknownPaymentDetails(payload.totalAmount, nodeParams.currentBlockHeight))
478+
} else {
479+
FailureReason.LocalFailure(IncorrectOrUnknownPaymentDetails(payload.totalAmount, nodeParams.currentBlockHeight))
480+
}
481+
val cmdFail = CMD_FAIL_HTLC(add.id, failure, Some(attribution), commit = true)
477482
val commonOk = validateCommon(nodeParams, add, payload, record)
478483
val secretOk = validatePaymentSecret(add, payload, record.invoice)
479484
if (commonOk && secretOk) None else Some(cmdFail)

0 commit comments

Comments
 (0)