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:
parent
5a5a0b96f0
commit
01f924ae86
7 changed files with 136 additions and 8 deletions
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)))
|
||||
|
|
|
@ -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") {
|
||||
|
|
Loading…
Add table
Reference in a new issue