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
199 changes: 139 additions & 60 deletions eclair-core/src/main/scala/fr/acinq/eclair/crypto/Sphinx.scala

Large diffs are not rendered by default.

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
133 changes: 97 additions & 36 deletions eclair-core/src/main/scala/fr/acinq/eclair/payment/PaymentPacket.scala
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,10 @@ object OutgoingPaymentPacket {
* In that case, packetPayloadLength_opt must be greater than the actual onion's content.
*/
def buildOnion(payloads: Seq[NodePayload], associatedData: ByteVector32, packetPayloadLength_opt: Option[Int]): Either[OutgoingPaymentError, Sphinx.PacketAndSecrets] = {
val sessionKey = randomKey()
buildOnion(randomKey(), payloads, associatedData, packetPayloadLength_opt)
}

def buildOnion(sessionKey: PrivateKey, payloads: Seq[NodePayload], associatedData: ByteVector32, packetPayloadLength_opt: Option[Int]): Either[OutgoingPaymentError, Sphinx.PacketAndSecrets] = {
val nodeIds = payloads.map(_.nodeId)
val payloadsBin = payloads
.map(p => PaymentOnionCodecs.perHopPayloadCodec.encode(p.payload.records))
Expand All @@ -353,61 +356,119 @@ object OutgoingPaymentPacket {
}
}

private def buildHtlcFailure(nodeSecret: PrivateKey, useAttributableFailures: Boolean, reason: FailureReason, add: UpdateAddHtlc, holdTime: FiniteDuration): Either[CannotExtractSharedSecret, (ByteVector, TlvStream[UpdateFailHtlcTlv])] = {
extractSharedSecret(nodeSecret, add).map(sharedSecret => {
val (packet, attribution) = reason match {
case FailureReason.EncryptedDownstreamFailure(packet, attribution) => (packet, attribution)
case FailureReason.LocalFailure(failure) => (Sphinx.FailurePacket.create(sharedSecret, failure), None)
}
val tlvs: TlvStream[UpdateFailHtlcTlv] = if (useAttributableFailures) {
TlvStream(UpdateFailHtlcTlv.AttributionData(Sphinx.Attribution.create(attribution, Some(packet), holdTime, sharedSecret)))
} else {
TlvStream.empty
}
(Sphinx.FailurePacket.wrap(packet, sharedSecret), tlvs)
})
}
private case class HtlcSharedSecrets(outerOnionSecret: ByteVector32, trampolineOnionSecret_opt: Option[ByteVector32], blinded: Boolean, isFinalNode: Boolean)

/**
* We decrypt the onion again to extract the shared secret used to encrypt onion failures.
* We could avoid this by storing the shared secret after the initial onion decryption, but we would have to store it
* in the database since we must be able to fail HTLCs after restarting our node.
* We decrypt the onion again to extract the shared secret(s) used to encrypt onion failures.
* We could avoid this by storing the shared secret(s) after the initial onion decryption, but we would have to store
* it in the database since we must be able to fail HTLCs after restarting our node.
* It's simpler to extract it again from the encrypted onion.
*/
private def extractSharedSecret(nodeSecret: PrivateKey, add: UpdateAddHtlc): Either[CannotExtractSharedSecret, ByteVector32] = {
Sphinx.peel(nodeSecret, Some(add.paymentHash), add.onionRoutingPacket) match {
case Right(Sphinx.DecryptedPacket(_, _, sharedSecret)) => Right(sharedSecret)
private def extractSharedSecret(nodeSecret: PrivateKey, add: UpdateAddHtlc): Either[CannotExtractSharedSecret, HtlcSharedSecrets] = {
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 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
})
// We check if we are an intermediate node in a blinded (potentially trampoline) path.
val blinded = trampolinePacket_opt match {
case Some(_) => perHopPayload.get[OnionPaymentPayloadTlv.PathKey].nonEmpty
case None => 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))
}
}

private case class AttributableHtlcFailure(encryptedReason: ByteVector, attribution_opt: Option[ByteVector])

private def buildHtlcFailure(nodeSecret: PrivateKey, reason: FailureReason, add: UpdateAddHtlc, holdTime: FiniteDuration): Either[CannotExtractSharedSecret, AttributableHtlcFailure] = {
extractSharedSecret(nodeSecret, add).map(ss => {
reason match {
case FailureReason.EncryptedDownstreamFailure(packet, downstreamAttribution_opt) =>
val attribution = Sphinx.Attribution.create(downstreamAttribution_opt, Some(packet), holdTime, ss.outerOnionSecret)
AttributableHtlcFailure(Sphinx.FailurePacket.wrap(packet, ss.outerOnionSecret), Some(attribution))
case FailureReason.LocalFailure(failure) =>
val packet = Sphinx.FailurePacket.create(ss.outerOnionSecret, failure)
val attribution = Sphinx.Attribution.create(downstreamAttribution_opt = None, Some(packet), holdTime, ss.outerOnionSecret)
AttributableHtlcFailure(Sphinx.FailurePacket.wrap(packet, ss.outerOnionSecret), Some(attribution))
}
})
}

