problem: users want to verify that response are coming from their nodes
solution: edge node can sign received valued Co-authored-by: Igor Artamonov <igor@artamonov.ru>
This commit is contained in:
committed by
GitHub
parent
530811d272
commit
d1ad77a345
@@ -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())
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<SignatureConfig> {
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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? {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Any> {
|
||||
return Predicate { _ ->
|
||||
!cq.isResolved()
|
||||
}
|
||||
}
|
||||
|
||||
fun asReducer(): BiFunction<CallQuorum, Tuple2<ByteArray, Upstream>, CallQuorum> {
|
||||
return BiFunction<CallQuorum, Tuple2<ByteArray, Upstream>, CallQuorum> { a, b ->
|
||||
a.record(b.t1, b.t2)
|
||||
return@BiFunction a
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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++
|
||||
}
|
||||
|
||||
|
||||
@@ -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++
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ByteArray> = 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()
|
||||
}
|
||||
|
||||
@@ -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<JsonRpcRequest, QuorumRpcReader.Result>
|
||||
fun create(apis: ApiSource, quorum: CallQuorum, signer: ResponseSigner?): Reader<JsonRpcRequest, QuorumRpcReader.Result>
|
||||
|
||||
class Default : QuorumReaderFactory {
|
||||
override fun create(apis: ApiSource, quorum: CallQuorum): Reader<JsonRpcRequest, QuorumRpcReader.Result> {
|
||||
return QuorumRpcReader(apis, quorum)
|
||||
override fun create(apis: ApiSource, quorum: CallQuorum, signer: ResponseSigner?): Reader<JsonRpcRequest, QuorumRpcReader.Result> {
|
||||
return QuorumRpcReader(apis, quorum, signer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<JsonRpcRequest, QuorumRpcReader.Result> {
|
||||
|
||||
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<Result> {
|
||||
// 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<Flux<Upstream>, Mono<CallQuorum>> {
|
||||
val quorumReduce = BiFunction<CallQuorum, Tuple2<ByteArray, Upstream>, CallQuorum> { res, a ->
|
||||
if (res.record(a.t1, a.t2)) {
|
||||
val quorumReduce = BiFunction<CallQuorum, Tuple3<ByteArray, Optional<ResponseSigner.Signature>, 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<Tuple2<ByteArray, Upstream>> {
|
||||
fun callApi(api: Upstream, key: JsonRpcRequest): Mono<Tuple3<ByteArray, Optional<ResponseSigner.Signature>, 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<ByteArray>, Mono<Tuple2<ByteArray, Optional<ResponseSigner.Signature>>>> {
|
||||
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 <T> withErrorResume(api: Upstream, key: JsonRpcRequest): Function<Mono<T>, Mono<T>> {
|
||||
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<Result> {
|
||||
@@ -162,6 +191,7 @@ class QuorumRpcReader(
|
||||
|
||||
class Result(
|
||||
val value: ByteArray,
|
||||
val signature: ResponseSigner.Signature?,
|
||||
val quorum: Int
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<T>(
|
||||
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
|
||||
|
||||
@@ -44,7 +44,7 @@ class BlockchainRpc(
|
||||
@Autowired private val trackAddress: List<TrackAddress>,
|
||||
@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)
|
||||
|
||||
@@ -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<BlockchainOuterClass.NativeCallReplyItem> {
|
||||
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<Selector.Matcher> =
|
||||
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<ParsedCallDetails>): Mono<CallResult> {
|
||||
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<T>(
|
||||
val id: Int,
|
||||
val nonce: Long?,
|
||||
val upstream: Multistream,
|
||||
val matcher: Selector.Matcher,
|
||||
val callQuorum: CallQuorum,
|
||||
@@ -280,7 +292,7 @@ open class NativeCall(
|
||||
}
|
||||
|
||||
fun <X> withPayload(payload: X): ValidCallContext<X> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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!!)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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!!)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -64,6 +64,12 @@ abstract class DefaultUpstream(
|
||||
.multicast()
|
||||
.directBestEffort<UpstreamAvailability>()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -132,7 +132,8 @@ class EthereumDirectReader(
|
||||
*/
|
||||
private fun readWithQuorum(request: JsonRpcRequest): Mono<ByteArray> {
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -283,7 +283,7 @@ class WsConnection(
|
||||
fun onRpc(msg: ResponseWSParser.WsResponse): Mono<Void> {
|
||||
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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,10 +24,11 @@ import io.emeraldpay.dshackle.Global
|
||||
data class JsonRpcRequest(
|
||||
val method: String,
|
||||
val params: List<Any?>,
|
||||
val id: Int
|
||||
val id: Int,
|
||||
val nonce: Long?
|
||||
) {
|
||||
|
||||
constructor(method: String, params: List<Any?>) : this(method, params, 1)
|
||||
@JvmOverloads constructor(method: String, params: List<Any?>, 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -25,11 +25,11 @@ open class ResponseRpcParser : ResponseParser<JsonRpcResponse>() {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ResponseSigner> {
|
||||
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(ResponseSignerFactory::class.java)
|
||||
}
|
||||
|
||||
fun readKey(algorithm: SignatureConfig.Algorithm, keyPath: String): Pair<ECPrivateKey, Long> {
|
||||
val reader = PemReader(Files.newBufferedReader(Path.of(keyPath)))
|
||||
return readKey(algorithm, reader.readPemObject())
|
||||
}
|
||||
|
||||
private fun readKey(algorithm: SignatureConfig.Algorithm, pem: PemObject): Pair<ECPrivateKey, Long> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user