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:
8
.gitignore
vendored
8
.gitignore
vendored
@@ -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/
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<*>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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"])))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user