Skip to content

Commit 01f924a

Browse files
authored
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}
1 parent 5a5a0b9 commit 01f924a

File tree

7 files changed

+136
-8
lines changed

7 files changed

+136
-8
lines changed

eclair-core/src/main/scala/fr/acinq/eclair/Eclair.scala

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,14 @@
1616

1717
package fr.acinq.eclair
1818

19+
import java.nio.charset.StandardCharsets
1920
import java.util.UUID
2021

2122
import akka.actor.ActorRef
2223
import akka.pattern._
2324
import akka.util.Timeout
2425
import fr.acinq.bitcoin.Crypto.PublicKey
25-
import fr.acinq.bitcoin.{ByteVector32, Crypto, Satoshi}
26+
import fr.acinq.bitcoin.{ByteVector32, ByteVector64, Crypto, Satoshi}
2627
import fr.acinq.eclair.TimestampQueryFilters._
2728
import fr.acinq.eclair.blockchain.OnChainBalance
2829
import fr.acinq.eclair.blockchain.bitcoind.BitcoinCoreWallet
@@ -38,7 +39,7 @@ import fr.acinq.eclair.payment.relay.Relayer.{GetOutgoingChannels, OutgoingChann
3839
import fr.acinq.eclair.payment.send.PaymentInitiator.{SendPaymentRequest, SendPaymentToRouteRequest, SendPaymentToRouteResponse}
3940
import fr.acinq.eclair.router.Router._
4041
import fr.acinq.eclair.router.{NetworkStats, RouteCalculation}
41-
import fr.acinq.eclair.wire.{ChannelAnnouncement, ChannelUpdate, NodeAddress, NodeAnnouncement, GenericTlv}
42+
import fr.acinq.eclair.wire._
4243
import scodec.bits.ByteVector
4344

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

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

55+
case class SignedMessage(nodeId: PublicKey, message: String, signature: ByteVector)
56+
57+
case class VerifiedMessage(valid: Boolean, publicKey: PublicKey)
58+
5459
object TimestampQueryFilters {
5560
/** We use this in the context of timestamp filtering, when we don't need an upper bound. */
5661
val MaxEpochMilliseconds = Duration.fromNanos(Long.MaxValue).toMillis
@@ -64,6 +69,11 @@ object TimestampQueryFilters {
6469
}
6570
}
6671

72+
object SignedMessage {
73+
def signedBytes(message: ByteVector): ByteVector32 =
74+
Crypto.hash256(ByteVector("Lightning Signed Message:".getBytes(StandardCharsets.UTF_8)) ++ message)
75+
}
76+
6777
object ApiTypes {
6878
type ChannelIdentifier = Either[ByteVector32, ShortChannelId]
6979
}
@@ -134,6 +144,9 @@ trait Eclair {
134144

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

147+
def signMessage(message: ByteVector): SignedMessage
148+
149+
def verifyMessage(message: ByteVector, recoverableSignature: ByteVector): VerifiedMessage
137150
}
138151

139152
class EclairImpl(appKit: Kit) extends Eclair {
@@ -393,4 +406,18 @@ class EclairImpl(appKit: Kit) extends Eclair {
393406
val sendPayment = SendPaymentRequest(amount, paymentHash, recipientNodeId, maxAttempts, externalId = externalId_opt, routeParams = Some(routeParams), userCustomTlvs = keySendTlvRecords)
394407
(appKit.paymentInitiator ? sendPayment).mapTo[UUID]
395408
}
409+
410+
override def signMessage(message: ByteVector): SignedMessage = {
411+
val bytesToSign = SignedMessage.signedBytes(message)
412+
val (signature, recoveryId) = appKit.nodeParams.keyManager.signDigest(bytesToSign)
413+
SignedMessage(appKit.nodeParams.nodeId, message.toBase64, (recoveryId + 31).toByte +: signature)
414+
}
415+
416+
override def verifyMessage(message: ByteVector, recoverableSignature: ByteVector): VerifiedMessage = {
417+
val signedBytes = SignedMessage.signedBytes(message)
418+
val signature = ByteVector64(recoverableSignature.tail)
419+
val recoveryId = recoverableSignature.head.toInt - 31
420+
val pubKeyFromSignature = Crypto.recoverPublicKey(signature, signedBytes, recoveryId)
421+
VerifiedMessage(true, pubKeyFromSignature)
422+
}
396423
}

eclair-core/src/main/scala/fr/acinq/eclair/crypto/KeyManager.scala

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,19 @@ trait KeyManager {
110110
* private key, bitcoinSig is the signature of the channel announcement with our funding private key
111111
*/
112112
def signChannelAnnouncement(fundingKeyPath: DeterministicWallet.KeyPath, chainHash: ByteVector32, shortChannelId: ShortChannelId, remoteNodeId: PublicKey, remoteFundingKey: PublicKey, features: Features): (ByteVector64, ByteVector64)
113+
114+
/**
115+
* Sign a digest, primarily used to prove ownership of the current node
116+
*
117+
* When recovering a public key from an ECDSA signature for secp256k1, there are 4 possible matching curve points
118+
* that can be found. The recoveryId identifies which of these points is the correct.
119+
*
120+
* @param digest SHA256 digest
121+
* @param privateKey private key to sign with, default the one from the current node
122+
* @return a (signature, recoveryId) pair. signature is a signature of the digest parameter generated with the
123+
* private key given in parameter. recoveryId is the corresponding recoveryId of the signature
124+
*/
125+
def signDigest(digest: ByteVector32, privateKey: PrivateKey = nodeKey.privateKey): (ByteVector64, Int)
113126
}
114127

115128
object KeyManager {

eclair-core/src/main/scala/fr/acinq/eclair/crypto/LocalKeyManager.scala

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,4 +153,11 @@ class LocalKeyManager(seed: ByteVector, chainHash: ByteVector32) extends KeyMana
153153
val localFundingPrivKey = privateKeys.get(fundingKeyPath).privateKey
154154
Announcements.signChannelAnnouncement(chainHash, shortChannelId, localNodeSecret, remoteNodeId, localFundingPrivKey, remoteFundingKey, features)
155155
}
156-
}
156+
157+
override def signDigest(digest: ByteVector32, privateKey: PrivateKey = nodeKey.privateKey): (ByteVector64, Int) = {
158+
val signature = Crypto.sign(digest, privateKey)
159+
val (pub1, _) = Crypto.recoverPublicKey(signature, digest)
160+
val recoveryId = if (nodeId == pub1) 0 else 1
161+
(signature, recoveryId)
162+
}
163+
}

