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