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:
Vyacheslav Shebanov
2022-05-27 06:00:01 +03:00
committed by GitHub
parent 530811d272
commit d1ad77a345
74 changed files with 1325 additions and 291 deletions

View File

@@ -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())

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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
}
}
}

View File

@@ -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

View File

@@ -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
)

View File

@@ -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
)
}

View File

@@ -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 {

View File

@@ -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()

View File

@@ -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? {

View File

@@ -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
}
}

View File

@@ -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
}
}
}
}

View File

@@ -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++
}

View File

@@ -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++
}

View File

@@ -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()
}

View File

@@ -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)
}
}
}

View File

@@ -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
)
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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)
}
}

View File

@@ -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!!)
)
}

View File

@@ -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!!)
)
}

View File

@@ -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
}

View File

@@ -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 }
}

View File

@@ -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) }

View File

@@ -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())

View File

@@ -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

View File

@@ -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

View File

@@ -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
)
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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 {

View File

@@ -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)
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}

View File

@@ -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()
}
}