Skip to content

Commit f8b65bb

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 8d0380b commit f8b65bb

File tree

15 files changed

+223
-80
lines changed

15 files changed

+223
-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: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -366,23 +366,49 @@ object OutgoingPaymentPacket {
366366
}
367367

368368
private def buildHtlcFailure(nodeSecret: PrivateKey, reason: FailureReason, add: UpdateAddHtlc): Either[CannotExtractSharedSecret, ByteVector] = {
369-
extractSharedSecret(nodeSecret, add).map(sharedSecret => {
369+
extractSharedSecret(nodeSecret, add).map(ss => {
370370
reason match {
371-
case FailureReason.EncryptedDownstreamFailure(packet) => Sphinx.FailurePacket.wrap(packet, sharedSecret)
372-
case FailureReason.LocalFailure(failure) => Sphinx.FailurePacket.create(sharedSecret, failure)
371+
case FailureReason.EncryptedDownstreamFailure(packet) =>
372+
ss.trampolineOnionSecret_opt match {
373+
case Some(trampolineOnionSecret) =>
374+
// If we are unable to decrypt the downstream failure and the payment is using trampoline, the failure is
375+
// intended for the payer. We encrypt it with the trampoline secret first and then the outer secret.
376+
Sphinx.FailurePacket.wrap(Sphinx.FailurePacket.wrap(packet, trampolineOnionSecret), ss.outerOnionSecret)
377+
case None => Sphinx.FailurePacket.wrap(packet, ss.outerOnionSecret)
378+
}
379+
case FailureReason.LocalFailure(failure) =>
380+
// This isn't a trampoline failure, so we only encrypt it for the node who created the outer onion.
381+
Sphinx.FailurePacket.create(ss.outerOnionSecret, failure)
382+
case FailureReason.LocalTrampolineFailure(failure) =>
383+
// This is a trampoline failure: we try to encrypt it to the node who created the trampoline onion.
384+
ss.trampolineOnionSecret_opt match {
385+
case Some(trampolineOnionSecret) => Sphinx.FailurePacket.wrap(Sphinx.FailurePacket.create(trampolineOnionSecret, failure), ss.outerOnionSecret)
386+
case None => Sphinx.FailurePacket.create(ss.outerOnionSecret, failure) // this shouldn't happen, we only generate trampoline failures when there was a trampoline onion
387+
}
373388
}
374389
})
375390
}
376391

392+
private case class HtlcSharedSecrets(outerOnionSecret: ByteVector32, trampolineOnionSecret_opt: Option[ByteVector32])
393+
377394
/**
378395
* We decrypt the onion again to extract the shared secret used to encrypt onion failures.
379396
* We could avoid this by storing the shared secret after the initial onion decryption, but we would have to store it
380397
* in the database since we must be able to fail HTLCs after restarting our node.
381398
* It's simpler to extract it again from the encrypted onion.
382399
*/
383-
private def extractSharedSecret(nodeSecret: PrivateKey, add: UpdateAddHtlc): Either[CannotExtractSharedSecret, ByteVector32] = {
400+
private def extractSharedSecret(nodeSecret: PrivateKey, add: UpdateAddHtlc): Either[CannotExtractSharedSecret, HtlcSharedSecrets] = {
384401
Sphinx.peel(nodeSecret, Some(add.paymentHash), add.onionRoutingPacket) match {
385-
case Right(Sphinx.DecryptedPacket(_, _, sharedSecret)) => Right(sharedSecret)
402+
case Right(Sphinx.DecryptedPacket(payload, _, outerOnionSecret)) =>
403+
// Let's look at the onion payload to see if it contains a trampoline onion.
404+
PaymentOnionCodecs.perHopPayloadCodec.decode(payload.bits) match {
405+
case Attempt.Successful(DecodeResult(perHopPayload, _)) =>
406+
perHopPayload.get[OnionPaymentPayloadTlv.TrampolineOnion].flatMap(p => Sphinx.peel(nodeSecret, Some(add.paymentHash), p.packet).toOption) match {
407+
case Some(Sphinx.DecryptedPacket(_, _, trampolineOnionSecret)) => Right(HtlcSharedSecrets(outerOnionSecret, Some(trampolineOnionSecret)))
408+
case None => Right(HtlcSharedSecrets(outerOnionSecret, None))
409+
}
410+
case Attempt.Failure(_) => Right(HtlcSharedSecrets(outerOnionSecret, None))
411+
}
386412
case Left(_) => Left(CannotExtractSharedSecret(add.channelId, add))
387413
}
388414
}

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
@@ -516,7 +516,12 @@ object MultiPartHandler {
516516

517517
private def validateStandardPayment(nodeParams: NodeParams, add: UpdateAddHtlc, payload: FinalPayload.Standard, record: IncomingStandardPayment)(implicit log: LoggingAdapter): Option[CMD_FAIL_HTLC] = {
518518
// We send the same error regardless of the failure to avoid probing attacks.
519-
val cmdFail = CMD_FAIL_HTLC(add.id, FailureReason.LocalFailure(IncorrectOrUnknownPaymentDetails(payload.totalAmount, nodeParams.currentBlockHeight)), commit = true)
519+
val failure = if (payload.isTrampoline) {
520+
FailureReason.LocalTrampolineFailure(IncorrectOrUnknownPaymentDetails(payload.totalAmount, nodeParams.currentBlockHeight))
521+
} else {
522+
FailureReason.LocalFailure(IncorrectOrUnknownPaymentDetails(payload.totalAmount, nodeParams.currentBlockHeight))
523+
}
524+
val cmdFail = CMD_FAIL_HTLC(add.id, failure, commit = true)
520525
val commonOk = validateCommon(nodeParams, add, payload, record)
521526
val secretOk = validatePaymentSecret(add, payload, record.invoice)
522527
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 {
@@ -181,31 +184,40 @@ object NodeRelay {
181184
* This helper method translates relaying errors (returned by the downstream nodes) to a BOLT 4 standard error that we
182185
* should return upstream.
183186
*/
184-
private def translateError(nodeParams: NodeParams, failures: Seq[PaymentFailure], upstream: Upstream.Hot.Trampoline, nextPayload: IntermediatePayload.NodeRelay): Option[FailureMessage] = {
187+
private def translateError(nodeParams: NodeParams, failures: Seq[PaymentFailure], upstream: Upstream.Hot.Trampoline, nextPayload: IntermediatePayload.NodeRelay): FailureReason = {
185188
val amountOut = outgoingAmount(upstream, nextPayload)
186189
val routeNotFound = failures.collectFirst { case f@LocalFailure(_, _, RouteNotFound) => f }.nonEmpty
187190
val routingFeeHigh = upstream.amountIn - amountOut >= nodeFee(nodeParams.relayParams.minTrampolineFees, amountOut) * 5
191+
val trampolineFeesFailure = TrampolineFeeOrExpiryInsufficient(nodeParams.relayParams.minTrampolineFees.feeBase * 5, nodeParams.relayParams.minTrampolineFees.feeProportionalMillionths * 5, nodeParams.channelConf.expiryDelta * 5)
192+
// We select the best error we can from our downstream attempts.
188193
failures match {
189-
case Nil => None
194+
case Nil => FailureReason.LocalTrampolineFailure(TemporaryTrampolineFailure())
190195
case LocalFailure(_, _, BalanceTooLow) :: Nil if routingFeeHigh =>
191196
// We have direct channels to the target node, but not enough outgoing liquidity to use those channels.
192-
// The routing fee proposed by the sender was high enough to find alternative, indirect routes, but didn't yield
193-
// any result so we tell them that we don't have enough outgoing liquidity at the moment.
194-
Some(TemporaryNodeFailure())
195-
case LocalFailure(_, _, BalanceTooLow) :: Nil => Some(TrampolineFeeInsufficient()) // a higher fee/cltv may find alternative, indirect routes
196-
case _ if routeNotFound => Some(TrampolineFeeInsufficient()) // if we couldn't find routes, it's likely that the fee/cltv was insufficient
197+
// The routing fee proposed by the sender was high enough to find alternative, indirect routes, but didn't
198+
// yield any result so we tell them that we don't have enough outgoing liquidity at the moment.
199+
FailureReason.LocalTrampolineFailure(TemporaryTrampolineFailure())
200+
case LocalFailure(_, _, BalanceTooLow) :: Nil =>
201+
// A higher fee/cltv may find alternative, indirect routes.
202+
FailureReason.LocalTrampolineFailure(trampolineFeesFailure)
203+
case _ if routeNotFound =>
204+
// If we couldn't find routes, it's likely that the fee/cltv was insufficient.
205+
FailureReason.LocalTrampolineFailure(trampolineFeesFailure)
197206
case _ =>
198-
// Otherwise, we try to find a downstream error that we could decrypt.
199-
val outgoingNodeFailure = nextPayload match {
200-
case nextPayload: IntermediatePayload.NodeRelay.Standard => failures.collectFirst { case RemoteFailure(_, _, e) if e.originNode == nextPayload.outgoingNodeId => e.failureMessage }
201-
case nextPayload: IntermediatePayload.NodeRelay.ToNonTrampoline => failures.collectFirst { case RemoteFailure(_, _, e) if e.originNode == nextPayload.outgoingNodeId => e.failureMessage }
207+
nextPayload match {
208+
case _: IntermediatePayload.NodeRelay.Standard =>
209+
// If we received a failure from the next trampoline node, we won't be able to decrypt it: we should encrypt
210+
// it with our trampoline shared secret and relay it upstream, because only the sender can decrypt it.
211+
failures.collectFirst { case UnreadableRemoteFailure(_, _, packet) => FailureReason.EncryptedDownstreamFailure(packet) }
212+
.getOrElse(FailureReason.LocalTrampolineFailure(TemporaryTrampolineFailure()))
213+
case nextPayload: IntermediatePayload.NodeRelay.ToNonTrampoline =>
214+
// The recipient doesn't support trampoline: if we received a failure from them, we forward it upstream.
215+
failures.collectFirst { case RemoteFailure(_, _, e) if e.originNode == nextPayload.outgoingNodeId => FailureReason.LocalFailure(e.failureMessage) }
216+
.getOrElse(FailureReason.LocalTrampolineFailure(TemporaryTrampolineFailure()))
202217
// When using blinded paths, we will never get a failure from the final node (for privacy reasons).
203-
case _: IntermediatePayload.NodeRelay.Blinded => None
204-
case _: IntermediatePayload.NodeRelay.ToBlindedPaths => None
218+
case _: IntermediatePayload.NodeRelay.Blinded => FailureReason.LocalTrampolineFailure(TemporaryTrampolineFailure())
219+
case _: IntermediatePayload.NodeRelay.ToBlindedPaths => FailureReason.LocalTrampolineFailure(TemporaryTrampolineFailure())
205220
}
206-
val otherNodeFailure = failures.collectFirst { case RemoteFailure(_, _, e) => e.failureMessage }
207-
val failure = outgoingNodeFailure.getOrElse(otherNodeFailure.getOrElse(TemporaryNodeFailure()))
208-
Some(failure)
209221
}
210222
}
211223

@@ -245,15 +257,17 @@ class NodeRelay private(nodeParams: NodeParams,
245257
case WrappedMultiPartPaymentFailed(MultiPartPaymentFSM.MultiPartPaymentFailed(_, failure, parts)) =>
246258
context.log.warn("could not complete incoming multi-part payment (parts={} paidAmount={} failure={})", parts.size, parts.map(_.amount).sum, failure)
247259
Metrics.recordPaymentRelayFailed(failure.getClass.getSimpleName, Tags.RelayType.Trampoline)
248-
parts.collect { case p: MultiPartPaymentFSM.HtlcPart => rejectHtlc(p.htlc.id, p.htlc.channelId, p.amount, Some(failure)) }
260+
// Note that we don't treat this as a trampoline failure, which would be encrypted for the payer.
261+
// This is a failure of the previous trampoline node who didn't send a valid MPP payment.
262+
parts.collect { case p: MultiPartPaymentFSM.HtlcPart => rejectHtlc(p.htlc.id, p.htlc.channelId, p.amount, Some(FailureReason.LocalFailure(failure))) }
249263
stopping()
250264
case WrappedMultiPartPaymentSucceeded(MultiPartPaymentFSM.MultiPartPaymentSucceeded(_, parts)) =>
251265
context.log.info("completed incoming multi-part payment with parts={} paidAmount={}", parts.size, parts.map(_.amount).sum)
252266
val upstream = Upstream.Hot.Trampoline(htlcs.toList)
253267
validateRelay(nodeParams, upstream, nextPayload) match {
254268
case Some(failure) =>
255269
context.log.warn(s"rejecting trampoline payment reason=$failure")
256-
rejectPayment(upstream, Some(failure))
270+
rejectPayment(upstream, FailureReason.LocalTrampolineFailure(failure), nextPayload.isLegacy)
257271
stopping()
258272
case None =>
259273
resolveNextNode(upstream, nextPayload, nextPacket_opt)
@@ -288,7 +302,7 @@ class NodeRelay private(nodeParams: NodeParams,
288302
ensureRecipientReady(upstream, recipient, nextPayload, nextPacket_opt)
289303
case WrappedOutgoingNodeId(None) =>
290304
context.log.warn("rejecting trampoline payment to blinded trampoline: cannot identify next node for scid={}", payloadOut.outgoing)
291-
rejectPayment(upstream, Some(UnknownNextPeer()))
305+
rejectPayment(upstream, FailureReason.LocalTrampolineFailure(UnknownNextPeer()), nextPayload.isLegacy)
292306
stopping()
293307
}
294308
}
@@ -308,7 +322,7 @@ class NodeRelay private(nodeParams: NodeParams,
308322
rejectExtraHtlcPartialFunction orElse {
309323
case WrappedResolvedPaths(resolved) if resolved.isEmpty =>
310324
context.log.warn("rejecting trampoline payment to blinded paths: no usable blinded path")
311-
rejectPayment(upstream, Some(UnknownNextPeer()))
325+
rejectPayment(upstream, FailureReason.LocalTrampolineFailure(UnknownNextPeer()), nextPayload.isLegacy)
312326
stopping()
313327
case WrappedResolvedPaths(resolved) =>
314328
// We don't have access to the invoice: we use the only node_id that somewhat makes sense for the recipient.
@@ -344,7 +358,7 @@ class NodeRelay private(nodeParams: NodeParams,
344358
rejectExtraHtlcPartialFunction orElse {
345359
case WrappedPeerReadyResult(_: PeerReadyNotifier.PeerUnavailable) =>
346360
context.log.warn("rejecting payment: failed to wake-up remote peer")
347-
rejectPayment(upstream, Some(UnknownNextPeer()))
361+
rejectPayment(upstream, FailureReason.LocalTrampolineFailure(UnknownNextPeer()), nextPayload.isLegacy)
348362
stopping()
349363
case WrappedPeerReadyResult(r: PeerReadyNotifier.PeerReady) =>
350364
relay(upstream, recipient, Some(walletNodeId), Some(r.remoteFeatures), nextPayload, nextPacket_opt)
@@ -420,7 +434,7 @@ class NodeRelay private(nodeParams: NodeParams,
420434
context.log.info("trampoline payment failed, attempting on-the-fly funding")
421435
attemptOnTheFlyFunding(upstream, walletNodeId, recipient, nextPayload, failures, startedAt)
422436
case _ =>
423-
rejectPayment(upstream, translateError(nodeParams, failures, upstream, nextPayload))
437+
rejectPayment(upstream, translateError(nodeParams, failures, upstream, nextPayload), nextPayload.isLegacy)
424438
recordRelayDuration(startedAt, isSuccess = false)
425439
stopping()
426440
}
@@ -443,7 +457,7 @@ class NodeRelay private(nodeParams: NodeParams,
443457
OutgoingPaymentPacket.buildOutgoingPayment(Origin.Hot(ActorRef.noSender, upstream), paymentHash, dummyRoute, recipient, 1.0) match {
444458
case Left(f) =>
445459
context.log.warn("could not create payment onion for on-the-fly funding: {}", f.getMessage)
446-
rejectPayment(upstream, translateError(nodeParams, failures, upstream, nextPayload))
460+
rejectPayment(upstream, translateError(nodeParams, failures, upstream, nextPayload), nextPayload.isLegacy)
447461
recordRelayDuration(startedAt, isSuccess = false)
448462
stopping()
449463
case Right(nextPacket) =>
@@ -462,7 +476,7 @@ class NodeRelay private(nodeParams: NodeParams,
462476
stopping()
463477
case ProposeOnTheFlyFundingResponse.NotAvailable(reason) =>
464478
context.log.warn("could not propose on-the-fly funding: {}", reason)
465-
rejectPayment(upstream, Some(UnknownNextPeer()))
479+
rejectPayment(upstream, FailureReason.LocalTrampolineFailure(UnknownNextPeer()), nextPayload.isLegacy)
466480
recordRelayDuration(startedAt, isSuccess = false)
467481
stopping()
468482
}
@@ -501,15 +515,30 @@ class NodeRelay private(nodeParams: NodeParams,
501515
rejectHtlc(add.id, add.channelId, add.amountMsat)
502516
}
503517

504-
private def rejectHtlc(htlcId: Long, channelId: ByteVector32, amount: MilliSatoshi, failure: Option[FailureMessage] = None): Unit = {
505-
val failureMessage = failure.getOrElse(IncorrectOrUnknownPaymentDetails(amount, nodeParams.currentBlockHeight))
506-
val cmd = CMD_FAIL_HTLC(htlcId, FailureReason.LocalFailure(failureMessage), commit = true)
518+
private def rejectHtlc(htlcId: Long, channelId: ByteVector32, amount: MilliSatoshi, failure_opt: Option[FailureReason] = None): Unit = {
519+
val failure = failure_opt.getOrElse(FailureReason.LocalFailure(IncorrectOrUnknownPaymentDetails(amount, nodeParams.currentBlockHeight)))
520+
val cmd = CMD_FAIL_HTLC(htlcId, failure, commit = true)
507521
PendingCommandsDb.safeSend(register, nodeParams.db.pendingCommands, channelId, cmd)
508522
}
509523

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

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

0 commit comments

Comments
 (0)