1
0
Fork 0
mirror of https://github.com/ACINQ/eclair.git synced 2025-02-21 22:11:46 +01:00

Add API commands to sign & verify arbitrary messages (#1499)

It can be useful to sign arbitrary messages with the key associated with our node_id (to prove ownership of a node).

Adds 2 new API commands:
  eclair-cli signmessage --msg=${message}
  eclair-cli verifymessage --msg=${message} --sig=${signature}
This commit is contained in:
Donovan 2020-08-07 16:33:20 +02:00 committed by GitHub
parent 5a5a0b96f0
commit 01f924ae86
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 136 additions and 8 deletions

View file

@ -16,13 +16,14 @@
package fr.acinq.eclair
import java.nio.charset.StandardCharsets
import java.util.UUID
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
@ -38,7 +39,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._
@ -51,6 +52,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
@ -64,6 +69,11 @@ object TimestampQueryFilters {
}
}
object SignedMessage {
def signedBytes(message: ByteVector): ByteVector32 =
Crypto.hash256(ByteVector("Lightning Signed Message:".getBytes(StandardCharsets.UTF_8)) ++ message)
}
object ApiTypes {
type ChannelIdentifier = Either[ByteVector32, ShortChannelId]
}
@ -134,6 +144,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 {
@ -393,4 +406,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)
}
}

View file

@ -110,6 +110,19 @@ 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.
*
* @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 {

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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
@ -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)
}
@ -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))
}
}

View file

@ -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
@ -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)))

View file

@ -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
@ -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
@ -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))
}
@ -310,6 +311,16 @@ 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") {