def buildHtlcFailure(nodeSecret: PrivateKey, useAttributableFailures: Boolean, cmd: CMD_FAIL_HTLC, add: UpdateAddHtlc, now: TimestampMilli = TimestampMilli.now()): Either[CannotExtractSharedSecret, HtlcFailureMessage] = {
add.pathKey_opt match {
case Some(_) =>
// We are part of a blinded route and we're not the introduction node.
// We return a standard error that doesn't disclose any information without any attribution data.
val failure = InvalidOnionBlinding(Sphinx.hash(add.onionRoutingPacket))
Right(UpdateFailMalformedHtlc(add.channelId, add.id, failure.onionHash, failure.code))
case None =>
// If the htlcReceivedAt was lost (because the node restarted), we use a hold time of 0 which should be ignored by the payer.
val holdTime = cmd.attribution_opt.map(now - _.htlcReceivedAt).getOrElse(0 millisecond)
buildHtlcFailure(nodeSecret, useAttributableFailures, cmd.reason, add, holdTime).map {
case (encryptedReason, tlvs) => UpdateFailHtlc(add.channelId, cmd.id, encryptedReason, tlvs)
// If the attribution was lost (because the node restarted), we use a hold time of 0 which should be ignored by the payer.
val holdTime = cmd.attribution_opt.map(a => now - a.htlcReceivedAt).getOrElse(0 millisecond)
buildHtlcFailure(nodeSecret, cmd.reason, add, holdTime).map { f =>
val tlvs: Set[UpdateFailHtlcTlv] = Set(
if (useAttributableFailures) f.attribution_opt.map(UpdateFailHtlcTlv.AttributionData(_)) else None
).flatten
UpdateFailHtlc(add.channelId, cmd.id, f.encryptedReason, TlvStream(tlvs))
}
}
}

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 populate attribution data.
val tlvs: TlvStream[UpdateFulfillHtlcTlv] = if (useAttributionData && add.pathKey_opt.isEmpty) {
extractSharedSecret(nodeSecret, add) match {
case Left(_) => TlvStream.empty
case Right(sharedSecret) =>
val holdTime = cmd.attribution_opt.map(now - _.htlcReceivedAt).getOrElse(0 millisecond)
TlvStream(UpdateFulfillHtlcTlv.AttributionData(Sphinx.Attribution.create(cmd.attribution_opt.flatMap(_.downstreamAttribution_opt), None, holdTime, sharedSecret)))
}
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 {
TlvStream.empty
// 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 = {
// 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)
}
UpdateFulfillHtlc(add.channelId, cmd.id, cmd.r, tlvs)
val tlvs: Set[UpdateFulfillHtlcTlv] = Set(
attributionData_opt.map(UpdateFulfillHtlcTlv.AttributionData(_)),
fulfillmentPayload_opt.map(UpdateFulfillHtlcTlv.FulfillmentPayload(_)),
).flatten
UpdateFulfillHtlc(add.channelId, cmd.id, cmd.r, TlvStream(tlvs))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ class MultiPartHandler(nodeParams: NodeParams, register: ActorRef, db: IncomingP
val received = PaymentReceived(paymentHash, PaymentEvent.IncomingPayment(p.htlc.channelId, p.remoteNodeId, p.amount, p.receivedAt) :: Nil)
if (db.receiveIncomingPayment(paymentHash, p.amount, received.settledAt)) {
val attribution = FulfillAttributionData(htlcReceivedAt = p.receivedAt, trampolineReceivedAt_opt = None, downstreamAttribution_opt = None)
PendingCommandsDb.safeSend(register, nodeParams.db.pendingCommands, p.htlc.channelId, CMD_FULFILL_HTLC(p.htlc.id, record.paymentPreimage, Some(attribution), commit = true))
PendingCommandsDb.safeSend(register, nodeParams.db.pendingCommands, p.htlc.channelId, CMD_FULFILL_HTLC(p.htlc.id, record.paymentPreimage, None, Some(attribution), commit = true))
ctx.system.eventStream.publish(received)
} else {
val attribution = FailureAttributionData(htlcReceivedAt = p.receivedAt, trampolineReceivedAt_opt = None)
Expand Down Expand Up @@ -223,7 +223,7 @@ class MultiPartHandler(nodeParams: NodeParams, register: ActorRef, db: IncomingP
parts.collect {
case p: MultiPartPaymentFSM.HtlcPart =>
val attribution = FulfillAttributionData(htlcReceivedAt = p.receivedAt, trampolineReceivedAt_opt = None, downstreamAttribution_opt = None)
PendingCommandsDb.safeSend(register, nodeParams.db.pendingCommands, p.htlc.channelId, CMD_FULFILL_HTLC(p.htlc.id, payment.paymentPreimage, Some(attribution), commit = true))
PendingCommandsDb.safeSend(register, nodeParams.db.pendingCommands, p.htlc.channelId, CMD_FULFILL_HTLC(p.htlc.id, payment.paymentPreimage, None, Some(attribution), commit = true))
}
postFulfill(received)
ctx.system.eventStream.publish(received)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ class ChannelRelay private(nodeParams: NodeParams,
case HtlcResult.OnChainFulfill(_) => None
}
val attribution = FulfillAttributionData(htlcReceivedAt = upstream.receivedAt, trampolineReceivedAt_opt = None, downstreamAttribution_opt = downstreamAttribution_opt)
val cmd = CMD_FULFILL_HTLC(upstream.add.id, fulfill.paymentPreimage, Some(attribution), commit = true)
val cmd = CMD_FULFILL_HTLC(upstream.add.id, fulfill.paymentPreimage, fulfill.fulfillmentPayload_opt, Some(attribution), commit = true)
val incoming = PaymentEvent.IncomingPayment(upstream.add.channelId, upstream.receivedFrom, upstream.amountIn, upstream.receivedAt)
val outgoing = PaymentEvent.OutgoingPayment(htlc.channelId, remoteNodeId, htlc.amountMsat, now)
context.system.eventStream ! EventStream.Publish(ChannelPaymentRelayed(htlc.paymentHash, Seq(incoming), Seq(outgoing)))
Expand Down
Loading
Loading