eclair-core/src/test/scala/fr/acinq/eclair/EclairImplSpec.scala

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,4 +429,61 @@ class EclairImplSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with I
429429
assert(expectedPaymentPreimage === ByteVector32(keySendTlv.value))
430430
}
431431

432+
test("sign & verify an arbitrary message with the node's private key") { f =>
433+
import f._
434+
435+
val eclair = new EclairImpl(kit)
436+
437+
val base64Msg = "aGVsbG8sIHdvcmxk" // echo -n 'hello, world' | base64
438+
val bytesMsg = ByteVector.fromValidBase64(base64Msg)
439+
440+
val signedMessage: SignedMessage = eclair.signMessage(bytesMsg)
441+
assert(signedMessage.nodeId === kit.nodeParams.nodeId)
442+
assert(signedMessage.message === base64Msg)
443+
444+
val verifiedMessage: VerifiedMessage = eclair.verifyMessage(bytesMsg, signedMessage.signature)
445+
assert(verifiedMessage.valid)
446+
assert(verifiedMessage.publicKey === kit.nodeParams.nodeId)
447+
448+
val prefix = ByteVector("Lightning Signed Message:".getBytes)
449+
val dhash256 = Crypto.hash256(prefix ++ bytesMsg)
450+
val expectedDigest = ByteVector32(hex"cbedbc1542fb139e2e10954f1ff9f82e8a1031cc63260636bbc45a90114552ea")
451+
assert(dhash256 === expectedDigest)
452+
assert(Crypto.verifySignature(dhash256, ByteVector64(signedMessage.signature.tail), kit.nodeParams.nodeId))
453+
}
454+
455+
test("verify an invalid signature for the given message") { f =>
456+
import f._
457+
458+
val eclair = new EclairImpl(kit)
459+
460+
val base64Msg = "aGVsbG8sIHdvcmxk" // echo -n 'hello, world' | base64
461+
val bytesMsg = ByteVector.fromValidBase64(base64Msg)
462+
463+
val signedMessage: SignedMessage = eclair.signMessage(bytesMsg)
464+
assert(signedMessage.nodeId === kit.nodeParams.nodeId)
465+
assert(signedMessage.message === base64Msg)
466+
467+
val wrongMsg = ByteVector.fromValidBase64(base64Msg.tail)
468+
val verifiedMessage: VerifiedMessage = eclair.verifyMessage(wrongMsg, signedMessage.signature)
469+
assert(verifiedMessage.valid)
470+
assert(verifiedMessage.publicKey !== kit.nodeParams.nodeId)
471+
}
472+
473+
test("ensure that an invalid recoveryId cause the signature verification to fail") { f =>
474+
import f._
475+
476+
val eclair = new EclairImpl(kit)
477+
478+
val base64Msg = "aGVsbG8sIHdvcmxk" // echo -n 'hello, world' | base64
479+
val bytesMsg = ByteVector.fromValidBase64(base64Msg)
480+
481+
val signedMessage: SignedMessage = eclair.signMessage(bytesMsg)
482+
assert(signedMessage.nodeId === kit.nodeParams.nodeId)
483+
assert(signedMessage.message === base64Msg)
484+
485+
val invalidSignature = (if (signedMessage.signature.head.toInt == 31) 32 else 31).toByte +: signedMessage.signature.tail
486+
val verifiedMessage: VerifiedMessage = eclair.verifyMessage(bytesMsg, invalidSignature)
487+
assert(verifiedMessage.publicKey !== kit.nodeParams.nodeId)
488+
}
432489
}

