Skip to content

Commit

Permalink
Add basic support for onion messages
Browse files Browse the repository at this point in the history
- Relay onion messages
- Add functions to create onion messages
  • Loading branch information
thomash-acinq committed Nov 5, 2021
1 parent b45dd00 commit 1b47940
Show file tree
Hide file tree
Showing 21 changed files with 591 additions and 40 deletions.
4 changes: 4 additions & 0 deletions docs/release-notes/eclair-vnext.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ Node operators should watch this file very regularly.
An event is also sent to the event stream for every such notification.
This lets plugins notify the node operator via external systems (push notifications, email, etc).

### Initial support for onion messages

Eclair now supports the feature `option_onion_messages`. If this feature is enabled, eclair will relay onion messages, initiating or receiving onion messages is not supported yet.

### API changes

#### Timestamps
Expand Down
1 change: 1 addition & 0 deletions eclair-core/src/main/resources/reference.conf
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ eclair {
option_anchor_outputs = disabled
option_anchors_zero_fee_htlc_tx = disabled
option_shutdown_anysegwit = optional
option_onion_messages = disabled
trampoline_payment = disabled
keysend = disabled
}
Expand Down
6 changes: 6 additions & 0 deletions eclair-core/src/main/scala/fr/acinq/eclair/Features.scala
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,11 @@ object Features {
val mandatory = 26
}

case object OnionMessages extends Feature {
val rfcName = "option_onion_messages"
val mandatory = 38
}

// TODO: @t-bast: update feature bits once spec-ed (currently reserved here: https://github.com/lightningnetwork/lightning-rfc/issues/605)
// We're not advertising these bits yet in our announcements, clients have to assume support.
// This is why we haven't added them yet to `areSupported`.
Expand Down Expand Up @@ -231,6 +236,7 @@ object Features {
AnchorOutputs,
AnchorOutputsZeroFeeHtlcTx,
ShutdownAnySegwit,
OnionMessages,
KeySend
)

Expand Down
33 changes: 20 additions & 13 deletions eclair-core/src/main/scala/fr/acinq/eclair/crypto/Sphinx.scala
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import fr.acinq.eclair.wire.protocol._
import grizzled.slf4j.Logging
import scodec.Attempt
import scodec.bits.ByteVector
import scodec.codecs.provide

