Skip to content

Commit

Permalink
Build onion messages (#623)
Browse files Browse the repository at this point in the history
Build onion messages and routes for onion messages. Also make blinded paths mandatory when building offers as we are a wallet hidden behind a trampoline node.

Co-authored-by: t-bast <[email protected]>
  • Loading branch information
thomash-acinq and t-bast authored Apr 12, 2024
1 parent cb0f5c5 commit 2cffdd7
Show file tree
Hide file tree
Showing 9 changed files with 844 additions and 143 deletions.
29 changes: 12 additions & 17 deletions src/commonMain/kotlin/fr/acinq/lightning/crypto/RouteBlinding.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,10 @@ import fr.acinq.lightning.crypto.sphinx.Sphinx
object RouteBlinding {

/**
* @param nodeId introduction node's id (which cannot be blinded since the sender need to find a route to it).
* @param blindedPublicKey blinded public key, which hides the real public key.
* @param blindingEphemeralKey blinding tweak that can be used by the receiving node to derive the private key that
* matches the blinded public key.
* @param encryptedPayload encrypted payload that can be decrypted with the introduction node's private key and the
* blinding ephemeral key.
* @param nodeId introduction node's id (which cannot be blinded since the sender need to find a route to it).
* @param blindedPublicKey blinded public key, which hides the real public key.
* @param blindingEphemeralKey blinding tweak that can be used by the receiving node to derive the private key that matches the blinded public key.
* @param encryptedPayload encrypted payload that can be decrypted with the introduction node's private key and the blinding ephemeral key.
*/
data class IntroductionNode(
val nodeId: EncodedNodeId,
Expand All @@ -26,16 +24,14 @@ object RouteBlinding {

/**
* @param blindedPublicKey blinded public key, which hides the real public key.
* @param encryptedPayload encrypted payload that can be decrypted with the receiving node's private key and the
* blinding ephemeral key.
* @param encryptedPayload encrypted payload that can be decrypted with the receiving node's private key and the blinding ephemeral key.
*/
data class BlindedNode(val blindedPublicKey: PublicKey, val encryptedPayload: ByteVector)

/**
* @param introductionNodeId the first node, not 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).
* @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).
*/
data class BlindedRoute(
val introductionNodeId: EncodedNodeId,
Expand All @@ -58,7 +54,7 @@ object RouteBlinding {
*
* @param sessionKey this node's session key.
* @param publicKeys public keys of each node on the route, starting from the introduction point.
* @param payloads payloads that should be encrypted for each node on the route.
* @param payloads payloads that should be encrypted for each node on the route.
* @return a blinded route.
*/
fun create(sessionKey: PrivateKey, publicKeys: List<PublicKey>, payloads: List<ByteVector>): BlindedRoute {
Expand All @@ -85,7 +81,7 @@ object RouteBlinding {
/**
* Compute the blinded private key that must be used to decrypt an incoming blinded onion.
*
* @param privateKey this node's private key.
* @param privateKey this node's private key.
* @param blindingEphemeralKey unblinding ephemeral key.
* @return this node's blinded private key.
*/
Expand All @@ -97,9 +93,9 @@ object RouteBlinding {
/**
* Decrypt the encrypted payload (usually found in the onion) that contains instructions to locate the next node.
*
* @param privateKey this node's private key.
* @param privateKey this node's private key.
* @param blindingEphemeralKey unblinding ephemeral key.
* @param encryptedPayload encrypted payload for this node.
* @param encryptedPayload encrypted payload for this node.
* @return a tuple (decrypted payload, unblinding ephemeral key for the next node)
*/
fun decryptPayload(
Expand All @@ -116,8 +112,7 @@ object RouteBlinding {
byteArrayOf(),
encryptedPayload.takeRight(16).toByteArray()
)
val nextBlindingEphemeralKey =
Sphinx.blind(blindingEphemeralKey, Sphinx.computeBlindingFactor(blindingEphemeralKey, sharedSecret))
val nextBlindingEphemeralKey = Sphinx.blind(blindingEphemeralKey, Sphinx.computeBlindingFactor(blindingEphemeralKey, sharedSecret))
return Pair(ByteVector(decrypted), nextBlindingEphemeralKey)
}
}
181 changes: 181 additions & 0 deletions src/commonMain/kotlin/fr/acinq/lightning/message/OnionMessages.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
package fr.acinq.lightning.message

import fr.acinq.bitcoin.ByteVector
import fr.acinq.bitcoin.PrivateKey
import fr.acinq.bitcoin.PublicKey
import fr.acinq.bitcoin.byteVector
import fr.acinq.bitcoin.utils.Either
import fr.acinq.lightning.EncodedNodeId
import fr.acinq.lightning.ShortChannelId
import fr.acinq.lightning.crypto.RouteBlinding
import fr.acinq.lightning.crypto.sphinx.Sphinx
import fr.acinq.lightning.logging.MDCLogger
import fr.acinq.lightning.utils.toByteVector
import fr.acinq.lightning.wire.*

object OnionMessages {
data class IntermediateNode(val nodeId: PublicKey, val outgoingChannelId: ShortChannelId? = null, val padding: ByteVector? = null, val customTlvs: Set<GenericTlv> = setOf()) {
fun toTlvStream(nextNodeId: EncodedNodeId, nextBlinding: PublicKey? = null): TlvStream<RouteBlindingEncryptedDataTlv> {
val tlvs = setOfNotNull(
outgoingChannelId?.let { RouteBlindingEncryptedDataTlv.OutgoingChannelId(it) } ?: RouteBlindingEncryptedDataTlv.OutgoingNodeId(nextNodeId),
nextBlinding?.let { RouteBlindingEncryptedDataTlv.NextBlinding(it) },
padding?.let { RouteBlindingEncryptedDataTlv.Padding(it) },
)
return TlvStream(tlvs, customTlvs)
}
}

sealed class Destination {
data class BlindedPath(val route: RouteBlinding.BlindedRoute) : Destination()
data class Recipient(val nodeId: PublicKey, val pathId: ByteVector?, val padding: ByteVector? = null, val customTlvs: Set<GenericTlv> = setOf()) : Destination()

companion object {
operator fun invoke(contactInfo: OfferTypes.ContactInfo): Destination =
when (contactInfo) {
is OfferTypes.ContactInfo.BlindedPath -> BlindedPath(contactInfo.route)
is OfferTypes.ContactInfo.RecipientNodeId -> Recipient(contactInfo.nodeId, null)
}
}
}

private fun buildIntermediatePayloads(
intermediateNodes: List<IntermediateNode>,
lastNodeId: EncodedNodeId,
lastBlinding: PublicKey? = null
): List<ByteVector> {
return if (intermediateNodes.isEmpty()) {
listOf()
} else {
val intermediatePayloads = intermediateNodes.dropLast(1).zip(intermediateNodes.drop(1)).map { (current, next) -> current.toTlvStream(EncodedNodeId(next.nodeId)) }
// The last intermediate node may contain a blinding override when the recipient is hidden behind a blinded path.
val lastPayload = intermediateNodes.last().toTlvStream(lastNodeId, lastBlinding)
(intermediatePayloads + lastPayload).map { RouteBlindingEncryptedData(it).write().byteVector() }
}
}

fun buildRoute(
blindingSecret: PrivateKey,
intermediateNodes: List<IntermediateNode>,
destination: Destination
): RouteBlinding.BlindedRoute {
return when (destination) {
is Destination.Recipient -> {
val intermediatePayloads = buildIntermediatePayloads(intermediateNodes, EncodedNodeId(destination.nodeId))
val tlvs = setOfNotNull(
destination.padding?.let { RouteBlindingEncryptedDataTlv.Padding(it) },
destination.pathId?.let { RouteBlindingEncryptedDataTlv.PathId(it) }
)
val lastPayload = RouteBlindingEncryptedData(TlvStream(tlvs, destination.customTlvs)).write().toByteVector()
RouteBlinding.create(
blindingSecret,
intermediateNodes.map { it.nodeId } + destination.nodeId,
intermediatePayloads + lastPayload
)
}
is Destination.BlindedPath -> when {
intermediateNodes.isEmpty() -> destination.route
else -> {
// We concatenate our blinded path with the destination's blinded path.
val intermediatePayloads = buildIntermediatePayloads(
intermediateNodes,
destination.route.introductionNodeId,
destination.route.blindingKey
)
val routePrefix = RouteBlinding.create(
blindingSecret,
intermediateNodes.map { it.nodeId },
intermediatePayloads
)
RouteBlinding.BlindedRoute(
routePrefix.introductionNodeId,
routePrefix.blindingKey,
routePrefix.blindedNodes + destination.route.blindedNodes
)
}
}
}
}

sealed class BuildMessageError
data class MessageTooLarge(val payloadSize: Int) : BuildMessageError()

/**
* Builds an encrypted onion containing a message that should be relayed to the destination.
*
* @param sessionKey a random key to encrypt the onion.
* @param blindingSecret a random key to create the blinded path.
* @param intermediateNodes list of intermediate nodes between us and the destination (can be empty if we want to contact the destination directly).
* @param destination the destination of this message, can be a node id or a blinded route.
* @param content list of TLVs to send to the recipient of the message.
*/
fun buildMessage(
sessionKey: PrivateKey,
blindingSecret: PrivateKey,
intermediateNodes: List<IntermediateNode>,
destination: Destination,
content: TlvStream<OnionMessagePayloadTlv>
): Either<BuildMessageError, OnionMessage> {
val route = buildRoute(blindingSecret, intermediateNodes, destination)
val payloads = buildList {
// Intermediate nodes only receive blinded path relay information.
addAll(route.encryptedPayloads.dropLast(1).map { MessageOnion(TlvStream(OnionMessagePayloadTlv.EncryptedData(it))).write() })
// The destination receives the message contents and the blinded path information.
add(MessageOnion(content.copy(records = content.records + OnionMessagePayloadTlv.EncryptedData(route.encryptedPayloads.last()))).write())
}
val payloadSize = payloads.sumOf { it.size + Sphinx.MacLength }
val packetSize = when {
payloadSize <= 1300 -> 1300
payloadSize <= 32768 -> 32768
payloadSize <= 65432 -> 65432 // this corresponds to a total lightning message size of 65535
else -> return Either.Left(MessageTooLarge(payloadSize))
}
// Since we are setting the packet size based on the payload, the onion creation should never fail.
val packet = Sphinx.create(
sessionKey,
route.blindedNodes.map { it.blindedPublicKey },
payloads,
associatedData = null,
packetSize
).packet
return Either.Right(OnionMessage(route.blindingKey, packet))
}

/**
* @param content message received.
* @param blindedPrivateKey private key of the blinded node id used in our blinded path.
* @param pathId path_id that we included in our blinded path for ourselves.
*/
data class DecryptedMessage(val content: MessageOnion, val blindedPrivateKey: PrivateKey, val pathId: ByteVector)

fun decryptMessage(privateKey: PrivateKey, msg: OnionMessage, logger: MDCLogger): DecryptedMessage? {
val blindedPrivateKey = RouteBlinding.derivePrivateKey(privateKey, msg.blindingKey)
return when (val decrypted = Sphinx.peel(blindedPrivateKey, associatedData = ByteVector.empty, msg.onionRoutingPacket)) {
is Either.Right -> try {
val message = MessageOnion.read(decrypted.value.payload.toByteArray())
val (decryptedPayload, nextBlinding) = RouteBlinding.decryptPayload(
privateKey,
msg.blindingKey,
message.encryptedData
)
val relayInfo = RouteBlindingEncryptedData.read(decryptedPayload.toByteArray())
if (!decrypted.value.isLastPacket && relayInfo.nextNodeId == EncodedNodeId(privateKey.publicKey())) {
// We may add ourselves to the route several times at the end to hide the real length of the route.
val nextMessage = OnionMessage(relayInfo.nextBlindingOverride ?: nextBlinding, decrypted.value.nextPacket)
decryptMessage(privateKey, nextMessage, logger)
} else if (decrypted.value.isLastPacket && relayInfo.pathId != null) {
DecryptedMessage(message, blindedPrivateKey, relayInfo.pathId)
} else {
logger.warning { "ignoring onion message for which we're not the destination (next_node_id=${relayInfo.nextNodeId}, path_id=${relayInfo.pathId?.toHex()})" }
null
}
} catch (e: Throwable) {
logger.warning { "ignoring onion message that couldn't be decoded: ${e.message}" }
null
}
is Either.Left -> {
logger.warning { "ignoring onion message that couldn't be decrypted: ${decrypted.value.message}" }
null
}
}
}
}
3 changes: 3 additions & 0 deletions src/commonMain/kotlin/fr/acinq/lightning/wire/MessageOnion.kt
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,9 @@ data class MessageOnion(val records: TlvStream<OnionMessagePayloadTlv>) {
true, @Suppress("UNCHECKED_CAST") mapOf(
OnionMessagePayloadTlv.ReplyPath.tag to OnionMessagePayloadTlv.ReplyPath.Companion as TlvValueReader<OnionMessagePayloadTlv>,
OnionMessagePayloadTlv.EncryptedData.tag to OnionMessagePayloadTlv.EncryptedData.Companion as TlvValueReader<OnionMessagePayloadTlv>,
OnionMessagePayloadTlv.InvoiceRequest.tag to OnionMessagePayloadTlv.InvoiceRequest.Companion as TlvValueReader<OnionMessagePayloadTlv>,
OnionMessagePayloadTlv.Invoice.tag to OnionMessagePayloadTlv.Invoice.Companion as TlvValueReader<OnionMessagePayloadTlv>,
OnionMessagePayloadTlv.InvoiceError.tag to OnionMessagePayloadTlv.InvoiceError.Companion as TlvValueReader<OnionMessagePayloadTlv>
)
)

Expand Down
Loading

0 comments on commit 2cffdd7

Please sign in to comment.