diff --git a/src/main/java/org/arkecosystem/crypto/transactions/Deserializer.java b/src/main/java/org/arkecosystem/crypto/transactions/Deserializer.java index 63aadbb..f093975 100644 --- a/src/main/java/org/arkecosystem/crypto/transactions/Deserializer.java +++ b/src/main/java/org/arkecosystem/crypto/transactions/Deserializer.java @@ -23,7 +23,7 @@ public static Deserializer newDeserializer(String serialized) { public AbstractTransaction deserialize() { List fields = RlpDecoder.decode(rawBytes); - // Fields: [nonce, gasPrice, gasLimit, to, value, data, v, r, s] + // Fields: [nonce, gasPrice, gasLimit, to, value, data, v, r, s, legacySecondSignature?] long nonce = bytesToLong(fields.get(0)); long gasPrice = bytesToLong(fields.get(1)); long gasLimit = bytesToLong(fields.get(2)); @@ -53,6 +53,8 @@ public AbstractTransaction deserialize() { } } + String legacySecondSignature = fields.size() == 10 ? Hex.encode(fields.get(9)) : null; + // Create temp transaction to guess type AbstractTransaction tempTransaction = new EvmCall(); tempTransaction.nonce = nonce; @@ -72,6 +74,7 @@ public AbstractTransaction deserialize() { transaction.data = data; transaction.network = Network.get().version(); transaction.signature = signature; + transaction.legacySecondSignature = legacySecondSignature; if (signature != null) { transaction.recoverSender(); 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 9db4387..9f936ed 100644 --- a/src/main/java/org/arkecosystem/crypto/transactions/types/AbstractTransaction.java +++ b/src/main/java/org/arkecosystem/crypto/transactions/types/AbstractTransaction.java @@ -13,8 +13,13 @@ import org.arkecosystem.crypto.utils.AbiDecoder; import org.arkecosystem.crypto.utils.TransactionUtils; import org.bitcoinj.core.ECKey; +import org.web3j.crypto.Hash; public abstract class AbstractTransaction { + // 0x8f ‖ "MAINSAIL_LSS_V1" + private static final byte[] LEGACY_SECOND_SIGNATURE_DOMAIN = + Hex.decode("8f4d41494e5341494c5f4c53535f5631"); + public int network; public long nonce; public String senderPublicKey; @@ -85,6 +90,9 @@ public AbstractTransaction(Map data) { if (data.containsKey("senderAddress")) { this.senderAddress = (String) data.get("senderAddress"); } + if (data.containsKey("legacySecondSignature")) { + this.legacySecondSignature = (String) data.get("legacySecondSignature"); + } } public String getPayload() { @@ -109,7 +117,10 @@ public byte[] hash(boolean skipSignature) { } public AbstractTransaction sign(String passphrase) { - ECKey privateKey = PrivateKey.fromPassphrase(passphrase); + return sign(PrivateKey.fromPassphrase(passphrase)); + } + + public AbstractTransaction sign(ECKey privateKey) { this.senderPublicKey = privateKey.getPublicKeyAsHex(); this.signature = signHash(this.hash(true), privateKey); @@ -118,9 +129,31 @@ public AbstractTransaction sign(String passphrase) { } public AbstractTransaction legacySecondSign(String secondPassphrase) { - ECKey privateKey = PrivateKey.fromPassphrase(secondPassphrase); + return legacySecondSign(PrivateKey.fromPassphrase(secondPassphrase)); + } + + public AbstractTransaction legacySecondSign(ECKey secondPrivateKey) { + if (this.signature == null) { + throw new IllegalStateException( + "Transaction must be signed before adding a legacy second signature"); + } + + byte[] transactionHash = this.hash(false); + byte[] message = new byte[LEGACY_SECOND_SIGNATURE_DOMAIN.length + transactionHash.length]; + System.arraycopy( + LEGACY_SECOND_SIGNATURE_DOMAIN, + 0, + message, + 0, + LEGACY_SECOND_SIGNATURE_DOMAIN.length); + System.arraycopy( + transactionHash, + 0, + message, + LEGACY_SECOND_SIGNATURE_DOMAIN.length, + transactionHash.length); - this.legacySecondSignature = signHash(this.hash(true), privateKey); + this.legacySecondSignature = signHash(Hash.sha3(message), secondPrivateKey); return this; } @@ -181,6 +214,7 @@ public HashMap toHashMap() { map.put("recipientAddress", this.recipientAddress); map.put("value", this.value); map.put("data", this.data); + map.put("legacySecondSignature", this.legacySecondSignature); return map; } diff --git a/src/main/java/org/arkecosystem/crypto/utils/TransactionUtils.java b/src/main/java/org/arkecosystem/crypto/utils/TransactionUtils.java index 182ab67..0acafd4 100644 --- a/src/main/java/org/arkecosystem/crypto/utils/TransactionUtils.java +++ b/src/main/java/org/arkecosystem/crypto/utils/TransactionUtils.java @@ -12,6 +12,13 @@ public class TransactionUtils { public static byte[] toBuffer(Map transaction, boolean skipSignature) { + return toBuffer(transaction, skipSignature, false); + } + + public static byte[] toBuffer( + Map transaction, + boolean skipSignature, + boolean skipLegacySecondSignature) { String toHex = parseHexFromStr((String) transaction.get("recipientAddress")); if (toHex.length() % 2 != 0) { toHex = "0" + toHex; @@ -42,8 +49,12 @@ public static byte[] toBuffer(Map transaction, boolean skipSigna int chainId = Network.get().chainId(); fields.add(RlpEncoder.toBeArray(BigInteger.valueOf(v + chainId * 2 + 35))); - fields.add(r); - fields.add(s); + fields.add(stripLeadingZeroBytes(r)); + fields.add(stripLeadingZeroBytes(s)); + + if (!skipLegacySecondSignature && transaction.get("legacySecondSignature") != null) { + fields.add(Hex.decode((String) transaction.get("legacySecondSignature"))); + } } else { int chainId = Network.get().chainId(); fields.add(RlpEncoder.toBeArray(BigInteger.valueOf(chainId))); @@ -55,7 +66,8 @@ public static byte[] toBuffer(Map transaction, boolean skipSigna } public static byte[] toHash(Map transaction, boolean skipSignature) { - byte[] buffer = toBuffer(transaction, skipSignature); + // The transaction hash never covers the legacy second signature + byte[] buffer = toBuffer(transaction, skipSignature, true); return Hash.sha3(buffer); } @@ -64,6 +76,14 @@ public static String getId(Map transaction) { return Hex.encode(hash); } + private static byte[] stripLeadingZeroBytes(byte[] value) { + int start = 0; + while (start < value.length && value[start] == 0) { + start++; + } + return Arrays.copyOfRange(value, start, value.length); + } + private static String parseHexFromStr(String value) { if (value == null) return ""; return value.replaceFirst("^0x", ""); diff --git a/src/test/java/org/arkecosystem/crypto/transactions/LegacySecondSignatureTest.java b/src/test/java/org/arkecosystem/crypto/transactions/LegacySecondSignatureTest.java new file mode 100644 index 0000000..5a7ec62 --- /dev/null +++ b/src/test/java/org/arkecosystem/crypto/transactions/LegacySecondSignatureTest.java @@ -0,0 +1,99 @@ +package org.arkecosystem.crypto.transactions; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.HashMap; +import java.util.Map; +import org.arkecosystem.crypto.AbstractTest; +import org.arkecosystem.crypto.configuration.Network; +import org.arkecosystem.crypto.encoding.Hex; +import org.arkecosystem.crypto.identities.PrivateKey; +import org.arkecosystem.crypto.networks.Testnet; +import org.arkecosystem.crypto.transactions.types.AbstractTransaction; +import org.arkecosystem.crypto.transactions.types.Transfer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +// Test vector from the Mainsail LSS v1 spec, built for chain 10_000 +public class LegacySecondSignatureTest extends AbstractTest { + private Map fixture; + private Map data; + + @BeforeEach + public void setUpVector() throws Exception { + this.fixture = loadFixture("transfer-legacy-second-signature-vector"); + this.data = (Map) fixture.get("data"); + + int chainId = ((Number) fixture.get("chainId")).intValue(); + Network.set( + new Testnet() { + @Override + public int chainId() { + return chainId; + } + }); + } + + private Transfer unsignedTransfer() { + Map unsignedData = new HashMap<>(this.data); + unsignedData.remove("legacySecondSignature"); + unsignedData.remove("id"); + + return new Transfer(unsignedData); + } + + @Test + public void it_should_sign_the_hash_of_the_signed_transaction_with_the_second_key() { + Transfer transaction = unsignedTransfer(); + + transaction.sign(PrivateKey.fromHex((String) fixture.get("privateKey"))); + + assertEquals(fixture.get("signed"), Hex.encode(transaction.serialize())); + assertEquals(data.get("id"), transaction.getId()); + + transaction.legacySecondSign(PrivateKey.fromHex((String) fixture.get("secondPrivateKey"))); + + assertEquals(data.get("legacySecondSignature"), transaction.legacySecondSignature); + assertEquals(fixture.get("serialized"), Hex.encode(transaction.serialize())); + assertEquals(data.get("id"), transaction.getId()); + } + + @Test + public void it_should_use_a_0_or_1_recovery_id() { + Transfer transaction = unsignedTransfer(); + + transaction.sign(PrivateKey.fromHex((String) fixture.get("privateKey"))); + transaction.legacySecondSign(PrivateKey.fromHex((String) fixture.get("secondPrivateKey"))); + + String recoveryId = transaction.legacySecondSignature.substring(128); + assertTrue(recoveryId.equals("00") || recoveryId.equals("01")); + } + + @Test + public void it_should_require_the_primary_signature_first() { + Transfer transaction = unsignedTransfer(); + + IllegalStateException exception = + assertThrows( + IllegalStateException.class, + () -> + transaction.legacySecondSign( + PrivateKey.fromHex( + (String) fixture.get("secondPrivateKey")))); + + assertEquals( + "Transaction must be signed before adding a legacy second signature", + exception.getMessage()); + } + + @Test + public void it_should_deserialize_and_hash_only_the_first_9_fields() { + AbstractTransaction transaction = + Deserializer.newDeserializer((String) fixture.get("serialized")).deserialize(); + + assertEquals(data.get("legacySecondSignature"), transaction.legacySecondSignature); + assertEquals(data.get("id"), transaction.id); + assertEquals(fixture.get("serialized"), Hex.encode(transaction.serialize())); + assertTrue(transaction.verify()); + } +} diff --git a/src/test/resources/transactions/transfer-legacy-second-signature-vector.json b/src/test/resources/transactions/transfer-legacy-second-signature-vector.json new file mode 100644 index 0000000..bb958fa --- /dev/null +++ b/src/test/resources/transactions/transfer-legacy-second-signature-vector.json @@ -0,0 +1,17 @@ +{ + "chainId": 10000, + "privateKey": "314f59dbc54460b18fc036d67dac36ad6e1fa1c3c0e0b3427a84e43faabfcecb", + "secondPrivateKey": "4427335cab240f6cc651b1df5ce5ff489c5a0bcb952507b542afe8f6c90e98e7", + "data": { + "nonce": "0", + "gasPrice": 5000000000, + "gasLimit": 21000, + "recipientAddress": "0x0DFeDD974c2e58Dc8E292ec159d1DC9AdeaFb1C0", + "value": "1000000000000000000", + "data": "", + "legacySecondSignature": "ea77adb968255ec1570c0e48cede8cceec3a9aff29e9dac1c53c2a6de4f2a18740df289edfcdfe97a189975cba8945334e5f230d66232751e468c5500dbe7dec01", + "id": "27af7a389f34f32569c90815ced4e23e8770eb2d77ace6f29dabec1a10bef205" + }, + "signed": "f86e8085012a05f200825208940dfedd974c2e58dc8e292ec159d1dc9adeafb1c0880de0b6b3a764000080824e44a00f4f4ac7f1effa669ea1a8970ed37f367e4aa0b549636619feead2d4a2408ffca04492af59379f74385bd951d229456b3b23ead33ce1d10106535c6830a688733d", + "serialized": "f8b18085012a05f200825208940dfedd974c2e58dc8e292ec159d1dc9adeafb1c0880de0b6b3a764000080824e44a00f4f4ac7f1effa669ea1a8970ed37f367e4aa0b549636619feead2d4a2408ffca04492af59379f74385bd951d229456b3b23ead33ce1d10106535c6830a688733db841ea77adb968255ec1570c0e48cede8cceec3a9aff29e9dac1c53c2a6de4f2a18740df289edfcdfe97a189975cba8945334e5f230d66232751e468c5500dbe7dec01" +}