From d1ad77a34508b0511189953ac9c5d228633c5d47 Mon Sep 17 00:00:00 2001 From: Vyacheslav Shebanov Date: Fri, 27 May 2022 06:00:01 +0300 Subject: [PATCH] problem: users want to verify that response are coming from their nodes solution: edge node can sign received valued Co-authored-by: Igor Artamonov --- docs/07-methods.adoc | 108 +++++++++++++- docs/reference-configuration.adoc | 42 ++++++ gradle/libs.versions.toml | 2 +- proto/blockchain.proto | 40 ++++- .../kotlin/io/emeraldpay/dshackle/Config.kt | 10 ++ .../emeraldpay/dshackle/config/MainConfig.kt | 1 + .../dshackle/config/MainConfigReader.kt | 4 + .../dshackle/config/SignatureConfig.kt | 31 ++++ .../dshackle/config/SignatureConfigReader.kt | 40 +++++ .../dshackle/config/UpstreamsConfigReader.kt | 2 + .../dshackle/monitoring/accesslog/Events.kt | 5 +- .../monitoring/accesslog/EventsBuilder.kt | 4 + .../emeraldpay/dshackle/proxy/BaseHandler.kt | 2 +- .../dshackle/proxy/WebsocketHandler.kt | 2 +- .../dshackle/quorum/AlwaysQuorum.kt | 12 +- .../dshackle/quorum/BroadcastQuorum.kt | 12 +- .../emeraldpay/dshackle/quorum/CallQuorum.kt | 24 +-- .../dshackle/quorum/NonEmptyQuorum.kt | 12 +- .../emeraldpay/dshackle/quorum/NonceQuorum.kt | 11 +- .../dshackle/quorum/NotLaggingQuorum.kt | 10 +- .../dshackle/quorum/QuorumReaderFactory.kt | 7 +- .../dshackle/quorum/QuorumRpcReader.kt | 80 ++++++---- .../dshackle/quorum/ValueAwareQuorum.kt | 17 ++- .../emeraldpay/dshackle/rpc/BlockchainRpc.kt | 2 +- .../io/emeraldpay/dshackle/rpc/NativeCall.kt | 64 ++++---- .../emeraldpay/dshackle/rpc/TrackBitcoinTx.kt | 2 - .../dshackle/rpc/TrackEthereumTx.kt | 2 - .../dshackle/upstream/DefaultUpstream.kt | 6 + .../upstream/ethereum/EthereumDirectReader.kt | 3 +- .../upstream/ethereum/LocalCallRouter.kt | 5 + .../upstream/ethereum/WsConnection.kt | 4 +- .../upstream/grpc/BitcoinGrpcUpstream.kt | 3 +- .../upstream/grpc/EthereumGrpcUpstream.kt | 3 +- .../upstream/rpcclient/JsonRpcGrpcClient.kt | 29 +++- .../upstream/rpcclient/JsonRpcRequest.kt | 7 +- .../upstream/rpcclient/JsonRpcResponse.kt | 14 +- .../upstream/rpcclient/ResponseRpcParser.kt | 6 +- .../dshackle/upstream/signature/NoSigner.kt | 9 ++ .../upstream/signature/ResponseSigner.kt | 32 ++++ .../signature/ResponseSignerFactory.kt | 88 +++++++++++ .../upstream/signature/Secp256KSigner.kt | 60 ++++++++ .../emeraldpay/dshackle/TlsSetupSpec.groovy | 12 +- .../config/SignatureConfigReaderSpec.groovy | 47 ++++++ .../dshackle/proxy/BaseHandlerSpec.groovy | 16 +- .../dshackle/proxy/HttpHandlerSpec.groovy | 4 +- .../proxy/WebsocketHandlerSpec.groovy | 2 +- .../dshackle/proxy/WriteRpcJsonSpec.groovy | 14 +- .../dshackle/quorum/AlwaysQuorumSpec.groovy | 6 +- .../quorum/BroadcastQuorumSpec.groovy | 30 ++-- .../dshackle/quorum/NonEmptyQuorumSpec.groovy | 19 +-- .../dshackle/quorum/NonceQuorumSpec.groovy | 35 ++--- .../quorum/NotLaggingQuorumSpec.groovy | 25 +++- .../quorum/ValueAwareQuorumSpec.groovy | 13 +- .../dshackle/rpc/NativeCallSpec.groovy | 114 +++++++++------ .../dshackle/rpc/TrackEthereumTxSpec.groovy | 2 - .../dshackle/test/ApiReaderMock.groovy | 4 +- .../dshackle/upstream/FilteredApisSpec.groovy | 4 +- .../dshackle/upstream/MultistreamSpec.groovy | 22 +-- .../upstream/RequestPostprocessorSpec.groovy | 4 +- .../ethereum/EthereumDirectReaderSpec.groovy | 28 ++-- .../ethereum/LocalCallRouterSpec.groovy | 19 +++ .../upstream/ethereum/WsConnectionSpec.groovy | 6 +- .../rpcclient/JsonRpcResponseSpec.groovy | 20 +-- .../ResponseSignerFactorySpec.groovy | 28 ++++ .../signature/Secp256KSignerSpec.groovy | 138 ++++++++++++++++++ .../resources/tls-local/127.0.0.1.invalid.key | 27 ++++ testing/dshackle/dshackle-basic.yaml | 6 + testing/dshackle/test_key | 5 + testing/dshackle/test_key.pub | 4 + testing/trial/build.gradle | 14 ++ .../dshackle/testing/trial/Client.groovy | 6 + .../dshackle/testing/trial/ProtoClient.groovy | 64 ++++++++ .../dshackle/testing/trial/ProxyClient.groovy | 2 +- .../trial/basicproxy/StandardCallsSpec.groovy | 59 +++++++- 74 files changed, 1325 insertions(+), 291 deletions(-) create mode 100644 src/main/kotlin/io/emeraldpay/dshackle/config/SignatureConfig.kt create mode 100644 src/main/kotlin/io/emeraldpay/dshackle/config/SignatureConfigReader.kt create mode 100644 src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/NoSigner.kt create mode 100644 src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/ResponseSigner.kt create mode 100644 src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/ResponseSignerFactory.kt create mode 100644 src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/Secp256KSigner.kt create mode 100644 src/test/groovy/io/emeraldpay/dshackle/config/SignatureConfigReaderSpec.groovy create mode 100644 src/test/groovy/io/emeraldpay/dshackle/upstream/signature/ResponseSignerFactorySpec.groovy create mode 100644 src/test/groovy/io/emeraldpay/dshackle/upstream/signature/Secp256KSignerSpec.groovy create mode 100644 src/test/resources/tls-local/127.0.0.1.invalid.key create mode 100644 testing/dshackle/test_key create mode 100644 testing/dshackle/test_key.pub create mode 100644 testing/trial/src/main/groovy/io/emeraldpay/dshackle/testing/trial/Client.groovy create mode 100644 testing/trial/src/main/groovy/io/emeraldpay/dshackle/testing/trial/ProtoClient.groovy diff --git a/docs/07-methods.adoc b/docs/07-methods.adoc index 7248ff6f..fe0e0b71 100644 --- a/docs/07-methods.adoc +++ b/docs/07-methods.adoc @@ -49,6 +49,7 @@ message NativeCallItem { uint32 id = 1; string method = 3; bytes payload = 4; + uint64 nonce = 5; } ---- @@ -70,6 +71,14 @@ message NativeCallReplyItem { bool succeed = 2; bytes payload = 3; bytes error = 4; + NativeCallReplySignature signature = 5; +} + +message NativeCallReplySignature { + uint64 nonce = 1; + bytes signature = 2; + uint64 key_id = 3; + string upstream_id = 4; } ---- @@ -79,7 +88,104 @@ Where: - or `error` if request failed (`succeed` is false) NOTE: Reply Items comes right after their execution on an upstream, therefore streaming response. -It allows to build non-blocking queries +It allows building non-blocking queries + +[#signatures] +=== Signed JSON RPC Responses + +Dshackle can sign the responses it received from an upstream. +It can be enabled on server by configuring a path to a Secp256K1 Key used for signing. +And passing a `nonce` as part of the call request. +When both of them are set Dshackle adds a Signature to the response, which can travel through multiple levels of Dshackle-Dshackle connections. + +WARNING: Caching is disabled for signed requests, and you always touch an actual node even if there is a ready to use data in local cache. + +The signed message looks like: +---- +DSHACKLESIG/$nonce/$upstreamId/hex(sha256($response)) +---- + +.Where +- `nonce` is a 64-bit number provided with the request +- `upstreamId` id of an upstream which produced the result +- `response` is a part of the original JSON RPC message, i.e. it's what you have in `payload` field of `NativeCallReplyItem` + +.A signed response includes: +- `nonce` original nonce used for the call +- `signature` signature bytes (of the message above) +- `key_id` identifier of a key used for the signing +- `upstream_id` id of upstream which produce the response + +==== How to generate a key + +Here we generate a pair of Secret and Public keys using openssl. + +[source, bash] +---- +export KEYNAME=mykey + +openssl ecparam -name secp256k1 -out $KEYNAME_param.pem +openssl ecparam -in $KEYNAME_param.pem -genkey -noout -out $KEYNAME.pem + +openssl ec -in $KEYNAME.pem -text +openssl ec -in $KEYNAME.pem -out ${KEYNAME}_pub.pem -pubout + +rm $KEYNAME_param.pem +cat ${KEYNAME}_pub.pem +---- + +As a result you get `mykey.pem` with secret key to use on server, and `mykey_pub.pem` with public key to use on client to verify signatures. + +==== How to verify a signature + +Here is the example how to verify the signature with command line, and it can be easily adapted for your language of choice. + +.First, let's prepare all the values: +[source, bash] +---- +export PUBKEY=testing/dshackle/test_key.pub +export NONCE=10 + +export UPSTREAM=infura +export PAYLOAD='["0xe670ec64341771606e55d6b4ca35a1a6b75ee3d5145a99d05921026d1527331", true]' + +export SIGNATURE=3045022100be1d730e0e381e25bff64f0fc598d19e31688a01db751098d0ed21847ca785b0022002f5651a0e8d447b0815aeb7b48738cb470cf46d60ee4e2f5bc9c0dc4e072dc3 +---- + +.Then rebuild the signed message: +[source, bash] +---- +echo -n "DSHACKLESIG/$NONCE/$UPSTREAM/" > msg.txt +echo -n $PAYLOAD | shasum -a 256 - | awk '{ printf $1 }' >> msg.txt +---- + +.And save signature as a binary file: +[source, bash] +---- +rm -f msg.sig && echo $SIGNATURE | xxd -r -p - msg.sig +---- + +.Now you can verify the payload with the following: +[source, bash] +---- +openssl dgst -sha256 -verify $PUBKEY -signature msg.sig msg.txt +---- + +.Which should print: +---- +Verified OK +---- + +==== What is the Key Identifier? + +Key Id is the first 64 bits of SHA-256 hash of the x509 encoded Public Key. +It's provided with the Signed Response for a reference. + +You can get it with: +[source, bash] +---- +cat $PUBKEY | sed -e '$ d' | awk '(NR>1)' | base64 -d | shasum -a 256 - | head -c 16 +---- === Wrapped JSON RPC subscriptions diff --git a/docs/reference-configuration.adoc b/docs/reference-configuration.adoc index 5e9a8950..111d5fa2 100644 --- a/docs/reference-configuration.adoc +++ b/docs/reference-configuration.adoc @@ -47,6 +47,11 @@ cache: db: 0 password: I1y0dGKy01by +signed-response: + enabled: true + algorithm: SECP256K1 + private-key: /path/key.pem + proxy: host: 0.0.0.0 port: 8080 @@ -199,6 +204,11 @@ See <> section | Caching configuration. See <> section. +| `signed-response` +| +| Signed responses +See <> section. + | `cluster` | | Setup connection to remote nodes.See <> section @@ -546,6 +556,38 @@ cache: |=== +[#signed-response] +== Signed Response + +[source,yaml] +---- +signed-response: + enabled: true + algorithm: SECP256K1 + private-key: /path/key.pem +---- + +.Redis Config +[cols="2a,2,5"] +|=== +| Option | Default Value | Description + +| `enabled` +| `false` +| Enable/disable Signed Responses + +| `algorithm` +| `SECP256K1` +| SECP256K1 only possible at this moment + +| `private-key` +| +| Path to a private key in PEM format + +|=== + +See more details at link:07-methods.adoc#signatures[Signed Response] in gRPC Methods. + [#cluster] == Cluster diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1c8e1f74..f77cf411 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -31,7 +31,7 @@ cglib-nodep = "cglib:cglib-nodep:3.3.0" detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt" } -emerald-api = "io.emeraldpay:emerald-api:0.11.1" +emerald-api = "io.emeraldpay:emerald-api:0.12-alpha.1" equals-verifier = "nl.jqno.equalsverifier:equalsverifier:3.3" diff --git a/proto/blockchain.proto b/proto/blockchain.proto index 5a8fb278..05d92c3c 100644 --- a/proto/blockchain.proto +++ b/proto/blockchain.proto @@ -34,6 +34,29 @@ message NativeCallItem { uint32 id = 1; string method = 3; bytes payload = 4; + uint64 nonce = 5; +} + +/** + * Signature for a response + */ +message NativeCallReplySignature { + /** + * Original nonce value used for the call + */ + uint64 nonce = 1; + /** + * Signature value + */ + bytes signature = 2; + /** + * Key Id used for the signing + */ + uint64 key_id = 3; + /** + * Id of the upstream produced the response + */ + string upstream_id = 4; } message NativeCallReplyItem { @@ -41,6 +64,11 @@ message NativeCallReplyItem { bool succeed = 2; bytes payload = 3; string errorMessage = 4; + /** + * Optional signature for the response. + * Available only when it's configured at the edge dshackle and nonce is provided wit the request. + */ + NativeCallReplySignature signature = 5; } message NativeSubscribeRequest { @@ -231,7 +259,7 @@ enum FeeEstimationMode { * Standard Ethereum Fee, supported by majority of forks and by Ethereum Mainnet before EIP-1559 */ message EthereumStdFees { - // Fee value in Wei + // Big Number encoded as string. Fee value in Wei string fee = 1; } @@ -239,11 +267,11 @@ message EthereumStdFees { * Ethereum Fee for EIP-1559 compatible forks */ message EthereumExtFees { - // Estimated fee that would be actually paid. I.e. it's the Base Fee + Priority Fee + // Big Number encoded as string. Estimated fee that expected to be actually paid. I.e. it's the Base Fee + Priority Fee string expect = 1; - // Priority Fee in Wei + // Big Number encoded as string. Priority Fee in Wei string priority = 2; - // Max Fee value in Wei. Note that it only indicated current preference and actual Max may be significantly lower, depending on the usage scenario. + // Big Number encoded as string. Max Fee value in Wei. Note that it only indicates the current preference, and the actual Max may be significantly lower, depending on the usage scenario. string max = 3; } @@ -252,5 +280,5 @@ message EthereumExtFees { */ message BitcoinStdFees { // Fee in Satoshi per Kilobyte. Note that the actual fee calculation MUST divide it by 1024 at the last step to get a fair fee. - string satPerKb = 1; -} \ No newline at end of file + uint64 satPerKb = 1; +} diff --git a/src/main/kotlin/io/emeraldpay/dshackle/Config.kt b/src/main/kotlin/io/emeraldpay/dshackle/Config.kt index 86ad40d8..9d38961a 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/Config.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/Config.kt @@ -21,8 +21,10 @@ import io.emeraldpay.dshackle.config.HealthConfig import io.emeraldpay.dshackle.config.MainConfig import io.emeraldpay.dshackle.config.MainConfigReader import io.emeraldpay.dshackle.config.MonitoringConfig +import io.emeraldpay.dshackle.config.SignatureConfig import io.emeraldpay.dshackle.config.TokensConfig import io.emeraldpay.dshackle.config.UpstreamsConfig +import org.bouncycastle.jce.provider.BouncyCastleProvider import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Qualifier @@ -37,6 +39,7 @@ import org.springframework.scheduling.annotation.EnableScheduling import reactor.core.scheduler.Scheduler import reactor.core.scheduler.Schedulers import java.io.File +import java.security.Security import java.util.concurrent.Executors import kotlin.system.exitProcess @@ -66,6 +69,8 @@ open class Config( it } } + + Security.addProvider(BouncyCastleProvider()) } fun getConfigPath(): File { @@ -119,6 +124,11 @@ open class Config( return mainConfig.cache ?: CacheConfig() } + @Bean + open fun signatureConfig(@Autowired mainConfig: MainConfig): SignatureConfig { + return mainConfig.signature ?: SignatureConfig() + } + @Bean open fun tokensConfig(@Autowired mainConfig: MainConfig): TokensConfig { return mainConfig.tokens ?: TokensConfig(emptyList()) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/config/MainConfig.kt b/src/main/kotlin/io/emeraldpay/dshackle/config/MainConfig.kt index 4fa6cf86..451d801e 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/config/MainConfig.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/config/MainConfig.kt @@ -26,4 +26,5 @@ class MainConfig { var monitoring: MonitoringConfig = MonitoringConfig.default() var accessLogConfig: AccessLogConfig = AccessLogConfig.default() var health: HealthConfig = HealthConfig.default() + var signature: SignatureConfig? = null } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/config/MainConfigReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/config/MainConfigReader.kt index 7ed84bd6..d75f5cd4 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/config/MainConfigReader.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/config/MainConfigReader.kt @@ -36,6 +36,7 @@ class MainConfigReader( private val monitoringConfigReader = MonitoringConfigReader() private val accessLogReader = AccessLogReader() private val healthConfigReader = HealthConfigReader() + private val signatureConfigReader = SignatureConfigReader(fileResolver) fun read(input: InputStream): MainConfig? { val configNode = readNode(input) @@ -75,6 +76,9 @@ class MainConfigReader( healthConfigReader.read(input).let { config.health = it } + signatureConfigReader.read(input).let { + config.signature = it + } return config } } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/config/SignatureConfig.kt b/src/main/kotlin/io/emeraldpay/dshackle/config/SignatureConfig.kt new file mode 100644 index 00000000..29b94f64 --- /dev/null +++ b/src/main/kotlin/io/emeraldpay/dshackle/config/SignatureConfig.kt @@ -0,0 +1,31 @@ +package io.emeraldpay.dshackle.config + +import java.util.Locale + +class SignatureConfig { + + enum class Algorithm { + SECP256K1 + } + + companion object { + fun algorithmOfString(algo: String): Algorithm { + val algorithm = when (algo.uppercase(Locale.getDefault())) { + "SECP256K1" -> Algorithm.SECP256K1 + else -> throw IllegalArgumentException("Unknown algorithm or not allowed") + } + return algorithm + } + } + + /** + * Signature scheme that we should use + */ + var algorithm: Algorithm = Algorithm.SECP256K1 + /** + * Should we generate signature on this instance if it's not already present + */ + var enabled: Boolean = false + + var privateKey: String? = null +} diff --git a/src/main/kotlin/io/emeraldpay/dshackle/config/SignatureConfigReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/config/SignatureConfigReader.kt new file mode 100644 index 00000000..8ea7ccf4 --- /dev/null +++ b/src/main/kotlin/io/emeraldpay/dshackle/config/SignatureConfigReader.kt @@ -0,0 +1,40 @@ +package io.emeraldpay.dshackle.config + +import io.emeraldpay.dshackle.FileResolver +import org.slf4j.LoggerFactory +import org.yaml.snakeyaml.nodes.MappingNode +import java.io.InputStream + +class SignatureConfigReader(val fileResolver: FileResolver) : YamlConfigReader(), ConfigReader { + + companion object { + private val log = LoggerFactory.getLogger(SignatureConfig::class.java) + } + + fun read(input: InputStream): SignatureConfig? { + val configNode = readNode(input) + return read(configNode) + } + + override fun read(input: MappingNode?): SignatureConfig? { + return getMapping(input, "signed-response")?.let { node -> + val config = SignatureConfig() + getValueAsBool(node, "enabled")?.let { + config.enabled = it + } + if (config.enabled) { + getValueAsString(node, "algorithm")?.let { + config.algorithm = SignatureConfig.algorithmOfString(it) + } + getValueAsString(node, "private-key")?.let { + val key = fileResolver.resolve(it) + config.privateKey = key.absolutePath + } + } + if (config.enabled && config.privateKey == null) { + throw IllegalStateException("Path to a private key (`signature.private-key`) is required when Response signature is enabled.") + } + config + } + } +} diff --git a/src/main/kotlin/io/emeraldpay/dshackle/config/UpstreamsConfigReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/config/UpstreamsConfigReader.kt index 4bb2fae9..a0fd5e8e 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/config/UpstreamsConfigReader.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/config/UpstreamsConfigReader.kt @@ -183,6 +183,8 @@ class UpstreamsConfigReader( fun isValid(upstream: UpstreamsConfig.Upstream<*>): Boolean { val id = upstream.id + // In general, we just check that id is suitable for urls and references, + // Besides that, if Response Signatures are enabled (CurrentResponseSigner) then it's critical that the id cannot have `/` symbol if (id == null || id.length < 3 || !id.matches(Regex("[a-zA-Z][a-zA-Z0-9_-]+[a-zA-Z0-9]"))) { log.warn("Invalid id: $id") return false diff --git a/src/main/kotlin/io/emeraldpay/dshackle/monitoring/accesslog/Events.kt b/src/main/kotlin/io/emeraldpay/dshackle/monitoring/accesslog/Events.kt index 8d6c55f8..9aee432b 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/monitoring/accesslog/Events.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/monitoring/accesslog/Events.kt @@ -110,7 +110,9 @@ class Events { val payloadSizeBytes: Long, val nativeCall: NativeCallItemDetails, val responseBody: String? = null, - val errorMessage: String? = null + val errorMessage: String? = null, + val nonce: Long? = null, + val signature: String? = null ) : ChainBase(blockchain, "NativeCall", id, channel) @JsonInclude(JsonInclude.Include.NON_NULL) @@ -163,6 +165,7 @@ class Events { val method: String, val id: Int, val payloadSizeBytes: Long, + val nonce: Long, val requestParams: String? = null ) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/monitoring/accesslog/EventsBuilder.kt b/src/main/kotlin/io/emeraldpay/dshackle/monitoring/accesslog/EventsBuilder.kt index 880e6bce..b7726b77 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/monitoring/accesslog/EventsBuilder.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/monitoring/accesslog/EventsBuilder.kt @@ -23,6 +23,7 @@ import io.grpc.Attributes import io.grpc.Grpc import io.grpc.Metadata import io.netty.handler.codec.http.HttpHeaders +import org.apache.commons.codec.binary.Hex import org.apache.commons.lang3.StringUtils import org.slf4j.LoggerFactory import reactor.netty.http.server.HttpServerRequest @@ -312,6 +313,7 @@ class EventsBuilder { item.method, item.id, item.payload.size().toLong(), + item.nonce, if (accessLogConfig.includeMessages) { if (item.payload != null && !item.payload.isEmpty && item.payload.isValidUtf8) item.payload.toStringUtf8() else "" } else null @@ -336,6 +338,8 @@ class EventsBuilder { if (msg.payload != null && !msg.payload.isEmpty && msg.payload.isValidUtf8) msg.payload.toStringUtf8() else "" } else null, errorMessage = if (accessLogConfig.includeMessages) msg.errorMessage else null, + signature = Hex.encodeHexString(msg.signature.signature.toByteArray()), + nonce = msg.signature.nonce ) } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/proxy/BaseHandler.kt b/src/main/kotlin/io/emeraldpay/dshackle/proxy/BaseHandler.kt index a11dc01e..7025260e 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/proxy/BaseHandler.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/proxy/BaseHandler.kt @@ -116,7 +116,7 @@ abstract class BaseHandler( // If Proxy is configured to preserve original order it means that a client expect responses at exact same position // as requests even if a request completely failed for a some reason. It's very unlikely situation, but still possible // At this case, if we found a gap in responses, we put a default response with an error - ?: NativeCall.CallResult(id, null, NativeCall.CallError(id, "No response", null)) + ?: NativeCall.CallResult(id, null, null, NativeCall.CallError(id, "No response", null), null) } } .flatMapMany { diff --git a/src/main/kotlin/io/emeraldpay/dshackle/proxy/WebsocketHandler.kt b/src/main/kotlin/io/emeraldpay/dshackle/proxy/WebsocketHandler.kt index df9fb795..0de59c53 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/proxy/WebsocketHandler.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/proxy/WebsocketHandler.kt @@ -180,7 +180,7 @@ class WebsocketHandler( } Mono.just(response) .map { Global.objectMapper.writeValueAsString(it) } - .doOnNext { eventHandler.onResponse(NativeCall.CallResult.ok(0, it.toByteArray())) } + .doOnNext { eventHandler.onResponse(NativeCall.CallResult.ok(0, null, it.toByteArray(), null)) } .doFinally { eventHandler.close() } } else { val eventHandler: AccessHandlerHttp.RequestHandler = eventHandlerFactory.call() diff --git a/src/main/kotlin/io/emeraldpay/dshackle/quorum/AlwaysQuorum.kt b/src/main/kotlin/io/emeraldpay/dshackle/quorum/AlwaysQuorum.kt index 235acfdd..a2b5d1dc 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/quorum/AlwaysQuorum.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/quorum/AlwaysQuorum.kt @@ -20,12 +20,14 @@ import io.emeraldpay.dshackle.upstream.Head import io.emeraldpay.dshackle.upstream.Upstream import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcError import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcException +import io.emeraldpay.dshackle.upstream.signature.ResponseSigner open class AlwaysQuorum : CallQuorum { private var resolved = false private var result: ByteArray? = null private var rpcError: JsonRpcError? = null + private var sig: ResponseSigner.Signature? = null override fun init(head: Head) { } @@ -38,14 +40,20 @@ open class AlwaysQuorum : CallQuorum { return rpcError != null } - override fun record(response: ByteArray, upstream: Upstream): Boolean { + override fun getSignature(): ResponseSigner.Signature? { + return sig + } + + override fun record(response: ByteArray, signature: ResponseSigner.Signature?, upstream: Upstream): Boolean { result = response resolved = true + sig = signature return true } - override fun record(error: JsonRpcException, upstream: Upstream) { + override fun record(error: JsonRpcException, signature: ResponseSigner.Signature?, upstream: Upstream) { this.rpcError = error.error + sig = signature } override fun getResult(): ByteArray? { diff --git a/src/main/kotlin/io/emeraldpay/dshackle/quorum/BroadcastQuorum.kt b/src/main/kotlin/io/emeraldpay/dshackle/quorum/BroadcastQuorum.kt index 90189744..b5cc5bbd 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/quorum/BroadcastQuorum.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/quorum/BroadcastQuorum.kt @@ -18,6 +18,7 @@ package io.emeraldpay.dshackle.quorum import io.emeraldpay.dshackle.upstream.Head import io.emeraldpay.dshackle.upstream.Upstream +import io.emeraldpay.dshackle.upstream.signature.ResponseSigner open class BroadcastQuorum( val quorum: Int = 3 @@ -26,6 +27,7 @@ open class BroadcastQuorum( private var result: ByteArray? = null private var txid: String? = null private var calls = 0 + private var sig: ResponseSigner.Signature? = null override fun init(head: Head) { } @@ -42,19 +44,25 @@ open class BroadcastQuorum( return result } - override fun recordValue(response: ByteArray, responseValue: String?, upstream: Upstream) { + override fun getSignature(): ResponseSigner.Signature? { + return sig + } + + override fun recordValue(response: ByteArray, responseValue: String?, signature: ResponseSigner.Signature?, upstream: Upstream) { calls++ if (txid == null && responseValue != null) { txid = responseValue + sig = signature result = response } } - override fun recordError(response: ByteArray?, errorMessage: String?, upstream: Upstream) { + override fun recordError(response: ByteArray?, errorMessage: String?, signature: ResponseSigner.Signature?, upstream: Upstream) { // can be "message: known transaction: TXID", "Transaction with the same hash was already imported" or "message: Nonce too low" calls++ if (result == null) { result = response + sig = signature } } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/quorum/CallQuorum.kt b/src/main/kotlin/io/emeraldpay/dshackle/quorum/CallQuorum.kt index 76ee9483..58122631 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/quorum/CallQuorum.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/quorum/CallQuorum.kt @@ -20,9 +20,7 @@ import io.emeraldpay.dshackle.upstream.Head import io.emeraldpay.dshackle.upstream.Upstream import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcError import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcException -import reactor.util.function.Tuple2 -import java.util.function.BiFunction -import java.util.function.Predicate +import io.emeraldpay.dshackle.upstream.signature.ResponseSigner interface CallQuorum { @@ -31,23 +29,9 @@ interface CallQuorum { fun isResolved(): Boolean fun isFailed(): Boolean - fun record(response: ByteArray, upstream: Upstream): Boolean - fun record(error: JsonRpcException, upstream: Upstream) + fun record(response: ByteArray, signature: ResponseSigner.Signature?, upstream: Upstream): Boolean + fun record(error: JsonRpcException, signature: ResponseSigner.Signature?, upstream: Upstream) + fun getSignature(): ResponseSigner.Signature? fun getResult(): ByteArray? fun getError(): JsonRpcError? - - companion object { - fun untilResolved(cq: CallQuorum): Predicate { - return Predicate { _ -> - !cq.isResolved() - } - } - - fun asReducer(): BiFunction, CallQuorum> { - return BiFunction, CallQuorum> { a, b -> - a.record(b.t1, b.t2) - return@BiFunction a - } - } - } } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/quorum/NonEmptyQuorum.kt b/src/main/kotlin/io/emeraldpay/dshackle/quorum/NonEmptyQuorum.kt index e847fca5..63709e8d 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/quorum/NonEmptyQuorum.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/quorum/NonEmptyQuorum.kt @@ -18,6 +18,7 @@ package io.emeraldpay.dshackle.quorum import io.emeraldpay.dshackle.upstream.Head import io.emeraldpay.dshackle.upstream.Upstream +import io.emeraldpay.dshackle.upstream.signature.ResponseSigner open class NonEmptyQuorum( val maxTries: Int = 3 @@ -25,7 +26,7 @@ open class NonEmptyQuorum( private var result: ByteArray? = null private var tries: Int = 0 - + private var sig: ResponseSigner.Signature? = null override fun init(head: Head) { } @@ -37,10 +38,15 @@ open class NonEmptyQuorum( return tries >= maxTries } - override fun recordValue(response: ByteArray, responseValue: Any?, upstream: Upstream) { + override fun getSignature(): ResponseSigner.Signature? { + return sig + } + + override fun recordValue(response: ByteArray, responseValue: Any?, signature: ResponseSigner.Signature?, upstream: Upstream) { tries++ if (responseValue != null) { result = response + sig = signature } } @@ -48,7 +54,7 @@ open class NonEmptyQuorum( return result } - override fun recordError(response: ByteArray?, errorMessage: String?, upstream: Upstream) { + override fun recordError(response: ByteArray?, errorMessage: String?, sig: ResponseSigner.Signature?, upstream: Upstream) { tries++ } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/quorum/NonceQuorum.kt b/src/main/kotlin/io/emeraldpay/dshackle/quorum/NonceQuorum.kt index 563b07f3..97f195ce 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/quorum/NonceQuorum.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/quorum/NonceQuorum.kt @@ -18,6 +18,7 @@ package io.emeraldpay.dshackle.quorum import io.emeraldpay.dshackle.upstream.Head import io.emeraldpay.dshackle.upstream.Upstream +import io.emeraldpay.dshackle.upstream.signature.ResponseSigner import io.emeraldpay.etherjar.hex.HexQuantity import java.util.concurrent.locks.ReentrantLock import kotlin.concurrent.withLock @@ -31,6 +32,7 @@ open class NonceQuorum( private var result: ByteArray? = null private var receivedTimes = 0 private var errors = 0 + private var sig: ResponseSigner.Signature? = null override fun init(head: Head) { } @@ -44,8 +46,11 @@ open class NonceQuorum( override fun isFailed(): Boolean { return errors >= tries } + override fun getSignature(): ResponseSigner.Signature? { + return sig + } - override fun recordValue(response: ByteArray, responseValue: String?, upstream: Upstream) { + override fun recordValue(response: ByteArray, responseValue: String?, signature: ResponseSigner.Signature?, upstream: Upstream) { val value = responseValue?.let { str -> HexQuantity.from(str).value.toLong() } @@ -54,8 +59,10 @@ open class NonceQuorum( if (value != null && value > resultValue) { resultValue = value result = response + sig = signature } else if (result == null) { result = response + sig = signature } } } @@ -64,7 +71,7 @@ open class NonceQuorum( return result } - override fun recordError(response: ByteArray?, errorMessage: String?, upstream: Upstream) { + override fun recordError(response: ByteArray?, errorMessage: String?, signature: ResponseSigner.Signature?, upstream: Upstream) { errors++ } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/quorum/NotLaggingQuorum.kt b/src/main/kotlin/io/emeraldpay/dshackle/quorum/NotLaggingQuorum.kt index 1d3eb795..89b6bbf2 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/quorum/NotLaggingQuorum.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/quorum/NotLaggingQuorum.kt @@ -20,6 +20,7 @@ import io.emeraldpay.dshackle.upstream.Head import io.emeraldpay.dshackle.upstream.Upstream import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcError import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcException +import io.emeraldpay.dshackle.upstream.signature.ResponseSigner import java.util.concurrent.atomic.AtomicReference /** @@ -32,6 +33,7 @@ class NotLaggingQuorum(val maxLag: Long = 0) : CallQuorum { private val result: AtomicReference = AtomicReference() private val failed = AtomicReference(false) private var rpcError: JsonRpcError? = null + private var sig: ResponseSigner.Signature? = null override fun init(head: Head) { } @@ -44,16 +46,17 @@ class NotLaggingQuorum(val maxLag: Long = 0) : CallQuorum { return failed.get() } - override fun record(response: ByteArray, upstream: Upstream): Boolean { + override fun record(response: ByteArray, signature: ResponseSigner.Signature?, upstream: Upstream): Boolean { val lagging = upstream.getLag() > maxLag if (!lagging) { result.set(response) + sig = signature return true } return false } - override fun record(error: JsonRpcException, upstream: Upstream) { + override fun record(error: JsonRpcException, signature: ResponseSigner.Signature?, upstream: Upstream) { this.rpcError = error.error val lagging = upstream.getLag() > maxLag if (!lagging && result.get() == null) { @@ -61,6 +64,9 @@ class NotLaggingQuorum(val maxLag: Long = 0) : CallQuorum { } } + override fun getSignature(): ResponseSigner.Signature? { + return sig + } override fun getResult(): ByteArray { return result.get() } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/quorum/QuorumReaderFactory.kt b/src/main/kotlin/io/emeraldpay/dshackle/quorum/QuorumReaderFactory.kt index 6edfd7fc..5a18d0ef 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/quorum/QuorumReaderFactory.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/quorum/QuorumReaderFactory.kt @@ -18,6 +18,7 @@ package io.emeraldpay.dshackle.quorum import io.emeraldpay.dshackle.reader.Reader import io.emeraldpay.dshackle.upstream.ApiSource import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcRequest +import io.emeraldpay.dshackle.upstream.signature.ResponseSigner // creates instance of a Quorum based reader interface QuorumReaderFactory { @@ -28,11 +29,11 @@ interface QuorumReaderFactory { } } - fun create(apis: ApiSource, quorum: CallQuorum): Reader + fun create(apis: ApiSource, quorum: CallQuorum, signer: ResponseSigner?): Reader class Default : QuorumReaderFactory { - override fun create(apis: ApiSource, quorum: CallQuorum): Reader { - return QuorumRpcReader(apis, quorum) + override fun create(apis: ApiSource, quorum: CallQuorum, signer: ResponseSigner?): Reader { + return QuorumRpcReader(apis, quorum, signer) } } } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/quorum/QuorumRpcReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/quorum/QuorumRpcReader.kt index e7f1586b..123bdfb5 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/quorum/QuorumRpcReader.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/quorum/QuorumRpcReader.kt @@ -22,12 +22,15 @@ import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcError import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcException import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcRequest import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcResponse +import io.emeraldpay.dshackle.upstream.signature.ResponseSigner import io.emeraldpay.etherjar.rpc.RpcException import org.slf4j.LoggerFactory import reactor.core.publisher.Flux import reactor.core.publisher.Mono import reactor.util.function.Tuple2 +import reactor.util.function.Tuple3 import reactor.util.function.Tuples +import java.util.Optional import java.util.function.BiFunction import java.util.function.Function @@ -36,13 +39,16 @@ import java.util.function.Function */ class QuorumRpcReader( private val apiControl: ApiSource, - private val quorum: CallQuorum + private val quorum: CallQuorum, + private val signer: ResponseSigner?, ) : Reader { companion object { private val log = LoggerFactory.getLogger(QuorumRpcReader::class.java) } + constructor(apiControl: ApiSource, quorum: CallQuorum) : this(apiControl, quorum, null) + override fun read(key: JsonRpcRequest): Mono { // needs at least one response, so start a request apiControl.request(1) @@ -83,8 +89,8 @@ class QuorumRpcReader( } fun execute(key: JsonRpcRequest, retrySpec: reactor.util.retry.Retry): Function, Mono> { - val quorumReduce = BiFunction, CallQuorum> { res, a -> - if (res.record(a.t1, a.t2)) { + val quorumReduce = BiFunction, Upstream>, CallQuorum> { res, a -> + if (res.record(a.t1, a.t2.orElse(null), a.t3)) { apiControl.resolve() } else { // quorum needs more responses, so ask api controller to make another @@ -112,38 +118,61 @@ class QuorumRpcReader( .filter { it.isResolved() } // return nothing if not resolved .map { // TODO find actual quorum number - QuorumRpcReader.Result(it.getResult()!!, 1) + QuorumRpcReader.Result(it.getResult()!!, it.getSignature(), 1) } .switchIfEmpty(defaultResult) } } - fun callApi(api: Upstream, key: JsonRpcRequest): Mono> { + fun callApi(api: Upstream, key: JsonRpcRequest): Mono, Upstream>> { return api.getApi() .read(key) .flatMap { response -> response.requireResult() - .onErrorResume { err -> - // on error notify quorum, it may use error message or other details - val cleanErr: JsonRpcException = when (err) { - is RpcException -> JsonRpcException.from(err) - is JsonRpcException -> err - else -> JsonRpcException( - JsonRpcResponse.NumberId(key.id), - JsonRpcError(-32603, "Unhandled internal error: ${err.javaClass}") - ) - } - quorum.record(cleanErr, api) - // if it's failed after that, then we don't need more calls, stop api source - if (quorum.isFailed()) { - apiControl.resolve() - } else { - apiControl.request(1) - } - Mono.empty() - } + .transform(withSignature(api, key, response)) + .transform(withErrorResume(api, key)) } - .map { Tuples.of(it, api) } + .map { Tuples.of(it.t1, it.t2, api) } + } + + fun withSignature(api: Upstream, key: JsonRpcRequest, response: JsonRpcResponse): Function, Mono>>> { + return Function { src -> + src.map { + val signature = response.providedSignature + ?: if (key.nonce != null) { + signer?.sign(key.nonce, response.getResult(), api) + } else { + null + } + Tuples.of(it, Optional.ofNullable(signature)) + } + } + } + + fun withErrorResume(api: Upstream, key: JsonRpcRequest): Function, Mono> { + return Function { src -> + src.onErrorResume { err -> + // when the call failed with an error we want to notify the quorum because + // it may use the error message or other details + // + val cleanErr: JsonRpcException = when (err) { + is RpcException -> JsonRpcException.from(err) + is JsonRpcException -> err + else -> JsonRpcException( + JsonRpcResponse.NumberId(key.id), + JsonRpcError(-32603, "Unhandled internal error: ${err.javaClass}") + ) + } + quorum.record(cleanErr, null, api) + // if it's failed after that, then we don't need more calls, stop api source + if (quorum.isFailed()) { + apiControl.resolve() + } else { + apiControl.request(1) + } + Mono.empty() + } + } } fun setupDefaultResult(key: JsonRpcRequest): Mono { @@ -162,6 +191,7 @@ class QuorumRpcReader( class Result( val value: ByteArray, + val signature: ResponseSigner.Signature?, val quorum: Int ) } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/quorum/ValueAwareQuorum.kt b/src/main/kotlin/io/emeraldpay/dshackle/quorum/ValueAwareQuorum.kt index 87eca4b2..2e6233e6 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/quorum/ValueAwareQuorum.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/quorum/ValueAwareQuorum.kt @@ -20,6 +20,7 @@ import io.emeraldpay.dshackle.Global import io.emeraldpay.dshackle.upstream.Upstream import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcError import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcException +import io.emeraldpay.dshackle.upstream.signature.ResponseSigner import io.emeraldpay.etherjar.rpc.RpcException import org.slf4j.LoggerFactory @@ -34,26 +35,26 @@ abstract class ValueAwareQuorum( return Global.objectMapper.readValue(response.inputStream(), clazz) } - override fun record(response: ByteArray, upstream: Upstream): Boolean { + override fun record(response: ByteArray, signature: ResponseSigner.Signature?, upstream: Upstream): Boolean { try { val value = extractValue(response, clazz) - recordValue(response, value, upstream) + recordValue(response, value, signature, upstream) } catch (e: RpcException) { - recordError(response, e.rpcMessage, upstream) + recordError(response, e.rpcMessage, signature, upstream) } catch (e: Exception) { - recordError(response, e.message, upstream) + recordError(response, e.message, signature, upstream) } return isResolved() } - override fun record(error: JsonRpcException, upstream: Upstream) { + override fun record(error: JsonRpcException, signature: ResponseSigner.Signature?, upstream: Upstream) { this.rpcError = error.error - recordError(null, error.error.message, upstream) + recordError(null, error.error.message, signature, upstream) } - abstract fun recordValue(response: ByteArray, responseValue: T?, upstream: Upstream) + abstract fun recordValue(response: ByteArray, responseValue: T?, signature: ResponseSigner.Signature?, upstream: Upstream) - abstract fun recordError(response: ByteArray?, errorMessage: String?, upstream: Upstream) + abstract fun recordError(response: ByteArray?, errorMessage: String?, signature: ResponseSigner.Signature?, upstream: Upstream) override fun getError(): JsonRpcError? { return rpcError diff --git a/src/main/kotlin/io/emeraldpay/dshackle/rpc/BlockchainRpc.kt b/src/main/kotlin/io/emeraldpay/dshackle/rpc/BlockchainRpc.kt index 7772dac0..e87f4c7a 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/rpc/BlockchainRpc.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/rpc/BlockchainRpc.kt @@ -44,7 +44,7 @@ class BlockchainRpc( @Autowired private val trackAddress: List, @Autowired private val describe: Describe, @Autowired private val subscribeStatus: SubscribeStatus, - @Autowired private val estimateFee: EstimateFee + @Autowired private val estimateFee: EstimateFee, ) : ReactorBlockchainGrpc.BlockchainImplBase() { private val log = LoggerFactory.getLogger(BlockchainRpc::class.java) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/rpc/NativeCall.kt b/src/main/kotlin/io/emeraldpay/dshackle/rpc/NativeCall.kt index b1b26284..41c9fd01 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/rpc/NativeCall.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/rpc/NativeCall.kt @@ -34,6 +34,7 @@ import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcError import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcException import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcRequest import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcResponse +import io.emeraldpay.dshackle.upstream.signature.ResponseSigner import io.emeraldpay.etherjar.rpc.RpcException import io.emeraldpay.etherjar.rpc.RpcResponseError import io.emeraldpay.grpc.BlockchainType @@ -49,7 +50,8 @@ import java.util.EnumMap @Service open class NativeCall( - @Autowired private val multistreamHolder: MultistreamHolder + @Autowired private val multistreamHolder: MultistreamHolder, + @Autowired private val signer: ResponseSigner, ) { private val log = LoggerFactory.getLogger(NativeCall::class.java) @@ -85,7 +87,7 @@ open class NativeCall( } else { val error = it.getError() Mono.just( - CallResult(error.id, null, error) + CallResult(error.id, 0, null, error, null) ) } } @@ -107,10 +109,21 @@ open class NativeCall( } else { result.payload = ByteString.copyFrom(it.result) } - + if (it.nonce != null && it.signature != null) { + result.signature = buildSignature(it.nonce, it.signature) + } return result.build() } + fun buildSignature(nonce: Long, signature: ResponseSigner.Signature): BlockchainOuterClass.NativeCallReplySignature { + val msg = BlockchainOuterClass.NativeCallReplySignature.newBuilder() + msg.signature = ByteString.copyFrom(signature.value) + msg.keyId = signature.keyId + msg.upstreamId = signature.upstreamId + msg.nonce = nonce + return msg.build() + } + fun processException(it: Throwable?): Mono { val id: Int = if (it != null && it is CallFailure) { it.id @@ -171,7 +184,6 @@ open class NativeCall( ) ) } - // for ethereum the actual block needed for the call may be specified in the call parameters val callSpecificMatcher: Mono = if (BlockchainType.from(upstream.chain) == BlockchainType.ETHEREUM) { @@ -179,7 +191,6 @@ open class NativeCall( } else { null } ?: Mono.empty() - return callSpecificMatcher.defaultIfEmpty(Selector.empty).map { csm -> val matcher = Selector.Builder() .withMatcher(csm) @@ -196,27 +207,27 @@ open class NativeCall( val heightMatcher = Selector.HeightMatcher(minHeight) matcher.withMatcher(heightMatcher) } - - ValidCallContext(requestItem.id, upstream, matcher.build(), callQuorum, RawCallDetails(method, params)) + val nonce = requestItem.nonce.let { if (it == 0L) null else it } + ValidCallContext(requestItem.id, nonce, upstream, matcher.build(), callQuorum, RawCallDetails(method, params)) } } fun fetch(ctx: ValidCallContext): Mono { return ctx.upstream.getRoutedApi(ctx.matcher) .flatMap { api -> - api.read(JsonRpcRequest(ctx.payload.method, ctx.payload.params)) + api.read(JsonRpcRequest(ctx.payload.method, ctx.payload.params, ctx.nonce)) .flatMap(JsonRpcResponse::requireResult) .map { - CallResult.ok(ctx.id, it) + CallResult.ok(ctx.id, ctx.nonce, it, null) } }.switchIfEmpty( Mono.just(ctx).flatMap(this::executeOnRemote) ) .onErrorResume { if (it is CallFailure) { - Mono.just(CallResult.fail(it.id, it.reason)) + Mono.just(CallResult.fail(it.id, ctx.nonce, it.reason)) } else { - Mono.just(CallResult.fail(ctx.id, it)) + Mono.just(CallResult.fail(ctx.id, ctx.nonce, it)) } } } @@ -226,22 +237,22 @@ open class NativeCall( if (!ctx.upstream.getMethods().isCallable(ctx.payload.method)) { return Mono.error(RpcException(RpcResponseError.CODE_METHOD_NOT_EXIST, "Unsupported method")) } - val reader = quorumReaderFactory.create(ctx.getApis(), ctx.callQuorum) + val reader = quorumReaderFactory.create(ctx.getApis(), ctx.callQuorum, signer) return reader - .read(JsonRpcRequest(ctx.payload.method, ctx.payload.params)) + .read(JsonRpcRequest(ctx.payload.method, ctx.payload.params, ctx.nonce)) .map { - CallResult(ctx.id, it.value, null) + CallResult(ctx.id, ctx.nonce, it.value, null, it.signature) } .onErrorResume { t -> val failure = when (t) { - is CallFailure -> CallResult.fail(t.id, t.reason) - is JsonRpcException -> CallResult.fail(ctx.id, t.error.code, t.error.message) - else -> CallResult.fail(ctx.id, t) + is CallFailure -> CallResult.fail(t.id, ctx.nonce, t.reason) + is JsonRpcException -> CallResult.fail(ctx.id, ctx.nonce, t.error.code, t.error.message) + else -> CallResult.fail(ctx.id, ctx.nonce, t) } Mono.just(failure) } .switchIfEmpty( - Mono.just(CallResult.fail(ctx.id, 1, "No response or no available upstream for ${ctx.payload.method}")) + Mono.just(CallResult.fail(ctx.id, ctx.nonce, 1, "No response or no available upstream for ${ctx.payload.method}")) ) } @@ -262,6 +273,7 @@ open class NativeCall( open class ValidCallContext( val id: Int, + val nonce: Long?, val upstream: Multistream, val matcher: Selector.Matcher, val callQuorum: CallQuorum, @@ -280,7 +292,7 @@ open class NativeCall( } fun withPayload(payload: X): ValidCallContext { - return ValidCallContext(id, upstream, matcher, callQuorum, payload) + return ValidCallContext(id, nonce, upstream, matcher, callQuorum, payload) } fun getApis(): ApiSource { @@ -322,18 +334,18 @@ open class NativeCall( } } - open class CallResult(val id: Int, val result: ByteArray?, val error: CallError?) { + open class CallResult(val id: Int, val nonce: Long?, val result: ByteArray?, val error: CallError?, val signature: ResponseSigner.Signature?) { companion object { - fun ok(id: Int, result: ByteArray): CallResult { - return CallResult(id, result, null) + fun ok(id: Int, nonce: Long?, result: ByteArray, signature: ResponseSigner.Signature?): CallResult { + return CallResult(id, nonce, result, null, signature) } - fun fail(id: Int, errorCore: Int, errorMessage: String): CallResult { - return CallResult(id, null, CallError(errorCore, errorMessage, null)) + fun fail(id: Int, nonce: Long?, errorCore: Int, errorMessage: String): CallResult { + return CallResult(id, nonce, null, CallError(errorCore, errorMessage, null), null) } - fun fail(id: Int, error: Throwable): CallResult { - return CallResult(id, null, CallError.from(error)) + fun fail(id: Int, nonce: Long?, error: Throwable): CallResult { + return CallResult(id, nonce, null, CallError.from(error), null) } } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/rpc/TrackBitcoinTx.kt b/src/main/kotlin/io/emeraldpay/dshackle/rpc/TrackBitcoinTx.kt index ca15e72e..6a0e0b3d 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/rpc/TrackBitcoinTx.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/rpc/TrackBitcoinTx.kt @@ -15,7 +15,6 @@ */ package io.emeraldpay.dshackle.rpc -import com.google.protobuf.ByteString import io.emeraldpay.api.proto.BlockchainOuterClass import io.emeraldpay.api.proto.Common import io.emeraldpay.dshackle.SilentException @@ -155,7 +154,6 @@ class TrackBitcoinTx( Common.BlockInfo.newBuilder() .setBlockId(tx.blockHash!!.substring(2)) .setTimestamp(tx.blockTime!!.toEpochMilli()) - .setWeight(ByteString.copyFrom(tx.blockTotalDifficulty!!.toByteArray())) .setHeight(tx.height!!) ) } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/rpc/TrackEthereumTx.kt b/src/main/kotlin/io/emeraldpay/dshackle/rpc/TrackEthereumTx.kt index 61a0656d..2323e25c 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/rpc/TrackEthereumTx.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/rpc/TrackEthereumTx.kt @@ -16,7 +16,6 @@ */ package io.emeraldpay.dshackle.rpc -import com.google.protobuf.ByteString import io.emeraldpay.api.proto.BlockchainOuterClass import io.emeraldpay.api.proto.Common import io.emeraldpay.dshackle.SilentException @@ -258,7 +257,6 @@ class TrackEthereumTx( Common.BlockInfo.newBuilder() .setBlockId(tx.status.blockHash!!.toHex().substring(2)) .setTimestamp(tx.status.blockTime!!.toEpochMilli()) - .setWeight(ByteString.copyFrom(tx.status.blockTotalDifficulty!!.toByteArray())) .setHeight(tx.status.height!!) ) } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/DefaultUpstream.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/DefaultUpstream.kt index b6c1ffdd..36470987 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/DefaultUpstream.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/DefaultUpstream.kt @@ -64,6 +64,12 @@ abstract class DefaultUpstream( .multicast() .directBestEffort() + init { + if (id.length < 3 || !id.matches(Regex("[a-zA-Z][a-zA-Z0-9_-]+[a-zA-Z0-9]"))) { + throw IllegalArgumentException("Invalid upstream id: $id") + } + } + override fun isAvailable(): Boolean { return getStatus() == UpstreamAvailability.OK } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumDirectReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumDirectReader.kt index 8aea6350..06ec5921 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumDirectReader.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumDirectReader.kt @@ -132,7 +132,8 @@ class EthereumDirectReader( */ private fun readWithQuorum(request: JsonRpcRequest): Mono { return quorumReaderFactory - .create(up.getApiSource(Selector.empty), callMethodsFactory.create().getQuorumFor(request.method)) + // we do not use Signer for internal requests because it doesn't make much sense + .create(up.getApiSource(Selector.empty), callMethodsFactory.create().getQuorumFor(request.method), null) .read(request) .map { it.value } } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/LocalCallRouter.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/LocalCallRouter.kt index ebe530c3..71f794fc 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/LocalCallRouter.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/LocalCallRouter.kt @@ -56,9 +56,14 @@ class LocalCallRouter( return Mono.just(methods.executeHardcoded(key.method)) .map { JsonRpcResponse(it, null) } } + if (!methods.isCallable(key.method)) { return Mono.error(RpcException(RpcResponseError.CODE_METHOD_NOT_EXIST, "Unsupported method")) } + if (key.nonce != null) { + // we do not want to serve any requests (except hardcoded) that have nonces from cache + return Mono.empty() + } val common = commonRequests(key) if (common != null) { return common.map { JsonRpcResponse(it, null) } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/WsConnection.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/WsConnection.kt index 6b8ebc32..f5fa0159 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/WsConnection.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/WsConnection.kt @@ -283,7 +283,7 @@ class WsConnection( fun onRpc(msg: ResponseWSParser.WsResponse): Mono { return if (msg.id.isNumber()) { val resp = JsonRpcResponse( - msg.value, msg.error, msg.id + msg.value, msg.error, msg.id, null ) Mono.fromCallable { val status = rpcReceive.tryEmitNext(resp) @@ -377,7 +377,7 @@ class WsConnection( RpcResponseError.CODE_INTERNAL_ERROR, "Response not received from WebSocket" ), - JsonRpcResponse.Id.from(originalId) + JsonRpcResponse.Id.from(originalId), null ) return Flux.from(rpcReceive.asFlux()) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/grpc/BitcoinGrpcUpstream.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/grpc/BitcoinGrpcUpstream.kt index ad88eeb4..542688d3 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/grpc/BitcoinGrpcUpstream.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/grpc/BitcoinGrpcUpstream.kt @@ -40,6 +40,7 @@ import org.springframework.context.Lifecycle import reactor.core.publisher.Mono import java.math.BigInteger import java.time.Instant +import java.util.Locale import java.util.concurrent.TimeoutException import java.util.function.Function @@ -50,7 +51,7 @@ class BitcoinGrpcUpstream( val remote: ReactorBlockchainGrpc.ReactorBlockchainStub, private val client: JsonRpcGrpcClient ) : BitcoinUpstream( - "$parentId/${chain.chainCode}", + "${parentId}_${chain.chainCode.lowercase(Locale.getDefault())}", chain, UpstreamsConfig.Options.getDefaults(), role diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/grpc/EthereumGrpcUpstream.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/grpc/EthereumGrpcUpstream.kt index 2ccebfd7..3951d981 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/grpc/EthereumGrpcUpstream.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/grpc/EthereumGrpcUpstream.kt @@ -43,6 +43,7 @@ import org.springframework.context.Lifecycle import reactor.core.publisher.Mono import java.math.BigInteger import java.time.Instant +import java.util.Locale import java.util.concurrent.TimeoutException import java.util.function.Function @@ -53,7 +54,7 @@ open class EthereumGrpcUpstream( private val remote: ReactorBlockchainGrpc.ReactorBlockchainStub, private val client: JsonRpcGrpcClient ) : EthereumUpstream( - "$parentId/${chain.chainCode}", + "${parentId}_${chain.chainCode.lowercase(Locale.getDefault())}", UpstreamsConfig.Options.getDefaults(), role, null, null diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcGrpcClient.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcGrpcClient.kt index 961eab02..8465f0c3 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcGrpcClient.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcGrpcClient.kt @@ -17,10 +17,12 @@ package io.emeraldpay.dshackle.upstream.rpcclient import com.google.protobuf.ByteString import io.emeraldpay.api.proto.BlockchainOuterClass +import io.emeraldpay.api.proto.BlockchainOuterClass.NativeCallReplySignature import io.emeraldpay.api.proto.ReactorBlockchainGrpc import io.emeraldpay.dshackle.Global import io.emeraldpay.dshackle.reader.Reader import io.emeraldpay.dshackle.upstream.Selector +import io.emeraldpay.dshackle.upstream.signature.ResponseSigner import io.emeraldpay.etherjar.rpc.RpcException import io.emeraldpay.etherjar.rpc.RpcResponseError import io.emeraldpay.grpc.Chain @@ -60,13 +62,14 @@ class JsonRpcGrpcClient( } } - BlockchainOuterClass.NativeCallItem.newBuilder() + val reqItem = BlockchainOuterClass.NativeCallItem.newBuilder() .setId(1) .setMethod(key.method) .setPayload(ByteString.copyFrom(Global.objectMapper.writeValueAsBytes(key.params))) - .build().let { - req.addItems(it) - } + if (key.nonce != null) { + reqItem.nonce = key.nonce + } + req.addItems(reqItem.build()) return Mono.just(key) .doOnNext { @@ -77,7 +80,12 @@ class JsonRpcGrpcClient( .flatMap { resp -> if (resp.succeed) { val bytes = resp.payload.toByteArray() - Mono.just(JsonRpcResponse(bytes, null)) + val signature = if (resp.hasSignature()) { + extractSignature(resp.signature) + } else { + null + } + Mono.just(JsonRpcResponse(bytes, null, JsonRpcResponse.NumberId(0), signature)) } else { metrics.fails.increment() Mono.error( @@ -96,5 +104,16 @@ class JsonRpcGrpcClient( } } } + + fun extractSignature(resp: NativeCallReplySignature?): ResponseSigner.Signature? { + if (resp == null || resp.signature == null || resp.signature.isEmpty || resp.upstreamId == null || resp.upstreamId.isEmpty()) { + return null + } + return ResponseSigner.Signature( + resp.signature.toByteArray(), + resp.upstreamId, + resp.keyId + ) + } } } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcRequest.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcRequest.kt index b0d92372..86109d6a 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcRequest.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcRequest.kt @@ -24,10 +24,11 @@ import io.emeraldpay.dshackle.Global data class JsonRpcRequest( val method: String, val params: List, - val id: Int + val id: Int, + val nonce: Long? ) { - constructor(method: String, params: List) : this(method, params, 1) + @JvmOverloads constructor(method: String, params: List, nonce: Long? = null) : this(method, params, 1, nonce) fun toJson(): ByteArray { val json = mapOf( @@ -62,7 +63,7 @@ data class JsonRpcRequest( throw IllegalStateException("Unsupported param type: ${it.asToken()}") } } - return JsonRpcRequest(method, params, id) + return JsonRpcRequest(method, params, id, null) } } } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcResponse.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcResponse.kt index 96854af4..63f946fa 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcResponse.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcResponse.kt @@ -18,12 +18,18 @@ package io.emeraldpay.dshackle.upstream.rpcclient import com.fasterxml.jackson.core.JsonGenerator import com.fasterxml.jackson.databind.JsonSerializer import com.fasterxml.jackson.databind.SerializerProvider +import io.emeraldpay.dshackle.upstream.signature.ResponseSigner import reactor.core.publisher.Mono class JsonRpcResponse( private val result: ByteArray?, val error: JsonRpcError?, - val id: Id + val id: Id, + + /** + * When making a request through Dshackle protocol a remote may provide its signature with the response, which we keep here + */ + val providedSignature: ResponseSigner.Signature? = null ) { constructor(result: ByteArray?, error: JsonRpcError?) : this(result, error, NumberId(0)) @@ -107,7 +113,11 @@ class JsonRpcResponse( } fun copyWithId(id: Id): JsonRpcResponse { - return JsonRpcResponse(result, error, id) + return JsonRpcResponse(result, error, id, providedSignature) + } + + fun copyWithSignature(signature: ResponseSigner.Signature): JsonRpcResponse { + return JsonRpcResponse(result, error, id, signature) } override fun equals(other: Any?): Boolean { diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/ResponseRpcParser.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/ResponseRpcParser.kt index 33d58b23..50e9f827 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/ResponseRpcParser.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/ResponseRpcParser.kt @@ -25,11 +25,11 @@ open class ResponseRpcParser : ResponseParser() { override fun build(state: Preparsed): JsonRpcResponse { if (state.error != null) { - return JsonRpcResponse(null, state.error, state.id ?: JsonRpcResponse.Id.from(-1)) + return JsonRpcResponse(null, state.error, state.id ?: JsonRpcResponse.Id.from(-1), null) } if (state.nullResult) { - return JsonRpcResponse("null".toByteArray(), null, state.id ?: JsonRpcResponse.Id.from(-1)) + return JsonRpcResponse("null".toByteArray(), null, state.id ?: JsonRpcResponse.Id.from(-1), null) } - return JsonRpcResponse(state.result, null, state.id ?: JsonRpcResponse.Id.from(-1)) + return JsonRpcResponse(state.result, null, state.id ?: JsonRpcResponse.Id.from(-1), null) } } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/NoSigner.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/NoSigner.kt new file mode 100644 index 00000000..2945041d --- /dev/null +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/NoSigner.kt @@ -0,0 +1,9 @@ +package io.emeraldpay.dshackle.upstream.signature + +import io.emeraldpay.dshackle.upstream.Upstream + +class NoSigner : ResponseSigner { + override fun sign(nonce: Long, message: ByteArray, source: Upstream): ResponseSigner.Signature? { + return null + } +} diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/ResponseSigner.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/ResponseSigner.kt new file mode 100644 index 00000000..d88aee4e --- /dev/null +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/ResponseSigner.kt @@ -0,0 +1,32 @@ +package io.emeraldpay.dshackle.upstream.signature + +import io.emeraldpay.dshackle.upstream.Upstream + +interface ResponseSigner { + + fun sign(nonce: Long, message: ByteArray, source: Upstream): Signature? + + data class Signature( + val value: ByteArray, + val upstreamId: String, + val keyId: Long, + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Signature) return false + + if (!value.contentEquals(other.value)) return false + if (upstreamId != other.upstreamId) return false + if (keyId != other.keyId) return false + + return true + } + + override fun hashCode(): Int { + var result = value.contentHashCode() + result = 31 * result + upstreamId.hashCode() + result = 31 * result + keyId.hashCode() + return result + } + } +} diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/ResponseSignerFactory.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/ResponseSignerFactory.kt new file mode 100644 index 00000000..19b318fd --- /dev/null +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/ResponseSignerFactory.kt @@ -0,0 +1,88 @@ +package io.emeraldpay.dshackle.upstream.signature + +import io.emeraldpay.dshackle.config.SignatureConfig +import org.apache.commons.codec.binary.Hex +import org.bouncycastle.jce.ECNamedCurveTable +import org.bouncycastle.jce.spec.ECPublicKeySpec +import org.bouncycastle.math.ec.ECPoint +import org.bouncycastle.util.io.pem.PemObject +import org.bouncycastle.util.io.pem.PemReader +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.FactoryBean +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.stereotype.Repository +import java.nio.ByteBuffer +import java.nio.file.Files +import java.nio.file.Path +import java.security.KeyFactory +import java.security.MessageDigest +import java.security.PublicKey +import java.security.interfaces.ECPrivateKey +import java.security.spec.PKCS8EncodedKeySpec + +@Repository +open class ResponseSignerFactory( + @Autowired private val config: SignatureConfig +) : FactoryBean { + + companion object { + private val log = LoggerFactory.getLogger(ResponseSignerFactory::class.java) + } + + fun readKey(algorithm: SignatureConfig.Algorithm, keyPath: String): Pair { + val reader = PemReader(Files.newBufferedReader(Path.of(keyPath))) + return readKey(algorithm, reader.readPemObject()) + } + + private fun readKey(algorithm: SignatureConfig.Algorithm, pem: PemObject): Pair { + val keyFactory = KeyFactory.getInstance("EC") + val key = when (algorithm) { + SignatureConfig.Algorithm.SECP256K1 -> { + val keySpec = PKCS8EncodedKeySpec(pem.content) + keyFactory.generatePrivate(keySpec) + } + } + + if (key !is ECPrivateKey) { + throw IllegalStateException("Only ECDSA SECP256K1 keys are allowed") + } + + if (key.params.toString() != "secp256k1 (1.3.132.0.10)") { + throw IllegalStateException("Only SECP256K1 are allowed for signing a response") + } + + val publicKey = extractPublicKey(keyFactory, key) + val id = getPublicKeyId(publicKey) + + return Pair(key, id) + } + + fun extractPublicKey(keyFactory: KeyFactory, privateKey: ECPrivateKey): PublicKey { + val ecSpec = ECNamedCurveTable.getParameterSpec("secp256k1") + val q: ECPoint = ecSpec.g.multiply(privateKey.s) + return keyFactory.generatePublic(ECPublicKeySpec(q, ecSpec)) + } + + private fun getPublicKeyId(publicKey: PublicKey): Long { + val digest = MessageDigest.getInstance("SHA-256") + val fullId = digest.digest(publicKey.encoded) + log.info("Using key to sign responses: ${Hex.encodeHexString(fullId).substring(0..15)}") + return ByteBuffer.wrap(fullId).asLongBuffer().get() + } + + override fun getObject(): ResponseSigner { + if (!config.enabled) { + return NoSigner() + } + if (config.privateKey == null) { + log.warn("Private Key for response signature is not set") + return NoSigner() + } + val key = readKey(config.algorithm, config.privateKey!!) + return Secp256KSigner(key.first, key.second) + } + + override fun getObjectType(): Class<*>? { + return ResponseSigner::class.java + } +} diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/Secp256KSigner.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/Secp256KSigner.kt new file mode 100644 index 00000000..262a4ef2 --- /dev/null +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/Secp256KSigner.kt @@ -0,0 +1,60 @@ +package io.emeraldpay.dshackle.upstream.signature + +import io.emeraldpay.dshackle.upstream.Upstream +import org.apache.commons.codec.binary.Hex +import java.security.MessageDigest +import java.security.Signature +import java.security.interfaces.ECPrivateKey + +class Secp256KSigner( + private val privateKey: ECPrivateKey, + val keyId: Long, +) : ResponseSigner { + + companion object { + const val SIGN_SCHEME = "SHA256withECDSA" + const val MSG_PREFIX = "DSHACKLESIG" + const val MSG_SEPARATOR = '/' + } + + override fun sign(nonce: Long, message: ByteArray, source: Upstream): ResponseSigner.Signature { + val sig = Signature.getInstance(SIGN_SCHEME) + sig.initSign(privateKey) + val wrapped = wrapMessage(nonce, message, source) + sig.update(wrapped.toByteArray()) + val value = sig.sign() + return ResponseSigner.Signature( + value, source.getId(), keyId + ) + } + + /** + * To avoid various attacks, such as various kinds of padding and message alternating attacks, + * we (1) tag the original message to specify the source, (2) ensure no parts of the message can affect each other + * and (3) ensure the message cannot break the wrapping. + * + * We're doing that by converting the message as `"DSHACKLESIG/" || str(nonce) || "/" || hex(sha256(msg))` + * + * I.e.: + * - three elements in the wrapped message + * - separated by "/" which is not a part of any element + * - first element is DSHACKLESIG tag + * - second is the nonce value encode as decimal string + * - third is SHA256 hash of the original message encoded as hex string + */ + fun wrapMessage(nonce: Long, message: ByteArray, source: Upstream): String { + val sha256 = MessageDigest.getInstance("SHA-256") + // we create it with max capacity that we expect for the result, which is total lengths of its parts + val formatterMsg = StringBuilder(11 + 1 + 18 + 1 + 64 + 1 + 64) + formatterMsg.append(MSG_PREFIX) + .append(MSG_SEPARATOR) + .append(nonce.toString()) + .append(MSG_SEPARATOR) + // We expect that the id is short enough (less than 64 symbols) and also it doesn't contain the `/` symbol + // which is verified in UpstreamConfigReader and DefaultUpstream constructor + .append(source.getId()) + .append(MSG_SEPARATOR) + .append(Hex.encodeHexString(sha256.digest(message))) + return formatterMsg.toString() + } +} diff --git a/src/test/groovy/io/emeraldpay/dshackle/TlsSetupSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/TlsSetupSpec.groovy index 4f20bf44..4ef30c38 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/TlsSetupSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/TlsSetupSpec.groovy @@ -21,14 +21,19 @@ import io.netty.handler.ssl.ClientAuth import io.netty.handler.ssl.OpenSsl import io.netty.handler.ssl.OpenSslServerContext import io.netty.handler.ssl.SslContext +import org.bouncycastle.jce.provider.BouncyCastleProvider import spock.lang.Specification import sun.security.x509.X509CertImpl +import java.security.Security + class TlsSetupSpec extends Specification { TlsSetup tlsSetup = new TlsSetup(new FileResolver(new File("src/test/resources/tls-local"))) - def setup() { + def setupSpec() { + Security.addProvider(new BouncyCastleProvider()) + // !!!!!!!!!!!! // run test on OS with OpenSSL installed // !!!!!!!!!!!! @@ -148,12 +153,13 @@ class TlsSetupSpec extends Specification { def config = new AuthConfig.ServerTlsAuth( enabled: true, certificate: "127.0.0.1.crt", - key: "127.0.0.1.key", + // note that JDK Security doesn't accept non-P8 keys, but with Bouncy Castle we should test with a really invalid key + key: "127.0.0.1.invalid.key", ) when: tlsSetup.setupServer("test", config, false) then: - def t = thrown(IllegalArgumentException) + thrown(Exception) } def "Fail if client certificate not set but required"() { diff --git a/src/test/groovy/io/emeraldpay/dshackle/config/SignatureConfigReaderSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/config/SignatureConfigReaderSpec.groovy new file mode 100644 index 00000000..3383af39 --- /dev/null +++ b/src/test/groovy/io/emeraldpay/dshackle/config/SignatureConfigReaderSpec.groovy @@ -0,0 +1,47 @@ +package io.emeraldpay.dshackle.config + +import io.emeraldpay.dshackle.test.TestingCommons +import org.bouncycastle.util.io.pem.PemObject +import org.bouncycastle.util.io.pem.PemWriter +import spock.lang.Specification + +import java.security.KeyPairGenerator +import java.security.SecureRandom +import java.security.spec.ECGenParameterSpec +import java.security.spec.PKCS8EncodedKeySpec + +class SignatureConfigReaderSpec extends Specification { + + def "Parse enabled"() { + setup: + def config = "signed-response:\n" + + " enabled: true\n" + + " algorithm: SECP256K1\n" + + " private-key: /root/key.pem\n" + + when: + def reader = new SignatureConfigReader(TestingCommons.fileResolver()) + def act = reader.read(new ByteArrayInputStream(config.bytes)) + + then: + act.enabled + act.privateKey == "/root/key.pem" + act.algorithm == SignatureConfig.Algorithm.SECP256K1 + } + + def "No path when disabled"() { + setup: + def config = "signed-response:\n" + + " enabled: false\n" + + " private-key: /root/key.pem\n" + + when: + def reader = new SignatureConfigReader(TestingCommons.fileResolver()) + def act = reader.read(new ByteArrayInputStream(config.bytes)) + + then: + !act.enabled + act.privateKey == null + } + +} diff --git a/src/test/groovy/io/emeraldpay/dshackle/proxy/BaseHandlerSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/proxy/BaseHandlerSpec.groovy index df5f15fb..9fb32de3 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/proxy/BaseHandlerSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/proxy/BaseHandlerSpec.groovy @@ -62,7 +62,7 @@ class BaseHandlerSpec extends Specification { def call = new ProxyCall(ProxyCall.RpcType.SINGLE) call.items.add(request) call.ids[0] = 5 - def response = new NativeCall.CallResult(0, '{"foo": 1}'.bytes, null) + def response = new NativeCall.CallResult(0, null, '{"foo": 1}'.bytes, null, null) when: def act = Flux.from(handler.execute(Chain.ETHEREUM, call, requestHandler, false)) .collectList() @@ -85,7 +85,7 @@ class BaseHandlerSpec extends Specification { def call = new ProxyCall(ProxyCall.RpcType.BATCH) call.items.add(request) call.ids[0] = 5 - def response = new NativeCall.CallResult(0, '{"foo": 1}'.bytes, null) + def response = new NativeCall.CallResult(0, null, '{"foo": 1}'.bytes, null, null) when: def act = Flux.from(handler.execute(Chain.ETHEREUM, call, requestHandler, false)) .collectList() @@ -116,8 +116,8 @@ class BaseHandlerSpec extends Specification { call.items.add(request2) call.ids[1] = 6 def response = [ - new NativeCall.CallResult(1, '{"foo": 2}'.bytes, null), - new NativeCall.CallResult(0, '{"foo": 1}'.bytes, null) + new NativeCall.CallResult(1, null, '{"foo": 2}'.bytes, null, null), + new NativeCall.CallResult(0, null, '{"foo": 1}'.bytes, null, null) ] when: def act = Flux.from(handler.execute(Chain.ETHEREUM, call, requestHandler, true)) @@ -149,8 +149,8 @@ class BaseHandlerSpec extends Specification { call.items.add(request2) call.ids[1] = 6 def response = [ - new NativeCall.CallResult(1, '{"foo": 2}'.bytes, null), - new NativeCall.CallResult(0, '{"foo": 1}'.bytes, null) + new NativeCall.CallResult(1, null, '{"foo": 2}'.bytes, null, null), + new NativeCall.CallResult(0, null, '{"foo": 1}'.bytes, null, null) ] when: def act = Flux.from(handler.execute(Chain.ETHEREUM, call, requestHandler, true)) @@ -189,8 +189,8 @@ class BaseHandlerSpec extends Specification { // note there is only 2 responses def response = [ - new NativeCall.CallResult(1, '{"foo": 2}'.bytes, null), - new NativeCall.CallResult(2, '{"foo": 3}'.bytes, null) + new NativeCall.CallResult(1, null, '{"foo": 2}'.bytes, null, null), + new NativeCall.CallResult(2, null, '{"foo": 3}'.bytes, null, null) ] when: def act = Flux.from(handler.execute(Chain.ETHEREUM, call, requestHandler, true)) diff --git a/src/test/groovy/io/emeraldpay/dshackle/proxy/HttpHandlerSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/proxy/HttpHandlerSpec.groovy index 670640fb..69e5e8a7 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/proxy/HttpHandlerSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/proxy/HttpHandlerSpec.groovy @@ -43,7 +43,7 @@ class HttpHandlerSpec extends Specification { .setMethod("test_test") .setPayload(ByteString.copyFromUtf8("[]")) .build() - def respItem = new NativeCall.CallResult(1, "100".bytes, null) + def respItem = new NativeCall.CallResult(1, null, "100".bytes, null, null) def req = BlockchainOuterClass.NativeCallRequest.newBuilder() .setChain(Common.ChainRef.CHAIN_ETHEREUM) .addItems(reqItem) @@ -129,7 +129,7 @@ class HttpHandlerSpec extends Specification { def act = handler.execute(Chain.ETHEREUM, call, new AccessHandlerHttp.NoOpHandler(), false) then: - 1 * nativeCall.nativeCallResult(_) >> Flux.just(new NativeCall.CallResult(1, "".bytes, null)) + 1 * nativeCall.nativeCallResult(_) >> Flux.just(new NativeCall.CallResult(1, null, "".bytes, null, null)) StepVerifier.create(act) .expectNext("hello") .expectComplete() diff --git a/src/test/groovy/io/emeraldpay/dshackle/proxy/WebsocketHandlerSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/proxy/WebsocketHandlerSpec.groovy index 76dd0035..815bc4a6 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/proxy/WebsocketHandlerSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/proxy/WebsocketHandlerSpec.groovy @@ -84,7 +84,7 @@ class WebsocketHandlerSpec extends Specification { def "Respond to a single call"() { setup: - def response = new NativeCall.CallResult(0, '{"foo": 1}'.bytes, null) + def response = new NativeCall.CallResult(0, null, '{"foo": 1}'.bytes, null, null) def nativeCall = Mock(NativeCall) { 1 * it.nativeCallResult(_) >> Flux.fromIterable([response]) diff --git a/src/test/groovy/io/emeraldpay/dshackle/proxy/WriteRpcJsonSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/proxy/WriteRpcJsonSpec.groovy index 7ddf8f09..68e2f56f 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/proxy/WriteRpcJsonSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/proxy/WriteRpcJsonSpec.groovy @@ -85,7 +85,7 @@ class WriteRpcJsonSpec extends Specification { def call = new ProxyCall(ProxyCall.RpcType.SINGLE) call.ids[1] = 105 def data = [ - new NativeCall.CallResult(1, '"0x98dbb1"'.bytes, null) + new NativeCall.CallResult(1, null, '"0x98dbb1"'.bytes, null, null) ] when: def act = writer.toJson(call, data[0]) @@ -98,7 +98,7 @@ class WriteRpcJsonSpec extends Specification { def call = new ProxyCall(ProxyCall.RpcType.SINGLE) call.ids[1] = 1 def data = [ - new NativeCall.CallResult(1, null, new NativeCall.CallError(1, "Internal Error", null)) + new NativeCall.CallResult(1, null, null, new NativeCall.CallError(1, "Internal Error", null), null) ] when: def act = writer.toJson(call, data[0]) @@ -111,7 +111,7 @@ class WriteRpcJsonSpec extends Specification { def call = new ProxyCall(ProxyCall.RpcType.SINGLE) call.ids[1] = "aaa" def data = [ - new NativeCall.CallResult(1, '"0x98dbb1"'.bytes, null) + new NativeCall.CallResult(1, null, '"0x98dbb1"'.bytes, null, null) ] when: def act = writer.toJson(call, data[0]) @@ -126,9 +126,9 @@ class WriteRpcJsonSpec extends Specification { call.ids[2] = 11 call.ids[3] = 15 def data = [ - new NativeCall.CallResult(1, '"0x98dbb1"'.bytes, null), - new NativeCall.CallResult(2, null, new NativeCall.CallError(2, "oops", null)), - new NativeCall.CallResult(3, '{"hash": "0x2484f459dc"}'.bytes, null), + new NativeCall.CallResult(1, null, '"0x98dbb1"'.bytes, null, null), + new NativeCall.CallResult(2, null, null, new NativeCall.CallError(2, "oops", null), null), + new NativeCall.CallResult(3, null, '{"hash": "0x2484f459dc"}'.bytes, null, null), ] when: def act = Flux.fromIterable(data) @@ -154,7 +154,7 @@ class WriteRpcJsonSpec extends Specification { def call = new ProxyCall(ProxyCall.RpcType.SINGLE) call.ids[1] = 10 def data = [ - new NativeCall.CallResult(1, '"0x1"'.bytes, null), + new NativeCall.CallResult(1, null, '"0x1"'.bytes, null, null), ] when: def act = Flux.fromIterable(data) diff --git a/src/test/groovy/io/emeraldpay/dshackle/quorum/AlwaysQuorumSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/quorum/AlwaysQuorumSpec.groovy index c2779c37..d34039c0 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/quorum/AlwaysQuorumSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/quorum/AlwaysQuorumSpec.groovy @@ -17,6 +17,7 @@ package io.emeraldpay.dshackle.quorum import io.emeraldpay.dshackle.upstream.Upstream import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcException +import io.emeraldpay.dshackle.upstream.signature.ResponseSigner import spock.lang.Specification class AlwaysQuorumSpec extends Specification { @@ -26,7 +27,7 @@ class AlwaysQuorumSpec extends Specification { def quorum = new AlwaysQuorum() def up = Stub(Upstream) when: - quorum.record(new JsonRpcException(1, "test"), up) + quorum.record(new JsonRpcException(1, "test"), null, up) then: quorum.isFailed() !quorum.isResolved() @@ -41,10 +42,11 @@ class AlwaysQuorumSpec extends Specification { def quorum = new AlwaysQuorum() def up = Stub(Upstream) when: - quorum.record("123".bytes, up) + quorum.record("123".bytes, new ResponseSigner.Signature("sig1".bytes, "test", 100), up) then: quorum.isResolved() quorum.getResult() == "123".bytes + quorum.signature == new ResponseSigner.Signature("sig1".bytes, "test", 100) !quorum.isFailed() } } diff --git a/src/test/groovy/io/emeraldpay/dshackle/quorum/BroadcastQuorumSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/quorum/BroadcastQuorumSpec.groovy index 6cb1118b..c6a8305b 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/quorum/BroadcastQuorumSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/quorum/BroadcastQuorumSpec.groovy @@ -43,21 +43,21 @@ class BroadcastQuorumSpec extends Specification { !q.isResolved() when: - q.record('"0xeaa972c0d8d1ecd3e34fbbef6d34e06670e745c788bdba31c4234a1762f0378c"'.bytes, upstream1) + q.record('"0xeaa972c0d8d1ecd3e34fbbef6d34e06670e745c788bdba31c4234a1762f0378c"'.bytes, null, upstream1) then: !q.isResolved() - 1 * q.recordValue(_, "0xeaa972c0d8d1ecd3e34fbbef6d34e06670e745c788bdba31c4234a1762f0378c", _) + 1 * q.recordValue(_, "0xeaa972c0d8d1ecd3e34fbbef6d34e06670e745c788bdba31c4234a1762f0378c", _, _) when: - q.record('"0xeaa972c0d8d1ecd3e34fbbef6d34e06670e745c788bdba31c4234a1762f0378c"'.bytes, upstream2) + q.record('"0xeaa972c0d8d1ecd3e34fbbef6d34e06670e745c788bdba31c4234a1762f0378c"'.bytes, null, upstream2) then: !q.isResolved() - 1 * q.recordValue(_, "0xeaa972c0d8d1ecd3e34fbbef6d34e06670e745c788bdba31c4234a1762f0378c", _) + 1 * q.recordValue(_, "0xeaa972c0d8d1ecd3e34fbbef6d34e06670e745c788bdba31c4234a1762f0378c", _, _) when: - q.record(new JsonRpcException(1, "Nonce too low"), upstream3) + q.record(new JsonRpcException(1, "Nonce too low"), null, upstream3) then: - 1 * q.recordError(_, _, _) + 1 * q.recordError(_, _, _, _) q.isResolved() objectMapper.readValue(q.result, Object) == "0xeaa972c0d8d1ecd3e34fbbef6d34e06670e745c788bdba31c4234a1762f0378c" } @@ -75,21 +75,21 @@ class BroadcastQuorumSpec extends Specification { !q.isResolved() when: - q.record(new JsonRpcException(1, "Internal error"), upstream1) + q.record(new JsonRpcException(1, "Internal error"), null, upstream1) then: !q.isResolved() - 1 * q.recordError(_, _, _) + 1 * q.recordError(_, _, _, _) when: - q.record('"0xeaa972c0d8d1ecd3e34fbbef6d34e06670e745c788bdba31c4234a1762f0378c"'.bytes, upstream2) + q.record('"0xeaa972c0d8d1ecd3e34fbbef6d34e06670e745c788bdba31c4234a1762f0378c"'.bytes, null, upstream2) then: !q.isResolved() - 1 * q.recordValue(_, "0xeaa972c0d8d1ecd3e34fbbef6d34e06670e745c788bdba31c4234a1762f0378c", _) + 1 * q.recordValue(_, "0xeaa972c0d8d1ecd3e34fbbef6d34e06670e745c788bdba31c4234a1762f0378c", _, _) when: - q.record(new JsonRpcException(1, "Nonce too low"), upstream3) + q.record(new JsonRpcException(1, "Nonce too low"), null, upstream3) then: - 1 * q.recordError(_, _, _) + 1 * q.recordError(_, _, _, _) q.isResolved() objectMapper.readValue(q.result, Object) == "0xeaa972c0d8d1ecd3e34fbbef6d34e06670e745c788bdba31c4234a1762f0378c" } @@ -99,19 +99,19 @@ class BroadcastQuorumSpec extends Specification { def quorum = new BroadcastQuorum(3) def up = Stub(Upstream) when: - quorum.record(new JsonRpcException(1, "test 1"), up) + quorum.record(new JsonRpcException(1, "test 1"), null, up) then: !quorum.isFailed() !quorum.isResolved() when: - quorum.record(new JsonRpcException(1, "test 2"), up) + quorum.record(new JsonRpcException(1, "test 2"), null, up) then: !quorum.isFailed() !quorum.isResolved() when: - quorum.record(new JsonRpcException(1, "test 3"), up) + quorum.record(new JsonRpcException(1, "test 3"), null, up) then: quorum.isFailed() !quorum.isResolved() diff --git a/src/test/groovy/io/emeraldpay/dshackle/quorum/NonEmptyQuorumSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/quorum/NonEmptyQuorumSpec.groovy index 052d665e..d35316e1 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/quorum/NonEmptyQuorumSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/quorum/NonEmptyQuorumSpec.groovy @@ -38,22 +38,23 @@ class NonEmptyQuorumSpec extends Specification { !q.isFailed() when: - q.record(new JsonRpcException(1, "Internal"), upstream1) + q.record(new JsonRpcException(1, "Internal"), null, upstream1) then: !q.isResolved() !q.isFailed() when: - q.record(new JsonRpcException(1, "Internal"), upstream2) + q.record(new JsonRpcException(1, "Internal"), null, upstream2) then: !q.isResolved() !q.isFailed() when: - q.record(new JsonRpcException(1, "Internal"), upstream3) + q.record(new JsonRpcException(1, "Internal"), null, upstream3) then: q.isFailed() !q.isResolved() + q.signature == null } def "Fail first if not error"() { @@ -70,7 +71,7 @@ class NonEmptyQuorumSpec extends Specification { !q.isFailed() when: - q.record('"0x11"'.bytes, upstream1) + q.record('"0x11"'.bytes, null, upstream1) then: q.isResolved() !q.isFailed() @@ -90,14 +91,14 @@ class NonEmptyQuorumSpec extends Specification { !q.isFailed() when: - q.record(new JsonRpcException(1, "Internal"), upstream1) + q.record(new JsonRpcException(1, "Internal"), null, upstream1) then: !q.isFailed() !q.isResolved() - + q.signature == null when: - q.record('"0x11"'.bytes, upstream2) + q.record('"0x11"'.bytes, null, upstream2) then: q.isResolved() !q.isFailed() @@ -117,14 +118,14 @@ class NonEmptyQuorumSpec extends Specification { !q.isFailed() when: - q.record('null'.bytes, upstream2) + q.record('null'.bytes, null, upstream2) then: !q.isFailed() !q.isResolved() when: - q.record('"0x11"'.bytes, upstream2) + q.record('"0x11"'.bytes, null, upstream2) then: q.isResolved() !q.isFailed() diff --git a/src/test/groovy/io/emeraldpay/dshackle/quorum/NonceQuorumSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/quorum/NonceQuorumSpec.groovy index b298b93a..e18a9572 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/quorum/NonceQuorumSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/quorum/NonceQuorumSpec.groovy @@ -43,21 +43,21 @@ class NonceQuorumSpec extends Specification { !q.isResolved() when: - q.record('"0x10"'.bytes, upstream1) + q.record('"0x10"'.bytes, null, upstream1) then: !q.isResolved() - 1 * q.recordValue(_, "0x10", _) + 1 * q.recordValue(_, "0x10", _, _) when: - q.record('"0x11"'.bytes, upstream2) + q.record('"0x11"'.bytes, null, upstream2) then: !q.isResolved() - 1 * q.recordValue(_, "0x11", _) + 1 * q.recordValue(_, "0x11", _, _) when: - q.record('"0x10"'.bytes, upstream3) + q.record('"0x10"'.bytes, null, upstream3) then: - 1 * q.recordValue(_, "0x10", _) + 1 * q.recordValue(_, "0x10", _, _) q.isResolved() objectMapper.readValue(q.result, Object) == "0x11" } @@ -75,27 +75,27 @@ class NonceQuorumSpec extends Specification { !q.isResolved() when: - q.record(new JsonRpcException(1, "Internal"), upstream1) + q.record(new JsonRpcException(1, "Internal"), null, upstream1) then: !q.isResolved() - 1 * q.recordError(_, _, _) + 1 * q.recordError(_, _, _, _) when: - q.record('"0x11"'.bytes, upstream2) + q.record('"0x11"'.bytes, null, upstream2) then: !q.isResolved() - 1 * q.recordValue(_, "0x11", _) + 1 * q.recordValue(_, "0x11", _, _) when: - q.record('"0x10"'.bytes, upstream3) + q.record('"0x10"'.bytes, null, upstream3) then: - 1 * q.recordValue(_, "0x10", _) + 1 * q.recordValue(_, "0x10", _, _) !q.isResolved() when: - q.record('"0x11"'.bytes, upstream1) + q.record('"0x11"'.bytes, null, upstream1) then: - 1 * q.recordValue(_, "0x11", _) + 1 * q.recordValue(_, "0x11", _, _) q.isResolved() objectMapper.readValue(q.result, Object) == "0x11" } @@ -114,23 +114,24 @@ class NonceQuorumSpec extends Specification { !q.isFailed() when: - q.record(new JsonRpcException(1, "Internal"), upstream1) + q.record(new JsonRpcException(1, "Internal"), null, upstream1) then: !q.isResolved() !q.isFailed() when: - q.record(new JsonRpcException(1, "Internal"), upstream2) + q.record(new JsonRpcException(1, "Internal"), null, upstream2) then: !q.isResolved() !q.isFailed() when: - q.record(new JsonRpcException(1, "Internal"), upstream3) + q.record(new JsonRpcException(1, "Internal"), null, upstream3) then: q.isFailed() !q.isResolved() q.getError() != null q.getError().message == "Internal" + q.signature == null } } diff --git a/src/test/groovy/io/emeraldpay/dshackle/quorum/NotLaggingQuorumSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/quorum/NotLaggingQuorumSpec.groovy index 57e26419..49399c96 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/quorum/NotLaggingQuorumSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/quorum/NotLaggingQuorumSpec.groovy @@ -19,6 +19,7 @@ package io.emeraldpay.dshackle.quorum import io.emeraldpay.dshackle.upstream.Upstream import io.emeraldpay.dshackle.quorum.NotLaggingQuorum import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcException +import io.emeraldpay.dshackle.upstream.signature.ResponseSigner import io.emeraldpay.etherjar.rpc.RpcException import spock.lang.Specification @@ -31,7 +32,7 @@ class NotLaggingQuorumSpec extends Specification { def quorum = new NotLaggingQuorum(1) when: - quorum.record(value, up) + quorum.record(value, null, up) then: 1 * up.getLag() >> 0 quorum.isResolved() @@ -39,6 +40,22 @@ class NotLaggingQuorumSpec extends Specification { quorum.result == value } + def "Keeps signature"() { + setup: + def up = Mock(Upstream) + def value = "foo".getBytes() + def quorum = new NotLaggingQuorum(1) + + when: + quorum.record(value, new ResponseSigner.Signature("sig1".bytes, "test", 100), up) + then: + 1 * up.getLag() >> 0 + quorum.isResolved() + !quorum.isFailed() + quorum.result == value + quorum.signature == new ResponseSigner.Signature("sig1".bytes, "test", 100) + } + def "Resolves if ok lag"() { setup: def up = Mock(Upstream) @@ -46,7 +63,7 @@ class NotLaggingQuorumSpec extends Specification { def quorum = new NotLaggingQuorum(1) when: - quorum.record(value, up) + quorum.record(value, null, up) then: 1 * up.getLag() >> 1 quorum.isResolved() @@ -61,7 +78,7 @@ class NotLaggingQuorumSpec extends Specification { def quorum = new NotLaggingQuorum(1) when: - quorum.record(value, up) + quorum.record(value, null, up) then: 1 * up.getLag() >> 2 !quorum.isResolved() @@ -75,7 +92,7 @@ class NotLaggingQuorumSpec extends Specification { def quorum = new NotLaggingQuorum(1) when: - quorum.record(new JsonRpcException(-100, "test error"), up) + quorum.record(new JsonRpcException(-100, "test error"), null, up) then: 1 * up.getLag() >> 1 !quorum.isResolved() diff --git a/src/test/groovy/io/emeraldpay/dshackle/quorum/ValueAwareQuorumSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/quorum/ValueAwareQuorumSpec.groovy index 04ae7c4e..9b9f9a62 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/quorum/ValueAwareQuorumSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/quorum/ValueAwareQuorumSpec.groovy @@ -15,7 +15,7 @@ */ package io.emeraldpay.dshackle.quorum -import io.emeraldpay.dshackle.test.TestingCommons +import io.emeraldpay.dshackle.upstream.signature.ResponseSigner import io.emeraldpay.dshackle.upstream.Head import io.emeraldpay.dshackle.upstream.Upstream import org.jetbrains.annotations.NotNull @@ -66,12 +66,14 @@ class ValueAwareQuorumSpec extends Specification { } @Override - void recordValue(@NotNull byte[] response, @Nullable Object responseValue, @NotNull Upstream upstream) { + void recordValue(@NotNull byte[] response, @Nullable Object responseValue, @Nullable ResponseSigner.Signature signature, @NotNull Upstream upstream) { } + + @Override - void recordError(@Nullable byte[] response, @Nullable String errorMessage, @NotNull Upstream upstream) { + void recordError(@Nullable byte[] response, @Nullable String errorMessage, @Nullable ResponseSigner.Signature signature, @NotNull Upstream upstream) { } @@ -85,6 +87,11 @@ class ValueAwareQuorumSpec extends Specification { return false } + @Override + ResponseSigner.Signature getSignature() { + return null + } + @Override byte[] getResult() { return new byte[0] diff --git a/src/test/groovy/io/emeraldpay/dshackle/rpc/NativeCallSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/rpc/NativeCallSpec.groovy index 16e9c630..2704fdbb 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/rpc/NativeCallSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/rpc/NativeCallSpec.groovy @@ -31,11 +31,11 @@ import io.emeraldpay.dshackle.upstream.Head import io.emeraldpay.dshackle.upstream.Multistream import io.emeraldpay.dshackle.upstream.Selector import io.emeraldpay.dshackle.upstream.MultistreamHolder -import io.emeraldpay.dshackle.upstream.Upstream import io.emeraldpay.dshackle.upstream.calls.DefaultEthereumMethods import io.emeraldpay.dshackle.upstream.calls.ManagedCallMethods import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcRequest import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcResponse +import io.emeraldpay.dshackle.upstream.signature.ResponseSigner import io.emeraldpay.grpc.Chain import io.emeraldpay.etherjar.rpc.RpcException import io.emeraldpay.etherjar.rpc.RpcResponseError @@ -51,6 +51,16 @@ class NativeCallSpec extends Specification { ObjectMapper objectMapper = Global.objectMapper + def nativeCall(MultistreamHolder upstreams = null, ResponseSigner signer = null) { + if (upstreams == null) { + upstreams = Stub(MultistreamHolder) + } + if (signer == null) { + signer = Stub(ResponseSigner) + } + new NativeCall(upstreams, signer) + } + def "Tries router first"() { def routedApi = Mock(Reader) { 1 * read(new JsonRpcRequest("eth_test", [])) >> Mono.just(new JsonRpcResponse("1".bytes, null)) @@ -58,11 +68,10 @@ class NativeCallSpec extends Specification { def upstream = Mock(Multistream) { 1 * getRoutedApi(_) >> Mono.just(routedApi) } - def upstreams = Stub(MultistreamHolder) - def nativeCall = new NativeCall(upstreams) + def nativeCall = nativeCall() def ctx = new NativeCall.ValidCallContext( - 1, upstream, Selector.empty, new AlwaysQuorum(), + 1, null, upstream, Selector.empty, new AlwaysQuorum(), new NativeCall.ParsedCallDetails("eth_test", []) ) @@ -72,6 +81,7 @@ class NativeCallSpec extends Specification { act.result == "1".bytes } + def "Return error if router denied the requests"() { def routedApi = Mock(Reader) { 1 * read(new JsonRpcRequest("eth_test", [])) >> Mono.error(new RpcException(RpcResponseError.CODE_METHOD_NOT_EXIST, "Test message")) @@ -79,11 +89,10 @@ class NativeCallSpec extends Specification { def upstream = Mock(Multistream) { 1 * getRoutedApi(_) >> Mono.just(routedApi) } - def upstreams = Stub(MultistreamHolder) - def nativeCall = new NativeCall(upstreams) + def nativeCall = nativeCall() def ctx = new NativeCall.ValidCallContext( - 15, upstream, Selector.empty, new AlwaysQuorum(), + 15, null, upstream, Selector.empty, new AlwaysQuorum(), new NativeCall.ParsedCallDetails("eth_test", []) ) @@ -102,13 +111,13 @@ class NativeCallSpec extends Specification { setup: def quorum = new AlwaysQuorum() - def nativeCall = new NativeCall(Stub(MultistreamHolder)) + def nativeCall = nativeCall() nativeCall.quorumReaderFactory = Mock(QuorumReaderFactory) { - 1 * create(_, _) >> Mock(Reader) { - 1 * read(_) >> Mono.just(new QuorumRpcReader.Result("\"foo\"".bytes, 1)) + 1 * create(_, _, _) >> Mock(Reader) { + 1 * read(_) >> Mono.just(new QuorumRpcReader.Result("\"foo\"".bytes, null, 1)) } } - def call = new NativeCall.ValidCallContext(1, TestingCommons.multistream(TestingCommons.api()), Selector.empty, quorum, + def call = new NativeCall.ValidCallContext(1, 10, TestingCommons.multistream(TestingCommons.api()), Selector.empty, quorum, new NativeCall.ParsedCallDetails("eth_test", [])) when: @@ -116,19 +125,20 @@ class NativeCallSpec extends Specification { def act = objectMapper.readValue(resp.result, Object) then: act == "foo" + resp.nonce == 10 } def "Returns error if no quorum"() { setup: def quorum = new AlwaysQuorum() - def nativeCall = new NativeCall(Stub(MultistreamHolder)) + def nativeCall = nativeCall() nativeCall.quorumReaderFactory = Mock(QuorumReaderFactory) { - 1 * create(_, _) >> Mock(Reader) { - 1 * read(_) >> Mono.empty() + 1 * create(_, _, _) >> Mock(Reader) { + 1 * read(new JsonRpcRequest("eth_test", [], 10)) >> Mono.empty() } } - def call = new NativeCall.ValidCallContext(1, TestingCommons.multistream(TestingCommons.api()), Selector.empty, quorum, + def call = new NativeCall.ValidCallContext(1, 10, TestingCommons.multistream(TestingCommons.api()), Selector.empty, quorum, new NativeCall.ParsedCallDetails("eth_test", [])) when: @@ -136,7 +146,7 @@ class NativeCallSpec extends Specification { then: StepVerifier.create(resp) .expectNextMatches { result -> - result.isError() + result.isError() && result.nonce == 10 } .expectComplete() .verify(Duration.ofSeconds(1)) @@ -144,8 +154,7 @@ class NativeCallSpec extends Specification { def "Packs call exception into response with id"() { setup: - def upstreams = Stub(MultistreamHolder) - def nativeCall = new NativeCall(upstreams) + def nativeCall = nativeCall() when: def resp = nativeCall.processException(new NativeCall.CallFailure(5, new IllegalArgumentException("test test"))) then: @@ -161,8 +170,7 @@ class NativeCallSpec extends Specification { def "Packs unknown exception into response"() { setup: - def upstreams = Stub(MultistreamHolder) - def nativeCall = new NativeCall(upstreams) + def nativeCall = nativeCall() when: def resp = nativeCall.processException(new IllegalArgumentException("test test")) then: @@ -177,13 +185,12 @@ class NativeCallSpec extends Specification { def "Builds normal response"() { setup: - def upstreams = Stub(MultistreamHolder) - def nativeCall = new NativeCall(upstreams) + def nativeCall = nativeCall() def json = [jsonrpc:"2.0", id:1, result: "foo"] when: def resp = nativeCall.buildResponse( - new NativeCall.CallResult(1561, objectMapper.writeValueAsBytes(json), null) + new NativeCall.CallResult(1561, 10, objectMapper.writeValueAsBytes(json), null, null) ) then: resp.id == 1561 @@ -191,10 +198,28 @@ class NativeCallSpec extends Specification { objectMapper.readValue(resp.payload.toByteArray(), Map.class) == [jsonrpc:"2.0", id:1, result: "foo"] } + def "Builds response with signature"() { + setup: + def nativeCall = nativeCall() + def json = [jsonrpc:"2.0", id:1, result: "foo"] + + when: + def resp = nativeCall.buildResponse( + new NativeCall.CallResult(1561, 10, objectMapper.writeValueAsBytes(json), null, new ResponseSigner.Signature("sig1".bytes, "test", 100)) + ) + then: + resp.id == 1561 + resp.succeed + resp.signature.nonce == 10 + resp.signature.signature.toByteArray() == "sig1".bytes + resp.signature.keyId == 100 + resp.signature.upstreamId == "test" + objectMapper.readValue(resp.payload.toByteArray(), Map.class) == [jsonrpc:"2.0", id:1, result: "foo"] + } + def "Returns error for invalid chain"() { setup: - def upstreams = Stub(MultistreamHolder) - def nativeCall = new NativeCall(upstreams) + def nativeCall = nativeCall() def req = BlockchainOuterClass.NativeCallRequest.newBuilder() .setChainValue(0) @@ -210,7 +235,6 @@ class NativeCallSpec extends Specification { then: StepVerifier.create(resp) .expectErrorMatches({t -> t instanceof NativeCall.CallFailure && t.id == 0}) -// .expectComplete() .verify(Duration.ofSeconds(1)) } @@ -219,7 +243,7 @@ class NativeCallSpec extends Specification { def upstreams = Mock(MultistreamHolder) { _ * it.observeChains() >> Flux.empty() } - def nativeCall = new NativeCall(upstreams) + def nativeCall = nativeCall(upstreams) def req = BlockchainOuterClass.NativeCallRequest.newBuilder() .setChainValue(Chain.TESTNET_MORDEN.id) @@ -245,13 +269,14 @@ class NativeCallSpec extends Specification { def upstreams = Mock(MultistreamHolder) { _ * it.observeChains() >> Flux.empty() } - def nativeCall = new NativeCall(upstreams) + def nativeCall = nativeCall(upstreams) def req = BlockchainOuterClass.NativeCallRequest.newBuilder() .setChain(Common.ChainRef.CHAIN_ETHEREUM) .addItems( BlockchainOuterClass.NativeCallItem.newBuilder() .setId(1) + .setNonce(10) .setMethod("eth_test") .setPayload(ByteString.copyFromUtf8("[]")) ) @@ -263,6 +288,7 @@ class NativeCallSpec extends Specification { act.size() == 1 with(act[0]) { id == 1 + nonce == 10 payload.method == "eth_test" payload.params == "[]" } @@ -273,7 +299,7 @@ class NativeCallSpec extends Specification { def upstreams = Mock(MultistreamHolder) { _ * it.observeChains() >> Flux.empty() } - def nativeCall = new NativeCall(upstreams) + def nativeCall = nativeCall(upstreams) def req = BlockchainOuterClass.NativeCallRequest.newBuilder() .setChain(Common.ChainRef.CHAIN_ETHEREUM) @@ -300,7 +326,7 @@ class NativeCallSpec extends Specification { def upstreams = Mock(MultistreamHolder) { _ * it.observeChains() >> Flux.empty() } - def nativeCall = new NativeCall(upstreams) + def nativeCall = nativeCall(upstreams) def item = BlockchainOuterClass.NativeCallItem.newBuilder() .setId(1) @@ -338,7 +364,7 @@ class NativeCallSpec extends Specification { def multistreamHolder = Mock(MultistreamHolder) { _ * it.observeChains() >> Flux.empty() } - def nativeCall = new NativeCall(multistreamHolder) + def nativeCall = nativeCall(multistreamHolder) def req = BlockchainOuterClass.NativeCallRequest.newBuilder() .setChain(Common.ChainRef.CHAIN_ETHEREUM) @@ -365,8 +391,8 @@ class NativeCallSpec extends Specification { def "Parse empty params"() { setup: - def nativeCall = new NativeCall(Stub(MultistreamHolder)) - def ctx = new NativeCall.ValidCallContext(1, Stub(Multistream), Selector.empty, new AlwaysQuorum(), + def nativeCall = nativeCall() + def ctx = new NativeCall.ValidCallContext(1, null, Stub(Multistream), Selector.empty, new AlwaysQuorum(), new NativeCall.RawCallDetails("eth_test", "[]")) when: def act = nativeCall.parseParams(ctx) @@ -378,8 +404,8 @@ class NativeCallSpec extends Specification { def "Parse none params"() { setup: - def nativeCall = new NativeCall(Stub(MultistreamHolder)) - def ctx = new NativeCall.ValidCallContext(1, Stub(Multistream), Selector.empty, new AlwaysQuorum(), + def nativeCall = nativeCall() + def ctx = new NativeCall.ValidCallContext(1, null, Stub(Multistream), Selector.empty, new AlwaysQuorum(), new NativeCall.RawCallDetails("eth_test", "")) when: def act = nativeCall.parseParams(ctx) @@ -391,8 +417,8 @@ class NativeCallSpec extends Specification { def "Parse single param"() { setup: - def nativeCall = new NativeCall(Stub(MultistreamHolder)) - def ctx = new NativeCall.ValidCallContext(1, Stub(Multistream), Selector.empty, new AlwaysQuorum(), + def nativeCall = nativeCall() + def ctx = new NativeCall.ValidCallContext(1, null, Stub(Multistream), Selector.empty, new AlwaysQuorum(), new NativeCall.RawCallDetails("eth_test", "[false]")) when: def act = nativeCall.parseParams(ctx) @@ -404,8 +430,8 @@ class NativeCallSpec extends Specification { def "Parse multi param"() { setup: - def nativeCall = new NativeCall(Stub(MultistreamHolder)) - def ctx = new NativeCall.ValidCallContext(1, Stub(Multistream), Selector.empty, new AlwaysQuorum(), + def nativeCall = nativeCall() + def ctx = new NativeCall.ValidCallContext(1, null, Stub(Multistream), Selector.empty, new AlwaysQuorum(), new NativeCall.RawCallDetails("eth_test", "[false, 123]")) when: def act = nativeCall.parseParams(ctx) @@ -419,12 +445,11 @@ class NativeCallSpec extends Specification { //TODO def "Calls cache before remote"() { setup: - def upstreams = Stub(MultistreamHolder) - def nativeCall = new NativeCall(upstreams) + def nativeCall = nativeCall() def api = TestingCommons.api() def upstream = TestingCommons.multistream(api) - def ctx = new NativeCall.ValidCallContext(10, + def ctx = new NativeCall.ValidCallContext(10, null, upstream, Selector.empty, new AlwaysQuorum(), new NativeCall.ParsedCallDetails("eth_test", [])) @@ -438,11 +463,10 @@ class NativeCallSpec extends Specification { //TODO def "Uses cached value"() { setup: - def upstreams = Stub(MultistreamHolder) - def nativeCall = new NativeCall(upstreams) + def nativeCall = nativeCall() def upstream = TestingCommons.multistream(TestingCommons.api()) - def ctx = new NativeCall.ValidCallContext(10, + def ctx = new NativeCall.ValidCallContext(10, null, upstream, Selector.empty, new AlwaysQuorum(), new NativeCall.ParsedCallDetails("eth_test", [])) diff --git a/src/test/groovy/io/emeraldpay/dshackle/rpc/TrackEthereumTxSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/rpc/TrackEthereumTxSpec.groovy index e7cebcbc..d371d4a4 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/rpc/TrackEthereumTxSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/rpc/TrackEthereumTxSpec.groovy @@ -91,7 +91,6 @@ class TrackEthereumTxSpec extends Specification { .setBlock( Common.BlockInfo.newBuilder() .setHeight(blockJson.number) - .setWeight(ByteString.copyFrom(blockJson.totalDifficulty.toByteArray())) .setBlockId(blockJson.hash.toHex().substring(2)) .setTimestamp(blockJson.timestamp.toEpochMilli()) ).build() @@ -280,7 +279,6 @@ class TrackEthereumTxSpec extends Specification { .setBlock( Common.BlockInfo.newBuilder() .setHeight(blocks[2].number) - .setWeight(ByteString.copyFrom(blocks[2].totalDifficulty.toByteArray())) .setBlockId(blocks[2].hash.toHex().substring(2)) .setTimestamp(blocks[2].timestamp.toEpochMilli()) ) diff --git a/src/test/groovy/io/emeraldpay/dshackle/test/ApiReaderMock.groovy b/src/test/groovy/io/emeraldpay/dshackle/test/ApiReaderMock.groovy index 4bdaa89c..9b2c073d 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/test/ApiReaderMock.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/test/ApiReaderMock.groovy @@ -108,7 +108,7 @@ class ApiReaderMock implements Reader { } error = new JsonRpcError(-32601, "Method ${request.method} with ${request.params} is not mocked") } - return new JsonRpcResponse(result, error, JsonRpcResponse.Id.from(request.id)) + return new JsonRpcResponse(result, error, JsonRpcResponse.Id.from(request.id), null) } as Callable return Mono.fromCallable(call) } @@ -323,7 +323,7 @@ class ApiReaderMock implements Reader { } @Override - def NettyOutbound sendUsing(Callable sourceInput, BiFunction mappedInput, Consumer sourceCleanup) { + NettyOutbound sendUsing(Callable sourceInput, BiFunction mappedInput, Consumer sourceCleanup) { return this } diff --git a/src/test/groovy/io/emeraldpay/dshackle/upstream/FilteredApisSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/upstream/FilteredApisSpec.groovy index 8ea9811e..960e0e40 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/upstream/FilteredApisSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/upstream/FilteredApisSpec.groovy @@ -214,7 +214,7 @@ class FilteredApisSpec extends Specification { setup: List standard = (0..1).collect { TestingCommons.upstream( - it.toString(), + "test_" + it, new EthereumApiStub(it) ) } @@ -246,7 +246,7 @@ class FilteredApisSpec extends Specification { setup: List standard = (0..1).collect { TestingCommons.upstream( - it.toString(), + "test_" + it, new EthereumApiStub(it) ) } diff --git a/src/test/groovy/io/emeraldpay/dshackle/upstream/MultistreamSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/upstream/MultistreamSpec.groovy index 60e53174..10a036ac 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/upstream/MultistreamSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/upstream/MultistreamSpec.groovy @@ -68,8 +68,8 @@ class MultistreamSpec extends Specification { def "Filter Best Status accepts better input"() { setup: - def up1 = TestingCommons.upstream("1") - def up2 = TestingCommons.upstream("2") + def up1 = TestingCommons.upstream("test-1") + def up2 = TestingCommons.upstream("test-2") def time0 = Instant.now() - Duration.ofSeconds(60) def filter = new Multistream.FilterBestAvailability() def update0 = new Multistream.UpstreamStatus( @@ -87,8 +87,8 @@ class MultistreamSpec extends Specification { def "Filter Best Status declines worse input"() { setup: - def up1 = TestingCommons.upstream("1") - def up2 = TestingCommons.upstream("2") + def up1 = TestingCommons.upstream("test-1") + def up2 = TestingCommons.upstream("test-2") def time0 = Instant.now() - Duration.ofSeconds(60) def filter = new Multistream.FilterBestAvailability() def update0 = new Multistream.UpstreamStatus( @@ -106,7 +106,7 @@ class MultistreamSpec extends Specification { def "Filter Best Status accepts worse input from same upstream"() { setup: - def up = TestingCommons.upstream("1") + def up = TestingCommons.upstream("test-1") def time0 = Instant.now() - Duration.ofSeconds(60) def filter = new Multistream.FilterBestAvailability() def update0 = new Multistream.UpstreamStatus( @@ -124,8 +124,8 @@ class MultistreamSpec extends Specification { def "Filter Best Status accepts any input if existing is outdated"() { setup: - def up1 = TestingCommons.upstream("1") - def up2 = TestingCommons.upstream("2") + def up1 = TestingCommons.upstream("test-1") + def up2 = TestingCommons.upstream("test-2") def time0 = Instant.now() - Duration.ofSeconds(90) def filter = new Multistream.FilterBestAvailability() def update0 = new Multistream.UpstreamStatus( @@ -143,9 +143,9 @@ class MultistreamSpec extends Specification { def "Filter Best Status declines same status"() { setup: - def up1 = TestingCommons.upstream("1") - def up2 = TestingCommons.upstream("2") - def up3 = TestingCommons.upstream("3") + def up1 = TestingCommons.upstream("test-1") + def up2 = TestingCommons.upstream("test-2") + def up3 = TestingCommons.upstream("test-3") def time0 = Instant.now() - Duration.ofSeconds(60) def filter = new Multistream.FilterBestAvailability() def update0 = new Multistream.UpstreamStatus( @@ -172,7 +172,7 @@ class MultistreamSpec extends Specification { def "Call postprocess after api use"() { setup: - def request = new JsonRpcRequest("test_foo", [1], 1) + def request = new JsonRpcRequest("test_foo", [1], 1, null) def api = TestingCommons.api() api.answer("test_foo", [1], "test") diff --git a/src/test/groovy/io/emeraldpay/dshackle/upstream/RequestPostprocessorSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/upstream/RequestPostprocessorSpec.groovy index 5d298f4f..bf73f37d 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/upstream/RequestPostprocessorSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/upstream/RequestPostprocessorSpec.groovy @@ -13,7 +13,7 @@ class RequestPostprocessorSpec extends Specification { def "Wrappers calls onReceive for a value"() { setup: - def request = new JsonRpcRequest("test_foo", [1], 1) + def request = new JsonRpcRequest("test_foo", [1], 1, null) def processor = Mock(RequestPostprocessor) def api = TestingCommons.api() api.answer("test_foo", [1], "test") @@ -30,7 +30,7 @@ class RequestPostprocessorSpec extends Specification { def "Wrappers doesn't call onReceive for no value"() { setup: - def request = new JsonRpcRequest("test_foo", [1], 1) + def request = new JsonRpcRequest("test_foo", [1], 1, null) def processor = Mock(RequestPostprocessor) Reader reader = Mock(Reader) { 1 * it.read(request) >> Mono.empty() diff --git a/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/EthereumDirectReaderSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/EthereumDirectReaderSpec.groovy index 6246dfec..ac2781f5 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/EthereumDirectReaderSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/EthereumDirectReaderSpec.groovy @@ -50,10 +50,10 @@ class EthereumDirectReaderSpec extends Specification { up, Caches.default(), new CurrentBlockCache(), calls ) reader.quorumReaderFactory = Mock(QuorumReaderFactory) { - 1 * create(_, _) >> Mock(Reader) { + 1 * create(_, _, _) >> Mock(Reader) { 1 * read(new JsonRpcRequest("eth_getBlockByHash", [hash1, false])) >> Mono.just( new QuorumRpcReader.Result( - Global.objectMapper.writeValueAsBytes(json), 1 + Global.objectMapper.writeValueAsBytes(json), null, 1 ) ) } @@ -81,10 +81,10 @@ class EthereumDirectReaderSpec extends Specification { up, Caches.default(), new CurrentBlockCache(), calls ) reader.quorumReaderFactory = Mock(QuorumReaderFactory) { - 1 * create(_, _) >> Mock(Reader) { + 1 * create(_, _, _) >> Mock(Reader) { 1 * read(new JsonRpcRequest("eth_getBlockByHash", [hash1, false])) >> Mono.just( new QuorumRpcReader.Result( - Global.objectMapper.writeValueAsBytes(null), 1 + Global.objectMapper.writeValueAsBytes(null), null, 1 ) ) } @@ -116,10 +116,10 @@ class EthereumDirectReaderSpec extends Specification { up, Caches.default(), new CurrentBlockCache(), calls ) reader.quorumReaderFactory = Mock(QuorumReaderFactory) { - 1 * create(_, _) >> Mock(Reader) { + 1 * create(_, _, _) >> Mock(Reader) { 1 * read(new JsonRpcRequest("eth_getBlockByNumber", ["0x64", false])) >> Mono.just( new QuorumRpcReader.Result( - Global.objectMapper.writeValueAsBytes(json), 1 + Global.objectMapper.writeValueAsBytes(json), null, 1 ) ) } @@ -152,10 +152,10 @@ class EthereumDirectReaderSpec extends Specification { up, Caches.default(), new CurrentBlockCache(), calls ) reader.quorumReaderFactory = Mock(QuorumReaderFactory) { - 1 * create(_, _) >> Mock(Reader) { + 1 * create(_, _, _) >> Mock(Reader) { 1 * read(new JsonRpcRequest("eth_getTransactionByHash", [hash1])) >> Mono.just( new QuorumRpcReader.Result( - Global.objectMapper.writeValueAsBytes(json), 1 + Global.objectMapper.writeValueAsBytes(json), null, 1 ) ) } @@ -183,10 +183,10 @@ class EthereumDirectReaderSpec extends Specification { up, Caches.default(), new CurrentBlockCache(), calls ) reader.quorumReaderFactory = Mock(QuorumReaderFactory) { - 1 * create(_, _) >> Mock(Reader) { + 1 * create(_, _, _) >> Mock(Reader) { 1 * read(new JsonRpcRequest("eth_getTransactionByHash", [hash1])) >> Mono.just( new QuorumRpcReader.Result( - Global.objectMapper.writeValueAsBytes(null), 1 + Global.objectMapper.writeValueAsBytes(null), null, 1 ) ) } @@ -214,10 +214,10 @@ class EthereumDirectReaderSpec extends Specification { up, Caches.default(), new CurrentBlockCache(), calls ) reader.quorumReaderFactory = Mock(QuorumReaderFactory) { - 1 * create(_, _) >> Mock(Reader) { + 1 * create(_, _, _) >> Mock(Reader) { 1 * read(new JsonRpcRequest("eth_getBalance", [address1, "latest"])) >> Mono.just( new QuorumRpcReader.Result( - Global.objectMapper.writeValueAsBytes("0x100"), 1 + Global.objectMapper.writeValueAsBytes("0x100"), null, 1 ) ) } @@ -246,10 +246,10 @@ class EthereumDirectReaderSpec extends Specification { up, Caches.default(), new CurrentBlockCache(), calls ) reader.quorumReaderFactory = Mock(QuorumReaderFactory) { - 1 * create(_, _) >> Mock(Reader) { + 1 * create(_, _, _) >> Mock(Reader) { 1 * read(new JsonRpcRequest("eth_getBalance", [address1, "0xa8c9bb"])) >> Mono.just( new QuorumRpcReader.Result( - Global.objectMapper.writeValueAsBytes("0x100"), 1 + Global.objectMapper.writeValueAsBytes("0x100"), null, 1 ) ) } diff --git a/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/LocalCallRouterSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/LocalCallRouterSpec.groovy index 2865fde1..fb84642b 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/LocalCallRouterSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/LocalCallRouterSpec.groovy @@ -37,6 +37,25 @@ class LocalCallRouterSpec extends Specification { act.resultAsProcessedString == "0x0000000000000000000000000000000000000000" } + def "Returns empty if nonce set"() { + setup: + def methods = new DefaultEthereumMethods(Chain.ETHEREUM) + def router = new LocalCallRouter( + new EthereumReader( + TestingCommons.multistream(TestingCommons.api()), + Caches.default(), + ConstantFactory.constantFactory(new DefaultEthereumMethods(Chain.ETHEREUM)) + ), + methods, + new EmptyHead() + ) + when: + def act = router.read(new JsonRpcRequest("eth_getTransactionByHash", ["test"], 10)) + .block(Duration.ofSeconds(1)) + then: + act == null + } + def "getBlockByNumber with latest uses latest id"() { setup: def head = Mock(Head) { diff --git a/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/WsConnectionSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/WsConnectionSpec.groovy index 3e8b1569..3b4b5edb 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/WsConnectionSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/WsConnectionSpec.groovy @@ -83,7 +83,7 @@ class WsConnectionSpec extends Specification { when: Flux.from(ws.handle(wsApiMock.inbound, wsApiMock.outbound)).subscribe() - def act = ws.call(new JsonRpcRequest("eth_getTransactionByHash", ["0x3ec2ebf5d0ec474d0ac6bc50d2770d8409ad76e119968e7919f85d5ec8915200"], 15)) + def act = ws.call(new JsonRpcRequest("eth_getTransactionByHash", ["0x3ec2ebf5d0ec474d0ac6bc50d2770d8409ad76e119968e7919f85d5ec8915200"], 15, null)) then: StepVerifier.create(act) @@ -105,7 +105,7 @@ class WsConnectionSpec extends Specification { when: Flux.from(ws.handle(wsApiMock.inbound, wsApiMock.outbound)).subscribe() - def act = ws.call(new JsonRpcRequest("eth_getTransactionByHash", ["0x3ec2ebf5d0ec474d0ac6bc50d2770d8409ad76e119968e7919f85d5ec8915200"], 15)) + def act = ws.call(new JsonRpcRequest("eth_getTransactionByHash", ["0x3ec2ebf5d0ec474d0ac6bc50d2770d8409ad76e119968e7919f85d5ec8915200"], 15, null)) then: StepVerifier.create(act) @@ -129,7 +129,7 @@ class WsConnectionSpec extends Specification { when: Flux.from(ws.handle(wsApiMock.inbound, wsApiMock.outbound)).subscribe() - def act = ws.call(new JsonRpcRequest("eth_getTransactionByHash", ["0x3ec2ebf5d0ec474d0ac6bc50d2770d8409ad76e119968e7919f85d5ec8915200"], 15)) + def act = ws.call(new JsonRpcRequest("eth_getTransactionByHash", ["0x3ec2ebf5d0ec474d0ac6bc50d2770d8409ad76e119968e7919f85d5ec8915200"], 15, null)) then: StepVerifier.create(act) diff --git a/src/test/groovy/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcResponseSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcResponseSpec.groovy index a0ba66c3..957f071a 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcResponseSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcResponseSpec.groovy @@ -30,7 +30,7 @@ class JsonRpcResponseSpec extends Specification { when: def act = resp1.equals(resp2) then: - act == true + act } def "Extract processed string without quoted"() { @@ -49,7 +49,7 @@ class JsonRpcResponseSpec extends Specification { def "Fails to extract processed string if not quoted"() { when: - def act = new JsonRpcResponse("{\"hello\": 1}".bytes, null).resultAsProcessedString + new JsonRpcResponse("{\"hello\": 1}".bytes, null).resultAsProcessedString then: thrown(IllegalStateException) } @@ -63,7 +63,7 @@ class JsonRpcResponseSpec extends Specification { def "Serialize int id and null result"() { setup: - def json = new JsonRpcResponse("null".bytes, null, new JsonRpcResponse.NumberId(1)) + def json = new JsonRpcResponse("null".bytes, null, new JsonRpcResponse.NumberId(1), null) when: def act = objectMapper.writeValueAsString(json) then: @@ -72,7 +72,7 @@ class JsonRpcResponseSpec extends Specification { def "Serialize int id and string result"() { setup: - def json = new JsonRpcResponse('"Hello World"'.bytes, null, new JsonRpcResponse.NumberId(10)) + def json = new JsonRpcResponse('"Hello World"'.bytes, null, new JsonRpcResponse.NumberId(10), null) when: def act = objectMapper.writeValueAsString(json) then: @@ -81,7 +81,7 @@ class JsonRpcResponseSpec extends Specification { def "Serialize int id and object result"() { setup: - def json = new JsonRpcResponse('{"foo": "Hello World", "bar": 1}'.bytes, null, new JsonRpcResponse.NumberId(101)) + def json = new JsonRpcResponse('{"foo": "Hello World", "bar": 1}'.bytes, null, new JsonRpcResponse.NumberId(101), null) when: def act = objectMapper.writeValueAsString(json) then: @@ -90,7 +90,7 @@ class JsonRpcResponseSpec extends Specification { def "Serialize int id and error"() { setup: - def json = new JsonRpcResponse(null, new JsonRpcError(-32041, "Oooops"), new JsonRpcResponse.NumberId(101)) + def json = new JsonRpcResponse(null, new JsonRpcError(-32041, "Oooops"), new JsonRpcResponse.NumberId(101), null) when: def act = objectMapper.writeValueAsString(json) then: @@ -99,7 +99,7 @@ class JsonRpcResponseSpec extends Specification { def "Serialize string id and null result"() { setup: - def json = new JsonRpcResponse("null".bytes, null, new JsonRpcResponse.StringId("asf01t1gg")) + def json = new JsonRpcResponse("null".bytes, null, new JsonRpcResponse.StringId("asf01t1gg"), null) when: def act = objectMapper.writeValueAsString(json) then: @@ -108,7 +108,7 @@ class JsonRpcResponseSpec extends Specification { def "Serialize string id and string result"() { setup: - def json = new JsonRpcResponse('"Hello World"'.bytes, null, new JsonRpcResponse.StringId("10")) + def json = new JsonRpcResponse('"Hello World"'.bytes, null, new JsonRpcResponse.StringId("10"), null) when: def act = objectMapper.writeValueAsString(json) then: @@ -117,7 +117,7 @@ class JsonRpcResponseSpec extends Specification { def "Serialize string id and object result"() { setup: - def json = new JsonRpcResponse('{"foo": "Hello World", "bar": 1}'.bytes, null, new JsonRpcResponse.StringId("g8gk19g")) + def json = new JsonRpcResponse('{"foo": "Hello World", "bar": 1}'.bytes, null, new JsonRpcResponse.StringId("g8gk19g"), null) when: def act = objectMapper.writeValueAsString(json) then: @@ -128,7 +128,7 @@ class JsonRpcResponseSpec extends Specification { setup: def json = new JsonRpcResponse(null, new JsonRpcError(-32041, "Oooops"), - new JsonRpcResponse.StringId("9kbo29gkaasf")) + new JsonRpcResponse.StringId("9kbo29gkaasf"), null) when: def act = objectMapper.writeValueAsString(json) then: diff --git a/src/test/groovy/io/emeraldpay/dshackle/upstream/signature/ResponseSignerFactorySpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/upstream/signature/ResponseSignerFactorySpec.groovy new file mode 100644 index 00000000..0e275a7b --- /dev/null +++ b/src/test/groovy/io/emeraldpay/dshackle/upstream/signature/ResponseSignerFactorySpec.groovy @@ -0,0 +1,28 @@ +package io.emeraldpay.dshackle.upstream.signature + + +import io.emeraldpay.dshackle.config.SignatureConfig +import spock.lang.Specification + +class ResponseSignerFactorySpec extends Specification { + + + def "No signer if not enabled"() { + setup: + def conf = new SignatureConfig() + when: + def signer = new ResponseSignerFactory(conf).getObject() + then: + signer instanceof NoSigner + } + + def "No signer if privkey is not configured"() { + setup: + def conf = new SignatureConfig() + when: + def signer = new ResponseSignerFactory(conf).getObject() + then: + signer instanceof NoSigner + } + +} diff --git a/src/test/groovy/io/emeraldpay/dshackle/upstream/signature/Secp256KSignerSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/upstream/signature/Secp256KSignerSpec.groovy new file mode 100644 index 00000000..7a1762ee --- /dev/null +++ b/src/test/groovy/io/emeraldpay/dshackle/upstream/signature/Secp256KSignerSpec.groovy @@ -0,0 +1,138 @@ +package io.emeraldpay.dshackle.upstream.signature + +import io.emeraldpay.dshackle.config.SignatureConfig +import io.emeraldpay.dshackle.config.SignatureConfigReader +import io.emeraldpay.dshackle.test.TestingCommons +import io.emeraldpay.dshackle.upstream.Upstream +import org.apache.commons.codec.binary.Hex +import org.bouncycastle.jce.provider.BouncyCastleProvider +import org.bouncycastle.util.io.pem.PemObject +import org.bouncycastle.util.io.pem.PemWriter +import spock.lang.Specification + +import java.security.KeyFactory +import java.security.KeyPairGenerator +import java.security.MessageDigest +import java.security.SecureRandom +import java.security.Security +import java.security.Signature +import java.security.interfaces.ECPrivateKey +import java.security.spec.ECGenParameterSpec +import java.security.spec.PKCS8EncodedKeySpec + +class Secp256KSignerSpec extends Specification { + + def setupSpec() { + Security.addProvider(new BouncyCastleProvider()) + } + + def "Reads private key"() { + setup: + def file = File.createTempFile("test", ".pem") + def keygen = KeyPairGenerator.getInstance("EC") + keygen.initialize(new ECGenParameterSpec("secp256k1")) + def key = keygen.generateKeyPair() + def keyBuilder = new PKCS8EncodedKeySpec(key.getPrivate().getEncoded()) + def writer = new PemWriter(new FileWriter(file.path)) + writer.writeObject(new PemObject("PRIVATE KEY", keyBuilder.getEncoded())) + writer.close() + + when: + def signer = new ResponseSignerFactory(new SignatureConfig()) + def act = signer.readKey(SignatureConfig.Algorithm.SECP256K1, file.absolutePath).first + + then: + act == key.getPrivate() + + cleanup: + file.delete() + } + + def "Id is a hash of x509 public key"() { + setup: + def conf = new SignatureConfig() + conf.enabled = true + conf.privateKey = "testing/dshackle/test_key" + def signer = new ResponseSignerFactory(conf).getObject() as Secp256KSigner + + // To verify the test, check the hash of test key above: + // + // echo MFYwEAYHKoZIzj0CAQYFK4EEAAoDQgAE3zetdMdyTO/sTFCLeOrI5moiZt2RjfUVdavhorgqd+gxAqM01cf5Q4QZ8INne9RykcQsbLYXQfDXJbGMm5+gdg== | base64 -d - | shasum -a 256 + // d25f1ff2c1a57235a9bc7725cd645ab0e9631475a12402f2881579d3f6887597 - + // + + when: + def id = signer.keyId + + then: + id == 0xd25f1ff2c1a57235L + } + + def "Wrap message"() { + setup: + def up = Mock(Upstream) { + _ * getId() >> "infura" + } + def signer = new Secp256KSigner(Stub(ECPrivateKey), 100L) + + when: + def act = signer.wrapMessage(10, "test".bytes, up) + + then: + act == "DSHACKLESIG/10/infura/9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" + } + + def "Signed message is valid"() { + setup: + def result = "test".bytes + def up = Mock(Upstream) { + _ * getId() >> "infura" + } + + def keyPairGen = KeyPairGenerator.getInstance("EC") + keyPairGen.initialize(new ECGenParameterSpec("secp256k1")) + def pair = keyPairGen.generateKeyPair() + def verifier = Signature.getInstance("SHA256withECDSA") + verifier.initVerify(pair.getPublic()) + verifier.update("DSHACKLESIG/10/infura/9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08".getBytes()) + + def signer = new Secp256KSigner((pair.getPrivate() as ECPrivateKey), 100L) + + when: + def sig = signer.sign(10, result, up) + + then: + verifier.verify(sig.value) + } + + def "Signed message is valid - for docs"() { + // it's the example used in docs + setup: + def result = '["0xe670ec64341771606e55d6b4ca35a1a6b75ee3d5145a99d05921026d1527331", true]'.bytes + def up = Mock(Upstream) { + _ * getId() >> "infura" + } + + def sha256 = MessageDigest.getInstance("SHA-256") + + def conf = new SignatureConfig() + conf.enabled = true + conf.privateKey = "testing/dshackle/test_key" + def factory = new ResponseSignerFactory(conf) + + def sk = factory.readKey(conf.algorithm, conf.privateKey).first + def pk = factory.extractPublicKey(KeyFactory.getInstance("EC"), sk) + def verifier = Signature.getInstance("SHA256withECDSA") + verifier.initVerify(pk) + verifier.update("DSHACKLESIG/10/infura/${Hex.encodeHexString(sha256.digest(result))}".getBytes()) + + def signer = factory.getObject() as Secp256KSigner + + when: + def sig = signer.sign(10, result, up) + println("Signature: ${Hex.encodeHexString(sig.value)}") + + then: + verifier.verify(sig.value) + } +} diff --git a/src/test/resources/tls-local/127.0.0.1.invalid.key b/src/test/resources/tls-local/127.0.0.1.invalid.key new file mode 100644 index 00000000..5ff1ed46 --- /dev/null +++ b/src/test/resources/tls-local/127.0.0.1.invalid.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpQIBAAKCAQEA0x1gOF8K9SFwQ9wohBe/EWsjLYdQ4z2yimW604BS/p1tM6xN +4JylRWC3rawllw+cTYjLWdgd5WX43u+6i9TG3Ni19bisZamuO2HitVxEeRY/DPlB +zIfobDGxSL/R0S1ug7HKqp/dbkL5XT4AlgZn9sj3ikYU4wLj7YdOQXyAIM5FYq5I +brtKqIP0cvQsOsR2mr4gk569bu386rP4NZw75UCLVPrbG6ngkv5YkjVQa0M94RPR +JGreRdvyfi17nxjo1ePKh1iWif4ERJjjYk/DhTDquNyYMdBWmGDkO2Xfjc5x830I +16G1dq+6zNB1FT25jVFR1Wy+DjjGFHrSG4UnzQIDAQABAoIBAQCX1uj9ol4fMI2u +QQpi9zFVNdl3RXvH9PgU0lYtCH6o4lFIeQUKJ6A25fk10Dq5C2E/4sNfOzFFbLIy +pfll2QOuk69LrCdSd1f5Hc4Q4uvcq0Nt8ViB4r4oExWPXWdrK2HxFk7NqW15gHIZ +vh5tyO29cY2Yxg7/t3R3wnlmYEVHUcS7HmhzgDveNzA0VLza3765ntgwXypY8N2j +heEQC1h5kMCurcKJyRXmlsXPRWizX0UBWDrMHFeqyhrH0BlRSFTNC3sKmyYaJQmp +daPNRr4zO0yfm8utVSbNHX2OM5DpIO1Ecq9Sd43QI+ATAtxFrhPoYK1rwll267CV +cJCRbz+BAoGBAPzz/1Xq8s1lGZ6eWz7H1JlzxYLH+TzQfrV1ym7xJgR/b3rVXiJ+ +D+qL8zUJDa5xZyflXB7zCg4I7ALmNwMJzVLdIOpEntHK2NtRJ2rp4qH+kMXojays +zOGYfbRLNVe+mgAK9Pu8eOi8NzXqkB/S8rml3xqSOpUFsvlc2qiYhGdXAoGBANWo +XcQDpisRFcrn3J0+pKU57ZIRjxyOTDlEwH7k+x+PprCRFki80kW22u4l22FdDaip +s4vCuAm5tmEogEjINU6ZhSKHxonjaGXfzuZ3gAMk/PN7zFazlgfYEKng+fa1YuZ+ +3Ubzq6py8enoffJ/PSF/lClKlV5sxjyilxeZmOd7AoGBAMtaJHUf0l4I3tXDnLsV +4JKzLpjm3X7uwfNehH/Q0t9EVYKk9/BPYs4h1zywEYwesJNEO7p8w/7mAMSZc5AB +zvYmOixvMxEO1C5xKXKS7utCv45SJcE48vat17FVO+h3RmSuYKaI4BZ0Wbfi92q7 ++BwDGx6zW+EdmcoaOba8FgU1AoGAV2WftWaoukUq3O0rWUceolenznBQUiYDGAn/ +k+imsKpaTS+MJgTXHp1FwNTLgHBH/g4s26azEYdeCzA+CYecBqLVyuIvXIghVErQ +n4WSX7bpoc+qLm0Xme3QIy1cEobwBckvSq6yMe8C9eOcYW2a2/EL8jgIEa/9ByCb +HZQ+77ECgYEAxR1eoxc/XV9rcftdaRl7+Db9Qvnhfwu7MFQ3lgomol1N1ckSjwnO +wo/HX4+8cMS5QN11d8l2hf+7TyuRCrQBLrYYkrVWb4Z+ote3ejrAsDg90xjOFYWf +MWSy7kPeDl3JTEdNPFIIa28EQhZYupD0ihBoVspz2eQ1Y66BOfqC9nU= +-----END RSA PRIVATE KEY----- diff --git a/testing/dshackle/dshackle-basic.yaml b/testing/dshackle/dshackle-basic.yaml index 549d3784..3e1a6dad 100644 --- a/testing/dshackle/dshackle-basic.yaml +++ b/testing/dshackle/dshackle-basic.yaml @@ -31,6 +31,12 @@ cache: redis: enabled: false +signature: + enabled: true + algorithm: ECDSA + signatureScheme: SHA256withECDSA + privateKey: "./test_key" + proxy: port: 18080 tls: diff --git a/testing/dshackle/test_key b/testing/dshackle/test_key new file mode 100644 index 00000000..dedd7587 --- /dev/null +++ b/testing/dshackle/test_key @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGEAgEAMBAGByqGSM49AgEGBSuBBAAKBG0wawIBAQQglWZBwGvH/I/TqQb3uPGq +d/6MB2tgFXUfQCYj5RmaXV2hRANCAATfN610x3JM7+xMUIt46sjmaiJm3ZGN9RV1 +q+GiuCp36DECozTVx/lDhBnwg2d71HKRxCxsthdB8NclsYybn6B2 +-----END PRIVATE KEY----- diff --git a/testing/dshackle/test_key.pub b/testing/dshackle/test_key.pub new file mode 100644 index 00000000..b99ed61c --- /dev/null +++ b/testing/dshackle/test_key.pub @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFYwEAYHKoZIzj0CAQYFK4EEAAoDQgAE3zetdMdyTO/sTFCLeOrI5moiZt2RjfUV +davhorgqd+gxAqM01cf5Q4QZ8INne9RykcQsbLYXQfDXJbGMm5+gdg== +-----END PUBLIC KEY----- diff --git a/testing/trial/build.gradle b/testing/trial/build.gradle index 3eab29ea..420a6ca1 100644 --- a/testing/trial/build.gradle +++ b/testing/trial/build.gradle @@ -7,6 +7,7 @@ plugins { repositories { mavenLocal() mavenCentral() + maven { url "https://maven.emrld.io" } } dependencies { @@ -14,12 +15,25 @@ dependencies { implementation "org.codehaus.groovy:groovy:3.0.4" implementation "com.fasterxml.jackson.core:jackson-core:2.9.8" implementation "com.fasterxml.jackson.core:jackson-databind:2.9.8" + implementation "io.grpc:grpc-netty:1.46.0" + implementation "org.bouncycastle:bcprov-jdk15on:1.61" + implementation("io.emeraldpay:emerald-api:0.11.1") { + exclude group: 'com.salesforce.servicelibs', module: 'reactor-grpc' + } testImplementation "org.spockframework:spock-core:2.0-M3-groovy-3.0" } +configurations.all { + resolutionStrategy.dependencySubstitution { + substitute module("io.emeraldpay:emerald-api") using project(":api") because "we work with the unreleased development version" + } +} + + test { systemProperty "trialMode", project.getProperty("dshackleTrialMode") + systemProperty "signatureKey", project.getProperty("signatureKey") useJUnitPlatform() testLogging { events "PASSED", "FAILED" diff --git a/testing/trial/src/main/groovy/io/emeraldpay/dshackle/testing/trial/Client.groovy b/testing/trial/src/main/groovy/io/emeraldpay/dshackle/testing/trial/Client.groovy new file mode 100644 index 00000000..9feac2ca --- /dev/null +++ b/testing/trial/src/main/groovy/io/emeraldpay/dshackle/testing/trial/Client.groovy @@ -0,0 +1,6 @@ +package io.emeraldpay.dshackle.testing.trial + +interface Client { + Map execute(String method, List params) + Map execute(Object id, String method, List params) +} \ No newline at end of file diff --git a/testing/trial/src/main/groovy/io/emeraldpay/dshackle/testing/trial/ProtoClient.groovy b/testing/trial/src/main/groovy/io/emeraldpay/dshackle/testing/trial/ProtoClient.groovy new file mode 100644 index 00000000..e7b2b292 --- /dev/null +++ b/testing/trial/src/main/groovy/io/emeraldpay/dshackle/testing/trial/ProtoClient.groovy @@ -0,0 +1,64 @@ +package io.emeraldpay.dshackle.testing.trial + +import com.fasterxml.jackson.databind.ObjectMapper +import com.google.common.primitives.Bytes +import com.google.protobuf.ByteString +import io.emeraldpay.api.proto.BlockchainOuterClass +import io.emeraldpay.api.proto.Common +import io.emeraldpay.api.proto.ReactorBlockchainGrpc +import io.emeraldpay.grpc.Chain +import io.grpc.ManagedChannel +import io.grpc.netty.NettyChannelBuilder + +class ProtoClient implements Client { + private int sequence = 0; + private ObjectMapper objectMapper; + private ReactorBlockchainGrpc.ReactorBlockchainStub stub + private Chain chain + + ProtoClient(ManagedChannel channel, Chain chain) { + this.stub = ReactorBlockchainGrpc.newReactorStub(channel) + this.objectMapper = new ObjectMapper() + this.chain= chain + } + + static ProtoClient create(String host, int port, Chain chain) { + def channel = NettyChannelBuilder.forAddress(host, port) + .maxInboundMessageSize(Integer.MAX_VALUE) + .usePlaintext() + new ProtoClient(channel.build(), chain) + } + + static ProtoClient basic() { + create("localhost", 12448, Chain.ETHEREUM) + } + + BlockchainOuterClass.NativeCallReplyItem executeNative(String method, List params, Long nonce) { + def req = BlockchainOuterClass.NativeCallRequest + .newBuilder() + .setChain(Common.ChainRef.CHAIN_ETHEREUM) + .addItems(BlockchainOuterClass.NativeCallItem + .newBuilder() + .setId(0) + .setNonce(nonce) + .setMethod(method) + .setPayload(ByteString.copyFrom(objectMapper.writeValueAsBytes(params))) + .build() + ).build() + stub.nativeCall(req) + .single() + .block() + } + + Map execute(String method, List params) { + return execute(sequence++, method, params) + } + Map execute(Object id, String method, List params) { + def result = executeNative(method, params, 0L) + if (result.errorMessage != "") { + return [error: result.errorMessage] + } else { + return objectMapper.readerFor(Map).readValue(Bytes.concat("{\"result\": ".bytes, result.payload.toByteArray(), "}".bytes)) + } + } +} \ No newline at end of file diff --git a/testing/trial/src/main/groovy/io/emeraldpay/dshackle/testing/trial/ProxyClient.groovy b/testing/trial/src/main/groovy/io/emeraldpay/dshackle/testing/trial/ProxyClient.groovy index ae5fb595..98d3e94f 100644 --- a/testing/trial/src/main/groovy/io/emeraldpay/dshackle/testing/trial/ProxyClient.groovy +++ b/testing/trial/src/main/groovy/io/emeraldpay/dshackle/testing/trial/ProxyClient.groovy @@ -12,7 +12,7 @@ import org.apache.http.impl.client.CloseableHttpClient import org.apache.http.impl.client.HttpClientBuilder import org.apache.http.impl.client.HttpClients -class ProxyClient { +class ProxyClient implements Client { private int sequence = 0; diff --git a/testing/trial/src/test/groovy/io/emeraldpay/dshackle/testing/trial/basicproxy/StandardCallsSpec.groovy b/testing/trial/src/test/groovy/io/emeraldpay/dshackle/testing/trial/basicproxy/StandardCallsSpec.groovy index fae88d27..ec24e40d 100644 --- a/testing/trial/src/test/groovy/io/emeraldpay/dshackle/testing/trial/basicproxy/StandardCallsSpec.groovy +++ b/testing/trial/src/test/groovy/io/emeraldpay/dshackle/testing/trial/basicproxy/StandardCallsSpec.groovy @@ -1,13 +1,28 @@ package io.emeraldpay.dshackle.testing.trial.basicproxy +import com.google.common.primitives.Bytes +import com.google.common.primitives.Longs +import io.emeraldpay.dshackle.testing.trial.ProtoClient import io.emeraldpay.dshackle.testing.trial.ProxyClient +import org.apache.commons.codec.binary.Hex import spock.lang.IgnoreIf +import spock.lang.Shared import spock.lang.Specification +import java.security.KeyFactory +import org.bouncycastle.util.io.pem.PemReader + +import java.security.MessageDigest +import java.security.Signature +import java.security.spec.PKCS8EncodedKeySpec +import java.security.spec.X509EncodedKeySpec @IgnoreIf({ System.getProperty('trialMode') != 'basic' }) class StandardCallsSpec extends Specification { - def client = ProxyClient.forPrefix("eth") + @Shared client_proto = ProtoClient.basic() + @Shared client_proxy = ProxyClient.forPrefix("eth") + + @Shared clients = [client_proto, client_proxy] def "get height"() { when: @@ -15,6 +30,8 @@ class StandardCallsSpec extends Specification { then: act.result == "0x100001" act.error == null + where: + client << clients } def "get block"() { @@ -31,6 +48,8 @@ class StandardCallsSpec extends Specification { ] } act.error == null + where: + client << clients } def "get non-existing block"() { @@ -39,6 +58,8 @@ class StandardCallsSpec extends Specification { then: act.result == null act.error == null + where: + client << clients } def "get tx"() { @@ -50,6 +71,8 @@ class StandardCallsSpec extends Specification { blockHash == "0x9a834c53bbee9c2665a5a84789a1d1ad73750b2d77b50de44f457f411d02e52e" } act.error == null + where: + client << clients } def "get non-existing tx"() { @@ -58,6 +81,8 @@ class StandardCallsSpec extends Specification { then: act.result == null act.error == null + where: + client << clients } def "get block with txes"() { @@ -76,6 +101,8 @@ class StandardCallsSpec extends Specification { } } act.error == null + where: + client << clients } def "returns original block json"() { @@ -87,6 +114,8 @@ class StandardCallsSpec extends Specification { testFoo == "bar" } act.error == null + where: + client << clients } def "returns original block json with tx"() { @@ -98,5 +127,33 @@ class StandardCallsSpec extends Specification { testFoo == "bar" } act.error == null + where: + client << clients + } + + def "check response signature with nonce"() { + when: + def act = client_proto.executeNative("eth_blockNumber", [], 10) + def keyFactory = KeyFactory.getInstance("EC") + def key = new File(System.getProperty('signatureKey')) + def reader = new PemReader(key.newReader()) + def keySpec = new X509EncodedKeySpec(reader.readPemObject().getContent()) + def pubKey = keyFactory.generatePublic(keySpec) + def sig = Signature.getInstance("SHA256withECDSA") + sig.initVerify(pubKey) + def sep = "/".bytes + def digest = MessageDigest.getInstance("SHA-256") + def messageHash = digest.digest(act.payload.toByteArray()) + sig.update(Bytes.concat("DSHACKLESIG".bytes, sep, Longs.toByteArray(10), sep, messageHash)) + then: + (new String(act.payload.toByteArray())) == "\"0x100001\"" + sig.verify(act.signature.sig.toByteArray()) + } + + def "check response signature without nonce"() { + when: + def act = client_proto.executeNative("eth_blockNumber", [], 0L) + then: + act.signature.sig.isEmpty() } }