Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 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
29 changes: 27 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: ByteVector)

case class VerifiedMessage(valid: Boolean, publicKey: 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 @@ -64,6 +68,10 @@ object TimestampQueryFilters {
}
}

object SignedMessage {
def signedBytes(message: ByteVector): ByteVector32 = Crypto.hash256(ByteVector("Lightning Signed Message:".getBytes) ++ message)
}

object ApiTypes {
type ChannelIdentifier = Either[ByteVector32, ShortChannelId]
}
Expand Down Expand Up @@ -134,6 +142,9 @@ trait Eclair {

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

def signMessage(message: ByteVector): SignedMessage

def verifyMessage(message: ByteVector, recoverableSignature: ByteVector): VerifiedMessage
}

class EclairImpl(appKit: Kit) extends Eclair {
Expand Down Expand Up @@ -393,4 +404,18 @@ 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(message: ByteVector): SignedMessage = {
val bytesToSign = SignedMessage.signedBytes(message)
val (signature, recoveryId) = appKit.nodeParams.keyManager.signDigest(bytesToSign)
SignedMessage(appKit.nodeParams.nodeId, message.toBase64, (recoveryId + 31).toByte +: signature)
}

override def verifyMessage(message: ByteVector, recoverableSignature: ByteVector): VerifiedMessage = {
val signedBytes = SignedMessage.signedBytes(message)
val signature = ByteVector64(recoverableSignature.tail)
val recoveryId = recoverableSignature.head.toInt - 31
val pubKeyFromSignature = Crypto.recoverPublicKey(signature, signedBytes, recoveryId)
VerifiedMessage(true, pubKeyFromSignature)
}
}
14 changes: 14 additions & 0 deletions eclair-core/src/main/scala/fr/acinq/eclair/crypto/KeyManager.scala
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,20 @@ 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
*
* When recovering a public key from an ECDSA signature for secp256k1, there are 4 possible matching curve points
* that can be found. The recoveryId identifies which of these points is the correct one to speed up recovery
* (otherwise we would need to check the signature's validity for each of these points to find the correct one).
*
* @param digest SHA256 digest
* @param privateKey private key to sign with, default the one from the current node
* @return a (signature, recoveryId) pair. signature is a signature of the digest parameter generated with the
private key given in parameter. recoveryId is the corresponding recoveryId of the signature
*/
def signDigest(digest: ByteVector32, privateKey: PrivateKey = nodeKey.privateKey): (ByteVector64, Int)
}

object KeyManager {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,4 +153,11 @@ 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, Int) = {
val signature = Crypto.sign(digest, privateKey)
val (pub1, _) = Crypto.recoverPublicKey(signature, digest)
val recoveryId = if (nodeId == pub1) 0 else 1
(signature, recoveryId)
}
}
57 changes: 57 additions & 0 deletions eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -429,4 +429,61 @@ class EclairImplSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with I
assert(expectedPaymentPreimage === ByteVector32(keySendTlv.value))
}

test("sign & verify an arbitrary message with the node's private key") { f =>
import f._

val eclair = new EclairImpl(kit)

val base64Msg = "aGVsbG8sIHdvcmxk" // echo -n 'hello, world' | base64
val bytesMsg = ByteVector.fromValidBase64(base64Msg)

val signedMessage: SignedMessage = eclair.signMessage(bytesMsg)
assert(signedMessage.nodeId === kit.nodeParams.nodeId)
assert(signedMessage.message === base64Msg)

val verifiedMessage: VerifiedMessage = eclair.verifyMessage(bytesMsg, signedMessage.signature)
assert(verifiedMessage.valid)
assert(verifiedMessage.publicKey === kit.nodeParams.nodeId)

val prefix = ByteVector("Lightning Signed Message:".getBytes)
val dhash256 = Crypto.hash256(prefix ++ bytesMsg)
val expectedDigest = ByteVector32(hex"cbedbc1542fb139e2e10954f1ff9f82e8a1031cc63260636bbc45a90114552ea")
assert(dhash256 === expectedDigest)
assert(Crypto.verifySignature(dhash256, ByteVector64(signedMessage.signature.tail), kit.nodeParams.nodeId))
}

test("verify an invalid signature for the given message") { f =>
import f._

val eclair = new EclairImpl(kit)

val base64Msg = "aGVsbG8sIHdvcmxk" // echo -n 'hello, world' | base64
val bytesMsg = ByteVector.fromValidBase64(base64Msg)

val signedMessage: SignedMessage = eclair.signMessage(bytesMsg)
assert(signedMessage.nodeId === kit.nodeParams.nodeId)
assert(signedMessage.message === base64Msg)

val wrongMsg = ByteVector.fromValidBase64(base64Msg.tail)
val verifiedMessage: VerifiedMessage = eclair.verifyMessage(wrongMsg, signedMessage.signature)
assert(verifiedMessage.valid)
assert(verifiedMessage.publicKey !== kit.nodeParams.nodeId)
}

