Skip to content

Commit 68d8aa2

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 29eb052 commit 68d8aa2

File tree

15 files changed

+655
-80
lines changed

15 files changed

+655
-80
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: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -376,23 +376,52 @@ object OutgoingPaymentPacket {
376376
}
377377

378378
private def buildHtlcFailure(nodeSecret: PrivateKey, reason: FailureReason, add: UpdateAddHtlc): Either[CannotExtractSharedSecret, ByteVector] = {
379-
extractSharedSecret(nodeSecret, add).map(sharedSecret => {
379+
extractSharedSecret(nodeSecret, add).map(ss => {
380380
reason match {
381-
case FailureReason.EncryptedDownstreamFailure(packet) => Sphinx.FailurePacket.wrap(packet, sharedSecret)
382-
case FailureReason.LocalFailure(failure) => Sphinx.FailurePacket.create(sharedSecret, failure)
381+
case FailureReason.EncryptedDownstreamFailure(packet) =>
382+
ss.trampolineOnionSecret_opt match {
383+
case Some(trampolineOnionSecret) =>
384+
// If we are unable to decrypt the downstream failure and the payment is using trampoline, the failure is
385+
// intended for the payer. We encrypt it with the trampoline secret first and then the outer secret.
386+
Sphinx.FailurePacket.wrap(Sphinx.FailurePacket.wrap(packet, trampolineOnionSecret), ss.outerOnionSecret)
387+
case None => Sphinx.FailurePacket.wrap(packet, ss.outerOnionSecret)
388+
}
389+
case FailureReason.LocalFailure(failure) =>
390+
// This isn't a trampoline failure, so we only encrypt it for the node who created the outer onion.
391+
Sphinx.FailurePacket.create(ss.outerOnionSecret, failure)
392+
case FailureReason.LocalTrampolineFailure(failure) =>
393+
// This is a trampoline failure: we try to encrypt it to the node who created the trampoline onion.
394+
ss.trampolineOnionSecret_opt match {
395+
case Some(trampolineOnionSecret) => Sphinx.FailurePacket.wrap(Sphinx.FailurePacket.create(trampolineOnionSecret, failure), ss.outerOnionSecret)
396+
case None => Sphinx.FailurePacket.create(ss.outerOnionSecret, failure) // this shouldn't happen, we only generate trampoline failures when there was a trampoline onion
397+
}
383398
}
384399
})
385400
}
386401

402+
private case class HtlcSharedSecrets(outerOnionSecret: ByteVector32, trampolineOnionSecret_opt: Option[ByteVector32])
403+
387404
/**
388405
* We decrypt the onion again to extract the shared secret used to encrypt onion failures.
389406
* We could avoid this by storing the shared secret after the initial onion decryption, but we would have to store it
390407
* in the database since we must be able to fail HTLCs after restarting our node.
391408
* It's simpler to extract it again from the encrypted onion.
392409
*/
393-
private def extractSharedSecret(nodeSecret: PrivateKey, add: UpdateAddHtlc): Either[CannotExtractSharedSecret, ByteVector32] = {
410+
private def extractSharedSecret(nodeSecret: PrivateKey, add: UpdateAddHtlc): Either[CannotExtractSharedSecret, HtlcSharedSecrets] = {
394411
Sphinx.peel(nodeSecret, Some(add.paymentHash), add.onionRoutingPacket) match {
395-
case Right(Sphinx.DecryptedPacket(_, _, sharedSecret)) => Right(sharedSecret)
412+
case Right(Sphinx.DecryptedPacket(payload, _, outerOnionSecret)) =>
413+
// Let's look at the onion payload to see if it contains a trampoline onion.
414+
PaymentOnionCodecs.perHopPayloadCodec.decode(payload.bits) match {
415+
case Attempt.Successful(DecodeResult(perHopPayload, _)) =>
416+
// We try to extract the trampoline shared secret, if we can find one.
417+
val trampolineOnionSecret_opt = perHopPayload.get[OnionPaymentPayloadTlv.TrampolineOnion].map(_.packet).flatMap(trampolinePacket => {
418+
val trampolinePathKey_opt = perHopPayload.get[OnionPaymentPayloadTlv.PathKey].map(_.publicKey)
419+
val trampolineOnionDecryptionKey = trampolinePathKey_opt.map(pathKey => Sphinx.RouteBlinding.derivePrivateKey(nodeSecret, pathKey)).getOrElse(nodeSecret)
420+
Sphinx.peel(trampolineOnionDecryptionKey, Some(add.paymentHash), trampolinePacket).toOption.map(_.sharedSecret)
421+
})
422+
Right(HtlcSharedSecrets(outerOnionSecret, trampolineOnionSecret_opt))
423+
case Attempt.Failure(_) => Right(HtlcSharedSecrets(outerOnionSecret, None))
424+
}
396425
case Left(_) => Left(CannotExtractSharedSecret(add.channelId, add))
397426
}
398427
}

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)(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)), 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, 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)

eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/NodeRelay.scala

Lines changed: 62 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -126,12 +126,15 @@ object NodeRelay {
126126
val amountOut = outgoingAmount(upstream, payloadOut)
127127
val expiryOut = outgoingExpiry(upstream, payloadOut)
128128
val fee = nodeFee(nodeParams.relayParams.minTrampolineFees, amountOut)
129+
// We don't know yet how costly it is to reach the next node: we use a rough first estimate of twice our trampoline
130+
// fees. If we fail to find routes, we will return a different error with higher fees and expiry delta.
131+
val failure = TrampolineFeeOrExpiryInsufficient(nodeParams.relayParams.minTrampolineFees.feeBase * 2, nodeParams.relayParams.minTrampolineFees.feeProportionalMillionths * 2, nodeParams.channelConf.expiryDelta * 2)
129132
if (upstream.amountIn - amountOut < fee) {
130-
Some(TrampolineFeeInsufficient())
133+
Some(failure)
131134
} else if (upstream.expiryIn - expiryOut < nodeParams.channelConf.expiryDelta) {
132-
Some(TrampolineExpiryTooSoon())
135+
Some(failure)
133136
} else if (expiryOut <= CltvExpiry(nodeParams.currentBlockHeight)) {
134-
Some(TrampolineExpiryTooSoon())
137+
Some(failure)
135138
} else if (amountOut <= MilliSatoshi(0)) {
136139
Some(InvalidOnionPayload(UInt64(2), 0))
137140
} else {
@@ -167,31 +170,40 @@ object NodeRelay {
167170
* This helper method translates relaying errors (returned by the downstream nodes) to a BOLT 4 standard error that we
168171
* should return upstream.
169172
*/
170-
private def translateError(nodeParams: NodeParams, failures: Seq[PaymentFailure], upstream: Upstream.Hot.Trampoline, nextPayload: IntermediatePayload.NodeRelay): Option[FailureMessage] = {
173+
private def translateError(nodeParams: NodeParams, failures: Seq[PaymentFailure], upstream: Upstream.Hot.Trampoline, nextPayload: IntermediatePayload.NodeRelay): FailureReason = {
171174
val amountOut = outgoingAmount(upstream, nextPayload)
172175
val routeNotFound = failures.collectFirst { case f@LocalFailure(_, _, RouteNotFound) => f }.nonEmpty
173176
val routingFeeHigh = upstream.amountIn - amountOut >= nodeFee(nodeParams.relayParams.minTrampolineFees, amountOut) * 5
177+
val trampolineFeesFailure = TrampolineFeeOrExpiryInsufficient(nodeParams.relayParams.minTrampolineFees.feeBase * 5, nodeParams.relayParams.minTrampolineFees.feeProportionalMillionths * 5, nodeParams.channelConf.expiryDelta * 5)
178+
// We select the best error we can from our downstream attempts.
174179
failures match {
175-
case Nil => None
180+
case Nil => FailureReason.LocalTrampolineFailure(TemporaryTrampolineFailure())
176181
case LocalFailure(_, _, BalanceTooLow) :: Nil if routingFeeHigh =>
177182
// We have direct channels to the target node, but not enough outgoing liquidity to use those channels.
178-
// The routing fee proposed by the sender was high enough to find alternative, indirect routes, but didn't yield
179-
// any result so we tell them that we don't have enough outgoing liquidity at the moment.
180-
Some(TemporaryNodeFailure())
181-
case LocalFailure(_, _, BalanceTooLow) :: Nil => Some(TrampolineFeeInsufficient()) // a higher fee/cltv may find alternative, indirect routes
182-
case _ if routeNotFound => Some(TrampolineFeeInsufficient()) // if we couldn't find routes, it's likely that the fee/cltv was insufficient
183+
// The routing fee proposed by the sender was high enough to find alternative, indirect routes, but didn't
184+
// yield any result so we tell them that we don't have enough outgoing liquidity at the moment.
185+
FailureReason.LocalTrampolineFailure(TemporaryTrampolineFailure())
186+
case LocalFailure(_, _, BalanceTooLow) :: Nil =>
187+
// A higher fee/cltv may find alternative, indirect routes.
188+
FailureReason.LocalTrampolineFailure(trampolineFeesFailure)
189+
case _ if routeNotFound =>
190+
// If we couldn't find routes, it's likely that the fee/cltv was insufficient.
191+
FailureReason.LocalTrampolineFailure(trampolineFeesFailure)
183192
case _ =>
184-
// Otherwise, we try to find a downstream error that we could decrypt.
185-
val outgoingNodeFailure = nextPayload match {
186-
case nextPayload: IntermediatePayload.NodeRelay.Standard => failures.collectFirst { case RemoteFailure(_, _, e) if e.originNode == nextPayload.outgoingNodeId => e.failureMessage }
187-
case nextPayload: IntermediatePayload.NodeRelay.ToNonTrampoline => failures.collectFirst { case RemoteFailure(_, _, e) if e.originNode == nextPayload.outgoingNodeId => e.failureMessage }
193+
nextPayload match {
194+
case _: IntermediatePayload.NodeRelay.Standard =>
195+
// If we received a failure from the next trampoline node, we won't be able to decrypt it: we should encrypt
196+
// it with our trampoline shared secret and relay it upstream, because only the sender can decrypt it.
197+
failures.collectFirst { case UnreadableRemoteFailure(_, _, packet) => FailureReason.EncryptedDownstreamFailure(packet) }
198+
.getOrElse(FailureReason.LocalTrampolineFailure(TemporaryTrampolineFailure()))
199+
case nextPayload: IntermediatePayload.NodeRelay.ToNonTrampoline =>
200+
// The recipient doesn't support trampoline: if we received a failure from them, we forward it upstream.
201+
failures.collectFirst { case RemoteFailure(_, _, e) if e.originNode == nextPayload.outgoingNodeId => FailureReason.LocalFailure(e.failureMessage) }
202+
.getOrElse(FailureReason.LocalTrampolineFailure(TemporaryTrampolineFailure()))
188203
// When using blinded paths, we will never get a failure from the final node (for privacy reasons).
189-
case _: IntermediatePayload.NodeRelay.Blinded => None
190-
case _: IntermediatePayload.NodeRelay.ToBlindedPaths => None
204+
case _: IntermediatePayload.NodeRelay.Blinded => FailureReason.LocalTrampolineFailure(TemporaryTrampolineFailure())
205+
case _: IntermediatePayload.NodeRelay.ToBlindedPaths => FailureReason.LocalTrampolineFailure(TemporaryTrampolineFailure())
191206
}
192-
val otherNodeFailure = failures.collectFirst { case RemoteFailure(_, _, e) => e.failureMessage }
193-
val failure = outgoingNodeFailure.getOrElse(otherNodeFailure.getOrElse(TemporaryNodeFailure()))
194-
Some(failure)
195207
}
196208
}
197209

@@ -231,15 +243,17 @@ class NodeRelay private(nodeParams: NodeParams,
231243
case WrappedMultiPartPaymentFailed(MultiPartPaymentFSM.MultiPartPaymentFailed(_, failure, parts)) =>
232244
context.log.warn("could not complete incoming multi-part payment (parts={} paidAmount={} failure={})", parts.size, parts.map(_.amount).sum, failure)
233245
Metrics.recordPaymentRelayFailed(failure.getClass.getSimpleName, Tags.RelayType.Trampoline)
234-
parts.collect { case p: MultiPartPaymentFSM.HtlcPart => rejectHtlc(p.htlc.id, p.htlc.channelId, p.amount, Some(failure)) }
246+
// Note that we don't treat this as a trampoline failure, which would be encrypted for the payer.
247+
// This is a failure of the previous trampoline node who didn't send a valid MPP payment.
248+
parts.collect { case p: MultiPartPaymentFSM.HtlcPart => rejectHtlc(p.htlc.id, p.htlc.channelId, p.amount, Some(FailureReason.LocalFailure(failure))) }
235249
stopping()
236250
case WrappedMultiPartPaymentSucceeded(MultiPartPaymentFSM.MultiPartPaymentSucceeded(_, parts)) =>
237251
context.log.info("completed incoming multi-part payment with parts={} paidAmount={}", parts.size, parts.map(_.amount).sum)
238252
val upstream = Upstream.Hot.Trampoline(htlcs.toList)
239253
validateRelay(nodeParams, upstream, nextPayload) match {
240254
case Some(failure) =>
241255
context.log.warn(s"rejecting trampoline payment reason=$failure")
242-
rejectPayment(upstream, Some(failure))
256+
rejectPayment(upstream, FailureReason.LocalTrampolineFailure(failure), nextPayload.isLegacy)
243257
stopping()
244258
case None =>
245259
resolveNextNode(upstream, nextPayload, nextPacket_opt)
@@ -274,7 +288,7 @@ class NodeRelay private(nodeParams: NodeParams,
274288
attemptWakeUpIfRecipientIsWallet(upstream, recipient, nextPayload, nextPacket_opt)
275289
case WrappedOutgoingNodeId(None) =>
276290
context.log.warn("rejecting trampoline payment to blinded trampoline: cannot identify next node for scid={}", payloadOut.outgoing)
277-
rejectPayment(upstream, Some(UnknownNextPeer()))
291+
rejectPayment(upstream, FailureReason.LocalTrampolineFailure(UnknownNextPeer()), nextPayload.isLegacy)
278292
stopping()
279293
}
280294
}
@@ -294,7 +308,7 @@ class NodeRelay private(nodeParams: NodeParams,
294308
rejectExtraHtlcPartialFunction orElse {
295309
case WrappedResolvedPaths(resolved) if resolved.isEmpty =>
296310
context.log.warn("rejecting trampoline payment to blinded paths: no usable blinded path")
297-
rejectPayment(upstream, Some(UnknownNextPeer()))
311+
rejectPayment(upstream, FailureReason.LocalTrampolineFailure(UnknownNextPeer()), nextPayload.isLegacy)
298312
stopping()
299313
case WrappedResolvedPaths(resolved) =>
300314
// We don't have access to the invoice: we use the only node_id that somewhat makes sense for the recipient.
@@ -349,7 +363,7 @@ class NodeRelay private(nodeParams: NodeParams,
349363
rejectExtraHtlcPartialFunction orElse {
350364
case WrappedPeerReadyResult(_: PeerReadyNotifier.PeerUnavailable) =>
351365
context.log.warn("rejecting payment: failed to wake-up remote peer")
352-
rejectPayment(upstream, Some(UnknownNextPeer()))
366+
rejectPayment(upstream, FailureReason.LocalTrampolineFailure(UnknownNextPeer()), nextPayload.isLegacy)
353367
stopping()
354368
case WrappedPeerReadyResult(r: PeerReadyNotifier.PeerReady) =>
355369
relay(upstream, recipient, Some(walletNodeId), Some(r.remoteFeatures), nextPayload, nextPacket_opt)
@@ -426,7 +440,7 @@ class NodeRelay private(nodeParams: NodeParams,
426440
context.log.info("trampoline payment failed, attempting on-the-fly funding")
427441
attemptOnTheFlyFunding(upstream, walletNodeId, recipient, nextPayload, failures, startedAt)
428442
case _ =>
429-
rejectPayment(upstream, translateError(nodeParams, failures, upstream, nextPayload))
443+
rejectPayment(upstream, translateError(nodeParams, failures, upstream, nextPayload), nextPayload.isLegacy)
430444
recordRelayDuration(startedAt, isSuccess = false)
431445
stopping()
432446
}
@@ -449,7 +463,7 @@ class NodeRelay private(nodeParams: NodeParams,
449463
OutgoingPaymentPacket.buildOutgoingPayment(Origin.Hot(ActorRef.noSender, upstream), paymentHash, dummyRoute, recipient, 1.0) match {
450464
case Left(f) =>
451465
context.log.warn("could not create payment onion for on-the-fly funding: {}", f.getMessage)
452-
rejectPayment(upstream, translateError(nodeParams, failures, upstream, nextPayload))
466+
rejectPayment(upstream, translateError(nodeParams, failures, upstream, nextPayload), nextPayload.isLegacy)
453467
recordRelayDuration(startedAt, isSuccess = false)
454468
stopping()
455469
case Right(nextPacket) =>
@@ -468,7 +482,7 @@ class NodeRelay private(nodeParams: NodeParams,
468482
stopping()
469483
case ProposeOnTheFlyFundingResponse.NotAvailable(reason) =>
470484
context.log.warn("could not propose on-the-fly funding: {}", reason)
471-
rejectPayment(upstream, Some(UnknownNextPeer()))
485+
rejectPayment(upstream, FailureReason.LocalTrampolineFailure(UnknownNextPeer()), nextPayload.isLegacy)
472486
recordRelayDuration(startedAt, isSuccess = false)
473487
stopping()
474488
}
@@ -507,15 +521,30 @@ class NodeRelay private(nodeParams: NodeParams,
507521
rejectHtlc(add.id, add.channelId, add.amountMsat)
508522
}
509523

510-
private def rejectHtlc(htlcId: Long, channelId: ByteVector32, amount: MilliSatoshi, failure: Option[FailureMessage] = None): Unit = {
511-
val failureMessage = failure.getOrElse(IncorrectOrUnknownPaymentDetails(amount, nodeParams.currentBlockHeight))
512-
val cmd = CMD_FAIL_HTLC(htlcId, FailureReason.LocalFailure(failureMessage), commit = true)
524+
private def rejectHtlc(htlcId: Long, channelId: ByteVector32, amount: MilliSatoshi, failure_opt: Option[FailureReason] = None): Unit = {
525+
val failure = failure_opt.getOrElse(FailureReason.LocalFailure(IncorrectOrUnknownPaymentDetails(amount, nodeParams.currentBlockHeight)))
526+
val cmd = CMD_FAIL_HTLC(htlcId, failure, commit = true)
513527
PendingCommandsDb.safeSend(register, nodeParams.db.pendingCommands, channelId, cmd)
514528
}
515529

516-
private def rejectPayment(upstream: Upstream.Hot.Trampoline, failure: Option[FailureMessage]): Unit = {
517-
Metrics.recordPaymentRelayFailed(failure.map(_.getClass.getSimpleName).getOrElse("Unknown"), Tags.RelayType.Trampoline)
518-
upstream.received.foreach(r => rejectHtlc(r.add.id, r.add.channelId, upstream.amountIn, failure))
530+
private def rejectPayment(upstream: Upstream.Hot.Trampoline, failure: FailureReason, isLegacy: Boolean): Unit = {
531+
val failure1 = failure match {
532+
case failure: FailureReason.EncryptedDownstreamFailure =>
533+
Metrics.recordPaymentRelayFailed("Unknown", Tags.RelayType.Trampoline)
534+
failure
535+
case failure: FailureReason.LocalFailure =>
536+
Metrics.recordPaymentRelayFailed(failure.getClass.getSimpleName, Tags.RelayType.Trampoline)
537+
failure
538+
case failure: FailureReason.LocalTrampolineFailure =>
539+
Metrics.recordPaymentRelayFailed(failure.getClass.getSimpleName, Tags.RelayType.Trampoline)
540+
if (isLegacy) {
541+
// The payer won't be able to decrypt our trampoline failure: we use a legacy failure for backwards-compat.
542+
FailureReason.LocalFailure(LegacyTrampolineFeeInsufficient())
543+
} else {
544+
failure
545+
}
546+
}
547+
upstream.received.foreach(r => rejectHtlc(r.add.id, r.add.channelId, upstream.amountIn, Some(failure1)))
519548
}
520549

521550
private def fulfillPayment(upstream: Upstream.Hot.Trampoline, paymentPreimage: ByteVector32): Unit = upstream.received.foreach(r => {

0 commit comments

Comments
 (0)