response signing with auth key (#811)

* 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
This commit is contained in:
a10zn8
2026-04-21 10:33:37 +03:00
committed by GitHub
parent e8232231df
commit 25dbc7c388
32 changed files with 439 additions and 399 deletions

8
.gitignore vendored
View File

@@ -26,4 +26,10 @@ http-client.env.json
.DS_Store
mise.toml
.claude/
.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/

View File

@@ -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 <<tokens>> section
| Caching configuration.
See <<cache>> section.
| `signed-response`
| `auth`
|
| Signed responses
See <<signed-response>> section.
| Authorization and response signing.
See <<auth>> 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 <<response-signing>>).
| `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/<nonce>/<upstream_id>/<hex-sha256(payload)>`. 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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<SignatureConfig>() {
override fun read(input: MappingNode?): SignatureConfig? {
return getMapping(input, "signed-response")?.let { node ->
val config = SignatureConfig()
getValueAsBool(node, "enabled")?.let {
config.enabled = it
}
if (config.enabled) {
getValueAsString(node, "algorithm")?.let {
config.algorithm = SignatureConfig.algorithmOfString(it)
}
getValueAsString(node, "private-key")?.let {
val key = fileResolver.resolve(it)
config.privateKey = key.absolutePath
}
}
if (config.enabled && config.privateKey == null) {
throw IllegalStateException("Path to a private key (`signature.private-key`) is required when Response signature is enabled.")
}
config
}
}
}

View File

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

View File

@@ -16,7 +16,7 @@ import java.util.concurrent.atomic.AtomicInteger
class BroadcastReader(
private val upstreams: List<Upstream>,
matcher: Selector.Matcher,
signer: ResponseSigner?,
signer: ResponseSigner,
private val quorum: CallQuorum,
) : RequestReader(signer) {
private val errorHandler = UpstreamErrorHandler

View File

@@ -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<ChainRequest, Result> {
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,
)
}

View File

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

View File

@@ -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<CompatibleVersionsRules?>,
) : GenericUpstreamCreator(chainsConfig, callTargets, connectorFactoryCreatorResolver, versionRules) {
signer: ResponseSigner,
) : GenericUpstreamCreator(chainsConfig, callTargets, connectorFactoryCreatorResolver, versionRules, signer) {
override fun createUpstream(
upstreamsConfig: UpstreamsConfig.Upstream<*>,

View File

@@ -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<CompatibleVersionsRules?>,
) : UpstreamCreator(chainsConfig, callTargets) {
signer: ResponseSigner,
) : UpstreamCreator(chainsConfig, callTargets, signer) {
private val hashes = HashSet<Short>()
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,

View File

@@ -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<String, String>): 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>): Short {
val hash = nodeId?.toShort()
?: run {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<ECPrivateKey, Long> {
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<ECPrivateKey, Long> {
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<RSAPrivateKey, Long> {
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)
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<String, String> 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"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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