Skip to content

Commit 9faa508

Browse files
authored
Merge pull request #766 from input-output-hk/ETCM-167-discovery-part3
ETCM-167: Scalanet Discovery part 3
2 parents 3b2198e + 0ee0cbe commit 9faa508

24 files changed

+1704
-42
lines changed

build.sbt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ val commonSettings = Seq(
1212
name := "mantis",
1313
version := "3.0",
1414
scalaVersion := "2.12.12",
15+
// Scalanet snapshots are published to Sonatype after each build.
16+
resolvers += "Sonatype OSS Snapshots" at "https://oss.sonatype.org/content/repositories/snapshots",
1517
testOptions in Test += Tests
1618
.Argument(TestFrameworks.ScalaTest, "-l", "EthashMinerSpec") // miner tests disabled by default
1719
)
@@ -35,6 +37,7 @@ val dep = {
3537
Dependencies.testing,
3638
Dependencies.cats,
3739
Dependencies.monix,
40+
Dependencies.network,
3841
Dependencies.twitterUtilCollection,
3942
Dependencies.crypto,
4043
Dependencies.scopt,

project/Dependencies.scala

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,14 @@ object Dependencies {
7070
"io.monix" %% "monix" % "3.2.2"
7171
)
7272

73+
val network: Seq[ModuleID] = {
74+
val scalanetVersion = "0.4-SNAPSHOT"
75+
Seq(
76+
"io.iohk" %% "scalanet" % scalanetVersion,
77+
"io.iohk" %% "scalanet-discovery" % scalanetVersion
78+
)
79+
}
80+
7381
val logging = Seq(
7482
"ch.qos.logback" % "logback-classic" % "1.2.3",
7583
"com.typesafe.scala-logging" %% "scala-logging" % "3.9.2",

repo.nix

Lines changed: 277 additions & 0 deletions
Large diffs are not rendered by default.

src/evmTest/scala/io/iohk/ethereum/vm/PrecompiledContractsSpecEvm.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ class PrecompiledContractsSpecEvm extends AnyFunSuite with Matchers with SecureR
1414

1515
test("Precompiled Contracts") {
1616
val keyPair = generateKeyPair(secureRandom)
17-
val bytes: Array[Byte] = ByteString("aabbccdd").toArray[Byte]
17+
val bytes: Array[Byte] = crypto.kec256(ByteString("aabbccdd").toArray[Byte])
1818
val signature = ECDSASignature.sign(bytes, keyPair)
1919
val pubKey = keyPair.getPublic.asInstanceOf[ECPublicKeyParameters].getQ.getEncoded(false)
2020
val address = crypto.kec256(pubKey.tail).slice(FirstByteOfAddress, LastByteOfAddress)

src/main/scala/io/iohk/ethereum/crypto/ECDSASignature.scala

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,11 @@ object ECDSASignature {
1515
val RLength = 32
1616
val VLength = 1
1717
val EncodedLength: Int = RLength + SLength + VLength
18+
1819
//byte value that indicates that bytes representing ECC point are in uncompressed format, and should be decoded properly
19-
val uncompressedIndicator: Byte = 0x04
20+
val UncompressedIndicator: Byte = 0x04
21+
val CompressedEvenIndicator: Byte = 0x02
22+
val CompressedOddIndicator: Byte = 0x03
2023

2124
//only naming convention
2225
val negativePointSign: Byte = 27
@@ -37,17 +40,22 @@ object ECDSASignature {
3740
None
3841
}
3942

40-
def sign(message: ByteString, prvKey: ByteString): ECDSASignature =
41-
sign(message.toArray, keyPairFromPrvKey(prvKey.toArray), None)
43+
def sign(messageHash: ByteString, prvKey: ByteString): ECDSASignature =
44+
sign(messageHash.toArray, keyPairFromPrvKey(prvKey.toArray), None)
4245

43-
def sign(message: Array[Byte], keyPair: AsymmetricCipherKeyPair, chainId: Option[Byte] = None): ECDSASignature = {
46+
/** Sign a messageHash, expected to be a Keccak256 hash of the original data. */
47+
def sign(messageHash: Array[Byte], keyPair: AsymmetricCipherKeyPair, chainId: Option[Byte] = None): ECDSASignature = {
48+
require(
49+
messageHash.size == 32,
50+
s"The message should be a hash, expected to be 32 bytes; got ${messageHash.size} bytes."
51+
)
4452
val signer = new ECDSASigner(new HMacDSAKCalculator(new SHA256Digest))
4553
signer.init(true, keyPair.getPrivate)
46-
val components = signer.generateSignature(message)
54+
val components = signer.generateSignature(messageHash)
4755
val r = components(0)
4856
val s = ECDSASignature.canonicalise(components(1))
4957
val v = ECDSASignature
50-
.calculateV(r, s, keyPair, message)
58+
.calculateV(r, s, keyPair, messageHash)
5159
.getOrElse(throw new RuntimeException("Failed to calculate signature rec id"))
5260

5361
val pointSign = chainId match {
@@ -83,11 +91,11 @@ object ECDSASignature {
8391
else s
8492
}
8593

86-
private def calculateV(r: BigInt, s: BigInt, key: AsymmetricCipherKeyPair, message: Array[Byte]): Option[Byte] = {
94+
private def calculateV(r: BigInt, s: BigInt, key: AsymmetricCipherKeyPair, messageHash: Array[Byte]): Option[Byte] = {
8795
//byte 0 of encoded ECC point indicates that it is uncompressed point, it is part of bouncycastle encoding
8896
val pubKey = key.getPublic.asInstanceOf[ECPublicKeyParameters].getQ.getEncoded(false).tail
8997
val recIdOpt = Seq(positivePointSign, negativePointSign).find { i =>
90-
recoverPubBytes(r, s, i, None, message).exists(java.util.Arrays.equals(_, pubKey))
98+
recoverPubBytes(r, s, i, None, messageHash).exists(java.util.Arrays.equals(_, pubKey))
9199
}
92100
recIdOpt
93101
}
@@ -97,7 +105,7 @@ object ECDSASignature {
97105
s: BigInt,
98106
recId: Byte,
99107
chainId: Option[Byte],
100-
message: Array[Byte]
108+
messageHash: Array[Byte]
101109
): Option[Array[Byte]] = {
102110
val order = curve.getCurve.getOrder
103111
//ignore case when x = r + order because it is negligibly improbable
@@ -110,7 +118,7 @@ object ECDSASignature {
110118
if (xCoordinate.compareTo(prime) < 0) {
111119
val R = constructPoint(xCoordinate, recovery)
112120
if (R.multiply(order).isInfinity) {
113-
val e = BigInt(1, message)
121+
val e = BigInt(1, messageHash)
114122
val rInv = r.modInverse(order)
115123
//Q = r^(-1)(sR - eG)
116124
val q = R.multiply(s.bigInteger).subtract(curve.getG.multiply(e.bigInteger)).multiply(rInv.bigInteger)
@@ -131,6 +139,10 @@ object ECDSASignature {
131139

132140
/**
133141
* ECDSASignature r and s are same as in documentation where signature is represented by tuple (r, s)
142+
*
143+
* The `publicKey` method is also the way to verify the signature: if the key can be retrieved based
144+
* on the signed message, the signature is correct, otherwise it isn't.
145+
*
134146
* @param r - x coordinate of ephemeral public key modulo curve order N
135147
* @param s - part of the signature calculated with signer private key
136148
* @param v - public key recovery id
@@ -139,26 +151,26 @@ case class ECDSASignature(r: BigInt, s: BigInt, v: Byte) {
139151

140152
/**
141153
* returns ECC point encoded with on compression and without leading byte indicating compression
142-
* @param message message to be signed
154+
* @param messageHash message to be signed; should be a hash of the actual data.
143155
* @param chainId optional value if you want new signing schema with recovery id calculated with chain id
144156
* @return
145157
*/
146-
def publicKey(message: Array[Byte], chainId: Option[Byte] = None): Option[Array[Byte]] =
147-
ECDSASignature.recoverPubBytes(r, s, v, chainId, message)
158+
def publicKey(messageHash: Array[Byte], chainId: Option[Byte] = None): Option[Array[Byte]] =
159+
ECDSASignature.recoverPubBytes(r, s, v, chainId, messageHash)
148160

149161
/**
150162
* returns ECC point encoded with on compression and without leading byte indicating compression
151-
* @param message message to be signed
163+
* @param messageHash message to be signed; should be a hash of the actual data.
152164
* @return
153165
*/
154-
def publicKey(message: ByteString): Option[ByteString] =
155-
ECDSASignature.recoverPubBytes(r, s, v, None, message.toArray[Byte]).map(ByteString(_))
166+
def publicKey(messageHash: ByteString): Option[ByteString] =
167+
ECDSASignature.recoverPubBytes(r, s, v, None, messageHash.toArray[Byte]).map(ByteString(_))
156168

157169
def toBytes: ByteString = {
158170
import ECDSASignature.RLength
159171

160172
def bigInt2Bytes(b: BigInt) =
161-
ByteUtils.padLeft(ByteString(b.toByteArray).takeRight(RLength), RLength, 0)
173+
ByteString(ByteUtils.bigIntToBytes(b, RLength))
162174

163175
bigInt2Bytes(r) ++ bigInt2Bytes(s) :+ v
164176
}

src/main/scala/io/iohk/ethereum/crypto/package.scala

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import java.nio.charset.StandardCharsets
44
import java.security.SecureRandom
55

66
import akka.util.ByteString
7+
import io.iohk.ethereum.utils.ByteUtils
78
import org.bouncycastle.asn1.sec.SECNamedCurves
89
import org.bouncycastle.asn1.x9.X9ECParameters
910
import org.bouncycastle.crypto.AsymmetricCipherKeyPair
@@ -62,9 +63,11 @@ package object crypto {
6263
bytes
6364
}
6465

65-
/** @return (privateKey, publicKey) pair */
66+
/** @return (privateKey, publicKey) pair.
67+
* The public key will be uncompressed and have its prefix dropped.
68+
*/
6669
def keyPairToByteArrays(keyPair: AsymmetricCipherKeyPair): (Array[Byte], Array[Byte]) = {
67-
val prvKey = keyPair.getPrivate.asInstanceOf[ECPrivateKeyParameters].getD.toByteArray
70+
val prvKey = ByteUtils.bigIntegerToBytes(keyPair.getPrivate.asInstanceOf[ECPrivateKeyParameters].getD, 32)
6871
val pubKey = keyPair.getPublic.asInstanceOf[ECPublicKeyParameters].getQ.getEncoded(false).tail
6972
(prvKey, pubKey)
7073
}
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
package io.iohk.ethereum.network.discovery
2+
3+
import akka.util.ByteString
4+
import io.iohk.ethereum.crypto
5+
import io.iohk.ethereum.crypto.ECDSASignature
6+
import io.iohk.scalanet.discovery.crypto.{SigAlg, PublicKey, PrivateKey, Signature}
7+
import io.iohk.ethereum.nodebuilder.SecureRandomBuilder
8+
import scodec.bits.BitVector
9+
import scodec.{Attempt, Err}
10+
import scodec.bits.BitVector
11+
import org.bouncycastle.crypto.params.ECPublicKeyParameters
12+
import org.bouncycastle.crypto.AsymmetricCipherKeyPair
13+
import scala.collection.concurrent.TrieMap
14+
15+
class Secp256k1SigAlg extends SigAlg with SecureRandomBuilder {
16+
// We'll be using the same private key over and over to sign messages.
17+
// To save the time transforming it into a public-private key pair every time, store the results.
18+
// In the future we might want to not pass around the private key but have it as a constructor argument.
19+
private val signingKeyPairCache = TrieMap.empty[PrivateKey, AsymmetricCipherKeyPair]
20+
21+
override val name = "secp256k1"
22+
23+
override val PrivateKeyBytesSize = 32
24+
25+
// A Secp256k1 public key is 32 bytes compressed or 64 bytes uncompressed,
26+
// with a 1 byte prefix showing which version it is.
27+
// See https://davidederosa.com/basic-blockchain-programming/elliptic-curve-keys
28+
//
29+
// However in the discovery v4 protocol the prefix is omitted.
30+
override val PublicKeyBytesSize = 64
31+
32+
// A normal Secp256k1 signature consists of 2 bigints `r` and `s` followed by a recovery ID `v`,
33+
// but it can be just 64 bytes if that's omitted, like in the ENR.
34+
override val SignatureBytesSize = 65
35+
36+
val SignatureWithoutRecoveryBytesSize = 64
37+
val PublicKeyCompressedBytesSize = 33
38+
39+
override def newKeyPair: (PublicKey, PrivateKey) = {
40+
val keyPair = crypto.generateKeyPair(secureRandom)
41+
val (privateKeyBytes, publicKeyBytes) = crypto.keyPairToByteArrays(keyPair)
42+
43+
val publicKey = toPublicKey(publicKeyBytes)
44+
val privateKey = toPrivateKey(privateKeyBytes)
45+
46+
publicKey -> privateKey
47+
}
48+
49+
override def sign(privateKey: PrivateKey, data: BitVector): Signature = {
50+
val message = crypto.kec256(data.toByteArray)
51+
val keyPair = signingKeyPairCache.getOrElseUpdate(privateKey, crypto.keyPairFromPrvKey(privateKey.toByteArray))
52+
val sig = ECDSASignature.sign(message, keyPair)
53+
toSignature(sig)
54+
}
55+
56+
// ENR wants the signature without recovery ID, just 64 bytes.
57+
// The Packet on the other hand has the full 65 bytes.
58+
override def removeRecoveryId(signature: Signature): Signature = {
59+
signature.size / 8 match {
60+
case SignatureBytesSize =>
61+
Signature(signature.dropRight(8))
62+
case SignatureWithoutRecoveryBytesSize =>
63+
signature
64+
case other =>
65+
throw new IllegalArgumentException(s"Unexpected signature size: $other bytes")
66+
}
67+
}
68+
69+
override def compressPublicKey(publicKey: PublicKey): PublicKey = {
70+
publicKey.size / 8 match {
71+
case PublicKeyBytesSize =>
72+
// This is a public key without the prefix, it consists of an x and y bigint.
73+
// To compress we drop y, and the first byte becomes 02 for even values of y and 03 for odd values.
74+
val point = crypto.curve.getCurve.decodePoint(ECDSASignature.UncompressedIndicator +: publicKey.toByteArray)
75+
val key = new ECPublicKeyParameters(point, crypto.curve)
76+
val bytes = key.getQ.getEncoded(true) // compressed encoding
77+
val compressed = PublicKey(BitVector(bytes))
78+
assert(compressed.size == PublicKeyCompressedBytesSize * 8)
79+
compressed
80+
81+
case PublicKeyCompressedBytesSize =>
82+
publicKey
83+
84+
case other =>
85+
throw new IllegalArgumentException(s"Unexpected uncompressed public key size: $other bytes")
86+
}
87+
}
88+
89+
// The public key points lie on the curve `y^2 = x^3 + 7`.
90+
// In the compressed form we have x and a prefix telling us whether y is even or odd.
91+
// https://bitcoin.stackexchange.com/questions/86234/how-to-uncompress-a-public-key
92+
// https://bitcoin.stackexchange.com/questions/44024/get-uncompressed-public-key-from-compressed-form
93+
def decompressPublicKey(publicKey: PublicKey): PublicKey = {
94+
publicKey.size / 8 match {
95+
case PublicKeyBytesSize =>
96+
publicKey
97+
98+
case PublicKeyCompressedBytesSize =>
99+
val point = crypto.curve.getCurve.decodePoint(publicKey.toByteArray)
100+
val key = new ECPublicKeyParameters(point, crypto.curve)
101+
val bytes = key.getQ.getEncoded(false).drop(1) // uncompressed encoding, drop prefix.
102+
toPublicKey(bytes)
103+
104+
case other =>
105+
throw new IllegalArgumentException(s"Unexpected compressed public key size: $other bytes")
106+
}
107+
}
108+
109+
override def verify(publicKey: PublicKey, signature: Signature, data: BitVector): Boolean = {
110+
val message = crypto.kec256(data.toByteArray)
111+
val uncompressedPublicKey = decompressPublicKey(publicKey)
112+
toECDSASignatures(signature).exists { sig =>
113+
sig.publicKey(message).map(toPublicKey).contains(uncompressedPublicKey)
114+
}
115+
}
116+
117+
override def recoverPublicKey(signature: Signature, data: BitVector): Attempt[PublicKey] = {
118+
val message = crypto.kec256(data.toByteArray)
119+
120+
val maybePublicKey = toECDSASignatures(signature).flatMap { sig =>
121+
sig.publicKey(message).map(toPublicKey)
122+
}.headOption
123+
124+
Attempt.fromOption(maybePublicKey, Err("Failed to recover the public key from the signature."))
125+
}
126+
127+
override def toPublicKey(privateKey: PrivateKey): PublicKey = {
128+
val publicKeyBytes = crypto.pubKeyFromPrvKey(privateKey.toByteArray)
129+
toPublicKey(publicKeyBytes)
130+
}
131+
132+
private def toPublicKey(publicKeyBytes: Array[Byte]): PublicKey = {
133+
// Discovery uses 64 byte keys, without the prefix.
134+
val publicKey = PublicKey(BitVector(publicKeyBytes))
135+
assert(publicKey.size == PublicKeyBytesSize * 8, s"Unexpected public key size: ${publicKey.size / 8} bytes")
136+
publicKey
137+
}
138+
139+
private def toPrivateKey(privateKeyBytes: Array[Byte]): PrivateKey = {
140+
val privateKey = PrivateKey(BitVector(privateKeyBytes))
141+
assert(privateKey.size == PrivateKeyBytesSize * 8, s"Unexpected private key size: ${privateKey.size / 8} bytes")
142+
privateKey
143+
}
144+
145+
// Apparently the `v` has to adjusted by 27, which is the negative point sign.
146+
private def vToWire(v: Byte): Byte =
147+
(v - ECDSASignature.negativePointSign).toByte
148+
149+
private def wireToV(w: Byte): Byte =
150+
(w + ECDSASignature.negativePointSign).toByte
151+
152+
private def adjustV(bytes: Array[Byte], f: Byte => Byte): Unit =
153+
bytes(bytes.size - 1) = f(bytes(bytes.size - 1))
154+
155+
private def toSignature(sig: ECDSASignature): Signature = {
156+
val signatureBytes = sig.toBytes.toArray[Byte]
157+
assert(signatureBytes.size == SignatureBytesSize)
158+
adjustV(signatureBytes, vToWire)
159+
Signature(BitVector(signatureBytes))
160+
}
161+
162+
// Based on whether we have the recovery ID in the signature we may have to try 1 or 2 signatures.
163+
private def toECDSASignatures(signature: Signature): Iterable[ECDSASignature] = {
164+
signature.size / 8 match {
165+
case SignatureBytesSize =>
166+
val signatureBytes = signature.toByteArray
167+
adjustV(signatureBytes, wireToV)
168+
Iterable(toECDSASignature(signatureBytes))
169+
170+
case SignatureWithoutRecoveryBytesSize =>
171+
val signatureBytes = signature.toByteArray
172+
// Try all allowed points signs.
173+
ECDSASignature.allowedPointSigns.toIterable.map { v =>
174+
toECDSASignature(signatureBytes :+ v)
175+
}
176+
177+
case other =>
178+
throw new IllegalArgumentException(s"Unexpected signature size: $other bytes")
179+
}
180+
}
181+
182+
private def toECDSASignature(signatureBytes: Array[Byte]): ECDSASignature =
183+
ECDSASignature.fromBytes(ByteString(signatureBytes)) getOrElse {
184+
throw new IllegalArgumentException(s"Could not convert to ECDSA signature.")
185+
}
186+
}

0 commit comments

Comments
 (0)