import scala.annotation.tailrec
import scala.util.{Failure, Success, Try}
Expand Down Expand Up @@ -162,12 +163,12 @@ object Sphinx extends Logging {
* failure messages upstream.
* or a BadOnion error containing the hash of the invalid onion.
*/
def peel(privateKey: PrivateKey, associatedData: ByteVector, packet: OnionRoutingPacket): Either[BadOnion, DecryptedPacket] = packet.version match {
def peel(privateKey: PrivateKey, associatedData: Option[ByteVector32], packet: OnionRoutingPacket): Either[BadOnion, DecryptedPacket] = packet.version match {
case 0 => Try(PublicKey(packet.publicKey, checkValid = true)) match {
case Success(packetEphKey) =>
val sharedSecret = computeSharedSecret(packetEphKey, privateKey)
val mu = generateKey("mu", sharedSecret)
val check = mac(mu, packet.payload ++ associatedData)
val check = mac(mu, associatedData.map(packet.payload ++ _).getOrElse(packet.payload))
if (check == packet.hmac) {
val rho = generateKey("rho", sharedSecret)
// Since we don't know the length of the per-hop payload (we will learn it once we decode the first bytes),
Expand Down Expand Up @@ -208,7 +209,7 @@ object Sphinx extends Logging {
* @param onionPayloadFiller optional onion payload filler, needed only when you're constructing the last packet.
* @return the next packet.
*/
def wrap(payload: ByteVector, associatedData: ByteVector32, ephemeralPublicKey: PublicKey, sharedSecret: ByteVector32, packet: Either[ByteVector, OnionRoutingPacket], onionPayloadFiller: ByteVector = ByteVector.empty): OnionRoutingPacket = {
def wrap(payload: ByteVector, associatedData: Option[ByteVector32], ephemeralPublicKey: PublicKey, sharedSecret: ByteVector32, packet: Either[ByteVector, OnionRoutingPacket], onionPayloadFiller: ByteVector = ByteVector.empty): OnionRoutingPacket = {
val packetPayloadLength = packet match {
case Left(startingBytes) => startingBytes.length.toInt
case Right(p) => p.payload.length.toInt
Expand All @@ -226,7 +227,7 @@ object Sphinx extends Logging {
onionPayload2.dropRight(onionPayloadFiller.length) ++ onionPayloadFiller
}

val nextHmac = mac(generateKey("mu", sharedSecret), nextOnionPayload ++ associatedData)
val nextHmac = mac(generateKey("mu", sharedSecret), associatedData.map(nextOnionPayload ++ _).getOrElse(nextOnionPayload))
val nextPacket = OnionRoutingPacket(Version, ephemeralPublicKey.value, nextOnionPayload, nextHmac)
nextPacket
}
Expand All @@ -242,7 +243,7 @@ object Sphinx extends Logging {
* @return An onion packet with all shared secrets. The onion packet can be sent to the first node in the list, and
* the shared secrets (one per node) can be used to parse returned failure messages if needed.
*/
def create(sessionKey: PrivateKey, packetPayloadLength: Int, publicKeys: Seq[PublicKey], payloads: Seq[ByteVector], associatedData: ByteVector32): PacketAndSecrets = {
def create(sessionKey: PrivateKey, packetPayloadLength: Int, publicKeys: Seq[PublicKey], payloads: Seq[ByteVector], associatedData: Option[ByteVector32]): PacketAndSecrets = {
require(payloads.map(_.length + MacLength).sum <= packetPayloadLength, s"packet per-hop payloads cannot exceed $packetPayloadLength bytes")
val (ephemeralPublicKeys, sharedsecrets) = computeEphemeralPublicKeysAndSharedSecrets(sessionKey, publicKeys)
val filler = generateFiller("rho", packetPayloadLength, sharedsecrets.dropRight(1), payloads.dropRight(1))
Expand All @@ -267,7 +268,7 @@ object Sphinx extends Logging {
* When an invalid onion is received, its hash should be included in the failure message.
*/
def hash(onion: OnionRoutingPacket): ByteVector32 =
Crypto.sha256(OnionRoutingCodecs.onionRoutingPacketCodec(onion.payload.length.toInt).encode(onion).require.toByteVector)
Crypto.sha256(OnionRoutingCodecs.onionRoutingPacketCodec(provide(onion.payload.length.toInt)).encode(onion).require.toByteVector)

/**
* A properly decrypted failure from a node in the route.
Expand Down Expand Up @@ -374,12 +375,19 @@ object Sphinx extends Logging {
case class BlindedNode(blindedPublicKey: PublicKey, encryptedPayload: ByteVector)

/**
* @param introductionNode the first node should not be blinded, otherwise the sender cannot locate it.
* @param blindedNodes blinded nodes (not including the introduction node).
* @param introductionNodeId the first node, not be blinded so that the sender can locate it.
* @param blindingKey blinding tweak that can be used by the introduction node to derive the private key that
* matches the blinded public key.
* @param blindedNodes blinded nodes (including the introduction node).
*/
case class BlindedRoute(introductionNode: IntroductionNode, blindedNodes: Seq[BlindedNode]) {
val nodeIds: Seq[PublicKey] = introductionNode.publicKey +: blindedNodes.map(_.blindedPublicKey)
val encryptedPayloads: Seq[ByteVector] = introductionNode.encryptedPayload +: blindedNodes.map(_.encryptedPayload)
case class BlindedRoute(introductionNodeId: PublicKey, blindingKey: PublicKey, blindedNodes: Seq[BlindedNode]) {
val introductionNode: IntroductionNode = IntroductionNode(introductionNodeId, blindedNodes.head.blindedPublicKey, blindingKey, blindedNodes.head.encryptedPayload)
val subsequentNodes: Seq[BlindedNode] = blindedNodes.tail

val blindedNodeIds: Seq[PublicKey] = blindedNodes.map(_.blindedPublicKey)
val encryptedPayloads: Seq[ByteVector] = blindedNodes.map(_.encryptedPayload)

val nodeIds: Seq[PublicKey] = introductionNodeId +: blindedNodeIds.tail
}

/**
Expand All @@ -402,8 +410,7 @@ object Sphinx extends Logging {
e = e.multiply(PrivateKey(Crypto.sha256(blindingKey.value ++ sharedSecret.bytes)))
(BlindedNode(blindedPublicKey, encryptedPayload ++ mac), blindingKey)
}.unzip
val introductionNode = IntroductionNode(publicKeys.head, blindedHops.head.blindedPublicKey, blindingKeys.head, blindedHops.head.encryptedPayload)
BlindedRoute(introductionNode, blindedHops.tail)
BlindedRoute(publicKeys.head, blindingKeys.head, blindedHops)
}

/**
Expand Down
43 changes: 39 additions & 4 deletions eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,10 @@ import fr.acinq.eclair.blockchain.{OnChainAddressGenerator, OnChainChannelFunder
import fr.acinq.eclair.channel._
import fr.acinq.eclair.io.Monitoring.Metrics
import fr.acinq.eclair.io.PeerConnection.KillReason
import fr.acinq.eclair.message.OnionMessages
import fr.acinq.eclair.remote.EclairInternalsSerializer.RemoteTypes
import fr.acinq.eclair.wire.protocol
import fr.acinq.eclair.wire.protocol.{Error, HasChannelId, HasTemporaryChannelId, LightningMessage, NodeAddress, RoutingMessage, UnknownMessage, Warning}
import fr.acinq.eclair.wire.protocol.{Error, HasChannelId, HasTemporaryChannelId, LightningMessage, NodeAddress, OnionMessage, RoutingMessage, UnknownMessage, Warning}
import scodec.bits.ByteVector

import java.net.InetSocketAddress
Expand Down Expand Up @@ -65,7 +66,7 @@ class Peer(val nodeParams: NodeParams, remoteNodeId: PublicKey, wallet: OnChainA
FinalChannelId(state.channelId) -> channel
}.toMap

goto(DISCONNECTED) using DisconnectedData(channels) // when we restart, we will attempt to reconnect right away, but then we'll wait
goto(DISCONNECTED) using DisconnectedData(channels, None) // when we restart, we will attempt to reconnect right away, but then we'll wait
}

when(DISCONNECTED) {
Expand All @@ -74,6 +75,7 @@ class Peer(val nodeParams: NodeParams, remoteNodeId: PublicKey, wallet: OnChainA
stay()

case Event(connectionReady: PeerConnection.ConnectionReady, d: DisconnectedData) =>
d.messageToRelay.foreach(msg => self ! Peer.SendOnionMessage(remoteNodeId, msg))
gotoConnected(connectionReady, d.channels.map { case (k: ChannelId, v) => (k, v) })

case Event(Terminated(actor), d: DisconnectedData) if d.channels.exists(_._2 == actor) =>
Expand All @@ -94,6 +96,19 @@ class Peer(val nodeParams: NodeParams, remoteNodeId: PublicKey, wallet: OnChainA
stay() using d.copy(channels = d.channels + (FinalChannelId(channelId) -> channel))

case Event(_: LightningMessage, _) => stay() // we probably just got disconnected and that's the last messages we received

case Event(Peer.SendOnionMessage(toNodeId, msg), d: DisconnectedData) if toNodeId == remoteNodeId =>
// We may drop a previous messageToRelay but that's fine. If we receive several messages to relay while trying to connect, we're probably getting spammed.
stay() using d.copy(messageToRelay = Some(msg))

case Event(_ : PeerConnection.ConnectionResult.Failure, d: DisconnectedData) =>
if (d.channels.isEmpty) {
// we don't have any channel with this peer and we can't connect to it so we just drop it
stopPeer()
} else {
stay() using d.copy(messageToRelay = None)
}

}

when(CONNECTED) {
Expand Down Expand Up @@ -223,7 +238,7 @@ class Peer(val nodeParams: NodeParams, remoteNodeId: PublicKey, wallet: OnChainA
stopPeer()
} else {
d.channels.values.toSet[ActorRef].foreach(_ ! INPUT_DISCONNECTED) // we deduplicate with toSet because there might be two entries per channel (tmp id and final id)
goto(DISCONNECTED) using DisconnectedData(d.channels.collect { case (k: FinalChannelId, v) => (k, v) })
goto(DISCONNECTED) using DisconnectedData(d.channels.collect { case (k: FinalChannelId, v) => (k, v) }, None)
}

case Event(Terminated(actor), d: ConnectedData) if d.channels.values.toSet.contains(actor) =>
Expand All @@ -243,6 +258,20 @@ class Peer(val nodeParams: NodeParams, remoteNodeId: PublicKey, wallet: OnChainA
d.channels.values.toSet[ActorRef].foreach(_ ! INPUT_DISCONNECTED) // we deduplicate with toSet because there might be two entries per channel (tmp id and final id)
gotoConnected(connectionReady, d.channels)

case Event(msg: OnionMessage, _: ConnectedData) =>
if (nodeParams.features.hasFeature(Features.OnionMessages)) {
OnionMessages.process(nodeParams.privateKey, msg) match {
case OnionMessages.DropMessage(_) => () // We ignore bad messages
case OnionMessages.RelayMessage(nextNodeId, dataToRelay) => context.parent ! Peer.SendOnionMessage(nextNodeId, dataToRelay)
case OnionMessages.ReceiveMessage(_, _) => () // We only relay messages
}
}
stay()

case Event(Peer.SendOnionMessage(toNodeId, msg), d: ConnectedData) if toNodeId == remoteNodeId =>
d.peerConnection ! msg
stay()

case Event(unknownMsg: UnknownMessage, d: ConnectedData) if nodeParams.pluginMessageTags.contains(unknownMsg.tag) =>
context.system.eventStream.publish(UnknownMessageReceived(self, remoteNodeId, unknownMsg, d.connectionInfo))
stay()
Expand Down Expand Up @@ -407,10 +436,14 @@ object Peer {
def channels: Map[_ <: ChannelId, ActorRef] // will be overridden by Map[FinalChannelId, ActorRef] or Map[ChannelId, ActorRef]
}
case object Nothing extends Data { override def channels = Map.empty }
case class DisconnectedData(channels: Map[FinalChannelId, ActorRef]) extends Data

case class DisconnectedData(channels: Map[FinalChannelId, ActorRef], messageToRelay: Option[OnionMessage]) extends Data

case class ConnectedData(address: InetSocketAddress, peerConnection: ActorRef, localInit: protocol.Init, remoteInit: protocol.Init, channels: Map[ChannelId, ActorRef]) extends Data {
val connectionInfo: ConnectionInfo = ConnectionInfo(address, peerConnection, localInit, remoteInit)

def localFeatures: Features = localInit.features

def remoteFeatures: Features = remoteInit.features
}

Expand All @@ -427,6 +460,8 @@ object Peer {
def apply(uri: NodeURI): Connect = new Connect(uri.nodeId, Some(uri.address))
}

case class SendOnionMessage(nodeId: PublicKey, message: OnionMessage) extends PossiblyHarmful

case class Disconnect(nodeId: PublicKey) extends PossiblyHarmful
case class OpenChannel(remoteNodeId: PublicKey, fundingSatoshis: Satoshi, pushMsat: MilliSatoshi, channelType_opt: Option[SupportedChannelType], fundingTxFeeratePerKw_opt: Option[FeeratePerKw], channelFlags: Option[Byte], timeout_opt: Option[Timeout]) extends PossiblyHarmful {
require(pushMsat <= fundingSatoshis, s"pushMsat must be less or equal to fundingSatoshis")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ class Switchboard(nodeParams: NodeParams, peerFactory: Switchboard.PeerFactory)
case None => sender() ! Status.Failure(new RuntimeException("peer not found"))
}

case s@Peer.SendOnionMessage(nodeId, _) =>
val peer = createOrGetPeer(nodeId, offlineChannels = Set.empty)
peer.tell(Peer.Connect(nodeId, None), peer)
peer forward s

case o: Peer.OpenChannel =>
getPeer(o.remoteNodeId) match {
case Some(peer) => peer forward o
Expand Down
Loading

0 comments on commit 1b47940

Please sign in to comment.