eclair-core/src/test/scala/fr/acinq/eclair/crypto/LocalKeyManagerSpec.scala

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ package fr.acinq.eclair.crypto
1818

1919
import fr.acinq.bitcoin.Crypto.{PrivateKey, PublicKey}
2020
import fr.acinq.bitcoin.DeterministicWallet.KeyPath
21-
import fr.acinq.bitcoin.{Block, ByteVector32, DeterministicWallet}
21+
import fr.acinq.bitcoin.{Block, ByteVector32, Crypto, DeterministicWallet}
2222
import fr.acinq.eclair.TestConstants
2323
import fr.acinq.eclair.channel.ChannelVersion
2424
import org.scalatest.funsuite.AnyFunSuite
@@ -69,7 +69,7 @@ class LocalKeyManagerSpec extends AnyFunSuite {
6969
}
7070

7171
def makefundingKeyPath(entropy: ByteVector, isFunder: Boolean) = {
72-
val items = for(i <- 0 to 7) yield entropy.drop(i * 4).take(4).toInt(signed = false) & 0xFFFFFFFFL
72+
val items = for (i <- 0 to 7) yield entropy.drop(i * 4).take(4).toInt(signed = false) & 0xFFFFFFFFL
7373
val last = DeterministicWallet.hardened(if (isFunder) 1L else 0L)
7474
KeyPath(items :+ last)
7575
}
@@ -141,4 +141,15 @@ class LocalKeyManagerSpec extends AnyFunSuite {
141141
assert(keyManager.htlcPoint(channelKeyPath).publicKey == PrivateKey(hex"b1be27b5232e3bc5d6a261949b4ee68d96fa61f481998d36342e2ad99444cf8a").publicKey)
142142
assert(keyManager.commitmentSecret(channelKeyPath, 0).value == ShaChain.shaChainFromSeed(ByteVector32.fromValidHex("eeb3bad6808e8bb5f1774581ccf64aa265fef38eca80a1463d6310bb801b3ba7"), 0xFFFFFFFFFFFFL))
143143
}
144+
145+
test("generate a signature from a digest") {
146+
val seed = hex"deadbeef"
147+
val testKeyManager = new LocalKeyManager(seed, Block.RegtestGenesisBlock.hash)
148+
val digest = ByteVector32(hex"d7914fe546b684688bb95f4f888a92dfc680603a75f23eb823658031fff766d9") // sha256(sha256("hello"))
149+
150+
val (signature, recid) = testKeyManager.signDigest(digest)
151+
val recoveredPubkey = Crypto.recoverPublicKey(signature, digest, recid)
152+
assert(recoveredPubkey === testKeyManager.nodeId)
153+
assert(Crypto.verifySignature(digest, signature, testKeyManager.nodeId))
154+
}
144155
}

