diff --git a/CHANGELOG.md b/CHANGELOG.md index ee6289041..8d2a26384 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Bulk load Ethereum v3 wallet files in eth1 mode. - Eth2 Signing request body now supports both `signingRoot` and the `signing_root` property - Add network configuration for Holesky testnet +- Add `eth_signTypedData` RPC method under the eth1 subcommand. [#893](https://github.com/Consensys/web3signer/pull/893) ### Bugs fixed - Upcheck was using application/json accept headers instead text/plain accept headers diff --git a/core/src/integrationTest/java/tech/pegasys/web3signer/core/jsonrpcproxy/EthSignTypedDataIntegrationTest.java b/core/src/integrationTest/java/tech/pegasys/web3signer/core/jsonrpcproxy/EthSignTypedDataIntegrationTest.java new file mode 100644 index 000000000..7940ac86c --- /dev/null +++ b/core/src/integrationTest/java/tech/pegasys/web3signer/core/jsonrpcproxy/EthSignTypedDataIntegrationTest.java @@ -0,0 +1,130 @@ +/* + * Copyright 2023 ConsenSys AG. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ +package tech.pegasys.web3signer.core.jsonrpcproxy; + +import static java.util.Collections.singletonList; +import static org.web3j.crypto.Keys.getAddress; +import static tech.pegasys.web3signer.core.service.jsonrpc.response.JsonRpcError.SIGNING_FROM_IS_NOT_AN_UNLOCKED_ACCOUNT; + +import tech.pegasys.web3signer.core.jsonrpcproxy.support.EthSignTypedData; +import tech.pegasys.web3signer.core.service.jsonrpc.response.JsonRpcErrorResponse; +import tech.pegasys.web3signer.core.service.jsonrpc.response.JsonRpcSuccessResponse; + +import java.security.InvalidAlgorithmParameterException; +import java.security.NoSuchAlgorithmException; +import java.security.NoSuchProviderException; +import java.util.Arrays; +import java.util.Map; + +import io.netty.handler.codec.http.HttpHeaderValues; +import io.vertx.core.json.Json; +import org.apache.commons.lang3.tuple.ImmutablePair; +import org.junit.jupiter.api.Test; +import org.web3j.crypto.Keys; +import org.web3j.protocol.core.Request; + +public class EthSignTypedDataIntegrationTest extends IntegrationTestBase { + + // Json taken and validated using https://metamask.github.io/test-dapp/#signTypedDataV4 + private static final String eip712Json = + """ + { + "types": { + "EIP712Domain": [ + { + "name": "name", + "type": "string" + }, + { + "name": "version", + "type": "string" + }, + { + "name": "chainId", + "type": "uint256" + }, + { + "name": "verifyingContract", + "type": "address" + } + ], + "Person": [ + { + "name": "name", + "type": "string" + }, + { + "name": "wallet", + "type": "address" + } + ] + }, + "domain": { + "name": "My Dapp", + "version": "1.0", + "chainId": 1, + "verifyingContract": "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC" + }, + "primaryType": "Person", + "message": { + "name": "John Doe", + "wallet": "0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B" + } + } + """; + + @Test + void ethSignTypedDataSignsDataWhenAnUnlockedAccountIsPassed() { + final Request requestBody = + new Request<>( + "eth_signTypedData", + Arrays.asList(unlockedAccount, eip712Json), + null, + EthSignTypedData.class); + + final Iterable> expectedHeaders = + singletonList(ImmutablePair.of("Content", HttpHeaderValues.APPLICATION_JSON.toString())); + + final JsonRpcSuccessResponse responseBody = + new JsonRpcSuccessResponse( + requestBody.getId(), + "0x11cb46f70ad43da86e15ca7c6bb28356859a5f4ba430b44dbf1e65726d467be6072be9d1e5b40bd5b7abe8888eb91a69f0e6d56a8a094718ed8080baf02d61c31c"); + + sendPostRequestAndVerifyResponse( + request.web3Signer(Json.encode(requestBody)), + response.web3Signer(expectedHeaders, Json.encode(responseBody))); + } + + @Test + void ethSignTypedDataDoNotSignMessageWhenSignerAccountIsNotUnlocked() + throws InvalidAlgorithmParameterException, NoSuchAlgorithmException, NoSuchProviderException { + final String A_RANDOM_ADDRESS = getAddress(Keys.createEcKeyPair().getPublicKey()); + + final Request requestBody = + new Request<>( + "eth_signTypedData", + Arrays.asList(A_RANDOM_ADDRESS, eip712Json), + null, + EthSignTypedData.class); + + final Iterable> expectedHeaders = + singletonList(ImmutablePair.of("Content", HttpHeaderValues.APPLICATION_JSON.toString())); + + final JsonRpcErrorResponse responseBody = + new JsonRpcErrorResponse(requestBody.getId(), SIGNING_FROM_IS_NOT_AN_UNLOCKED_ACCOUNT); + + sendPostRequestAndVerifyResponse( + request.web3Signer(Json.encode(requestBody)), + response.web3Signer(expectedHeaders, Json.encode(responseBody))); + } +} diff --git a/core/src/integrationTest/java/tech/pegasys/web3signer/core/jsonrpcproxy/support/EthSignTypedData.java b/core/src/integrationTest/java/tech/pegasys/web3signer/core/jsonrpcproxy/support/EthSignTypedData.java new file mode 100644 index 000000000..ca7af12b8 --- /dev/null +++ b/core/src/integrationTest/java/tech/pegasys/web3signer/core/jsonrpcproxy/support/EthSignTypedData.java @@ -0,0 +1,21 @@ +/* + * Copyright 2023 ConsenSys AG. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ +package tech.pegasys.web3signer.core.jsonrpcproxy.support; + +import org.web3j.protocol.core.Response; + +public class EthSignTypedData extends Response { + public String getSignature() { + return getResult(); + } +} diff --git a/core/src/main/java/tech/pegasys/web3signer/core/Eth1Runner.java b/core/src/main/java/tech/pegasys/web3signer/core/Eth1Runner.java index a4c9c1ca3..2e55d53a7 100644 --- a/core/src/main/java/tech/pegasys/web3signer/core/Eth1Runner.java +++ b/core/src/main/java/tech/pegasys/web3signer/core/Eth1Runner.java @@ -34,6 +34,7 @@ import tech.pegasys.web3signer.core.service.jsonrpc.handlers.RequestMapper; import tech.pegasys.web3signer.core.service.jsonrpc.handlers.internalresponse.EthSignResultProvider; import tech.pegasys.web3signer.core.service.jsonrpc.handlers.internalresponse.EthSignTransactionResultProvider; +import tech.pegasys.web3signer.core.service.jsonrpc.handlers.internalresponse.EthSignTypedDataResultProvider; import tech.pegasys.web3signer.core.service.jsonrpc.handlers.internalresponse.InternalResponseHandler; import tech.pegasys.web3signer.core.service.jsonrpc.handlers.sendtransaction.SendTransactionHandler; import tech.pegasys.web3signer.core.service.jsonrpc.handlers.sendtransaction.transaction.TransactionFactory; @@ -306,6 +307,10 @@ private RequestMapper createRequestMapper( requestMapper.addHandler( "eth_sign", new InternalResponseHandler<>(responseFactory, new EthSignResultProvider(secpSigner))); + requestMapper.addHandler( + "eth_signTypedData", + new InternalResponseHandler<>( + responseFactory, new EthSignTypedDataResultProvider(secpSigner))); requestMapper.addHandler( "eth_signTransaction", new InternalResponseHandler<>( diff --git a/core/src/main/java/tech/pegasys/web3signer/core/service/jsonrpc/handlers/internalresponse/EthSignTypedDataResultProvider.java b/core/src/main/java/tech/pegasys/web3signer/core/service/jsonrpc/handlers/internalresponse/EthSignTypedDataResultProvider.java new file mode 100644 index 000000000..2e496550b --- /dev/null +++ b/core/src/main/java/tech/pegasys/web3signer/core/service/jsonrpc/handlers/internalresponse/EthSignTypedDataResultProvider.java @@ -0,0 +1,85 @@ +/* + * Copyright 2023 ConsenSys AG. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ +package tech.pegasys.web3signer.core.service.jsonrpc.handlers.internalresponse; + +import static tech.pegasys.web3signer.core.service.jsonrpc.response.JsonRpcError.INVALID_PARAMS; +import static tech.pegasys.web3signer.core.service.jsonrpc.response.JsonRpcError.SIGNING_FROM_IS_NOT_AN_UNLOCKED_ACCOUNT; +import static tech.pegasys.web3signer.signing.util.IdentifierUtils.normaliseIdentifier; + +import tech.pegasys.web3signer.core.service.http.handlers.signing.SignerForIdentifier; +import tech.pegasys.web3signer.core.service.jsonrpc.JsonRpcRequest; +import tech.pegasys.web3signer.core.service.jsonrpc.exceptions.JsonRpcException; +import tech.pegasys.web3signer.core.service.jsonrpc.handlers.ResultProvider; +import tech.pegasys.web3signer.signing.SecpArtifactSignature; + +import java.io.IOException; +import java.util.List; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.tuweni.bytes.Bytes; +import org.web3j.crypto.StructuredDataEncoder; + +public class EthSignTypedDataResultProvider implements ResultProvider { + + private static final Logger LOG = LogManager.getLogger(); + + private final SignerForIdentifier transactionSignerProvider; + + public EthSignTypedDataResultProvider( + final SignerForIdentifier transactionSignerProvider) { + this.transactionSignerProvider = transactionSignerProvider; + } + + @Override + public String createResponseResult(final JsonRpcRequest request) { + final List params = getParams(request); + if (params == null || params.size() != 2) { + LOG.debug( + "eth_signTypedData should have a list of 2 parameters, but has {}", + params == null ? "null" : params.size()); + throw new JsonRpcException(INVALID_PARAMS); + } + + final String eth1Address = params.get(0); + final String jsonData = params.get(1); + + final StructuredDataEncoder dataEncoder; + try { + dataEncoder = new StructuredDataEncoder(jsonData); + } catch (IOException e) { + throw new RuntimeException("Exception thrown while enconding the json provided"); + } + final Bytes structuredData = Bytes.of(dataEncoder.getStructuredData()); + return transactionSignerProvider + .sign(normaliseIdentifier(eth1Address), structuredData) + .orElseThrow( + () -> { + LOG.debug("Address ({}) does not match any available account", eth1Address); + return new JsonRpcException(SIGNING_FROM_IS_NOT_AN_UNLOCKED_ACCOUNT); + }); + } + + private List getParams(final JsonRpcRequest request) { + try { + @SuppressWarnings("unchecked") + final List params = (List) request.getParams(); + return params; + } catch (final ClassCastException e) { + LOG.debug( + "eth_signTypedData should have a list of 2 parameters, but received an object: {}", + request.getParams()); + throw new JsonRpcException(INVALID_PARAMS); + } + } +} diff --git a/core/src/test/java/tech/pegasys/web3signer/core/service/jsonrpc/EthSignTypedDataResultProviderTest.java b/core/src/test/java/tech/pegasys/web3signer/core/service/jsonrpc/EthSignTypedDataResultProviderTest.java new file mode 100644 index 000000000..a73a90139 --- /dev/null +++ b/core/src/test/java/tech/pegasys/web3signer/core/service/jsonrpc/EthSignTypedDataResultProviderTest.java @@ -0,0 +1,158 @@ +/* + * Copyright 2023 ConsenSys AG. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ +package tech.pegasys.web3signer.core.service.jsonrpc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.web3j.crypto.Keys.getAddress; +import static org.web3j.crypto.Sign.signMessage; +import static tech.pegasys.web3signer.core.service.jsonrpc.response.JsonRpcError.INVALID_PARAMS; +import static tech.pegasys.web3signer.core.service.jsonrpc.response.JsonRpcError.SIGNING_FROM_IS_NOT_AN_UNLOCKED_ACCOUNT; + +import tech.pegasys.web3signer.core.service.http.handlers.signing.SignerForIdentifier; +import tech.pegasys.web3signer.core.service.jsonrpc.exceptions.JsonRpcException; +import tech.pegasys.web3signer.core.service.jsonrpc.handlers.internalresponse.EthSignTypedDataResultProvider; +import tech.pegasys.web3signer.signing.SecpArtifactSignature; +import tech.pegasys.web3signer.signing.secp256k1.Signature; + +import java.io.IOException; +import java.math.BigInteger; +import java.security.InvalidAlgorithmParameterException; +import java.security.NoSuchAlgorithmException; +import java.security.NoSuchProviderException; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; + +import org.apache.tuweni.bytes.Bytes; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.ArgumentsProvider; +import org.junit.jupiter.params.provider.ArgumentsSource; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.web3j.crypto.ECKeyPair; +import org.web3j.crypto.Keys; +import org.web3j.crypto.Sign; +import org.web3j.utils.Numeric; + +@ExtendWith(MockitoExtension.class) +public class EthSignTypedDataResultProviderTest { + private static final String PRIVATE_KEY_STRING = + "a392604efc2fad9c0b3da43b5f698a2e3f270f170d859912be0d54742275c5f6"; + private static final String PUBLIC_KEY_STRING = + "0x506bc1dc099358e5137292f4efdd57e400f29ba5132aa5d12b18dac1c1f6aab" + + "a645c0b7b58158babbfa6c6cd5a48aa7340a8749176b120e8516216787a13dc76"; + + private static final String EIP712_VALID_JSON = + "{\"types\": { \"EIP712Domain\": [ {\"name\": \"name\", \"type\": \"string\"}, {\"name\": \"version\", \"type\": \"string\"}, {\"name\": \"chainId\", \"type\": \"uint256\"}, {\"name\": \"verifyingContract\", \"type\": \"address\"} ], \"Person\": [ {\"name\": \"name\", \"type\": \"string\"}, {\"name\": \"wallet\", \"type\": \"address\"} ] }, \"domain\": { \"name\": \"My Dapp\", \"version\": \"1.0\", \"chainId\": 1, \"verifyingContract\": \"0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC\" }, \"primaryType\": \"Person\", \"message\": { \"name\": \"John Doe\", \"wallet\": \"0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B\" }}"; + + private static final BigInteger PRIVATE_KEY = Numeric.toBigInt(PRIVATE_KEY_STRING); + private static final BigInteger PUBLIC_KEY = Numeric.toBigInt(PUBLIC_KEY_STRING); + + private static final ECKeyPair KEY_PAIR = new ECKeyPair(PRIVATE_KEY, PUBLIC_KEY); + + @Mock SignerForIdentifier transactionSignerProvider; + + @ParameterizedTest + @ArgumentsSource(InvalidParamsProvider.class) + @NullSource + public void ifParamIsInvalidExceptionIsThrownWithInvalidParams(final Object params) { + + final EthSignTypedDataResultProvider resultProvider = + new EthSignTypedDataResultProvider(transactionSignerProvider); + + final JsonRpcRequest request = new JsonRpcRequest("2.0", "eth_signTypedData"); + request.setId(new JsonRpcRequestId(1)); + request.setParams(params); + + final Throwable thrown = catchThrowable(() -> resultProvider.createResponseResult(request)); + assertThat(thrown).isInstanceOf(JsonRpcException.class); + final JsonRpcException rpcException = (JsonRpcException) thrown; + assertThat(rpcException.getJsonRpcError()).isEqualTo(INVALID_PARAMS); + } + + @Test + public void ifAddressIsNotUnlockedExceptionIsThrownWithSigningNotUnlocked() + throws InvalidAlgorithmParameterException, NoSuchAlgorithmException, NoSuchProviderException { + + final EthSignTypedDataResultProvider resultProvider = + new EthSignTypedDataResultProvider(transactionSignerProvider); + final JsonRpcRequest request = new JsonRpcRequest("2.0", "eth_signTypedData"); + request.setId(new JsonRpcRequestId(1)); + request.setParams( + List.of(getAddress(Keys.createEcKeyPair().getPublicKey()), EIP712_VALID_JSON)); + final Throwable thrown = catchThrowable(() -> resultProvider.createResponseResult(request)); + assertThat(thrown).isInstanceOf(JsonRpcException.class); + final JsonRpcException rpcException = (JsonRpcException) thrown; + assertThat(rpcException.getJsonRpcError()).isEqualTo(SIGNING_FROM_IS_NOT_AN_UNLOCKED_ACCOUNT); + } + + @ParameterizedTest + @ValueSource(strings = {EIP712_VALID_JSON}) + public void returnsExpectedSignature(final String message) throws IOException { + + doAnswer( + answer -> { + Bytes data = answer.getArgument(1, Bytes.class); + final Sign.SignatureData signatureData = signMessage(data.toArrayUnsafe(), KEY_PAIR); + return Optional.of(hexFromSignatureData(signatureData)); + }) + .when(transactionSignerProvider) + .sign(anyString(), any(Bytes.class)); + + final EthSignTypedDataResultProvider resultProvider = + new EthSignTypedDataResultProvider(transactionSignerProvider); + + final JsonRpcRequest request = new JsonRpcRequest("2.0", "eth_signTypedData"); + final int id = 1; + request.setId(new JsonRpcRequestId(id)); + request.setParams(List.of("address", message)); + + final Object result = resultProvider.createResponseResult(request); + assertThat(result).isInstanceOf(String.class); + final String hexSignature = (String) result; + Sign.SignatureData expectedSignature = Sign.signTypedData(message, KEY_PAIR); + assertThat(hexSignature).isEqualTo(hexFromSignatureData(expectedSignature)); + } + + private static class InvalidParamsProvider implements ArgumentsProvider { + @Override + public Stream provideArguments(final ExtensionContext context) { + return Stream.of( + Arguments.of(Collections.emptyList()), + Arguments.of(Collections.singleton(2)), + Arguments.of(List.of(1, 2, 3)), + Arguments.of(new Object())); + } + } + + private String hexFromSignatureData(Sign.SignatureData signature) { + return SecpArtifactSignature.toBytes( + new SecpArtifactSignature( + new Signature( + new BigInteger(signature.getV()), + new BigInteger(1, signature.getR()), + new BigInteger(1, signature.getS())))) + .toHexString(); + } +} diff --git a/gradle/versions.gradle b/gradle/versions.gradle index e2c077359..02e8a7223 100644 --- a/gradle/versions.gradle +++ b/gradle/versions.gradle @@ -112,7 +112,7 @@ dependencyManagement { dependency 'io.rest-assured:rest-assured:4.4.0' dependency 'org.zeroturnaround:zt-exec:1.12' - dependencySet(group: 'org.web3j', version: '4.9.5') { + dependencySet(group: 'org.web3j', version: '4.10.2') { entry 'besu' entry ('core') { exclude group: 'com.github.jnr', name: 'jnr-unixsocket'