Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
31 changes: 29 additions & 2 deletions eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import akka.actor.ActorRef
import akka.pattern._
import akka.util.Timeout
import fr.acinq.bitcoin.Crypto.PublicKey
import fr.acinq.bitcoin.{ByteVector32, Crypto, Satoshi}
import fr.acinq.bitcoin.{ByteVector32, ByteVector64, Crypto, Satoshi}
import fr.acinq.eclair.TimestampQueryFilters._
import fr.acinq.eclair.blockchain.OnChainBalance
import fr.acinq.eclair.blockchain.bitcoind.BitcoinCoreWallet
Expand All @@ -38,7 +38,7 @@ import fr.acinq.eclair.payment.relay.Relayer.{GetOutgoingChannels, OutgoingChann
import fr.acinq.eclair.payment.send.PaymentInitiator.{SendPaymentRequest, SendPaymentToRouteRequest, SendPaymentToRouteResponse}
import fr.acinq.eclair.router.Router._
import fr.acinq.eclair.router.{NetworkStats, RouteCalculation}
import fr.acinq.eclair.wire.{ChannelAnnouncement, ChannelUpdate, NodeAddress, NodeAnnouncement, GenericTlv}
import fr.acinq.eclair.wire._
import scodec.bits.ByteVector

import scala.concurrent.duration._
Expand All @@ -51,6 +51,10 @@ case class AuditResponse(sent: Seq[PaymentSent], received: Seq[PaymentReceived],

case class TimestampQueryFilters(from: Long, to: Long)

case class SignedMessage(nodeId: PublicKey, message: String, signature: ByteVector64)

case class VerifiedMessage(valid: Boolean, signerNodeId: Option[PublicKey])

object TimestampQueryFilters {
/** We use this in the context of timestamp filtering, when we don't need an upper bound. */
val MaxEpochMilliseconds = Duration.fromNanos(Long.MaxValue).toMillis
Expand All @@ -70,6 +74,9 @@ object ApiTypes {

trait Eclair {

// String prefixed when signing arbitrary data to ensure we don't sign sensitive material
private val messagePrefix = ByteVector("Lightning Signed Message:".getBytes)

def connect(target: Either[NodeURI, PublicKey])(implicit timeout: Timeout): Future[String]

def disconnect(nodeId: PublicKey)(implicit timeout: Timeout): Future[String]
Expand Down Expand Up @@ -134,6 +141,9 @@ trait Eclair {

def onChainTransactions(count: Int, skip: Int): Future[Iterable[WalletTransaction]]

def signMessage(base64Message: ByteVector, prefix: ByteVector = messagePrefix): SignedMessage

def verifyMessage(base64Message: ByteVector, signature: ByteVector64, prefix: ByteVector = messagePrefix): VerifiedMessage
}

class EclairImpl(appKit: Kit) extends Eclair {
Expand Down Expand Up @@ -393,4 +403,21 @@ class EclairImpl(appKit: Kit) extends Eclair {
val sendPayment = SendPaymentRequest(amount, paymentHash, recipientNodeId, maxAttempts, externalId = externalId_opt, routeParams = Some(routeParams), userCustomTlvs = keySendTlvRecords)
(appKit.paymentInitiator ? sendPayment).mapTo[UUID]
}

override def signMessage(base64Message: ByteVector, prefix: ByteVector): SignedMessage = {
val hash256Message = Crypto.hash256(prefix ++ base64Message)
val signature = appKit.nodeParams.keyManager.signDigest(hash256Message)
SignedMessage(appKit.nodeParams.keyManager.nodeId, base64Message.toBase64, signature)
}

override def verifyMessage(base64Message: ByteVector, signature: ByteVector64, prefix: ByteVector): VerifiedMessage = {
val hash256Message = Crypto.hash256(prefix ++ base64Message)
val (pubKey1, pubKey2) = Crypto.recoverPublicKey(signature, hash256Message)
if (appKit.nodeParams.db.network.getNode(pubKey1).isDefined)
VerifiedMessage(true, Some(pubKey1))
else if (appKit.nodeParams.db.network.getNode(pubKey2).isDefined)
VerifiedMessage(true, Some(pubKey2))
else
VerifiedMessage(false, None)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,15 @@ trait KeyManager {
* private key, bitcoinSig is the signature of the channel announcement with our funding private key
*/
def signChannelAnnouncement(fundingKeyPath: DeterministicWallet.KeyPath, chainHash: ByteVector32, shortChannelId: ShortChannelId, remoteNodeId: PublicKey, remoteFundingKey: PublicKey, features: Features): (ByteVector64, ByteVector64)

/**
* Sign a digest, primarily used to prove ownership of the current node
*
* @param digest SHA256 digest
* @param privateKey private key to sign with, default the one from the current node
* @return a signature generated with the private key given in parameter
*/
def signDigest(digest: ByteVector32, privateKey: PrivateKey = nodeKey.privateKey): ByteVector64
}

object KeyManager {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import com.google.common.cache.{CacheBuilder, CacheLoader, LoadingCache}
import fr.acinq.bitcoin.Crypto.{PrivateKey, PublicKey}
import fr.acinq.bitcoin.DeterministicWallet.{derivePrivateKey, _}
import fr.acinq.bitcoin.{Block, ByteVector32, ByteVector64, Crypto, DeterministicWallet}
import fr.acinq.eclair.channel.{ChannelVersion, LocalParams}
import fr.acinq.eclair.router.Announcements
import fr.acinq.eclair.transactions.Transactions
import fr.acinq.eclair.transactions.Transactions.{CommitmentFormat, TransactionWithInputInfo, TxOwner}
Expand Down Expand Up @@ -153,4 +154,6 @@ class LocalKeyManager(seed: ByteVector, chainHash: ByteVector32) extends KeyMana
val localFundingPrivKey = privateKeys.get(fundingKeyPath).privateKey
Announcements.signChannelAnnouncement(chainHash, shortChannelId, localNodeSecret, remoteNodeId, localFundingPrivKey, remoteFundingKey, features)
}

override def signDigest(digest: ByteVector32, privateKey: PrivateKey = nodeKey.privateKey): ByteVector64 = Crypto.sign(digest, privateKey)
}
95 changes: 94 additions & 1 deletion eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import fr.acinq.bitcoin.{Block, ByteVector32, ByteVector64, Crypto}
import fr.acinq.eclair.TestConstants._
import fr.acinq.eclair.blockchain.TestWallet
import fr.acinq.eclair.channel.{CMD_FORCECLOSE, Register, _}
import fr.acinq.eclair.crypto.LocalKeyManager
import fr.acinq.eclair.db._
import fr.acinq.eclair.io.Peer.OpenChannel
import fr.acinq.eclair.payment.PaymentRequest
Expand All @@ -35,7 +36,7 @@ import fr.acinq.eclair.payment.send.PaymentInitiator.{SendPaymentRequest, SendPa
import fr.acinq.eclair.router.RouteCalculationSpec.makeUpdateShort
import fr.acinq.eclair.router.Router.{GetNetworkStats, GetNetworkStatsResponse, PublicChannel}
import fr.acinq.eclair.router.{Announcements, NetworkStats, Router, Stats}
import fr.acinq.eclair.wire.{Color, NodeAnnouncement}
import fr.acinq.eclair.wire.{Color, NodeAddress, NodeAnnouncement}
import org.mockito.Mockito
import org.mockito.scalatest.IdiomaticMockito
import org.scalatest.funsuite.FixtureAnyFunSuiteLike
Expand Down Expand Up @@ -429,4 +430,96 @@ class EclairImplSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with I
assert(expectedPaymentPreimage === ByteVector32(keySendTlv.value))
}

test("sign a base64-encoded message with the node's private key") { f =>
import f._

val seed = ByteVector.fromValidHex("17b086b228025fa8f4416324b6ba2ec36e68570ae2fc3d392520969f2a9d0c1501")
val testKeyManager = new LocalKeyManager(seed, Block.RegtestGenesisBlock.hash)

val kitWithTestKeyManager = kit.copy(nodeParams = kit.nodeParams.copy(keyManager = testKeyManager))
val eclair = new EclairImpl(kitWithTestKeyManager)

val msg = ByteVector("aGVsbG8gd29ybGQ=".getBytes()) // echo -n 'hello world' | base64
val prefix = ByteVector("Lightning Signed Message:".getBytes())
val dhash256 = Crypto.hash256(prefix ++ msg)
val expectedSignature = Crypto.sign(dhash256, testKeyManager.nodeKey.privateKey)

val signedMessage: SignedMessage = eclair.signMessage(msg)
assert(signedMessage.nodeId === testKeyManager.nodeId)
assert(signedMessage.message === msg.toBase64)
assert(signedMessage.signature === expectedSignature)
assert(Crypto.verifySignature(dhash256, signedMessage.signature, testKeyManager.nodeKey.publicKey))
}

test("verify a valid signature created by an active node") { f =>
import f._

val fakeNodePrivateKey = randomKey
val fakeNodePublicKey = fakeNodePrivateKey.publicKey

val msg = ByteVector("aGVsbG8gd29ybGQ=".getBytes()) // echo -n 'hello world' | base64
val prefix = ByteVector("Lightning Signed Message:".getBytes())
val dhash256 = Crypto.hash256(prefix ++ msg)
val signature = Crypto.sign(dhash256, fakeNodePrivateKey)

forAllDbs { dbs =>
val mockNetworkDB = dbs.network()
val mockDB = mock[Databases]
mockDB.network returns mockNetworkDB

val mockNodeParams = kit.nodeParams.copy(db = mockDB)
val eclair = new EclairImpl(kit.copy(nodeParams = mockNodeParams))

val fakeNode1 = Announcements.makeNodeAnnouncement(fakeNodePrivateKey, "alice-node", Color(100.toByte, 200.toByte, 300.toByte), NodeAddress.fromParts("192.168.1.42", 42000).get :: Nil, Features.empty)
val fakeNode2 = Announcements.makeNodeAnnouncement(randomKey, "bob-node", Color(100.toByte, 200.toByte, 300.toByte), NodeAddress.fromParts("192.168.1.42", 42000).get :: Nil, Features.empty)
val fakeNode3 = Announcements.makeNodeAnnouncement(randomKey, "carol-node", Color(100.toByte, 200.toByte, 300.toByte), NodeAddress.fromParts("192.168.1.42", 42000).get :: Nil, Features.empty)
val fakeNode4 = Announcements.makeNodeAnnouncement(randomKey, "dave-node", Color(100.toByte, 200.toByte, 300.toByte), NodeAddress.fromParts("192.168.1.42", 42000).get :: Nil, Features.empty)
mockNodeParams.db.network.addNode(fakeNode1)
mockNodeParams.db.network.addNode(fakeNode2)
mockNodeParams.db.network.addNode(fakeNode3)
mockNodeParams.db.network.addNode(fakeNode4)

assert(mockNodeParams.db.network.getNode(fakeNodePublicKey).isDefined)

val verifiedMessage = eclair.verifyMessage(msg, signature)
assert(verifiedMessage.valid)
assert(verifiedMessage.signerNodeId.get === fakeNodePublicKey)
}
}

test("verify an invalid signature created by an active node") { f =>
import f._

val fakeNodePrivateKey = randomKey
val fakeNodePublicKey = fakeNodePrivateKey.publicKey

val msg = ByteVector("aGVsbG8gd29ybGQ=".getBytes()) // echo -n 'hello world' | base64
val prefix = ByteVector("Lightning Signed Message:".getBytes())
val dhash256 = Crypto.hash256(prefix ++ msg)
val signature = Crypto.sign(dhash256, fakeNodePrivateKey)

forAllDbs { dbs =>
val mockNetworkDB = dbs.network()
val mockDB = mock[Databases]
mockDB.network returns mockNetworkDB

val mockNodeParams = kit.nodeParams.copy(db = mockDB)
val eclair = new EclairImpl(kit.copy(nodeParams = mockNodeParams))

val fakeNode1 = Announcements.makeNodeAnnouncement(randomKey, "alice-node", Color(100.toByte, 200.toByte, 300.toByte), NodeAddress.fromParts("192.168.1.42", 42000).get :: Nil, Features.empty)
val fakeNode2 = Announcements.makeNodeAnnouncement(randomKey, "bob-node", Color(100.toByte, 200.toByte, 300.toByte), NodeAddress.fromParts("192.168.1.42", 42000).get :: Nil, Features.empty)
val fakeNode3 = Announcements.makeNodeAnnouncement(randomKey, "carol-node", Color(100.toByte, 200.toByte, 300.toByte), NodeAddress.fromParts("192.168.1.42", 42000).get :: Nil, Features.empty)
val fakeNode4 = Announcements.makeNodeAnnouncement(randomKey, "dave-node", Color(100.toByte, 200.toByte, 300.toByte), NodeAddress.fromParts("192.168.1.42", 42000).get :: Nil, Features.empty)
mockNodeParams.db.network.addNode(fakeNode1)
mockNodeParams.db.network.addNode(fakeNode2)
mockNodeParams.db.network.addNode(fakeNode3)
mockNodeParams.db.network.addNode(fakeNode4)

assert(mockNodeParams.db.network.getNode(fakeNodePublicKey).isEmpty)

val verifiedMessage = eclair.verifyMessage(msg, signature)
assert(!verifiedMessage.valid)
assert(verifiedMessage.signerNodeId === None)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,14 @@ import akka.http.scaladsl.marshalling.ToResponseMarshaller
import akka.http.scaladsl.model.StatusCodes.NotFound
import akka.http.scaladsl.model.{ContentTypes, HttpResponse}
import akka.http.scaladsl.server.{Directive1, Directives, MalformedFormFieldRejection, Route}
import fr.acinq.bitcoin.ByteVector32
import fr.acinq.bitcoin.{ByteVector32, ByteVector64}
import fr.acinq.bitcoin.Crypto.PublicKey
import fr.acinq.eclair.ApiTypes.ChannelIdentifier
import fr.acinq.eclair.api.FormParamExtractors._
import fr.acinq.eclair.api.JsonSupport._
import fr.acinq.eclair.payment.PaymentRequest
import fr.acinq.eclair.{MilliSatoshi, ShortChannelId}
import scodec.bits.ByteVector

import scala.concurrent.Future
import scala.util.{Failure, Success}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import java.util.UUID
import akka.http.scaladsl.unmarshalling.Unmarshaller
import akka.util.Timeout
import fr.acinq.bitcoin.Crypto.PublicKey
import fr.acinq.bitcoin.{ByteVector32, Satoshi}
import fr.acinq.bitcoin.{ByteVector32, ByteVector64, Satoshi}
import fr.acinq.eclair.api.JsonSupport._
import fr.acinq.eclair.io.NodeURI
import fr.acinq.eclair.payment.PaymentRequest
Expand Down Expand Up @@ -59,6 +59,10 @@ object FormParamExtractors {

implicit val millisatoshiUnmarshaller: Unmarshaller[String, MilliSatoshi] = Unmarshaller.strict { str => MilliSatoshi(str.toLong) }

implicit val base64DataUnmarshaller: Unmarshaller[String, ByteVector] = Unmarshaller.strict { str => ByteVector.fromValidBase64(str) }

implicit val signatureUnmarshaller: Unmarshaller[String, ByteVector64] = Unmarshaller.strict { bin => ByteVector64.fromValidHex(bin) }

private def listUnmarshaller[T](unmarshal: String => T): Unmarshaller[String, List[T]] = Unmarshaller.strict { str =>
Try(serialization.read[List[String]](str).map(unmarshal))
.recoverWith(_ => Try(str.split(",").toList.map(unmarshal)))
Expand Down
18 changes: 14 additions & 4 deletions eclair-node/src/main/scala/fr/acinq/eclair/api/Service.scala
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import akka.stream.{Materializer, OverflowStrategy}
import akka.util.Timeout
import com.google.common.net.HostAndPort
import fr.acinq.bitcoin.Crypto.PublicKey
import fr.acinq.bitcoin.{ByteVector32, Satoshi}
import fr.acinq.bitcoin.{ByteVector32, ByteVector64, Satoshi}
import fr.acinq.eclair.api.FormParamExtractors._
import fr.acinq.eclair.io.NodeURI
import fr.acinq.eclair.payment.{PaymentEvent, PaymentRequest}
Expand Down Expand Up @@ -310,10 +310,20 @@ trait Service extends ExtraDirectives with Logging {
formFields("count".as[Int].?, "skip".as[Int].?) { (count_opt, skip_opt) =>
complete(eclairApi.onChainTransactions(count_opt.getOrElse(10), skip_opt.getOrElse(0)))
}
} ~
path("signmessage") {
formFields("msg".as[ByteVector](base64DataUnmarshaller)) { base64Message =>
complete(eclairApi.signMessage(base64Message))
}
} ~
path("verifymessage") {
formFields("msg".as[ByteVector](base64DataUnmarshaller), "sig".as[ByteVector64](signatureUnmarshaller)) { (base64Message, signature) =>
complete(eclairApi.verifyMessage(base64Message, signature))
}
} ~ get {
path("ws") {
handleWebSocketMessages(makeSocketHandler)
}
} ~ get {
path("ws") {
handleWebSocketMessages(makeSocketHandler)
}
}
}
Expand Down