Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ case class FailureAttributionData(htlcReceivedAt: TimestampMilli, trampolineRece
case class FulfillAttributionData(htlcReceivedAt: TimestampMilli, trampolineReceivedAt_opt: Option[TimestampMilli], downstreamAttribution_opt: Option[ByteVector])

sealed trait HtlcSettlementCommand extends HasOptionalReplyToCommand with ForbiddenCommandDuringQuiescenceNegotiation with ForbiddenCommandWhenQuiescent { def id: Long }
final case class CMD_FULFILL_HTLC(id: Long, r: ByteVector32, attribution_opt: Option[FulfillAttributionData], commit: Boolean = false, replyTo_opt: Option[ActorRef] = None) extends HtlcSettlementCommand
final case class CMD_FULFILL_HTLC(id: Long, r: ByteVector32, fulfillmentPayload_opt: Option[ByteVector], attribution_opt: Option[FulfillAttributionData], commit: Boolean = false, replyTo_opt: Option[ActorRef] = None) extends HtlcSettlementCommand
final case class CMD_FAIL_HTLC(id: Long, reason: FailureReason, attribution_opt: Option[FailureAttributionData], delay_opt: Option[FiniteDuration] = None, commit: Boolean = false, replyTo_opt: Option[ActorRef] = None) extends HtlcSettlementCommand
final case class CMD_FAIL_MALFORMED_HTLC(id: Long, onionHash: ByteVector32, failureCode: Int, commit: Boolean = false, replyTo_opt: Option[ActorRef] = None) extends HtlcSettlementCommand
final case class CMD_UPDATE_FEE(feeratePerKw: FeeratePerKw, commit: Boolean = false, replyTo_opt: Option[ActorRef] = None) extends HasOptionalReplyToCommand with ForbiddenCommandDuringQuiescenceNegotiation with ForbiddenCommandWhenQuiescent
Expand Down Expand Up @@ -304,7 +304,13 @@ final case class RES_FAILURE[+C <: Command, +T <: Throwable](cmd: C, t: T) exten
final case class RES_ADD_FAILED[+T <: ChannelException](c: CMD_ADD_HTLC, t: T, channelUpdate: Option[ChannelUpdate]) extends CommandFailure[CMD_ADD_HTLC, T] { override def toString = s"cannot add htlc with origin=${c.origin} reason=${t.getMessage}" }
sealed trait HtlcResult
object HtlcResult {
sealed trait Fulfill extends HtlcResult { def paymentPreimage: ByteVector32 }
sealed trait Fulfill extends HtlcResult {
def paymentPreimage: ByteVector32
def fulfillmentPayload_opt: Option[ByteVector] = this match {
case RemoteFulfill(fulfill) => fulfill.fulfillmentPayload_opt
case _: OnChainFulfill => None
}
}
case class RemoteFulfill(fulfill: UpdateFulfillHtlc) extends Fulfill { override val paymentPreimage: ByteVector32 = fulfill.paymentPreimage }
case class OnChainFulfill(paymentPreimage: ByteVector32) extends Fulfill
sealed trait Fail extends HtlcResult
Expand Down
73 changes: 59 additions & 14 deletions eclair-core/src/main/scala/fr/acinq/eclair/crypto/Sphinx.scala
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@ object Sphinx extends Logging {

case class HtlcFailure(holdTimes: Seq[HoldTime], failure: Either[CannotDecryptFailurePacket, DecryptedFailurePacket])

case class HtlcSuccess(holdTimes: Seq[HoldTime], remainingAttribution_opt: Option[ByteVector])
case class HtlcSuccess(holdTimes: Seq[HoldTime], fulfillmentPayload_opt: Option[ByteVector], remainingAttribution_opt: Option[ByteVector])

object FailurePacket {
/**
Expand Down Expand Up @@ -347,7 +347,7 @@ object Sphinx extends Logging {
val downstream = FailureMessageCodecs.failureOnionCodec(Hmac256(um)).decode(packet1.toBitVector) match {
// We've identified the failing node: no need to continue decrypting.
case Attempt.Successful(value) => HtlcFailure(Nil, Right(DecryptedFailurePacket(ss.remoteNodeId, index, value.value)))
// The failing node may be downstream: we keep decrypting.
// The failing node may be downstream: we keep decrypting.
case _ => decrypt(packet1, attribution1_opt.map(_.downstreamAttribution), tail, index + 1)
}
HtlcFailure(attribution1_opt.map(_.holdTime).toSeq ++ downstream.holdTimes, downstream.failure)
Expand All @@ -357,26 +357,71 @@ object Sphinx extends Logging {

object SuccessPacket {
/**
* Decrypt the attribution data provided in the HTLC-success case.
* Create an encrypted fulfillment payload, that will be wrapped by each intermediate node before being returned to
* the sender. Note that the final node (which creates this payload) does *not* apply additional wrapping since this
* is encrypted (unlike what happens for failure messages).
*
* Note that malicious intermediate hops may drop the packet or alter it, but since each intermediate node includes
* the payload they received in their attribution data, the sender will be able to infer who dropped or altered the
* payload.
*/
def create(sharedSecret: ByteVector32, payload: ByteVector): ByteVector = {
val key = generateKey("fulfillment", sharedSecret)
val (encryptedPayload, mac) = ChaCha20Poly1305.encrypt(key, zeroes(12), payload, ByteVector.empty)
encryptedPayload ++ mac
}

/**
* Wrap the fulfillment payload received from the downstream node in an additional layer of onion encryption.
* Each intermediate node wraps the fulfillment payload until it reaches the original sender.
* Each intermediate node also includes the received fulfillment payload in its attribution data.
*/
def wrap(payload: ByteVector, sharedSecret: ByteVector32): ByteVector = {
val key = generateKey("ammag", sharedSecret)
val stream = generateStream(key, payload.length.toInt)
payload xor stream
}

/**
* Decrypt the fulfillment payload and attribution data provided in the HTLC-success case.
* Node shared secrets are applied until we reach the recipient's shared secret, where the decryption step differs.
* Note that malicious nodes in the route may have altered the packet, triggering a decryption failure.
*
* @param payload_opt fulfillment payload.
* @param attribution_opt attribution data for this success packet.
* @param sharedSecrets nodes shared secrets.
* @param fullRoute must be set to false when decrypting a partial route (e.g. as an intermediate trampoline).
*/
def decrypt(attribution_opt: Option[ByteVector], sharedSecrets: Seq[SharedSecret], index: Int = 1): HtlcSuccess = {
def decrypt(payload_opt: Option[ByteVector], attribution_opt: Option[ByteVector], sharedSecrets: Seq[SharedSecret], fullRoute: Boolean = true): HtlcSuccess = {
sharedSecrets match {
case Nil => HtlcSuccess(Nil, attribution_opt)
case Nil => HtlcSuccess(Nil, payload_opt, attribution_opt)
case ss :: tail =>
attribution_opt match {
case None => HtlcSuccess(Nil, None)
case Some(attribution) =>
Attribution.decrypt(attribution, None, ss, sharedSecrets.length) match {
case Some(perHopAttribution) =>
val downstream = decrypt(Some(perHopAttribution.downstreamAttribution), tail, index + 1)
HtlcSuccess(perHopAttribution.holdTime +: downstream.holdTimes, downstream.remainingAttribution_opt)
case None => HtlcSuccess(Nil, Some(attribution))
}
// We start by unwrapping the fulfillment payload, if provided.
val isFinalNode = tail.isEmpty && fullRoute
val unwrappedPayload_opt = payload_opt match {
case Some(payload) if isFinalNode =>
// We decrypt the payload provided by the final node.
val key = generateKey("fulfillment", ss.secret)
Try(ChaCha20Poly1305.decrypt(key, zeroes(12), payload.dropRight(16), ByteVector.empty, payload.takeRight(16))).toOption
case Some(payload) =>
// We peel the wrapping added by the intermediate node.
Some(wrap(payload, ss.secret))
case None => None
}
// We decrypt the attribution data provided by this node: its HMACs must cover the unwrapped fulfillment payload.
// The code below is quite subtle, because nodes inside a blinded path don't include attribution data, so when
// we reach that point in the recursion, attribution decryption will fail, which is expected.
// After that, attribution_opt will be set to None for recursive calls, because there is no point trying to
// decrypt attribution data that wasn't actually provided or that was tampered with.
// Note that if an intermediate node tampered with the attribution data, it will have the same effect: we will
// stop processing attribution after that node.
// The caller can look at the reported hold times to know which nodes provided valid attribution data: this
// allows identifying which nodes are acting maliciously, if any.
// We keep processing the fulfillment payload recursively though, because we need to use all shared secrets
// to decrypt it.
val attribution1_opt = attribution_opt.flatMap(attribution => Attribution.decrypt(attribution, if (!isFinalNode) unwrappedPayload_opt else None, ss, sharedSecrets.length))
val downstream = decrypt(unwrappedPayload_opt, attribution1_opt.map(_.downstreamAttribution), tail, fullRoute)
HtlcSuccess(attribution1_opt.map(_.holdTime).toSeq ++ downstream.holdTimes, downstream.fulfillmentPayload_opt, downstream.remainingAttribution_opt)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,7 @@ class PgAuditDb(implicit ds: DataSource) extends AuditDb with Logging {
PublicKey(rs.getByteVectorFromHex("recipient_node_id")),
Seq(part),
None,
None,
part.startedAt)
}
sentByParentId + (parentId -> sent)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,7 @@ class SqliteAuditDb(val sqlite: Connection) extends AuditDb with Logging {
PublicKey(rs.getByteVectorFromHex("recipient_node_id")),
Seq(part),
None,
None,
part.startedAt)
}
sentByParentId + (parentId -> sent)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ object PaymentEvent {
* @param parts child payments (actual outgoing HTLCs).
* @param remainingAttribution_opt for relayed trampoline payments, the attribution data that needs to be sent upstream
*/
case class PaymentSent(id: UUID, paymentPreimage: ByteVector32, recipientAmount: MilliSatoshi, recipientNodeId: PublicKey, parts: Seq[PaymentSent.PaymentPart], remainingAttribution_opt: Option[ByteVector], startedAt: TimestampMilli) extends PaymentEvent {
case class PaymentSent(id: UUID, paymentPreimage: ByteVector32, recipientAmount: MilliSatoshi, recipientNodeId: PublicKey, parts: Seq[PaymentSent.PaymentPart], fulfillmentPayload_opt: Option[ByteVector], remainingAttribution_opt: Option[ByteVector], startedAt: TimestampMilli) extends PaymentEvent {
require(parts.nonEmpty, "must have at least one payment part")
val paymentHash: ByteVector32 = Crypto.sha256(paymentPreimage)
val amountWithFees: MilliSatoshi = parts.map(_.amountWithFees).sum
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,7 @@ object OutgoingPaymentPacket {
}
}

private case class HtlcSharedSecrets(outerOnionSecret: ByteVector32, trampolineOnionSecret_opt: Option[ByteVector32], blinded: Boolean)
private case class HtlcSharedSecrets(outerOnionSecret: ByteVector32, trampolineOnionSecret_opt: Option[ByteVector32], blinded: Boolean, isFinalNode: Boolean)

/**
* We decrypt the onion again to extract the shared secret(s) used to encrypt onion failures.
Expand All @@ -365,24 +365,32 @@ object OutgoingPaymentPacket {
* It's simpler to extract it again from the encrypted onion.
*/
private def extractSharedSecret(nodeSecret: PrivateKey, add: UpdateAddHtlc): Either[CannotExtractSharedSecret, HtlcSharedSecrets] = {
Sphinx.peel(nodeSecret, Some(add.paymentHash), add.onionRoutingPacket) match {
case Right(Sphinx.DecryptedPacket(payload, _, outerOnionSecret)) =>
val outerOnionDecryptionKey = add.pathKey_opt match {
case Some(blinding) => Sphinx.RouteBlinding.derivePrivateKey(nodeSecret, blinding)
case None => nodeSecret
}
Sphinx.peel(outerOnionDecryptionKey, Some(add.paymentHash), add.onionRoutingPacket) match {
case Right(packet@Sphinx.DecryptedPacket(payload, _, outerOnionSecret)) =>
// Let's look at the onion payload to see if it contains a trampoline onion.
PaymentOnionCodecs.perHopPayloadCodec.decode(payload.bits) match {
case Attempt.Successful(DecodeResult(perHopPayload, _)) =>
// We try to extract the trampoline shared secret, if we can find one.
val trampolineOnionSecret_opt = perHopPayload.get[OnionPaymentPayloadTlv.TrampolineOnion].map(_.packet).flatMap(trampolinePacket => {
val trampolinePacket_opt = perHopPayload.get[OnionPaymentPayloadTlv.TrampolineOnion].map(_.packet).flatMap(trampolinePacket => {
val trampolinePathKey_opt = perHopPayload.get[OnionPaymentPayloadTlv.PathKey].map(_.publicKey)
val trampolineOnionDecryptionKey = trampolinePathKey_opt.map(pathKey => Sphinx.RouteBlinding.derivePrivateKey(nodeSecret, pathKey)).getOrElse(nodeSecret)
Sphinx.peel(trampolineOnionDecryptionKey, Some(add.paymentHash), trampolinePacket).toOption.map(_.sharedSecret)
Sphinx.peel(trampolineOnionDecryptionKey, Some(add.paymentHash), trampolinePacket).toOption
})
// We check if we are an intermediate node in a blinded (potentially trampoline) path.
val blinded = trampolineOnionSecret_opt match {
val blinded = trampolinePacket_opt match {
case Some(_) => perHopPayload.get[OnionPaymentPayloadTlv.PathKey].nonEmpty
case None => add.pathKey_opt.nonEmpty
}
Right(HtlcSharedSecrets(outerOnionSecret, trampolineOnionSecret_opt, blinded))
case Attempt.Failure(_) => Right(HtlcSharedSecrets(outerOnionSecret, None, blinded = add.pathKey_opt.nonEmpty))
val isFinalNode = trampolinePacket_opt match {
case Some(trampolinePacket) => trampolinePacket.isLastPacket
case None => packet.isLastPacket
}
Right(HtlcSharedSecrets(outerOnionSecret, trampolinePacket_opt.map(_.sharedSecret), blinded, isFinalNode))
case Attempt.Failure(_) => Right(HtlcSharedSecrets(outerOnionSecret, None, blinded = add.pathKey_opt.nonEmpty, isFinalNode = packet.isLastPacket))
}
case Left(_) => Left(CannotExtractSharedSecret(add.channelId, add))
}
Expand Down Expand Up @@ -423,24 +431,43 @@ object OutgoingPaymentPacket {
}
}

private def wrapFulfillmentPayload(cmd: CMD_FULFILL_HTLC, sharedSecret: ByteVector32, isFinalNode: Boolean): Option[ByteVector] = {
if (isFinalNode) {
// The final node encrypts the fulfillment payload without applying the wrapping step.
cmd.fulfillmentPayload_opt.map(p => Sphinx.SuccessPacket.create(sharedSecret, p))
} else {
// Intermediate nodes wrap the downstream fulfillment payload with their shared secret.
cmd.fulfillmentPayload_opt.map(p => Sphinx.SuccessPacket.wrap(p, sharedSecret))
}
}

def buildHtlcFulfill(nodeSecret: PrivateKey, useAttributionData: Boolean, cmd: CMD_FULFILL_HTLC, add: UpdateAddHtlc, now: TimestampMilli = TimestampMilli.now()): UpdateFulfillHtlc = {
// If we are part of a blinded route, we must not include any attribution data.
val attributionData_opt = add.pathKey_opt match {
case None if useAttributionData =>
val trampolineHoldTime = cmd.attribution_opt.flatMap(_.trampolineReceivedAt_opt).map(receivedAt => now - receivedAt).getOrElse(0 millisecond)
val holdTime = cmd.attribution_opt.map(a => now - a.htlcReceivedAt).getOrElse(0 millisecond)
extractSharedSecret(nodeSecret, add) match {
case Right(HtlcSharedSecrets(outerOnionSecret, None, _)) =>
Some(Sphinx.Attribution.create(cmd.attribution_opt.flatMap(_.downstreamAttribution_opt), None, holdTime, outerOnionSecret))
case Right(HtlcSharedSecrets(outerOnionSecret, Some(trampolineOnionSecret), blinded)) if !blinded =>
val trampolineAttribution = Sphinx.Attribution.create(cmd.attribution_opt.flatMap(_.downstreamAttribution_opt), None, trampolineHoldTime, trampolineOnionSecret)
Some(Sphinx.Attribution.create(Some(trampolineAttribution), None, holdTime, outerOnionSecret))
case _ => None
}
case _ => None
// Note that if we are part of a blinded route, we must not include any attribution data.
// But we must wrap the fulfillment payload in all cases to ensure that the sender receives it.
val downstreamAttribution_opt = cmd.attribution_opt.flatMap(_.downstreamAttribution_opt)
val trampolineHoldTime = cmd.attribution_opt.flatMap(_.trampolineReceivedAt_opt).map(receivedAt => now - receivedAt).getOrElse(0 millisecond)
val holdTime = cmd.attribution_opt.map(a => now - a.htlcReceivedAt).getOrElse(0 millisecond)
val (attributionData_opt, fulfillmentPayload_opt) = extractSharedSecret(nodeSecret, add) match {
case Right(HtlcSharedSecrets(outerOnionSecret, None, blinded, isFinalNode)) =>
// The final node doesn't include its fulfillment payload in the attribution HMACs: it already has its own MAC.
val attribution = Sphinx.Attribution.create(downstreamAttribution_opt, if (!isFinalNode) cmd.fulfillmentPayload_opt else None, holdTime, outerOnionSecret)
val attribution_opt = if (useAttributionData && !blinded) Some(attribution) else None
val fulfillmentPayload_opt = wrapFulfillmentPayload(cmd, outerOnionSecret, isFinalNode)
(attribution_opt, fulfillmentPayload_opt)
case Right(HtlcSharedSecrets(outerOnionSecret, Some(trampolineOnionSecret), blinded, isFinalNode)) =>
// We do a first pass with the trampoline shared secret.
val trampolineAttribution = Sphinx.Attribution.create(downstreamAttribution_opt, if (!isFinalNode) cmd.fulfillmentPayload_opt else None, trampolineHoldTime, trampolineOnionSecret)
val trampolineFulfillmentPayload_opt = wrapFulfillmentPayload(cmd, trampolineOnionSecret, isFinalNode)
// Then a second pass with the outer onion shared secret.
val attribution = Sphinx.Attribution.create(Some(trampolineAttribution), trampolineFulfillmentPayload_opt, holdTime, outerOnionSecret)
val attribution_opt = if (useAttributionData && !blinded) Some(attribution) else None
val fulfillmentPayload_opt = trampolineFulfillmentPayload_opt.map(p => Sphinx.SuccessPacket.wrap(p, outerOnionSecret))
(attribution_opt, fulfillmentPayload_opt)
case Left(_) => (None, None)
}
val tlvs: Set[UpdateFulfillHtlcTlv] = Set(
attributionData_opt.map(UpdateFulfillHtlcTlv.AttributionData(_))
attributionData_opt.map(UpdateFulfillHtlcTlv.AttributionData(_)),
fulfillmentPayload_opt.map(UpdateFulfillHtlcTlv.FulfillmentPayload(_)),
).flatten
UpdateFulfillHtlc(add.channelId, cmd.id, cmd.r, TlvStream(tlvs))
}
Expand Down
Loading
Loading