test("ensure that an invalid recoveryId cause the signature verification to fail") { f =>
import f._

val eclair = new EclairImpl(kit)

val base64Msg = "aGVsbG8sIHdvcmxk" // echo -n 'hello, world' | base64
val bytesMsg = ByteVector.fromValidBase64(base64Msg)

val signedMessage: SignedMessage = eclair.signMessage(bytesMsg)
assert(signedMessage.nodeId === kit.nodeParams.nodeId)
assert(signedMessage.message === base64Msg)

val invalidSignature = (if (signedMessage.signature.head.toInt == 31) 32 else 31).toByte +: signedMessage.signature.tail
val verifiedMessage: VerifiedMessage = eclair.verifyMessage(bytesMsg, invalidSignature)
assert(verifiedMessage.publicKey !== kit.nodeParams.nodeId)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ package fr.acinq.eclair.crypto

import fr.acinq.bitcoin.Crypto.{PrivateKey, PublicKey}
import fr.acinq.bitcoin.DeterministicWallet.KeyPath
import fr.acinq.bitcoin.{Block, ByteVector32, DeterministicWallet}
import fr.acinq.bitcoin.{Block, ByteVector32, Crypto, DeterministicWallet}
import fr.acinq.eclair.TestConstants
import fr.acinq.eclair.channel.ChannelVersion
import org.scalatest.funsuite.AnyFunSuite
Expand Down Expand Up @@ -69,7 +69,7 @@ class LocalKeyManagerSpec extends AnyFunSuite {
}

def makefundingKeyPath(entropy: ByteVector, isFunder: Boolean) = {
val items = for(i <- 0 to 7) yield entropy.drop(i * 4).take(4).toInt(signed = false) & 0xFFFFFFFFL
val items = for (i <- 0 to 7) yield entropy.drop(i * 4).take(4).toInt(signed = false) & 0xFFFFFFFFL
val last = DeterministicWallet.hardened(if (isFunder) 1L else 0L)
KeyPath(items :+ last)
}
Expand Down Expand Up @@ -141,4 +141,15 @@ class LocalKeyManagerSpec extends AnyFunSuite {
assert(keyManager.htlcPoint(channelKeyPath).publicKey == PrivateKey(hex"b1be27b5232e3bc5d6a261949b4ee68d96fa61f481998d36342e2ad99444cf8a").publicKey)
assert(keyManager.commitmentSecret(channelKeyPath, 0).value == ShaChain.shaChainFromSeed(ByteVector32.fromValidHex("eeb3bad6808e8bb5f1774581ccf64aa265fef38eca80a1463d6310bb801b3ba7"), 0xFFFFFFFFFFFFL))
}

test("generate a signature from a digest") {
val seed = hex"deadbeef"
val testKeyManager = new LocalKeyManager(seed, Block.RegtestGenesisBlock.hash)
val digest = ByteVector32(hex"d7914fe546b684688bb95f4f888a92dfc680603a75f23eb823658031fff766d9") // sha256(sha256("hello"))

val (signature, recid) = testKeyManager.signDigest(digest)
val recoveredPubkey = Crypto.recoverPublicKey(signature, digest, recid)
assert(recoveredPubkey === testKeyManager.nodeId)
assert(Crypto.verifySignature(digest, signature, testKeyManager.nodeId))
}
}
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,8 @@ 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) }

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
21 changes: 16 additions & 5 deletions eclair-node/src/main/scala/fr/acinq/eclair/api/Service.scala
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import fr.acinq.bitcoin.{ByteVector32, Satoshi}
import fr.acinq.eclair.api.FormParamExtractors._
import fr.acinq.eclair.io.NodeURI
import fr.acinq.eclair.payment.{PaymentEvent, PaymentRequest}
import fr.acinq.eclair.{CltvExpiryDelta, Eclair, MilliSatoshi, randomBytes32}
import fr.acinq.eclair.{CltvExpiryDelta, Eclair, MilliSatoshi}
import grizzled.slf4j.Logging
import scodec.bits.ByteVector

Expand All @@ -48,6 +48,7 @@ case class ErrorResponse(error: String)
trait Service extends ExtraDirectives with Logging {

// important! Must NOT import the unmarshaller as it is too generic...see https://github.com/akka/akka-http/issues/541

import JsonSupport.{formats, marshaller, serialization}

def password: String
Expand Down Expand Up @@ -226,7 +227,7 @@ trait Service extends ExtraDirectives with Logging {
path("sendtonode") {
formFields(amountMsatFormParam, nodeIdFormParam, paymentHashFormParam.?, "maxAttempts".as[Int].?, "feeThresholdSat".as[Satoshi].?, "maxFeePct".as[Double].?, "externalId".?, "keysend".as[Boolean].?) {
case (amountMsat, nodeId, Some(paymentHash), maxAttempts_opt, feeThresholdSat_opt, maxFeePct_opt, externalId_opt, keySend) =>
keySend match {
keySend match {
case Some(true) => reject(MalformedFormFieldRejection("paymentHash", "You cannot request a KeySend payment and specify a paymentHash"))
case _ => complete(eclairApi.send(externalId_opt, nodeId, amountMsat, paymentHash, maxAttempts_opt = maxAttempts_opt, feeThresholdSat_opt = feeThresholdSat_opt, maxFeePct_opt = maxFeePct_opt))
}
Expand Down Expand Up @@ -310,10 +311,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)) { message =>
complete(eclairApi.signMessage(message))
}
} ~
path("verifymessage") {
formFields("msg".as[ByteVector](base64DataUnmarshaller), "sig".as[ByteVector](binaryDataUnmarshaller)) { (message, signature) =>
complete(eclairApi.verifyMessage(message, signature))
}
} ~ get {
path("ws") {
handleWebSocketMessages(makeSocketHandler)
}
} ~ get {
path("ws") {
handleWebSocketMessages(makeSocketHandler)
}
}
}
Expand Down