diff --git a/src/main/java/org/arkecosystem/crypto/enums/AbiFunction.java b/src/main/java/org/arkecosystem/crypto/enums/AbiFunction.java index cd23b82..e6d5a64 100644 --- a/src/main/java/org/arkecosystem/crypto/enums/AbiFunction.java +++ b/src/main/java/org/arkecosystem/crypto/enums/AbiFunction.java @@ -5,6 +5,7 @@ public enum AbiFunction { UNVOTE("unvote"), VALIDATOR_REGISTRATION("registerValidator"), VALIDATOR_RESIGNATION("resignValidator"), + UPDATE_VALIDATOR("updateValidator"), USERNAME_REGISTRATION("registerUsername"), USERNAME_RESIGNATION("resignUsername"), MULTIPAYMENT("pay"), diff --git a/src/main/java/org/arkecosystem/crypto/identities/PrivateKey.java b/src/main/java/org/arkecosystem/crypto/identities/PrivateKey.java index 3e14fe3..b8182ea 100644 --- a/src/main/java/org/arkecosystem/crypto/identities/PrivateKey.java +++ b/src/main/java/org/arkecosystem/crypto/identities/PrivateKey.java @@ -1,5 +1,6 @@ package org.arkecosystem.crypto.identities; +import java.math.BigInteger; import java.util.Arrays; import org.arkecosystem.crypto.configuration.Network; import org.arkecosystem.crypto.encoding.Base58; @@ -35,4 +36,44 @@ public static ECKey fromWif(String wif) { return ECKey.fromPrivate(privateKeyBytes, true); } + + public static byte[] sign(byte[] message, String passphrase) { + return sign(message, fromPassphrase(passphrase)); + } + + public static byte[] sign(byte[] message, ECKey privateKey) { + ECKey.ECDSASignature signature = privateKey.sign(Sha256Hash.wrap(message)); + + int recId = -1; + for (int i = 0; i < 4; i++) { + ECKey k = ECKey.recoverFromSignature(i, signature, Sha256Hash.wrap(message), true); + if (k != null && k.getPubKeyPoint().equals(privateKey.getPubKeyPoint())) { + recId = i; + break; + } + } + if (recId == -1) { + throw new RuntimeException("Could not find recId"); + } + + byte[] rBytes = bigIntegerToBytes(signature.r, 32); + byte[] sBytes = bigIntegerToBytes(signature.s, 32); + + byte[] signatureWithRecId = new byte[65]; + System.arraycopy(rBytes, 0, signatureWithRecId, 0, 32); + System.arraycopy(sBytes, 0, signatureWithRecId, 32, 32); + signatureWithRecId[64] = (byte) recId; + + return signatureWithRecId; + } + + private static byte[] bigIntegerToBytes(BigInteger b, int numBytes) { + byte[] src = b.toByteArray(); + byte[] dest = new byte[numBytes]; + int srcPos = Math.max(0, src.length - numBytes); + int destPos = Math.max(0, numBytes - src.length); + int length = Math.min(src.length, numBytes); + System.arraycopy(src, srcPos, dest, destPos, length); + return dest; + } } diff --git a/src/main/java/org/arkecosystem/crypto/identities/PublicKey.java b/src/main/java/org/arkecosystem/crypto/identities/PublicKey.java index dd87088..f418538 100644 --- a/src/main/java/org/arkecosystem/crypto/identities/PublicKey.java +++ b/src/main/java/org/arkecosystem/crypto/identities/PublicKey.java @@ -1,7 +1,10 @@ package org.arkecosystem.crypto.identities; +import java.math.BigInteger; +import java.util.Arrays; import org.arkecosystem.crypto.encoding.Hex; import org.bitcoinj.core.ECKey; +import org.bitcoinj.core.Sha256Hash; public class PublicKey { public static String fromPassphrase(String passphrase) { @@ -12,4 +15,23 @@ public static ECKey fromHex(String publicKey) { ECKey key = ECKey.fromPublicOnly(Hex.decode(publicKey)); return ECKey.fromPublicOnly(key.getPubKeyPoint().getEncoded(true)); } + + public static ECKey recover(byte[] message, byte[] signature) { + if (signature == null || signature.length != 65) { + throw new IllegalArgumentException( + "Signature must be a 65-byte compact signature (R || S || recId)."); + } + + byte recId = signature[64]; + BigInteger r = new BigInteger(1, Arrays.copyOfRange(signature, 0, 32)); + BigInteger s = new BigInteger(1, Arrays.copyOfRange(signature, 32, 64)); + + ECKey.ECDSASignature ecdsa = new ECKey.ECDSASignature(r, s); + ECKey recovered = ECKey.recoverFromSignature(recId, ecdsa, Sha256Hash.wrap(message), true); + if (recovered == null) { + throw new RuntimeException("Could not recover public key from signature."); + } + + return recovered; + } } diff --git a/src/main/java/org/arkecosystem/crypto/transactions/types/AbstractTransaction.java b/src/main/java/org/arkecosystem/crypto/transactions/types/AbstractTransaction.java index 7c7ded8..b1c2594 100644 --- a/src/main/java/org/arkecosystem/crypto/transactions/types/AbstractTransaction.java +++ b/src/main/java/org/arkecosystem/crypto/transactions/types/AbstractTransaction.java @@ -2,18 +2,17 @@ import com.google.gson.GsonBuilder; import java.math.BigInteger; -import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; import org.arkecosystem.crypto.encoding.Hex; import org.arkecosystem.crypto.identities.Address; import org.arkecosystem.crypto.identities.PrivateKey; +import org.arkecosystem.crypto.identities.PublicKey; import org.arkecosystem.crypto.transactions.Serializer; import org.arkecosystem.crypto.utils.AbiDecoder; import org.arkecosystem.crypto.utils.TransactionUtils; import org.bitcoinj.core.ECKey; -import org.bitcoinj.core.Sha256Hash; public abstract class AbstractTransaction { public int network; @@ -123,42 +122,7 @@ public AbstractTransaction legacySecondSign(String secondPassphrase) { } private static String signHash(byte[] hash, ECKey privateKey) { - ECKey.ECDSASignature signature = privateKey.sign(Sha256Hash.wrap(hash)); - - int recId = -1; - for (int i = 0; i < 4; i++) { - ECKey k = ECKey.recoverFromSignature(i, signature, Sha256Hash.wrap(hash), true); - if (k != null && k.getPubKeyPoint().equals(privateKey.getPubKeyPoint())) { - recId = i; - break; - } - } - if (recId == -1) { - throw new RuntimeException("Could not find recId"); - } - - byte[] rBytes = bigIntegerToBytes(signature.r, 32); - byte[] sBytes = bigIntegerToBytes(signature.s, 32); - - byte[] signatureBytes = new byte[64]; - System.arraycopy(rBytes, 0, signatureBytes, 0, 32); - System.arraycopy(sBytes, 0, signatureBytes, 32, 32); - - byte[] signatureWithRecId = new byte[65]; - System.arraycopy(signatureBytes, 0, signatureWithRecId, 0, 64); - signatureWithRecId[64] = (byte) recId; - - return Hex.encode(signatureWithRecId); - } - - private static byte[] bigIntegerToBytes(BigInteger b, int numBytes) { - byte[] src = b.toByteArray(); - byte[] dest = new byte[numBytes]; - int srcPos = Math.max(0, src.length - numBytes); - int destPos = Math.max(0, numBytes - src.length); - int length = Math.min(src.length, numBytes); - System.arraycopy(src, srcPos, dest, destPos, length); - return dest; + return Hex.encode(PrivateKey.sign(hash, privateKey)); } public void recoverSender() { @@ -166,26 +130,7 @@ public void recoverSender() { throw new RuntimeException("Invalid signature"); } - byte[] signatureWithRecId = Hex.decode(this.signature); - if (signatureWithRecId.length != 65) { - throw new RuntimeException("Invalid signature length"); - } - - byte recId = signatureWithRecId[64]; - byte[] signatureBytes = Arrays.copyOfRange(signatureWithRecId, 0, 64); - - BigInteger r = new BigInteger(1, Arrays.copyOfRange(signatureBytes, 0, 32)); - BigInteger s = new BigInteger(1, Arrays.copyOfRange(signatureBytes, 32, 64)); - - ECKey.ECDSASignature signature = new ECKey.ECDSASignature(r, s); - - byte[] hash = this.hash(true); - - ECKey recoveredKey = - ECKey.recoverFromSignature(recId, signature, Sha256Hash.wrap(hash), true); - if (recoveredKey == null) { - throw new RuntimeException("Could not recover public key from signature"); - } + ECKey recoveredKey = PublicKey.recover(this.hash(true), Hex.decode(this.signature)); this.senderPublicKey = recoveredKey.getPublicKeyAsHex(); @@ -199,27 +144,7 @@ public boolean verify() { } ECKey keys = ECKey.fromPublicOnly(Hex.decode(this.senderPublicKey)); - - byte[] signatureWithRecId = Hex.decode(this.signature); - if (signatureWithRecId.length != 65) { - return false; - } - - byte recId = signatureWithRecId[64]; - byte[] signatureBytes = Arrays.copyOfRange(signatureWithRecId, 0, 64); - - BigInteger r = new BigInteger(1, Arrays.copyOfRange(signatureBytes, 0, 32)); - BigInteger s = new BigInteger(1, Arrays.copyOfRange(signatureBytes, 32, 64)); - - ECKey.ECDSASignature signature = new ECKey.ECDSASignature(r, s); - - byte[] hash = this.hash(true); - - ECKey recoveredKey = - ECKey.recoverFromSignature(recId, signature, Sha256Hash.wrap(hash), true); - if (recoveredKey == null) { - return false; - } + ECKey recoveredKey = PublicKey.recover(this.hash(true), Hex.decode(this.signature)); return recoveredKey.getPubKeyPoint().equals(keys.getPubKeyPoint()); } catch (Exception e) { diff --git a/src/main/java/org/arkecosystem/crypto/utils/TransactionEncoder.java b/src/main/java/org/arkecosystem/crypto/utils/TransactionEncoder.java new file mode 100644 index 0000000..1a00804 --- /dev/null +++ b/src/main/java/org/arkecosystem/crypto/utils/TransactionEncoder.java @@ -0,0 +1,86 @@ +package org.arkecosystem.crypto.utils; + +import java.math.BigInteger; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.arkecosystem.crypto.enums.AbiFunction; +import org.arkecosystem.crypto.enums.ContractAbiType; + +public final class TransactionEncoder { + + private TransactionEncoder() {} + + public static String multiPayment(List recipients, List amounts) { + return encode( + ContractAbiType.MULTIPAYMENT, + AbiFunction.MULTIPAYMENT, + Arrays.asList(recipients, amounts)); + } + + public static String tokenTransfer(String recipientAddress, BigInteger amount) { + return encode( + ContractAbiType.TOKEN, + AbiFunction.TRANSFER, + Arrays.asList(recipientAddress, amount)); + } + + public static String usernameRegistration(String username) { + return encode( + ContractAbiType.USERNAMES, + AbiFunction.USERNAME_REGISTRATION, + Collections.singletonList(username)); + } + + public static String usernameResignation() { + return encode( + ContractAbiType.USERNAMES, + AbiFunction.USERNAME_RESIGNATION, + Collections.emptyList()); + } + + public static String validatorRegistration(String validatorPublicKey) { + return encode( + ContractAbiType.CONSENSUS, + AbiFunction.VALIDATOR_REGISTRATION, + Collections.singletonList(addHexPrefix(validatorPublicKey))); + } + + public static String validatorResignation() { + return encode( + ContractAbiType.CONSENSUS, + AbiFunction.VALIDATOR_RESIGNATION, + Collections.emptyList()); + } + + public static String updateValidator(String validatorPublicKey) { + return encode( + ContractAbiType.CONSENSUS, + AbiFunction.UPDATE_VALIDATOR, + Collections.singletonList(addHexPrefix(validatorPublicKey))); + } + + public static String vote(String voteAddress) { + return encode( + ContractAbiType.CONSENSUS, + AbiFunction.VOTE, + Collections.singletonList(voteAddress)); + } + + public static String unvote() { + return encode(ContractAbiType.CONSENSUS, AbiFunction.UNVOTE, Collections.emptyList()); + } + + private static String encode(ContractAbiType type, AbiFunction function, List args) { + try { + return new AbiEncoder(type).encodeFunctionCall(function.toString(), args); + } catch (Exception e) { + throw new RuntimeException( + "Error encoding " + function + " against " + type + " ABI", e); + } + } + + private static String addHexPrefix(String value) { + return value.startsWith("0x") ? value : "0x" + value; + } +} diff --git a/src/test/java/org/arkecosystem/crypto/identities/PrivateKeyTest.java b/src/test/java/org/arkecosystem/crypto/identities/PrivateKeyTest.java index 300262a..1d1c183 100644 --- a/src/test/java/org/arkecosystem/crypto/identities/PrivateKeyTest.java +++ b/src/test/java/org/arkecosystem/crypto/identities/PrivateKeyTest.java @@ -50,6 +50,31 @@ public void fromWif_round_trips_with_fromPassphrase() throws IOException { assertEquals(fromPassphrase, fromWif); } + @Test + public void sign_produces_a_65_byte_signature_recoverable_to_the_signer_public_key() { + byte[] message = org.bitcoinj.core.Sha256Hash.hash("payload".getBytes()); + + byte[] signature = PrivateKey.sign(message, "this is a top secret passphrase"); + + assertEquals(65, signature.length); + assertEquals( + "034151a3ec46b5670a682b0a63394f863587d1bc97483b1b6c70eb58e7f0aed192", + PublicKey.recover(message, signature).getPublicKeyAsHex()); + } + + @Test + public void sign_with_eckey_overload_matches_passphrase_overload() { + byte[] message = org.bitcoinj.core.Sha256Hash.hash("payload".getBytes()); + org.bitcoinj.core.ECKey key = PrivateKey.fromPassphrase("this is a top secret passphrase"); + + byte[] viaPassphrase = PrivateKey.sign(message, "this is a top secret passphrase"); + byte[] viaEcKey = PrivateKey.sign(message, key); + + assertEquals( + org.arkecosystem.crypto.encoding.Hex.encode(viaPassphrase), + org.arkecosystem.crypto.encoding.Hex.encode(viaEcKey)); + } + @Test public void fromWif_rejects_when_version_byte_belongs_to_another_network() throws IOException { String mainnetWif; diff --git a/src/test/java/org/arkecosystem/crypto/identities/PublicKeyTest.java b/src/test/java/org/arkecosystem/crypto/identities/PublicKeyTest.java index 57c156d..a20b88f 100644 --- a/src/test/java/org/arkecosystem/crypto/identities/PublicKeyTest.java +++ b/src/test/java/org/arkecosystem/crypto/identities/PublicKeyTest.java @@ -31,4 +31,23 @@ public void fromHex_accepts_uncompressed_public_key() { Assertions.assertEquals(compressed, actual); } + + @Test + public void recover_round_trips_with_PrivateKey_sign() { + byte[] message = org.bitcoinj.core.Sha256Hash.hash("Hello World".getBytes()); + byte[] signature = PrivateKey.sign(message, "this is a top secret passphrase"); + + org.bitcoinj.core.ECKey recovered = PublicKey.recover(message, signature); + + Assertions.assertEquals( + "034151a3ec46b5670a682b0a63394f863587d1bc97483b1b6c70eb58e7f0aed192", + recovered.getPublicKeyAsHex()); + } + + @Test + public void recover_throws_on_invalid_signature_length() { + byte[] message = new byte[32]; + Assertions.assertThrows( + IllegalArgumentException.class, () -> PublicKey.recover(message, new byte[64])); + } } diff --git a/src/test/java/org/arkecosystem/crypto/utils/TransactionEncoderTest.java b/src/test/java/org/arkecosystem/crypto/utils/TransactionEncoderTest.java new file mode 100644 index 0000000..4fae508 --- /dev/null +++ b/src/test/java/org/arkecosystem/crypto/utils/TransactionEncoderTest.java @@ -0,0 +1,116 @@ +package org.arkecosystem.crypto.utils; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.math.BigInteger; +import java.util.Arrays; +import java.util.Map; +import org.arkecosystem.crypto.enums.ContractAbiType; +import org.junit.jupiter.api.Test; + +public class TransactionEncoderTest { + + @Test + public void it_should_encode_a_vote_payload() throws Exception { + String address = "0xC3bBE9B1CeE1ff85Ad72b87414B0E9B7F2366763"; + + String payload = TransactionEncoder.vote(address); + + Map decoded = new AbiDecoder().decodeFunctionData(payload); + assertEquals("vote", decoded.get("functionName")); + assertEquals(Arrays.asList(address), decoded.get("args")); + } + + @Test + public void it_should_encode_an_unvote_payload() throws Exception { + String payload = TransactionEncoder.unvote(); + + Map decoded = new AbiDecoder().decodeFunctionData(payload); + assertEquals("unvote", decoded.get("functionName")); + } + + @Test + public void it_should_encode_a_validator_registration_payload() throws Exception { + String key = + "954f46d6097a1d314e900e66e11e0dad0a57cd03e04ec99f0dedd1c765dcb11e6d7fa02e22cf40f9ee23d9cc1c0624bd"; + + String payload = TransactionEncoder.validatorRegistration(key); + + Map decoded = new AbiDecoder().decodeFunctionData(payload); + assertEquals("registerValidator", decoded.get("functionName")); + } + + @Test + public void it_should_normalise_validator_public_key_prefix() { + String key = + "954f46d6097a1d314e900e66e11e0dad0a57cd03e04ec99f0dedd1c765dcb11e6d7fa02e22cf40f9ee23d9cc1c0624bd"; + + String withoutPrefix = TransactionEncoder.validatorRegistration(key); + String withPrefix = TransactionEncoder.validatorRegistration("0x" + key); + + assertEquals(withoutPrefix, withPrefix); + } + + @Test + public void it_should_encode_a_validator_resignation_payload() throws Exception { + String payload = TransactionEncoder.validatorResignation(); + + Map decoded = new AbiDecoder().decodeFunctionData(payload); + assertEquals("resignValidator", decoded.get("functionName")); + } + + @Test + public void it_should_encode_an_update_validator_payload() throws Exception { + String key = + "954f46d6097a1d314e900e66e11e0dad0a57cd03e04ec99f0dedd1c765dcb11e6d7fa02e22cf40f9ee23d9cc1c0624bd"; + + String payload = TransactionEncoder.updateValidator(key); + + Map decoded = new AbiDecoder().decodeFunctionData(payload); + assertEquals("updateValidator", decoded.get("functionName")); + } + + @Test + public void it_should_encode_a_username_registration_payload() throws Exception { + String payload = TransactionEncoder.usernameRegistration("alice"); + + Map decoded = + new AbiDecoder(ContractAbiType.USERNAMES).decodeFunctionData(payload); + assertEquals("registerUsername", decoded.get("functionName")); + assertEquals(Arrays.asList("alice"), decoded.get("args")); + } + + @Test + public void it_should_encode_a_username_resignation_payload() throws Exception { + String payload = TransactionEncoder.usernameResignation(); + + Map decoded = + new AbiDecoder(ContractAbiType.USERNAMES).decodeFunctionData(payload); + assertEquals("resignUsername", decoded.get("functionName")); + } + + @Test + public void it_should_encode_a_multipayment_payload() throws Exception { + String payload = + TransactionEncoder.multiPayment( + Arrays.asList( + "0xb693449AdDa7EFc015D87944EAE8b7C37EB1690A", + "0x512F366D524157BcF734546eB29a6d687B762255"), + Arrays.asList(new BigInteger("100000000"), new BigInteger("200000000"))); + + assertTrue(payload.startsWith("0x084ce708")); + } + + @Test + public void it_should_encode_a_token_transfer_payload() throws Exception { + String payload = + TransactionEncoder.tokenTransfer( + "0xb693449AdDa7EFc015D87944EAE8b7C37EB1690A", + new BigInteger("1000000000000000000")); + + Map decoded = + new AbiDecoder(ContractAbiType.TOKEN).decodeFunctionData(payload); + assertEquals("transfer", decoded.get("functionName")); + } +}