Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add eth_signTypedData #893

Merged
merged 18 commits into from
Sep 6, 2023
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.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 {

private static final String eip712Json =
gfukushima marked this conversation as resolved.
Show resolved Hide resolved
"{\"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<?, EthSignTypedData> requestBody =
new Request<>(
"eth_signTypedData",
Arrays.asList(unlockedAccount, eip712Json),
null,
EthSignTypedData.class);

final Iterable<Map.Entry<String, String>> expectedHeaders =
singletonList(ImmutablePair.of("Content", HttpHeaderValues.APPLICATION_JSON.toString()));

final JsonRpcSuccessResponse responseBody =
new JsonRpcSuccessResponse(
requestBody.getId(),
"0x11cb46f70ad43da86e15ca7c6bb28356859a5f4ba430b44dbf1e65726d467be6072be9d1e5b40bd5b7abe8888eb91a69f0e6d56a8a094718ed8080baf02d61c31c");
jframe marked this conversation as resolved.
Show resolved Hide resolved

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<?, EthSignTypedData> requestBody =
new Request<>(
"eth_signTypedData",
Arrays.asList(A_RANDOM_ADDRESS, eip712Json),
null,
EthSignTypedData.class);

final Iterable<Map.Entry<String, String>> 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)));
}
}
Original file line number Diff line number Diff line change
@@ -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<String> {
public String getSignature() {
return getResult();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,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;
Expand Down Expand Up @@ -277,6 +278,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<>(
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> {

private static final Logger LOG = LogManager.getLogger();

private final SignerForIdentifier<SecpArtifactSignature> transactionSignerProvider;

public EthSignTypedDataResultProvider(
final SignerForIdentifier<SecpArtifactSignature> transactionSignerProvider) {
this.transactionSignerProvider = transactionSignerProvider;
}

@Override
public String createResponseResult(final JsonRpcRequest request) {
final List<String> 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<String> getParams(final JsonRpcRequest request) {
try {
@SuppressWarnings("unchecked")
final List<String> params = (List<String>) 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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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<SecpArtifactSignature> 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<? extends Arguments> 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();
}
}
2 changes: 1 addition & 1 deletion gradle/versions.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down