eclair-node/src/main/scala/fr/acinq/eclair/api/FormParamExtractors.scala

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import java.util.UUID
2121
import akka.http.scaladsl.unmarshalling.Unmarshaller
2222
import akka.util.Timeout
2323
import fr.acinq.bitcoin.Crypto.PublicKey
24-
import fr.acinq.bitcoin.{ByteVector32, Satoshi}
24+
import fr.acinq.bitcoin.{ByteVector32, ByteVector64, Satoshi}
2525
import fr.acinq.eclair.api.JsonSupport._
2626
import fr.acinq.eclair.io.NodeURI
2727
import fr.acinq.eclair.payment.PaymentRequest
@@ -59,6 +59,8 @@ object FormParamExtractors {
5959

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

62+
implicit val base64DataUnmarshaller: Unmarshaller[String, ByteVector] = Unmarshaller.strict { str => ByteVector.fromValidBase64(str) }
63+
6264
private def listUnmarshaller[T](unmarshal: String => T): Unmarshaller[String, List[T]] = Unmarshaller.strict { str =>
6365
Try(serialization.read[List[String]](str).map(unmarshal))
6466
.recoverWith(_ => Try(str.split(",").toList.map(unmarshal)))

eclair-node/src/main/scala/fr/acinq/eclair/api/Service.scala

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import fr.acinq.bitcoin.{ByteVector32, Satoshi}
3636
import fr.acinq.eclair.api.FormParamExtractors._
3737
import fr.acinq.eclair.io.NodeURI
3838
import fr.acinq.eclair.payment.{PaymentEvent, PaymentRequest}
39-
import fr.acinq.eclair.{CltvExpiryDelta, Eclair, MilliSatoshi, randomBytes32}
39+
import fr.acinq.eclair.{CltvExpiryDelta, Eclair, MilliSatoshi}
4040
import grizzled.slf4j.Logging
4141
import scodec.bits.ByteVector
4242

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

5050
// important! Must NOT import the unmarshaller as it is too generic...see https://github.com/akka/akka-http/issues/541
51+
5152
import JsonSupport.{formats, marshaller, serialization}
5253

5354
def password: String
@@ -226,7 +227,7 @@ trait Service extends ExtraDirectives with Logging {
226227
path("sendtonode") {
227228
formFields(amountMsatFormParam, nodeIdFormParam, paymentHashFormParam.?, "maxAttempts".as[Int].?, "feeThresholdSat".as[Satoshi].?, "maxFeePct".as[Double].?, "externalId".?, "keysend".as[Boolean].?) {
228229
case (amountMsat, nodeId, Some(paymentHash), maxAttempts_opt, feeThresholdSat_opt, maxFeePct_opt, externalId_opt, keySend) =>
229-
keySend match {
230+
keySend match {
230231
case Some(true) => reject(MalformedFormFieldRejection("paymentHash", "You cannot request a KeySend payment and specify a paymentHash"))
231232
case _ => complete(eclairApi.send(externalId_opt, nodeId, amountMsat, paymentHash, maxAttempts_opt = maxAttempts_opt, feeThresholdSat_opt = feeThresholdSat_opt, maxFeePct_opt = maxFeePct_opt))
232233
}
@@ -310,6 +311,16 @@ trait Service extends ExtraDirectives with Logging {
310311
formFields("count".as[Int].?, "skip".as[Int].?) { (count_opt, skip_opt) =>
311312
complete(eclairApi.onChainTransactions(count_opt.getOrElse(10), skip_opt.getOrElse(0)))
312313
}
314+
} ~
315+
path("signmessage") {
316+
formFields("msg".as[ByteVector](base64DataUnmarshaller)) { message =>
317+
complete(eclairApi.signMessage(message))
318+
}
319+
} ~
320+
path("verifymessage") {
321+
formFields("msg".as[ByteVector](base64DataUnmarshaller), "sig".as[ByteVector](binaryDataUnmarshaller)) { (message, signature) =>
322+
complete(eclairApi.verifyMessage(message, signature))
323+
}
313324
}
314325
} ~ get {
315326
path("ws") {

0 commit comments

Comments
 (0)