From 25dbc7c3880218800c5c216ed8812fae27128d0f Mon Sep 17 00:00:00 2001 From: a10zn8 Date: Tue, 21 Apr 2026 10:33:37 +0300 Subject: [PATCH] response signing with auth key (#811) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: spec for unifying response signing with auth key Design для замены отдельной SignatureConfig на подпись ответов RSA-ключом, взятым из auth.server.provider-private-key. Made-with: Cursor * docs: implementation plan for auth-based response signing План реализации по спеке 2026-04-20-response-signing-with-auth-key-design.md. Разбит на 9 задач: новые RsaSigner/DisabledSigner по TDD, переработка фабрики под AuthorizationConfig, удаление SignatureConfig/signed-response, снятие cache-гейта в EthereumLocalReader, обновление документации. Made-with: Cursor * feat(signature): add RsaSigner using SHA256withRSA Made-with: Cursor * feat(signature): add DisabledSigner that throws on sign() Made-with: Cursor * refactor(signature): ResponseSigner.sign returns non-null Signature Made-with: Cursor * refactor(signature): build signer from AuthorizationConfig Made-with: Cursor * refactor(config): remove signed-response YAML section and SignatureConfig Made-with: Cursor * refactor(signature): remove EcdsaSigner and NoSigner Remove the obsolete ECDSA signer and the transitional NoSigner stub. Replace NoSigner usages in tests with DisabledSigner, and pass DisabledSigner to BroadcastReader in tests (signer is now non-null). Made-with: Cursor * refactor(local-reader): serve cached results for requests with nonce Made-with: Cursor * docs: replace signed-response with auth section incl. response signing Made-with: Cursor * docs: correct auth section YAML and expand Response Signing note - docs/reference-configuration.adoc: fix the YAML shape of the auth section (server.keys.{provider-private-key,external-public-key} and publicKeyOwner) to match AuthorizationConfigReader, and expand the Response Signing subsection with the actual signed-message format. - .gitignore: ignore /demo/response-signing/ (local sandbox with keys and generated gRPC stubs — regenerate with generate-keys.sh + protoc) and /docs/superpowers/ (specs/plans kept local-only for now). Any previously tracked files under these paths are untracked here. Made-with: Cursor * feat(upstream): auto-inject secure-signed label when signing is enabled Expose ResponseSigner.enabled and thread the bean through UpstreamCreator so Generic/Ethereum/Bitcoin creators can enrich each upstream's labels with secure-signed=true whenever auth-backed response signing is active. A user-provided value for the label is preserved. Made-with: Cursor --- .gitignore | 8 +- docs/reference-configuration.adoc | 73 +++++++--- .../kotlin/io/emeraldpay/dshackle/Config.kt | 6 - .../emeraldpay/dshackle/config/MainConfig.kt | 1 - .../dshackle/config/MainConfigReader.kt | 4 - .../dshackle/config/SignatureConfig.kt | 41 ------ .../dshackle/config/SignatureConfigReader.kt | 29 ---- .../dshackle/quorum/QuorumRequestReader.kt | 5 +- .../dshackle/reader/BroadcastReader.kt | 2 +- .../dshackle/reader/RequestReaderFactory.kt | 6 +- .../configure/BitcoinUpstreamCreator.kt | 6 +- .../configure/EthereumUpstreamCreator.kt | 4 +- .../configure/GenericUpstreamCreator.kt | 6 +- .../startup/configure/UpstreamCreator.kt | 18 +++ .../upstream/ethereum/EthereumDirectReader.kt | 3 +- .../upstream/ethereum/EthereumLocalReader.kt | 4 - .../upstream/signature/DisabledSigner.kt | 15 ++ .../dshackle/upstream/signature/NoSigner.kt | 7 - .../upstream/signature/ResponseSigner.kt | 8 +- .../signature/ResponseSignerFactory.kt | 86 +++++------ .../{EcdsaSigner.kt => RsaSigner.kt} | 26 +--- .../config/SignatureConfigReaderSpec.groovy | 40 ------ .../reader/BroadcastReaderSpec.groovy | 11 +- .../dshackle/rpc/NativeSubscribeSpec.groovy | 4 +- .../UpstreamCreatorLabelsSpec.groovy | 74 ++++++++++ .../ethereum/EthereumLocalReaderSpec.groovy | 13 +- .../signature/DisabledSignerSpec.groovy | 28 ++++ .../upstream/signature/EcdsaSignerSpec.groovy | 135 ------------------ .../ResponseSignerFactorySpec.groovy | 90 ++++++++++-- .../upstream/signature/RsaSignerSpec.groovy | 75 ++++++++++ .../io/emeraldpay/dshackle/IntegrationTest.kt | 5 +- .../reader/RequestReaderFactoryTest.kt | 5 +- 32 files changed, 439 insertions(+), 399 deletions(-) delete mode 100644 src/main/kotlin/io/emeraldpay/dshackle/config/SignatureConfig.kt delete mode 100644 src/main/kotlin/io/emeraldpay/dshackle/config/SignatureConfigReader.kt create mode 100644 src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/DisabledSigner.kt delete mode 100644 src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/NoSigner.kt rename src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/{EcdsaSigner.kt => RsaSigner.kt} (51%) delete mode 100644 src/test/groovy/io/emeraldpay/dshackle/config/SignatureConfigReaderSpec.groovy create mode 100644 src/test/groovy/io/emeraldpay/dshackle/startup/configure/UpstreamCreatorLabelsSpec.groovy create mode 100644 src/test/groovy/io/emeraldpay/dshackle/upstream/signature/DisabledSignerSpec.groovy delete mode 100644 src/test/groovy/io/emeraldpay/dshackle/upstream/signature/EcdsaSignerSpec.groovy create mode 100644 src/test/groovy/io/emeraldpay/dshackle/upstream/signature/RsaSignerSpec.groovy diff --git a/.gitignore b/.gitignore index 25ca0b16..5c031af2 100644 --- a/.gitignore +++ b/.gitignore @@ -26,4 +26,10 @@ http-client.env.json .DS_Store mise.toml -.claude/ \ No newline at end of file +.claude/ + +# demo sandbox (local-only, regenerate with demo/response-signing/generate-keys.sh + protoc) +/demo/response-signing/ + +# superpowers scratch: specs and plans live locally only for now +/docs/superpowers/ \ No newline at end of file diff --git a/docs/reference-configuration.adoc b/docs/reference-configuration.adoc index b3a40cc7..5deeaf1d 100644 --- a/docs/reference-configuration.adoc +++ b/docs/reference-configuration.adoc @@ -47,10 +47,8 @@ cache: db: 0 password: I1y0dGKy01by -signed-response: - enabled: true - algorithm: SECP256K1 - private-key: /path/key.pem +auth: + enabled: false proxy: host: 0.0.0.0 @@ -207,10 +205,10 @@ See <> section | Caching configuration. See <> section. -| `signed-response` +| `auth` | -| Signed responses -See <> section. +| Authorization and response signing. +See <> section. | `cluster` | @@ -559,37 +557,66 @@ cache: |=== -[#signed-response] -== Signed Response +[#auth] +== Authorization + +dshackle supports optional client authentication via signed JWT tokens (RS256). When +`auth.enabled` is `true`, dshackle validates tokens issued by a trusted provider and +rejects unauthenticated requests. [source,yaml] ---- -signed-response: +auth: enabled: true - algorithm: SECP256K1 - private-key: /path/key.pem + publicKeyOwner: "token-issuer-name" + server: + keys: + provider-private-key: "/etc/dshackle/auth/jwt-rsa.pem" + external-public-key: "/etc/dshackle/auth/jwt-rsa.pub" ---- -.Redis Config -[cols="2a,2,5"] |=== -| Option | Default Value | Description +| Name | Default | Description | `enabled` | `false` -| Enable/disable Signed Responses +| Enables authorization and response signing. -| `algorithm` -| `SECP256K1` -| `SECP256K1` or `NIST-P256` - -| `private-key` +| `publicKeyOwner` | -| Path to a private key in PEM format +| Expected value of the `iss` claim on inbound JWT tokens. +| `server.keys.provider-private-key` +| +| Path to a PKCS#8 PEM RSA private key. Used both to sign session JWTs issued by + dshackle and to sign `NativeCall` response payloads (see <>). + +| `server.keys.external-public-key` +| +| Path to a PEM-encoded RSA public key (X.509 SubjectPublicKeyInfo) used to verify + the JWTs clients present to `emerald.Auth/Authenticate`. |=== -See more details at xref:07-methods.adoc#signatures[Signed Response] in gRPC Methods. +[#response-signing] +==== Response Signing + +When `auth.enabled` is `true` and `auth.server.keys.provider-private-key` points to a +valid PKCS#8 RSA private key, dshackle automatically signs gRPC responses with +`SHA256withRSA` for any `NativeCall` request that provides a non-zero `nonce`. The same +key used for issuing JWT tokens (RS256) is reused for response signatures — no separate +configuration is required. + +The signed blob is `DSHACKLESIG///`. The +returned `NativeCallReplySignature` carries the original `nonce`, the signature bytes +and a `key_id` (first 8 bytes of the SHA-256 of the public key). Clients verify with +the public half of `provider-private-key`. + +If a client sends a nonce but the signing key is not configured (auth disabled or the +path is empty), dshackle returns an error with code `-32603` and message +"Response signing requested via nonce but signing key is not configured". + +A runnable end-to-end example (dshackle config, demo RSA keys and a Go client) lives +in `demo/response-signing/` in the repository. [#cluster] == Cluster diff --git a/src/main/kotlin/io/emeraldpay/dshackle/Config.kt b/src/main/kotlin/io/emeraldpay/dshackle/Config.kt index b33ab61b..51e8f08d 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/Config.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/Config.kt @@ -24,7 +24,6 @@ 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 @@ -130,11 +129,6 @@ open class Config( return mainConfig.cache ?: CacheConfig() } - @Bean - open fun signatureConfig(@Autowired mainConfig: MainConfig): SignatureConfig { - return mainConfig.signature ?: SignatureConfig() - } - @Bean open fun tokensConfig(@Autowired mainConfig: MainConfig): TokensConfig { return mainConfig.tokens ?: TokensConfig(emptyList()) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/config/MainConfig.kt b/src/main/kotlin/io/emeraldpay/dshackle/config/MainConfig.kt index a1d8e4b4..0d579aa3 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/config/MainConfig.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/config/MainConfig.kt @@ -41,7 +41,6 @@ class MainConfig { var monitoring: MonitoringConfig = MonitoringConfig.default() var accessLogConfig: AccessLogConfig = AccessLogConfig.default() var health: HealthConfig = HealthConfig.default() - var signature: SignatureConfig? = null var compression: CompressionConfig = CompressionConfig.default() var chains: ChainsConfig = ChainsConfig.default() var authorization: AuthorizationConfig = AuthorizationConfig.default() diff --git a/src/main/kotlin/io/emeraldpay/dshackle/config/MainConfigReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/config/MainConfigReader.kt index 90cdef65..5664eaf6 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/config/MainConfigReader.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/config/MainConfigReader.kt @@ -33,7 +33,6 @@ class MainConfigReader( private val monitoringConfigReader = MonitoringConfigReader() private val accessLogReader = AccessLogReader() private val healthConfigReader = HealthConfigReader() - private val signatureConfigReader = SignatureConfigReader(fileResolver) private val compressionConfigReader = CompressionConfigReader() private val chainsConfigReader = ChainsConfigReader(optionsReader) private val authorizationConfigReader = AuthorizationConfigReader() @@ -75,9 +74,6 @@ class MainConfigReader( healthConfigReader.read(input).let { config.health = it } - signatureConfigReader.read(input).let { - config.signature = it - } compressionConfigReader.read(input).let { config.compression = it } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/config/SignatureConfig.kt b/src/main/kotlin/io/emeraldpay/dshackle/config/SignatureConfig.kt deleted file mode 100644 index 3239382a..00000000 --- a/src/main/kotlin/io/emeraldpay/dshackle/config/SignatureConfig.kt +++ /dev/null @@ -1,41 +0,0 @@ -package io.emeraldpay.dshackle.config - -import java.util.Locale - -class SignatureConfig { - - enum class Algorithm { - NIST_P256, - ; - - fun getCurveName(): String { - return if (this == NIST_P256) { - "secp256r1" - } else { - throw IllegalStateException() - } - } - } - - companion object { - fun algorithmOfString(algo: String): Algorithm { - val algorithm = when (algo.uppercase(Locale.getDefault())) { - "NIST_P256", "NIST-P256", "NISTP256", "SECP256R1" -> Algorithm.NIST_P256 - else -> throw IllegalArgumentException("Unknown algorithm or not allowed") - } - return algorithm - } - } - - /** - * Signature scheme that we should use - */ - var algorithm: Algorithm = Algorithm.NIST_P256 - - /** - * Should we generate signature on this instance if it's not already present - */ - var enabled: Boolean = false - - var privateKey: String? = null -} diff --git a/src/main/kotlin/io/emeraldpay/dshackle/config/SignatureConfigReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/config/SignatureConfigReader.kt deleted file mode 100644 index 21fe1b6d..00000000 --- a/src/main/kotlin/io/emeraldpay/dshackle/config/SignatureConfigReader.kt +++ /dev/null @@ -1,29 +0,0 @@ -package io.emeraldpay.dshackle.config - -import io.emeraldpay.dshackle.FileResolver -import io.emeraldpay.dshackle.foundation.YamlConfigReader -import org.yaml.snakeyaml.nodes.MappingNode - -class SignatureConfigReader(val fileResolver: FileResolver) : YamlConfigReader() { - override fun read(input: MappingNode?): SignatureConfig? { - return getMapping(input, "signed-response")?.let { node -> - val config = SignatureConfig() - getValueAsBool(node, "enabled")?.let { - config.enabled = it - } - if (config.enabled) { - getValueAsString(node, "algorithm")?.let { - config.algorithm = SignatureConfig.algorithmOfString(it) - } - getValueAsString(node, "private-key")?.let { - val key = fileResolver.resolve(it) - config.privateKey = key.absolutePath - } - } - if (config.enabled && config.privateKey == null) { - throw IllegalStateException("Path to a private key (`signature.private-key`) is required when Response signature is enabled.") - } - config - } - } -} diff --git a/src/main/kotlin/io/emeraldpay/dshackle/quorum/QuorumRequestReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/quorum/QuorumRequestReader.kt index 33bf5a4c..54bca259 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/quorum/QuorumRequestReader.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/quorum/QuorumRequestReader.kt @@ -27,6 +27,7 @@ import io.emeraldpay.dshackle.upstream.ChainResponse import io.emeraldpay.dshackle.upstream.Upstream import io.emeraldpay.dshackle.upstream.error.UpstreamErrorHandler import io.emeraldpay.dshackle.upstream.ethereum.rpc.RpcException +import io.emeraldpay.dshackle.upstream.signature.DisabledSigner import io.emeraldpay.dshackle.upstream.signature.ResponseSigner import org.slf4j.LoggerFactory import reactor.core.publisher.Flux @@ -45,7 +46,7 @@ import java.util.function.Function class QuorumRequestReader( private val apiControl: ApiSource, private val quorum: CallQuorum, - signer: ResponseSigner?, + signer: ResponseSigner, ) : RequestReader(signer) { private val errorHandler = UpstreamErrorHandler @@ -53,7 +54,7 @@ class QuorumRequestReader( private val log = LoggerFactory.getLogger(QuorumRequestReader::class.java) } - constructor(apiControl: ApiSource, quorum: CallQuorum) : this(apiControl, quorum, null) + constructor(apiControl: ApiSource, quorum: CallQuorum) : this(apiControl, quorum, DisabledSigner()) override fun attempts(): AtomicInteger = apiControl.attempts() diff --git a/src/main/kotlin/io/emeraldpay/dshackle/reader/BroadcastReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/reader/BroadcastReader.kt index 9df74d25..b4c221d1 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/reader/BroadcastReader.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/reader/BroadcastReader.kt @@ -16,7 +16,7 @@ import java.util.concurrent.atomic.AtomicInteger class BroadcastReader( private val upstreams: List, matcher: Selector.Matcher, - signer: ResponseSigner?, + signer: ResponseSigner, private val quorum: CallQuorum, ) : RequestReader(signer) { private val errorHandler = UpstreamErrorHandler diff --git a/src/main/kotlin/io/emeraldpay/dshackle/reader/RequestReaderFactory.kt b/src/main/kotlin/io/emeraldpay/dshackle/reader/RequestReaderFactory.kt index f16d36c2..f5816162 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/reader/RequestReaderFactory.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/reader/RequestReaderFactory.kt @@ -19,7 +19,7 @@ import reactor.core.publisher.Flux import java.util.concurrent.atomic.AtomicInteger abstract class RequestReader( - private val signer: ResponseSigner?, + private val signer: ResponseSigner, ) : Reader { abstract fun attempts(): AtomicInteger @@ -43,7 +43,7 @@ abstract class RequestReader( protected fun getSignature(key: ChainRequest, response: ChainResponse, upstreamId: String) = response.providedSignature ?: if (key.nonce != null) { - signer?.sign(key.nonce, response.getResult(), upstreamId) + signer.sign(key.nonce, response.getResult(), upstreamId) } else { null } @@ -82,6 +82,6 @@ interface RequestReaderFactory { val multistream: Multistream, val upstreamFilter: Selector.UpstreamFilter, val quorum: CallQuorum, - val signer: ResponseSigner?, + val signer: ResponseSigner, ) } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/BitcoinUpstreamCreator.kt b/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/BitcoinUpstreamCreator.kt index 305650a4..2579466d 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/BitcoinUpstreamCreator.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/BitcoinUpstreamCreator.kt @@ -16,6 +16,7 @@ import io.emeraldpay.dshackle.upstream.bitcoin.EsploraClient import io.emeraldpay.dshackle.upstream.bitcoin.ExtractBlock import io.emeraldpay.dshackle.upstream.bitcoin.ZMQServer import io.emeraldpay.dshackle.upstream.forkchoice.MostWorkForkChoice +import io.emeraldpay.dshackle.upstream.signature.ResponseSigner import org.springframework.stereotype.Component import reactor.core.scheduler.Scheduler import java.util.concurrent.atomic.AtomicInteger @@ -27,7 +28,8 @@ class BitcoinUpstreamCreator( private val genericConnectorFactoryCreator: ConnectorFactoryCreator, private val fileResolver: FileResolver, private val headScheduler: Scheduler, -) : UpstreamCreator(chainsConfig, callTargets) { + signer: ResponseSigner, +) : UpstreamCreator(chainsConfig, callTargets, signer) { private var seq = AtomicInteger(0) override fun createUpstream( @@ -67,7 +69,7 @@ class BitcoinUpstreamCreator( ?: "bitcoin-${seq.getAndIncrement()}", chain, directApi, head, options, config.role, - QuorumForLabels.QuorumItem(1, UpstreamsConfig.Labels.fromMap(config.labels)), + QuorumForLabels.QuorumItem(1, buildUpstreamLabels(config.labels)), methods, esplora, chainConf, ) upstream.start() diff --git a/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/EthereumUpstreamCreator.kt b/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/EthereumUpstreamCreator.kt index 2b9243fb..154508fa 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/EthereumUpstreamCreator.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/EthereumUpstreamCreator.kt @@ -6,6 +6,7 @@ import io.emeraldpay.dshackle.config.UpstreamsConfig import io.emeraldpay.dshackle.config.hot.CompatibleVersionsRules import io.emeraldpay.dshackle.foundation.ChainOptions import io.emeraldpay.dshackle.upstream.CallTargetsHolder +import io.emeraldpay.dshackle.upstream.signature.ResponseSigner import org.springframework.stereotype.Component import java.util.function.Supplier @@ -15,7 +16,8 @@ class EthereumUpstreamCreator( callTargets: CallTargetsHolder, connectorFactoryCreatorResolver: ConnectorFactoryCreatorResolver, versionRules: Supplier, -) : GenericUpstreamCreator(chainsConfig, callTargets, connectorFactoryCreatorResolver, versionRules) { + signer: ResponseSigner, +) : GenericUpstreamCreator(chainsConfig, callTargets, connectorFactoryCreatorResolver, versionRules, signer) { override fun createUpstream( upstreamsConfig: UpstreamsConfig.Upstream<*>, diff --git a/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/GenericUpstreamCreator.kt b/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/GenericUpstreamCreator.kt index e38bcca2..c4d0c7b0 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/GenericUpstreamCreator.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/GenericUpstreamCreator.kt @@ -12,6 +12,7 @@ import io.emeraldpay.dshackle.upstream.forkchoice.NoChoiceWithPriorityForkChoice import io.emeraldpay.dshackle.upstream.generic.ChainSpecificRegistry import io.emeraldpay.dshackle.upstream.generic.GenericUpstream import io.emeraldpay.dshackle.upstream.generic.connectors.GenericConnectorFactory +import io.emeraldpay.dshackle.upstream.signature.ResponseSigner import org.springframework.stereotype.Component import java.util.function.Supplier @@ -21,7 +22,8 @@ open class GenericUpstreamCreator( callTargets: CallTargetsHolder, private val connectorFactoryCreatorResolver: ConnectorFactoryCreatorResolver, private val versionRules: Supplier, -) : UpstreamCreator(chainsConfig, callTargets) { + signer: ResponseSigner, +) : UpstreamCreator(chainsConfig, callTargets, signer) { private val hashes = HashSet() override fun createUpstream( @@ -78,7 +80,7 @@ open class GenericUpstreamCreator( chain, hash, options, - QuorumForLabels.QuorumItem(1, UpstreamsConfig.Labels.fromMap(config.labels)), + QuorumForLabels.QuorumItem(1, buildUpstreamLabels(config.labels)), chainConfig, connectorFactory, cs::validator, diff --git a/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/UpstreamCreator.kt b/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/UpstreamCreator.kt index ec36a2d7..1ef9a8f4 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/UpstreamCreator.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/startup/configure/UpstreamCreator.kt @@ -10,6 +10,7 @@ import io.emeraldpay.dshackle.upstream.CallTargetsHolder import io.emeraldpay.dshackle.upstream.calls.CallMethods import io.emeraldpay.dshackle.upstream.calls.ManagedCallMethods import io.emeraldpay.dshackle.upstream.lowerbound.GoldLowerBounds +import io.emeraldpay.dshackle.upstream.signature.ResponseSigner import jakarta.annotation.PostConstruct import org.slf4j.Logger import org.slf4j.LoggerFactory @@ -19,15 +20,32 @@ import kotlin.math.abs abstract class UpstreamCreator( private val chainsConfig: ChainsConfig, private val callTargets: CallTargetsHolder, + private val signer: ResponseSigner, ) { protected val log: Logger = LoggerFactory.getLogger(this::class.java) + /** + * Builds the label map for an upstream starting from the user-provided labels. + * When response signing is actually enabled on this dshackle instance, the + * `secure-signed=true` label is injected automatically (unless the user has + * explicitly overridden it). + */ + protected fun buildUpstreamLabels(userLabels: Map): UpstreamsConfig.Labels { + val labels = UpstreamsConfig.Labels.fromMap(userLabels) + if (signer.enabled && !labels.containsKey(SECURE_SIGNED_LABEL)) { + labels[SECURE_SIGNED_LABEL] = "true" + } + return labels + } + @PostConstruct fun init() { GoldLowerBounds.init(chainsConfig.getChainConfigs()) } companion object { + const val SECURE_SIGNED_LABEL = "secure-signed" + fun getHash(nodeId: Int?, obj: Any, hashes: MutableSet): Short { val hash = nodeId?.toShort() ?: run { diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumDirectReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumDirectReader.kt index e3706228..324acb51 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumDirectReader.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumDirectReader.kt @@ -34,6 +34,7 @@ import io.emeraldpay.dshackle.upstream.ethereum.rpc.RpcException import io.emeraldpay.dshackle.upstream.ethereum.rpc.RpcResponseError import io.emeraldpay.dshackle.upstream.finalization.FinalizationType import io.emeraldpay.dshackle.upstream.rpcclient.ListParams +import io.emeraldpay.dshackle.upstream.signature.DisabledSigner import org.apache.commons.collections4.Factory import org.apache.commons.lang3.exception.ExceptionUtils import org.slf4j.LoggerFactory @@ -254,7 +255,7 @@ class EthereumDirectReader( up, Selector.UpstreamFilter(sort, matcher), callMethodsFactory.create().createQuorumFor(request.method), - null, + DisabledSigner(), ), ) }.flatMap { diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLocalReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLocalReader.kt index 925451a5..5983dbfe 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLocalReader.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/ethereum/EthereumLocalReader.kt @@ -48,10 +48,6 @@ class EthereumLocalReader( 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() - } return commonRequests(key)?.switchIfEmpty { // we need to explicitly return null to prevent executeOnRemote // for example diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/DisabledSigner.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/DisabledSigner.kt new file mode 100644 index 00000000..17cd084c --- /dev/null +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/DisabledSigner.kt @@ -0,0 +1,15 @@ +package io.emeraldpay.dshackle.upstream.signature + +import io.emeraldpay.dshackle.upstream.ethereum.rpc.RpcException +import io.emeraldpay.dshackle.upstream.ethereum.rpc.RpcResponseError + +class DisabledSigner : ResponseSigner { + override val enabled: Boolean = false + + override fun sign(nonce: Long, message: ByteArray, source: String): ResponseSigner.Signature { + throw RpcException( + RpcResponseError.CODE_INTERNAL_ERROR, + "Response signing requested via nonce but signing key is not configured", + ) + } +} diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/NoSigner.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/NoSigner.kt deleted file mode 100644 index 5c963b0a..00000000 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/NoSigner.kt +++ /dev/null @@ -1,7 +0,0 @@ -package io.emeraldpay.dshackle.upstream.signature - -class NoSigner : ResponseSigner { - override fun sign(nonce: Long, message: ByteArray, source: String): ResponseSigner.Signature? { - return null - } -} diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/ResponseSigner.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/ResponseSigner.kt index 39549941..96cd5cbc 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/ResponseSigner.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/ResponseSigner.kt @@ -2,7 +2,13 @@ package io.emeraldpay.dshackle.upstream.signature interface ResponseSigner { - fun sign(nonce: Long, message: ByteArray, source: String): Signature? + /** + * `true` when an actual signing key is configured and [sign] can produce signatures. + * `false` for placeholder signers that reject any signing attempt (e.g. when auth is disabled). + */ + val enabled: Boolean + + fun sign(nonce: Long, message: ByteArray, source: String): Signature data class Signature( val value: ByteArray, diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/ResponseSignerFactory.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/ResponseSignerFactory.kt index 59ed2426..d7080150 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/ResponseSignerFactory.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/ResponseSignerFactory.kt @@ -1,24 +1,23 @@ package io.emeraldpay.dshackle.upstream.signature -import io.emeraldpay.dshackle.config.SignatureConfig +import io.emeraldpay.dshackle.config.AuthorizationConfig 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.bouncycastle.openssl.PEMParser import org.slf4j.LoggerFactory import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.stereotype.Component +import java.io.StringReader import java.nio.ByteBuffer import java.nio.file.Files -import java.nio.file.Path +import java.nio.file.Paths import java.security.KeyFactory import java.security.MessageDigest import java.security.PublicKey -import java.security.interfaces.ECPrivateKey +import java.security.interfaces.RSAPrivateCrtKey +import java.security.interfaces.RSAPrivateKey import java.security.spec.PKCS8EncodedKeySpec +import java.security.spec.RSAPublicKeySpec @Configuration open class SignatureBeans { @@ -29,45 +28,50 @@ open class SignatureBeans { @Component open class ResponseSignerFactory( - private val signatureConfig: SignatureConfig, + private val authorizationConfig: AuthorizationConfig, ) { companion object { private val log = LoggerFactory.getLogger(ResponseSignerFactory::class.java) } - fun readKey(algorithm: SignatureConfig.Algorithm, keyPath: String): Pair { - val reader = PemReader(Files.newBufferedReader(Path.of(keyPath))) - return readKey(algorithm, reader.readPemObject()) + fun createSigner(): ResponseSigner { + if (!authorizationConfig.enabled) { + log.info("Response signing disabled: auth is not enabled") + return DisabledSigner() + } + val path = authorizationConfig.serverConfig.providerPrivateKeyPath + if (path.isBlank()) { + log.warn("Response signing disabled: auth.server.provider-private-key is not set") + return DisabledSigner() + } + + val (privateKey, keyId) = readRsaKey(path) + return RsaSigner(privateKey, keyId) } - private fun readKey(algorithm: SignatureConfig.Algorithm, pem: PemObject): Pair { - val keyFactory = KeyFactory.getInstance("EC") - val key = when (algorithm) { - SignatureConfig.Algorithm.NIST_P256 -> { - val keySpec = PKCS8EncodedKeySpec(pem.content) - keyFactory.generatePrivate(keySpec) - } + internal fun readRsaKey(path: String): Pair { + val pemContent = StringReader(Files.readString(Paths.get(path))) + val pemObject = PEMParser(pemContent).readPemObject() + ?: throw IllegalStateException("Cannot parse PEM key at $path") + + val keyFactory = KeyFactory.getInstance("RSA") + val privateKey = keyFactory.generatePrivate(PKCS8EncodedKeySpec(pemObject.content)) + + if (privateKey !is RSAPrivateKey) { + throw IllegalStateException("Only RSA keys are supported for response signing") } - if (key !is ECPrivateKey) { - throw IllegalStateException("Only EC keys are allowed") - } - - if (algorithm == SignatureConfig.Algorithm.NIST_P256 && key.params.toString().indexOf(SignatureConfig.Algorithm.NIST_P256.getCurveName()) < 0) { - throw IllegalStateException("Key is not NIST P256, generate NIST P256 or use another algorithm") - } - - val publicKey = extractPublicKey(keyFactory, key, algorithm) - val id = getPublicKeyId(publicKey) - - return Pair(key, id) + val publicKey = extractPublicKey(keyFactory, privateKey) + val keyId = getPublicKeyId(publicKey) + return Pair(privateKey, keyId) } - fun extractPublicKey(keyFactory: KeyFactory, privateKey: ECPrivateKey, algorithm: SignatureConfig.Algorithm): PublicKey { - val ecSpec = ECNamedCurveTable.getParameterSpec(algorithm.getCurveName()) - val q: ECPoint = ecSpec.g.multiply(privateKey.s) - return keyFactory.generatePublic(ECPublicKeySpec(q, ecSpec)) + private fun extractPublicKey(keyFactory: KeyFactory, privateKey: RSAPrivateKey): PublicKey { + val crt = privateKey as? RSAPrivateCrtKey + ?: throw IllegalStateException("RSA private key does not expose public exponent; use a PKCS#8 key that contains CRT parameters") + val spec = RSAPublicKeySpec(crt.modulus, crt.publicExponent) + return keyFactory.generatePublic(spec) } private fun getPublicKeyId(publicKey: PublicKey): Long { @@ -76,16 +80,4 @@ open class ResponseSignerFactory( log.info("Using key to sign responses: ${Hex.encodeHexString(fullId).substring(0..15)}") return ByteBuffer.wrap(fullId).asLongBuffer().get() } - - fun createSigner(): ResponseSigner { - if (!signatureConfig.enabled) { - return NoSigner() - } - if (signatureConfig.privateKey == null) { - log.warn("Private Key for response signature is not set") - return NoSigner() - } - val key = readKey(signatureConfig.algorithm, signatureConfig.privateKey!!) - return EcdsaSigner(key.first, key.second) - } } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/EcdsaSigner.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/RsaSigner.kt similarity index 51% rename from src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/EcdsaSigner.kt rename to src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/RsaSigner.kt index 902ff4e3..ee0e8f04 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/EcdsaSigner.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/signature/RsaSigner.kt @@ -3,19 +3,21 @@ package io.emeraldpay.dshackle.upstream.signature import org.apache.commons.codec.binary.Hex import java.security.MessageDigest import java.security.Signature -import java.security.interfaces.ECPrivateKey +import java.security.interfaces.RSAPrivateKey -class EcdsaSigner( - private val privateKey: ECPrivateKey, +class RsaSigner( + private val privateKey: RSAPrivateKey, val keyId: Long, ) : ResponseSigner { companion object { - const val SIGN_SCHEME = "SHA256withECDSA" + const val SIGN_SCHEME = "SHA256withRSA" const val MSG_PREFIX = "DSHACKLESIG" const val MSG_SEPARATOR = '/' } + override val enabled: Boolean = true + override fun sign(nonce: Long, message: ByteArray, source: String): ResponseSigner.Signature { val sig = Signature.getInstance(SIGN_SCHEME, "BC") sig.initSign(privateKey) @@ -26,29 +28,15 @@ class EcdsaSigner( } /** - * 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 + * Wrapping format: `"DSHACKLESIG/" || str(nonce) || "/" || source || "/" || hex(sha256(msg))` */ fun wrapMessage(nonce: Long, message: ByteArray, source: String): 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) .append(MSG_SEPARATOR) .append(Hex.encodeHexString(sha256.digest(message))) diff --git a/src/test/groovy/io/emeraldpay/dshackle/config/SignatureConfigReaderSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/config/SignatureConfigReaderSpec.groovy deleted file mode 100644 index 2beb419f..00000000 --- a/src/test/groovy/io/emeraldpay/dshackle/config/SignatureConfigReaderSpec.groovy +++ /dev/null @@ -1,40 +0,0 @@ -package io.emeraldpay.dshackle.config - -import io.emeraldpay.dshackle.test.TestingCommons -import spock.lang.Specification - -class SignatureConfigReaderSpec extends Specification { - - def "Parse enabled"() { - setup: - def config = "signed-response:\n" + - " enabled: true\n" + - " algorithm: NIST_P256\n" + - " private-key: /root/key.pem\n" - - when: - def reader = new SignatureConfigReader(TestingCommons.fileResolver()) - def act = reader.read(new ByteArrayInputStream(config.bytes)) - - then: - act.enabled - act.privateKey == "/root/key.pem" - act.algorithm == SignatureConfig.Algorithm.NIST_P256 - } - - def "No path when disabled"() { - setup: - def config = "signed-response:\n" + - " enabled: false\n" + - " private-key: /root/key.pem\n" - - when: - def reader = new SignatureConfigReader(TestingCommons.fileResolver()) - def act = reader.read(new ByteArrayInputStream(config.bytes)) - - then: - !act.enabled - act.privateKey == null - } - -} diff --git a/src/test/groovy/io/emeraldpay/dshackle/reader/BroadcastReaderSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/reader/BroadcastReaderSpec.groovy index 17c487d1..7695c551 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/reader/BroadcastReaderSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/reader/BroadcastReaderSpec.groovy @@ -7,6 +7,7 @@ import io.emeraldpay.dshackle.upstream.ChainException import io.emeraldpay.dshackle.upstream.ChainRequest import io.emeraldpay.dshackle.upstream.ChainResponse import io.emeraldpay.dshackle.upstream.rpcclient.ListParams +import io.emeraldpay.dshackle.upstream.signature.DisabledSigner import reactor.core.publisher.Mono import reactor.test.StepVerifier import spock.lang.Specification @@ -42,7 +43,7 @@ class BroadcastReaderSpec extends Specification { Mono.just(new ChainResponse(result, null)) } } - def reader = new BroadcastReader([up, up1, up2], new Selector.EmptyMatcher(), null, new BroadcastQuorum()) + def reader = new BroadcastReader([up, up1, up2], new Selector.EmptyMatcher(), new DisabledSigner(), new BroadcastQuorum()) when: def act = reader.read(new ChainRequest("eth_sendRawTransaction", new ListParams(["0x1"]))) then: @@ -80,7 +81,7 @@ class BroadcastReaderSpec extends Specification { 1 * read(new ChainRequest("eth_sendRawTransaction", new ListParams(["0x1"]))) >> Mono.error(new ChainException(1, "too low")) } } - def reader = new BroadcastReader([up, up1, up2], new Selector.EmptyMatcher(), null, new BroadcastQuorum()) + def reader = new BroadcastReader([up, up1, up2], new Selector.EmptyMatcher(), new DisabledSigner(), new BroadcastQuorum()) when: def act = reader.read(new ChainRequest("eth_sendRawTransaction", new ListParams(["0x1"]))) then: @@ -113,7 +114,7 @@ class BroadcastReaderSpec extends Specification { 0 * getId() >> "id" 0 * getIngressReader() >> Mock(Reader) } - def reader = new BroadcastReader([up, up1, up2], new Selector.EmptyMatcher(), null, new BroadcastQuorum()) + def reader = new BroadcastReader([up, up1, up2], new Selector.EmptyMatcher(), new DisabledSigner(), new BroadcastQuorum()) when: def act = reader.read(new ChainRequest("eth_sendRawTransaction", new ListParams(["0x1"]))) then: @@ -151,7 +152,7 @@ class BroadcastReaderSpec extends Specification { Mono.error(new ChainException(1, "too low")) } } - def reader = new BroadcastReader([up, up1, up2], new Selector.EmptyMatcher(), null, new BroadcastQuorum()) + def reader = new BroadcastReader([up, up1, up2], new Selector.EmptyMatcher(), new DisabledSigner(), new BroadcastQuorum()) when: def act = reader.read(new ChainRequest("eth_sendRawTransaction", new ListParams(["0x1"]))) then: @@ -177,7 +178,7 @@ class BroadcastReaderSpec extends Specification { 0 * getId() >> "id" 0 * getIngressReader() >> Mock(Reader) } - def reader = new BroadcastReader([up, up1, up2], new Selector.EmptyMatcher(), null, new BroadcastQuorum()) + def reader = new BroadcastReader([up, up1, up2], new Selector.EmptyMatcher(), new DisabledSigner(), new BroadcastQuorum()) when: def act = reader .read(new ChainRequest("eth_sendRawTransaction", new ListParams(["0x1"]))) diff --git a/src/test/groovy/io/emeraldpay/dshackle/rpc/NativeSubscribeSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/rpc/NativeSubscribeSpec.groovy index 3cd3d090..70c18801 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/rpc/NativeSubscribeSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/rpc/NativeSubscribeSpec.groovy @@ -22,7 +22,7 @@ import io.emeraldpay.dshackle.test.MultistreamHolderMock import io.emeraldpay.dshackle.upstream.Selector import io.emeraldpay.dshackle.upstream.ethereum.EthereumEgressSubscription import io.emeraldpay.dshackle.upstream.generic.GenericMultistream -import io.emeraldpay.dshackle.upstream.signature.NoSigner +import io.emeraldpay.dshackle.upstream.signature.DisabledSigner import reactor.core.publisher.Flux import reactor.test.StepVerifier import spock.lang.Specification @@ -30,7 +30,7 @@ import spock.lang.Specification import java.time.Duration class NativeSubscribeSpec extends Specification { - def signer = new NoSigner() + def signer = new DisabledSigner() def "Call with empty params when not provided"() { setup: diff --git a/src/test/groovy/io/emeraldpay/dshackle/startup/configure/UpstreamCreatorLabelsSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/startup/configure/UpstreamCreatorLabelsSpec.groovy new file mode 100644 index 00000000..29a4060a --- /dev/null +++ b/src/test/groovy/io/emeraldpay/dshackle/startup/configure/UpstreamCreatorLabelsSpec.groovy @@ -0,0 +1,74 @@ +package io.emeraldpay.dshackle.startup.configure + +import io.emeraldpay.dshackle.Chain +import io.emeraldpay.dshackle.config.ChainsConfig +import io.emeraldpay.dshackle.config.UpstreamsConfig +import io.emeraldpay.dshackle.foundation.ChainOptions +import io.emeraldpay.dshackle.upstream.CallTargetsHolder +import io.emeraldpay.dshackle.upstream.signature.DisabledSigner +import io.emeraldpay.dshackle.upstream.signature.ResponseSigner +import io.emeraldpay.dshackle.upstream.signature.RsaSigner +import spock.lang.Specification + +import java.security.interfaces.RSAPrivateKey + +class UpstreamCreatorLabelsSpec extends Specification { + + static class TestCreator extends UpstreamCreator { + TestCreator(ChainsConfig chainsConfig, CallTargetsHolder callTargets, ResponseSigner signer) { + super(chainsConfig, callTargets, signer) + } + + @Override + protected UpstreamCreationData createUpstream( + UpstreamsConfig.Upstream upstreamsConfig, + Chain chain, + ChainOptions.Options options, + ChainsConfig.ChainConfig chainConf) { + return UpstreamCreationData.default() + } + + UpstreamsConfig.Labels callBuildLabels(Map src) { + return buildUpstreamLabels(src) + } + } + + TestCreator makeCreator(ResponseSigner signer) { + return new TestCreator(Mock(ChainsConfig), Mock(CallTargetsHolder), signer) + } + + def "Adds secure-signed label when signer is enabled"() { + setup: + def creator = makeCreator(new RsaSigner(Stub(RSAPrivateKey), 1L)) + + when: + def labels = creator.callBuildLabels(["provider": "drpc"]) + + then: + labels["provider"] == "drpc" + labels["secure-signed"] == "true" + } + + def "Does not add secure-signed label when signer is disabled"() { + setup: + def creator = makeCreator(new DisabledSigner()) + + when: + def labels = creator.callBuildLabels(["provider": "drpc"]) + + then: + labels["provider"] == "drpc" + !labels.containsKey("secure-signed") + } + + def "Does not override user-provided secure-signed label"() { + setup: + def creator = makeCreator(new RsaSigner(Stub(RSAPrivateKey), 1L)) + + when: + def labels = creator.callBuildLabels(["secure-signed": "false"]) + + then: + labels["secure-signed"] == "false" + } +} diff --git a/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/EthereumLocalReaderSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/EthereumLocalReaderSpec.groovy index d167ce33..d970c4be 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/EthereumLocalReaderSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/upstream/ethereum/EthereumLocalReaderSpec.groovy @@ -30,21 +30,26 @@ class EthereumLocalReaderSpec extends Specification { act.resultAsProcessedString == "0x0000000000000000000000000000000000000000" } - def "Returns empty if nonce set"() { + def "Serves non-hardcoded call when nonce is set"() { setup: def methods = new DefaultEthereumMethods(Chain.ETHEREUM__MAINNET) + def api = TestingCommons.api() + api.answer("eth_getTransactionByHash", + ["0x0000000000000000000000000000000000000000000000000000000000000001"], null) def router = new EthereumLocalReader( new EthereumCachingReader( - TestingCommons.multistream(TestingCommons.api()), + TestingCommons.multistream(api), Caches.default(), ConstantFactory.constantFactory(new DefaultEthereumMethods(Chain.ETHEREUM__MAINNET)), ), methods ) when: - def act = router.read(new ChainRequest("eth_getTransactionByHash", new ListParams(["test"]), 10)) + def act = router.read(new ChainRequest("eth_getTransactionByHash", + new ListParams(["0x0000000000000000000000000000000000000000000000000000000000000001"]), + 10)) .block(Duration.ofSeconds(1)) then: - act == null + act != null } } diff --git a/src/test/groovy/io/emeraldpay/dshackle/upstream/signature/DisabledSignerSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/upstream/signature/DisabledSignerSpec.groovy new file mode 100644 index 00000000..54f42364 --- /dev/null +++ b/src/test/groovy/io/emeraldpay/dshackle/upstream/signature/DisabledSignerSpec.groovy @@ -0,0 +1,28 @@ +package io.emeraldpay.dshackle.upstream.signature + +import io.emeraldpay.dshackle.upstream.ethereum.rpc.RpcException +import spock.lang.Specification + +class DisabledSignerSpec extends Specification { + + def "sign throws RpcException with CODE_INTERNAL_ERROR"() { + setup: + def signer = new DisabledSigner() + + when: + signer.sign(1L, "data".bytes, "upstreamId") + + then: + def ex = thrown(RpcException) + ex.code == -32603 + ex.rpcMessage.contains("signing key is not configured") + } + + def "Signer is not enabled"() { + setup: + def signer = new DisabledSigner() + + expect: + !signer.enabled + } +} diff --git a/src/test/groovy/io/emeraldpay/dshackle/upstream/signature/EcdsaSignerSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/upstream/signature/EcdsaSignerSpec.groovy deleted file mode 100644 index c49a5b6f..00000000 --- a/src/test/groovy/io/emeraldpay/dshackle/upstream/signature/EcdsaSignerSpec.groovy +++ /dev/null @@ -1,135 +0,0 @@ -package io.emeraldpay.dshackle.upstream.signature - -import io.emeraldpay.dshackle.config.SignatureConfig -import io.emeraldpay.dshackle.upstream.Upstream -import org.apache.commons.codec.binary.Hex -import org.bouncycastle.jce.provider.BouncyCastleProvider -import org.bouncycastle.util.io.pem.PemObject -import org.bouncycastle.util.io.pem.PemWriter -import spock.lang.Specification - -import java.security.KeyFactory -import java.security.KeyPairGenerator -import java.security.MessageDigest -import java.security.Security -import java.security.Signature -import java.security.interfaces.ECPrivateKey -import java.security.spec.ECGenParameterSpec -import java.security.spec.PKCS8EncodedKeySpec - -class EcdsaSignerSpec extends Specification { - - def setupSpec() { - Security.addProvider(new BouncyCastleProvider()) - } - - def "Reads private key NIST P256"() { - setup: - def file = File.createTempFile("test", ".pem") - def keygen = KeyPairGenerator.getInstance("EC") - keygen.initialize(new ECGenParameterSpec("secp256r1")) - def key = keygen.generateKeyPair() - def keyBuilder = new PKCS8EncodedKeySpec(key.getPrivate().getEncoded()) - def writer = new PemWriter(new FileWriter(file.path)) - writer.writeObject(new PemObject("PRIVATE KEY", keyBuilder.getEncoded())) - writer.close() - - when: - def signer = new ResponseSignerFactory(new SignatureConfig()) - def act = signer.readKey(SignatureConfig.Algorithm.NIST_P256, file.absolutePath).first - - then: - act == key.getPrivate() - - cleanup: - file.delete() - } - - def "Id is a hash of x509 public key"() { - setup: - def conf = new SignatureConfig() - conf.enabled = true - conf.privateKey = "src/test/resources/signer/test_key" - def signer = new ResponseSignerFactory(conf).createSigner() as EcdsaSigner - - // To verify the test, check the hash of test key above: - // - // echo MFYwEAYHKoZIzj0CAQYFK4EEAAoDQgAE3zetdMdyTO/sTFCLeOrI5moiZt2RjfUVdavhorgqd+gxAqM01cf5Q4QZ8INne9RykcQsbLYXQfDXJbGMm5+gdg== | base64 -d - | shasum -a 256 - // d25f1ff2c1a57235a9bc7725cd645ab0e9631475a12402f2881579d3f6887597 - - // - - when: - def id = signer.keyId - - then: - id == 0xed397068b172b393L - } - - def "Wrap message"() { - setup: - def up = Mock(Upstream) { - _ * getId() >> "infura" - } - def signer = new EcdsaSigner(Stub(ECPrivateKey), 100L) - - when: - def act = signer.wrapMessage(10, "test".bytes, up.id) - - then: - act == "DSHACKLESIG/10/infura/9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" - } - - def "Signed message is valid"() { - setup: - def result = "test".bytes - def up = Mock(Upstream) { - _ * getId() >> "infura" - } - - def keyPairGen = KeyPairGenerator.getInstance("EC") - keyPairGen.initialize(new ECGenParameterSpec("secp256r1")) - def pair = keyPairGen.generateKeyPair() - def verifier = Signature.getInstance("SHA256withECDSA") - verifier.initVerify(pair.getPublic()) - verifier.update("DSHACKLESIG/10/infura/9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08".getBytes()) - - def signer = new EcdsaSigner((pair.getPrivate() as ECPrivateKey), 100L) - - when: - def sig = signer.sign(10, result, up.id) - - then: - verifier.verify(sig.value) - } - - def "Signed message is valid - for docs"() { - // it's the example used in docs - setup: - def result = '["0xe670ec64341771606e55d6b4ca35a1a6b75ee3d5145a99d05921026d1527331", true]'.bytes - def up = Mock(Upstream) { - _ * getId() >> "infura" - } - - def sha256 = MessageDigest.getInstance("SHA-256") - - def conf = new SignatureConfig() - conf.enabled = true - conf.privateKey = "src/test/resources/signer/test_key" - def factory = new ResponseSignerFactory(conf) - - def sk = factory.readKey(conf.algorithm, conf.privateKey).first - def pk = factory.extractPublicKey(KeyFactory.getInstance("EC"), sk, SignatureConfig.Algorithm.NIST_P256) - def verifier = Signature.getInstance("SHA256withECDSA") - verifier.initVerify(pk) - verifier.update("DSHACKLESIG/10/infura/${Hex.encodeHexString(sha256.digest(result))}".getBytes()) - - def signer = factory.createSigner() as EcdsaSigner - - when: - def sig = signer.sign(10, result, up.id) - println("Signature: ${Hex.encodeHexString(sig.value)}") - - then: - verifier.verify(sig.value) - } -} diff --git a/src/test/groovy/io/emeraldpay/dshackle/upstream/signature/ResponseSignerFactorySpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/upstream/signature/ResponseSignerFactorySpec.groovy index 91f26853..4769bb2f 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/upstream/signature/ResponseSignerFactorySpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/upstream/signature/ResponseSignerFactorySpec.groovy @@ -1,28 +1,90 @@ package io.emeraldpay.dshackle.upstream.signature - -import io.emeraldpay.dshackle.config.SignatureConfig +import io.emeraldpay.dshackle.config.AuthorizationConfig +import io.emeraldpay.dshackle.upstream.ethereum.rpc.RpcException +import org.bouncycastle.jce.provider.BouncyCastleProvider +import org.springframework.util.ResourceUtils import spock.lang.Specification +import java.security.Security + class ResponseSignerFactorySpec extends Specification { - - def "No signer if not enabled"() { - setup: - def conf = new SignatureConfig() - when: - def signer = new ResponseSignerFactory(conf).createSigner() - then: - signer instanceof NoSigner + def setupSpec() { + Security.addProvider(new BouncyCastleProvider()) } - def "No signer if privkey is not configured"() { + def "DisabledSigner when auth disabled"() { setup: - def conf = new SignatureConfig() + def auth = AuthorizationConfig.default() + when: - def signer = new ResponseSignerFactory(conf).createSigner() + def signer = new ResponseSignerFactory(auth).createSigner() + then: - signer instanceof NoSigner + signer instanceof DisabledSigner } + def "DisabledSigner when provider-private-key path is blank"() { + setup: + def auth = new AuthorizationConfig( + true, + "owner", + new AuthorizationConfig.ServerConfig("", "classpath:keys/public.pem"), + AuthorizationConfig.ClientConfig.default(), + ) + + when: + def signer = new ResponseSignerFactory(auth).createSigner() + + then: + signer instanceof DisabledSigner + } + + def "RsaSigner built from valid RSA key"() { + setup: + def privPath = ResourceUtils.getFile("classpath:keys/priv.p8.key").absolutePath + def pubPath = ResourceUtils.getFile("classpath:keys/public.pem").absolutePath + def auth = new AuthorizationConfig( + true, + "owner", + new AuthorizationConfig.ServerConfig(privPath, pubPath), + AuthorizationConfig.ClientConfig.default(), + ) + + when: + def signer = new ResponseSignerFactory(auth).createSigner() + + then: + signer instanceof RsaSigner + (signer as RsaSigner).keyId != 0L + } + + def "Fails on missing key file"() { + setup: + def auth = new AuthorizationConfig( + true, + "owner", + new AuthorizationConfig.ServerConfig("/no/such/file.pem", "classpath:keys/public.pem"), + AuthorizationConfig.ClientConfig.default(), + ) + + when: + new ResponseSignerFactory(auth).createSigner() + + then: + thrown(Exception) + } + + def "DisabledSigner.sign throws RpcException"() { + setup: + def signer = new ResponseSignerFactory(AuthorizationConfig.default()).createSigner() + + when: + signer.sign(1L, "data".bytes, "up") + + then: + def ex = thrown(RpcException) + ex.code == -32603 + } } diff --git a/src/test/groovy/io/emeraldpay/dshackle/upstream/signature/RsaSignerSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/upstream/signature/RsaSignerSpec.groovy new file mode 100644 index 00000000..2e212a45 --- /dev/null +++ b/src/test/groovy/io/emeraldpay/dshackle/upstream/signature/RsaSignerSpec.groovy @@ -0,0 +1,75 @@ +package io.emeraldpay.dshackle.upstream.signature + +import org.apache.commons.codec.binary.Hex +import org.bouncycastle.jce.provider.BouncyCastleProvider +import spock.lang.Specification + +import java.security.KeyPairGenerator +import java.security.MessageDigest +import java.security.Security +import java.security.Signature +import java.security.interfaces.RSAPrivateKey + +class RsaSignerSpec extends Specification { + + def setupSpec() { + Security.addProvider(new BouncyCastleProvider()) + } + + def "Wrap message"() { + setup: + def signer = new RsaSigner(Stub(RSAPrivateKey), 100L) + + when: + def act = signer.wrapMessage(10, "test".bytes, "infura") + + then: + act == "DSHACKLESIG/10/infura/9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" + } + + def "Signed message is valid"() { + setup: + def result = "test".bytes + def keyGen = KeyPairGenerator.getInstance("RSA") + keyGen.initialize(2048) + def pair = keyGen.generateKeyPair() + + def sha256 = MessageDigest.getInstance("SHA-256") + def verifier = Signature.getInstance("SHA256withRSA", "BC") + verifier.initVerify(pair.getPublic()) + verifier.update("DSHACKLESIG/10/infura/${Hex.encodeHexString(sha256.digest(result))}".getBytes()) + + def signer = new RsaSigner((pair.getPrivate() as RSAPrivateKey), 100L) + + when: + def sig = signer.sign(10, result, "infura") + + then: + verifier.verify(sig.value) + sig.upstreamId == "infura" + sig.keyId == 100L + } + + def "Signer is enabled"() { + setup: + def signer = new RsaSigner(Stub(RSAPrivateKey), 1L) + + expect: + signer.enabled + } + + def "Different nonce produces different signature"() { + setup: + def keyGen = KeyPairGenerator.getInstance("RSA") + keyGen.initialize(2048) + def pair = keyGen.generateKeyPair() + def signer = new RsaSigner((pair.getPrivate() as RSAPrivateKey), 1L) + + when: + def sig1 = signer.sign(1, "test".bytes, "up") + def sig2 = signer.sign(2, "test".bytes, "up") + + then: + !Arrays.equals(sig1.value, sig2.value) + } +} diff --git a/src/test/kotlin/io/emeraldpay/dshackle/IntegrationTest.kt b/src/test/kotlin/io/emeraldpay/dshackle/IntegrationTest.kt index 57c7c8f5..83191b30 100644 --- a/src/test/kotlin/io/emeraldpay/dshackle/IntegrationTest.kt +++ b/src/test/kotlin/io/emeraldpay/dshackle/IntegrationTest.kt @@ -10,6 +10,7 @@ import io.emeraldpay.dshackle.reader.BroadcastReader import io.emeraldpay.dshackle.reader.RequestReaderFactory import io.emeraldpay.dshackle.upstream.MultistreamHolder import io.emeraldpay.dshackle.upstream.Selector +import io.emeraldpay.dshackle.upstream.signature.DisabledSigner import io.grpc.BindableService import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test @@ -65,7 +66,7 @@ class IntegrationTest { ms, Selector.UpstreamFilter.default, txQuorum, - null, + DisabledSigner(), ), ) val txCountReader = reqReader.create( @@ -73,7 +74,7 @@ class IntegrationTest { ms, Selector.UpstreamFilter.default, txCountQuorum, - null, + DisabledSigner(), ), ) diff --git a/src/test/kotlin/io/emeraldpay/dshackle/reader/RequestReaderFactoryTest.kt b/src/test/kotlin/io/emeraldpay/dshackle/reader/RequestReaderFactoryTest.kt index 169a4ade..22bd2627 100644 --- a/src/test/kotlin/io/emeraldpay/dshackle/reader/RequestReaderFactoryTest.kt +++ b/src/test/kotlin/io/emeraldpay/dshackle/reader/RequestReaderFactoryTest.kt @@ -4,6 +4,7 @@ import io.emeraldpay.dshackle.quorum.BroadcastQuorum import io.emeraldpay.dshackle.quorum.MaximumValueQuorum import io.emeraldpay.dshackle.upstream.Multistream import io.emeraldpay.dshackle.upstream.Selector +import io.emeraldpay.dshackle.upstream.signature.DisabledSigner import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments @@ -34,7 +35,7 @@ class RequestReaderFactoryTest { ms, Selector.UpstreamFilter(Selector.empty), MaximumValueQuorum(), - null, + DisabledSigner(), ), ), Arguments.of( @@ -42,7 +43,7 @@ class RequestReaderFactoryTest { ms, Selector.UpstreamFilter(Selector.empty), BroadcastQuorum(), - null, + DisabledSigner(), ), ), )