diff --git a/acceptance-tests/src/test/java/tech/pegasys/web3signer/tests/commitboost/CommitBoostAcceptanceTest.java b/acceptance-tests/src/test/java/tech/pegasys/web3signer/tests/commitboost/CommitBoostAcceptanceTest.java index 277f6714d..5f34348d8 100644 --- a/acceptance-tests/src/test/java/tech/pegasys/web3signer/tests/commitboost/CommitBoostAcceptanceTest.java +++ b/acceptance-tests/src/test/java/tech/pegasys/web3signer/tests/commitboost/CommitBoostAcceptanceTest.java @@ -12,10 +12,10 @@ */ package tech.pegasys.web3signer.tests.commitboost; +import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.hasSize; import tech.pegasys.teku.bls.BLSKeyPair; -import tech.pegasys.teku.bls.BLSPublicKey; import tech.pegasys.web3signer.KeystoreUtil; import tech.pegasys.web3signer.dsl.signer.SignerConfigurationBuilder; import tech.pegasys.web3signer.dsl.utils.DefaultKeystoresParameters; @@ -101,9 +101,26 @@ void listCommitBoostPublicKeys() { .statusCode(200) .contentType(ContentType.JSON) .body("keys", hasSize(2)); - // .body("keys[0].consensus", equalTo(CONSENSUS_PUB_KEY.getPublicKey().toHexString())) - // .body("keys[0].proxy_bls", containsInAnyOrder(proxyBlsPubKeys.toArray())) - // .body("keys[0].proxy_ecdsa", containsInAnyOrder(proxyECPubKeys.toArray())); + + // extract consensus public keys from response + final List> responseKeys = response.jsonPath().getList("keys"); + for (final Map responseKeyMap : responseKeys) { + final String consensusKeyHex = (String) responseKeyMap.get("consensus"); + // verify if consensus public key is present in the map + assertThat(proxyBLSKeysMap.keySet()).contains(consensusKeyHex); + + // verify if proxy BLS keys are present in the response + final List responseProxyBlsKeys = (List) responseKeyMap.get("proxy_bls"); + final List expectedProxyBLSKeys = getProxyBLSPubKeys(consensusKeyHex); + assertThat(responseProxyBlsKeys) + .containsExactlyInAnyOrder(expectedProxyBLSKeys.toArray(String[]::new)); + + // verify if proxy SECP keys are present in the response + final List responseProxySECPKeys = (List) responseKeyMap.get("proxy_ecdsa"); + final List expectedProxySECPKeys = getProxyECPubKeys(consensusKeyHex); + assertThat(responseProxySECPKeys) + .containsExactlyInAnyOrder(expectedProxySECPKeys.toArray(String[]::new)); + } } private List getProxyECPubKeys(final String consensusKeyHex) { @@ -112,8 +129,7 @@ private List getProxyECPubKeys(final String consensusKeyHex) { .map( ecKeyPair -> EthPublicKeyUtils.toHexStringCompressed( - EthPublicKeyUtils.web3JPublicKeyToECPublicKey(ecKeyPair.getPublicKey())) - .toHexString()) + EthPublicKeyUtils.web3JPublicKeyToECPublicKey(ecKeyPair.getPublicKey()))) .toList(); } diff --git a/core/src/main/java/tech/pegasys/web3signer/core/service/http/handlers/signing/SignerForIdentifier.java b/core/src/main/java/tech/pegasys/web3signer/core/service/http/handlers/signing/SignerForIdentifier.java index 69e22f85f..bd9f46442 100644 --- a/core/src/main/java/tech/pegasys/web3signer/core/service/http/handlers/signing/SignerForIdentifier.java +++ b/core/src/main/java/tech/pegasys/web3signer/core/service/http/handlers/signing/SignerForIdentifier.java @@ -46,15 +46,13 @@ public Optional sign(final String identifier, final Bytes data) { /** * Sign data for given identifier and return ArtifactSignature. Useful for SECP signing usages. * - * @param The specific type of ArtifactSignature * @param identifier The identifier for which to sign data. * @param data Bytes which is signed * @return Optional ArtifactSignature of type T. Empty if no signer available for given identifier */ - @SuppressWarnings("unchecked") - public > Optional signAndGetArtifactSignature( + public Optional signAndGetArtifactSignature( final String identifier, final Bytes data) { - return signerProvider.getSigner(identifier).map(signer -> (T) signer.sign(data)); + return signerProvider.getSigner(identifier).map(signer -> signer.sign(data)); } /** diff --git a/core/src/main/java/tech/pegasys/web3signer/core/service/jsonrpc/handlers/signing/TransactionSerializer.java b/core/src/main/java/tech/pegasys/web3signer/core/service/jsonrpc/handlers/signing/TransactionSerializer.java index 4535635ce..186a17b45 100644 --- a/core/src/main/java/tech/pegasys/web3signer/core/service/jsonrpc/handlers/signing/TransactionSerializer.java +++ b/core/src/main/java/tech/pegasys/web3signer/core/service/jsonrpc/handlers/signing/TransactionSerializer.java @@ -78,10 +78,11 @@ private static byte[] prependEip1559TransactionType(byte[] bytesToSign) { private SignatureData sign(final String eth1Address, final byte[] bytesToSign) { final SecpArtifactSignature artifactSignature = - secpSigner - .signAndGetArtifactSignature( - normaliseIdentifier(eth1Address), Bytes.of(bytesToSign)) - .orElseThrow(() -> new JsonRpcException(SIGNING_FROM_IS_NOT_AN_UNLOCKED_ACCOUNT)); + (SecpArtifactSignature) + secpSigner + .signAndGetArtifactSignature( + normaliseIdentifier(eth1Address), Bytes.of(bytesToSign)) + .orElseThrow(() -> new JsonRpcException(SIGNING_FROM_IS_NOT_AN_UNLOCKED_ACCOUNT)); final Signature signature = artifactSignature.getSignatureData(); diff --git a/signing/src/main/java/tech/pegasys/web3signer/signing/K256ArtifactSigner.java b/signing/src/main/java/tech/pegasys/web3signer/signing/K256ArtifactSigner.java new file mode 100644 index 000000000..37bb0d3c2 --- /dev/null +++ b/signing/src/main/java/tech/pegasys/web3signer/signing/K256ArtifactSigner.java @@ -0,0 +1,144 @@ +/* + * Copyright 2024 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.signing; + +import tech.pegasys.web3signer.signing.secp256k1.EthPublicKeyUtils; +import tech.pegasys.web3signer.signing.util.IdentifierUtils; + +import java.math.BigInteger; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Objects; + +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.MutableBytes; +import org.apache.tuweni.units.bigints.UInt256; +import org.bouncycastle.asn1.x9.X9ECParameters; +import org.bouncycastle.crypto.digests.SHA256Digest; +import org.bouncycastle.crypto.ec.CustomNamedCurves; +import org.bouncycastle.crypto.params.ECDomainParameters; +import org.bouncycastle.crypto.params.ECPrivateKeyParameters; +import org.bouncycastle.crypto.signers.ECDSASigner; +import org.bouncycastle.crypto.signers.HMacDSAKCalculator; +import org.web3j.crypto.ECDSASignature; +import org.web3j.crypto.ECKeyPair; + +/** + * An artifact signer for SECP256K1 keys used specifically for Commit Boost API ECDSA proxy keys. It + * uses compressed public key as identifier and signs the message with just sha256 digest. The + * signature complies with RFC-6979. + */ +public class K256ArtifactSigner implements ArtifactSigner { + private final ECKeyPair ecKeyPair; + public static final X9ECParameters CURVE_PARAMS = CustomNamedCurves.getByName("secp256k1"); + public static final ECDomainParameters CURVE = + new ECDomainParameters( + CURVE_PARAMS.getCurve(), CURVE_PARAMS.getG(), CURVE_PARAMS.getN(), CURVE_PARAMS.getH()); + + public K256ArtifactSigner(final ECKeyPair web3JECKeypair) { + this.ecKeyPair = web3JECKeypair; + } + + @Override + public String getIdentifier() { + final String hexString = + EthPublicKeyUtils.toHexStringCompressed( + EthPublicKeyUtils.web3JPublicKeyToECPublicKey(ecKeyPair.getPublicKey())); + return IdentifierUtils.normaliseIdentifier(hexString); + } + + @Override + public K256ArtifactSignature sign(final Bytes message) { + try { + // Use BouncyCastle's ECDSASigner with HMacDSAKCalculator for deterministic ECDSA + final ECPrivateKeyParameters privKey = + new ECPrivateKeyParameters(ecKeyPair.getPrivateKey(), CURVE); + final ECDSASigner signer = new ECDSASigner(new HMacDSAKCalculator(new SHA256Digest())); + signer.init(true, privKey); + + // apply sha256 digest to the message before sending it to signing + final BigInteger[] components = signer.generateSignature(calculateSHA256(message.toArray())); + + // create a canonicalised signature using Web3J ECDSASignature class + final ECDSASignature signature = + new ECDSASignature(components[0], components[1]).toCanonicalised(); + + return new K256ArtifactSignature(signature); + } catch (final Exception e) { + throw new RuntimeException("Error signing message", e); + } + } + + @Override + public KeyType getKeyType() { + return KeyType.SECP256K1; + } + + public static byte[] calculateSHA256(byte[] message) { + try { + // Create a MessageDigest instance for SHA-256 + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + + // Update the MessageDigest with the message bytes + return digest.digest(message); + } catch (final NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-256 algorithm not found", e); + } + } + + /** An artifact signature for SECP256K1 keys used specifically for Commit Boost API ECDSA proxy */ + public static class K256ArtifactSignature implements ArtifactSignature { + final Bytes signature; + + public K256ArtifactSignature(final ECDSASignature signature) { + // convert to compact signature format + final MutableBytes concatenated = MutableBytes.create(64); + UInt256.valueOf(signature.r).copyTo(concatenated, 0); + UInt256.valueOf(signature.s).copyTo(concatenated, 32); + this.signature = concatenated; + } + + @Override + public KeyType getType() { + return KeyType.SECP256K1; + } + + @Override + public String asHex() { + return signature.toHexString(); + } + + @Override + public Bytes getSignatureData() { + return signature; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + K256ArtifactSignature that = (K256ArtifactSignature) o; + return Objects.equals(signature, that.signature); + } + + @Override + public int hashCode() { + return Objects.hashCode(signature); + } + + @Override + public String toString() { + return signature.toHexString(); + } + } +} diff --git a/signing/src/main/java/tech/pegasys/web3signer/signing/bulkloading/SecpV3KeystoresBulkLoader.java b/signing/src/main/java/tech/pegasys/web3signer/signing/bulkloading/SecpV3KeystoresBulkLoader.java index d9e998c55..fb8c7767a 100644 --- a/signing/src/main/java/tech/pegasys/web3signer/signing/bulkloading/SecpV3KeystoresBulkLoader.java +++ b/signing/src/main/java/tech/pegasys/web3signer/signing/bulkloading/SecpV3KeystoresBulkLoader.java @@ -15,6 +15,7 @@ import tech.pegasys.web3signer.keystorage.common.MappedResults; import tech.pegasys.web3signer.signing.ArtifactSigner; import tech.pegasys.web3signer.signing.EthSecpArtifactSigner; +import tech.pegasys.web3signer.signing.K256ArtifactSigner; import tech.pegasys.web3signer.signing.secp256k1.filebased.CredentialSigner; import tech.pegasys.web3signer.signing.secp256k1.util.JsonFilesUtil; @@ -34,8 +35,38 @@ public class SecpV3KeystoresBulkLoader { private static final Logger LOG = LogManager.getLogger(); + /** + * Bulk-load Ethereum compatible SECP Artifact Signers from encrypted v3 keystores. + * + * @param keystoresPath Path to the directory containing the v3 keystores + * @param pwrdFileOrDirPath Path to the password file or directory containing the passwords for + * the v3 keystores + * @return MappedResults containing the loaded ArtifactSigners + */ public static MappedResults loadV3KeystoresUsingPasswordFileOrDir( final Path keystoresPath, final Path pwrdFileOrDirPath) { + return loadV3KeystoresUsingPasswordFileOrDir(keystoresPath, pwrdFileOrDirPath, true); + } + + /** + * Bulk-load generic SECP Artifact Signers from encrypted v3 keystores that can be used by Commit + * Boost API. It uses compressed public key as identifier and apply SHA256 digest on the message + * before signing. + * + * @param keystoresPath Path to the directory containing the v3 keystores + * @param pwrdFileOrDirPath Path to the password file or directory containing the passwords for + * the v3 keystores + * @return MappedResults containing the loaded ArtifactSigners + */ + public static MappedResults loadECDSAProxyKeystores( + final Path keystoresPath, final Path pwrdFileOrDirPath) { + return loadV3KeystoresUsingPasswordFileOrDir(keystoresPath, pwrdFileOrDirPath, false); + } + + private static MappedResults loadV3KeystoresUsingPasswordFileOrDir( + final Path keystoresPath, + final Path pwrdFileOrDirPath, + final boolean ethereumSECPCompatible) { if (!Files.exists(pwrdFileOrDirPath)) { LOG.error("Password file or directory doesn't exist."); return MappedResults.errorResult(); @@ -67,12 +98,16 @@ public static MappedResults loadV3KeystoresUsingPasswordFileOrDi } return keystoresFiles.parallelStream() - .map(keystoreFile -> createSecpArtifactSigner(keystoreFile, passwordReader)) + .map( + keystoreFile -> + createSecpArtifactSigner(keystoreFile, passwordReader, ethereumSECPCompatible)) .reduce(MappedResults.newSetInstance(), MappedResults::merge); } private static MappedResults createSecpArtifactSigner( - final Path v3KeystorePath, final PasswordReader passwordReader) { + final Path v3KeystorePath, + final PasswordReader passwordReader, + final boolean ethereumSECPCompatible) { try { final String fileNameWithoutExt = FilenameUtils.removeExtension(v3KeystorePath.getFileName().toString()); @@ -81,8 +116,11 @@ private static MappedResults createSecpArtifactSigner( final Credentials credentials = WalletUtils.loadCredentials(password, v3KeystorePath.toFile()); - final EthSecpArtifactSigner artifactSigner = - new EthSecpArtifactSigner(new CredentialSigner(credentials)); + final ArtifactSigner artifactSigner = + ethereumSECPCompatible + ? new EthSecpArtifactSigner(new CredentialSigner(credentials)) + : new K256ArtifactSigner(credentials.getEcKeyPair()); + return MappedResults.newInstance(Set.of(artifactSigner), 0); } catch (final IOException | CipherException | RuntimeException e) { LOG.error("Error loading v3 keystore {}", v3KeystorePath, e); diff --git a/signing/src/test/java/tech/pegasys/web3signer/signing/K256ArtifactSignerTest.java b/signing/src/test/java/tech/pegasys/web3signer/signing/K256ArtifactSignerTest.java new file mode 100644 index 000000000..6abdf9898 --- /dev/null +++ b/signing/src/test/java/tech/pegasys/web3signer/signing/K256ArtifactSignerTest.java @@ -0,0 +1,53 @@ +/* + * Copyright 2024 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.signing; + +import static org.assertj.core.api.Assertions.assertThat; + +import tech.pegasys.web3signer.K256TestUtil; + +import org.apache.tuweni.bytes.Bytes; +import org.junit.jupiter.api.Test; +import org.web3j.crypto.ECKeyPair; +import org.web3j.crypto.Sign; +import org.web3j.utils.Numeric; + +class K256ArtifactSignerTest { + private static final String PRIVATE_KEY_HEX = + "8f2a55949038a9610f50fb23b5883af3b4ecb3c3bb792cbcefbd1542c692be63"; + private static final Bytes OBJECT_ROOT = + Bytes.fromHexString("419a4f6b748659b3ac4fc3534f3767fffe78127d210af0b2e1c1c8e7b345cf64"); + private static final ECKeyPair EC_KEY_PAIR = ECKeyPair.create(Numeric.toBigInt(PRIVATE_KEY_HEX)); + + @Test + void signCreatesVerifiableSignature() { + // generate using K256ArtifactSigner + final K256ArtifactSigner k256ArtifactSigner = new K256ArtifactSigner(EC_KEY_PAIR); + final ArtifactSignature artifactSignature = k256ArtifactSigner.sign(OBJECT_ROOT); + final byte[] signature = Bytes.fromHexString(artifactSignature.asHex()).toArray(); + + // Verify the signature against public key + assertThat( + K256TestUtil.verifySignature( + Sign.publicPointFromPrivate(EC_KEY_PAIR.getPrivateKey()), + OBJECT_ROOT.toArray(), + signature)) + .isTrue(); + + // copied from Rust K-256 and Python ecdsa module + final Bytes expectedSignature = + Bytes.fromHexString( + "8C32902BE980399CA59FCC222CCF0A5FE355A159122DEA58789A3938E29D89797FC6C9C0ECCCD29705915729F5326BB7D245F8E54D3A793A06DE3C92ABA85057"); + assertThat(Bytes.fromHexString(artifactSignature.asHex())).isEqualTo(expectedSignature); + } +} diff --git a/signing/src/test/java/tech/pegasys/web3signer/signing/config/DefaultArtifactSignerProviderTest.java b/signing/src/test/java/tech/pegasys/web3signer/signing/config/DefaultArtifactSignerProviderTest.java index 1c72573e1..dd1dd7c82 100644 --- a/signing/src/test/java/tech/pegasys/web3signer/signing/config/DefaultArtifactSignerProviderTest.java +++ b/signing/src/test/java/tech/pegasys/web3signer/signing/config/DefaultArtifactSignerProviderTest.java @@ -33,6 +33,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.stream.IntStream; import org.junit.jupiter.api.AfterEach; @@ -135,21 +136,22 @@ void proxySignersAreLoadedCorrectly() throws IOException { assertThatCode(() -> signerProvider.load().get()).doesNotThrowAnyException(); // assert that the proxy keys are loaded correctly - final Map> key1ProxyPublicKeys = + final Map> key1ProxyPublicKeys = signerProvider.getProxyIdentifiers(PUBLIC_KEY1); assertThat(key1ProxyPublicKeys.get(KeyType.BLS)) .containsExactlyInAnyOrder(getPublicKeysArray(key1ProxyKeyPairs)); + // proxy public keys are compressed assertThat(key1ProxyPublicKeys.get(KeyType.SECP256K1)) - .containsExactlyInAnyOrder(getSecpPublicKeysArray(key1SecpKeyPairs)); + .containsExactlyInAnyOrder(getCompressedSECPPublicKeysArray(key1SecpKeyPairs)); - final Map> key2ProxyPublicKeys = + final Map> key2ProxyPublicKeys = signerProvider.getProxyIdentifiers(PUBLIC_KEY2); assertThat(key2ProxyPublicKeys.get(KeyType.BLS)) .containsExactlyInAnyOrder(getPublicKeysArray(key2ProxyKeyPairs)); assertThat(key2ProxyPublicKeys.get(KeyType.SECP256K1)) - .containsExactlyInAnyOrder(getSecpPublicKeysArray(key2SecpKeyPairs)); + .containsExactlyInAnyOrder(getCompressedSECPPublicKeysArray(key2SecpKeyPairs)); } @Test @@ -172,7 +174,7 @@ void emptyProxySignersAreLoadedSuccessfully() { assertThatCode(() -> signerProvider.load().get()).doesNotThrowAnyException(); for (String identifier : List.of(PUBLIC_KEY1, PUBLIC_KEY2)) { - final Map> keyProxyPublicKeys = + final Map> keyProxyPublicKeys = signerProvider.getProxyIdentifiers(identifier); assertThat(keyProxyPublicKeys).isEmpty(); } @@ -220,11 +222,12 @@ private static String[] getPublicKeysArray(final List blsKeyPairs) { .toArray(String[]::new); } - private static String[] getSecpPublicKeysArray(final List ecKeyPairs) { + private static String[] getCompressedSECPPublicKeysArray(final List ecKeyPairs) { + // compressed public keys return ecKeyPairs.stream() .map( keyPair -> - EthPublicKeyUtils.toHexString( + EthPublicKeyUtils.toHexStringCompressed( EthPublicKeyUtils.web3JPublicKeyToECPublicKey(keyPair.getPublicKey()))) .toList() .toArray(String[]::new);