Skip to content

Commit 7d669ff

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 9e97dd2 commit 7d669ff

19 files changed

+796
-129
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
@@ -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, trampolineHoldTime: 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 trampolineAttribution = Sphinx.Attribution.create(previousAttribution_opt, Some(packet), trampolineHoldTime, trampolineOnionSecret)
391+
val outerAttribution = Sphinx.Attribution.create(Some(trampolineAttribution), Some(trampolinePacket), holdTime, ss.outerOnionSecret)
392+
(Sphinx.FailurePacket.wrap(trampolinePacket, ss.outerOnionSecret), Some(outerAttribution))
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 trampolineAttribution = Sphinx.Attribution.create(previousAttribution_opt = None, Some(packet), trampolineHoldTime, trampolineOnionSecret)
413+
val outerAttribution = Sphinx.Attribution.create(Some(trampolineAttribution), Some(trampolinePacket), holdTime, ss.outerOnionSecret)
414+
(Sphinx.FailurePacket.wrap(trampolinePacket, ss.outerOnionSecret), Some(outerAttribution))
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
}
@@ -413,26 +464,38 @@ object OutgoingPaymentPacket {
413464
val failure = InvalidOnionBlinding(Sphinx.hash(add.onionRoutingPacket))
414465
Right(UpdateFailMalformedHtlc(add.channelId, add.id, failure.onionHash, failure.code))
415466
case None =>
416-
// If the htlcReceivedAt was lost (because the node restarted), we use a hold time of 0 which should be ignored by the payer.
467+
// If the attribution data was lost (because the node restarted), we use a hold time of 0 which should be ignored by the payer.
468+
val trampolineHoldTime = cmd.attribution_opt.flatMap(_.trampolineReceivedAt_opt).map(now - _).getOrElse(0 millisecond)
417469
val holdTime = cmd.attribution_opt.map(now - _.htlcReceivedAt).getOrElse(0 millisecond)
418-
buildHtlcFailure(nodeSecret, useAttributableFailures, cmd.reason, add, holdTime).map {
419-
case (encryptedReason, tlvs) => UpdateFailHtlc(add.channelId, cmd.id, encryptedReason, tlvs)
470+
buildHtlcFailure(nodeSecret, cmd.reason, add, holdTime, trampolineHoldTime).map {
471+
case (encryptedReason, attributionData_opt) =>
472+
val tlvs: Set[UpdateFailHtlcTlv] = Set(
473+
if (useAttributableFailures) attributionData_opt.map(UpdateFailHtlcTlv.AttributionData(_)) else None
474+
).flatten
475+
UpdateFailHtlc(add.channelId, cmd.id, encryptedReason, TlvStream(tlvs))
420476
}
421477
}
422478
}
423479

424480
def buildHtlcFulfill(nodeSecret: PrivateKey, useAttributionData: Boolean, cmd: CMD_FULFILL_HTLC, add: UpdateAddHtlc, now: TimestampMilli = TimestampMilli.now()): UpdateFulfillHtlc = {
425481
// 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.attribution_opt.map(now - _.htlcReceivedAt).getOrElse(0 millisecond)
431-
TlvStream(UpdateFulfillHtlcTlv.AttributionData(Sphinx.Attribution.create(cmd.attribution_opt.flatMap(_.downstreamAttribution_opt), None, holdTime, sharedSecret)))
432-
}
433-
} else {
434-
TlvStream.empty
482+
val attributionData_opt = add.pathKey_opt match {
483+
case None if useAttributionData =>
484+
val trampolineHoldTime = cmd.attribution_opt.flatMap(_.trampolineReceivedAt_opt).map(now - _).getOrElse(0 millisecond)
485+
val holdTime = cmd.attribution_opt.map(now - _.htlcReceivedAt).getOrElse(0 millisecond)
486+
extractSharedSecret(nodeSecret, add) match {
487+
case Right(HtlcSharedSecrets(outerOnionSecret, None, _)) =>
488+
Some(Sphinx.Attribution.create(cmd.attribution_opt.flatMap(_.downstreamAttribution_opt), None, holdTime, outerOnionSecret))
489+
case Right(HtlcSharedSecrets(outerOnionSecret, Some(trampolineOnionSecret), blinded)) if !blinded =>
490+
val trampolineAttribution = Sphinx.Attribution.create(cmd.attribution_opt.flatMap(_.downstreamAttribution_opt), None, trampolineHoldTime, trampolineOnionSecret)
491+
Some(Sphinx.Attribution.create(Some(trampolineAttribution), None, holdTime, outerOnionSecret))
492+
case _ => None
493+
}
494+
case _ => None
435495
}
436-
UpdateFulfillHtlc(add.channelId, cmd.id, cmd.r, tlvs)
496+
val tlvs: Set[UpdateFulfillHtlcTlv] = Set(
497+
attributionData_opt.map(UpdateFulfillHtlcTlv.AttributionData(_))
498+
).flatten
499+
UpdateFulfillHtlc(add.channelId, cmd.id, cmd.r, TlvStream(tlvs))
437500
}
438501
}

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)