diff --git a/framework/src/main/java/org/tron/core/Wallet.java b/framework/src/main/java/org/tron/core/Wallet.java index 0482643d8d0..c0772007e3c 100755 --- a/framework/src/main/java/org/tron/core/Wallet.java +++ b/framework/src/main/java/org/tron/core/Wallet.java @@ -270,6 +270,8 @@ public class Wallet { "BurnNewLeaf(uint256,bytes32,bytes32,bytes32,bytes32[21])")); private static final byte[] SHIELDED_TRC20_LOG_TOPICS_BURN_TOKEN = Hash.sha3(ByteArray .fromString("TokenBurn(address,uint256,bytes32[3])")); + private static final byte[] SHIELDED_TRC20_LOG_TOPICS_NOTE_SPENT = Hash.sha3(ByteArray + .fromString("NoteSpent(bytes32)")); private static final String BROADCAST_TRANS_FAILED = "Broadcast transaction {} failed, {}."; @Getter @@ -3672,9 +3674,7 @@ public ShieldedTRC20Parameters createShieldedContractParameters( builder.setTransparentToAddress(transparentToAddressTvm); builder.setTransparentToAmount(toAmount); - Optional cipher = NoteEncryption.Encryption - .encryptBurnMessageByOvk(ovk, toAmount, transparentToAddress); - cipher.ifPresent(builder::setBurnCiphertext); + builder.setOvk(ovk); ExpandedSpendingKey expsk = new ExpandedSpendingKey(ask, nsk, ovk); GrpcAPI.SpendNoteTRC20 spendNote = shieldedSpends.get(0); @@ -3799,9 +3799,7 @@ public ShieldedTRC20Parameters createShieldedContractParametersWithoutAsk( System.arraycopy(transparentToAddress, 1, transparentToAddressTvm, 0, 20); builder.setTransparentToAddress(transparentToAddressTvm); builder.setTransparentToAmount(toAmount); - Optional cipher = NoteEncryption.Encryption - .encryptBurnMessageByOvk(ovk, toAmount, transparentToAddress); - cipher.ifPresent(builder::setBurnCiphertext); + builder.setOvk(ovk); GrpcAPI.SpendNoteTRC20 spendNote = shieldedSpends.get(0); buildShieldedTRC20InputWithAK(builder, spendNote, ak, nsk); if (receiveSize == 1) { @@ -3838,6 +3836,8 @@ private int getShieldedTRC20LogType(TransactionInfo.Log log, byte[] contractAddr return 3; } else if (Arrays.equals(topicsBytes, SHIELDED_TRC20_LOG_TOPICS_BURN_TOKEN)) { return 4; + } else if (Arrays.equals(topicsBytes, SHIELDED_TRC20_LOG_TOPICS_NOTE_SPENT)) { + return 5; } } return 0; @@ -4001,7 +4001,8 @@ public DecryptNotesTRC20 scanShieldedTRC20NotesByIvk( private Optional getNoteTxFromLogListByOvk( DecryptNotesTRC20.NoteTx.Builder builder, - TransactionInfo.Log log, byte[] ovk, int logType) throws ZksnarkException { + TransactionInfo.Log log, byte[] ovk, int logType, byte[] pendingNf) + throws ZksnarkException { byte[] logData = log.getData().toByteArray(); if (!ArrayUtils.isEmpty(logData)) { if (logType > 0 && logType < 4) { @@ -4040,18 +4041,27 @@ private Optional getNoteTxFromLogListByOvk( } } } else if (logType == 4) { - //Data = toAddress(32) + value(32) + ciphertext(80) + padding(16) + //Data = toAddress(32) + value(32) + cipher(80) + nonce(12) + reserved(4) + if (logData.length < 64 + NoteEncryption.Encryption.BURN_CIPHER_RECORD_SIZE) { + return Optional.empty(); + } byte[] logToAddress = ByteArray.subArray(logData, 12, 32); byte[] logAmountArray = ByteArray.subArray(logData, 32, 64); byte[] cipher = ByteArray.subArray(logData, 64, 144); + byte[] nonceFromLog = ByteArray.subArray(logData, 144, 156); BigInteger logAmount = ByteUtil.bytesToBigInteger(logAmountArray); byte[] plaintext; byte[] amountArray = new byte[32]; byte[] decryptedAddress = new byte[20]; + Optional decryptedText = NoteEncryption.Encryption - .decryptBurnMessageByOvk(ovk, cipher); + .decryptBurnMessageByOvk(ovk, cipher, nonceFromLog, pendingNf); + if (decryptedText.isPresent()) { plaintext = decryptedText.get(); + if (plaintext[32] != Wallet.getAddressPreFixByte()) { + return Optional.empty(); + } System.arraycopy(plaintext, 0, amountArray, 0, 32); System.arraycopy(plaintext, 33, decryptedAddress, 0, 20); BigInteger decryptedAmount = ByteUtil.bytesToBigInteger(amountArray); @@ -4091,15 +4101,24 @@ public DecryptNotesTRC20 scanShieldedTRC20NotesByOvk(long startNum, long endNum, if (!Objects.isNull(logList)) { Optional noteTx; int index = 0; + byte[] pendingNf = null; for (TransactionInfo.Log log : logList) { int logType = getShieldedTRC20LogType(log, shieldedTRC20ContractAddress); - if (logType > 0) { + if (logType == 5) { + byte[] logData = log.getData().toByteArray(); + if (logData.length >= 32) { + pendingNf = ByteArray.subArray(logData, 0, 32); + } + } else if (logType > 0) { noteBuilder = DecryptNotesTRC20.NoteTx.newBuilder(); noteBuilder.setTxid(ByteString.copyFrom(txid)); noteBuilder.setIndex(index); index += 1; - noteTx = getNoteTxFromLogListByOvk(noteBuilder, log, ovk, logType); + noteTx = getNoteTxFromLogListByOvk(noteBuilder, log, ovk, logType, pendingNf); noteTx.ifPresent(builder::addNoteTxs); + if (logType == 4) { + pendingNf = null; + } } } } @@ -4283,8 +4302,13 @@ public BytesMessage getTriggerInputForShieldedTRC20Contract( parameterType); if (parametersBuilder.getShieldedTRC20ParametersType() == ShieldedTRC20ParametersType.BURN) { byte[] burnCiper = ByteArray.fromHexString(shieldedTRC20Parameters.getTriggerContractInput()); - if (!ArrayUtils.isEmpty(burnCiper) && burnCiper.length == 80) { + if (!ArrayUtils.isEmpty(burnCiper) + && burnCiper.length == NoteEncryption.Encryption.BURN_CIPHER_RECORD_SIZE) { parametersBuilder.setBurnCiphertext(burnCiper); + } else if (!ArrayUtils.isEmpty(burnCiper) && burnCiper.length == 80) { + throw new ZksnarkException( + "burn cipher length 80 is deprecated, expected " + + NoteEncryption.Encryption.BURN_CIPHER_RECORD_SIZE); } else { throw new ZksnarkException( "invalid shielded TRC-20 contract parameters for burn trigger input"); diff --git a/framework/src/main/java/org/tron/core/zen/ShieldedTRC20ParametersBuilder.java b/framework/src/main/java/org/tron/core/zen/ShieldedTRC20ParametersBuilder.java index 4b980c7b7c9..7c90551fedf 100644 --- a/framework/src/main/java/org/tron/core/zen/ShieldedTRC20ParametersBuilder.java +++ b/framework/src/main/java/org/tron/core/zen/ShieldedTRC20ParametersBuilder.java @@ -30,6 +30,7 @@ import org.tron.core.zen.address.PaymentAddress; import org.tron.core.zen.note.Note; import org.tron.core.zen.note.NoteEncryption; +import org.tron.core.zen.note.NoteEncryption.Encryption; import org.tron.core.zen.note.OutgoingPlaintext; import org.tron.protos.contract.ShieldContract; import org.tron.protos.contract.ShieldContract.ReceiveDescription; @@ -61,7 +62,9 @@ public class ShieldedTRC20ParametersBuilder { @Setter private BigInteger transparentToAmount; @Setter - private byte[] burnCiphertext = new byte[80]; + private byte[] ovk; + @Setter + private byte[] burnCiphertext = new byte[Encryption.BURN_CIPHER_RECORD_SIZE]; public ShieldedTRC20ParametersBuilder() { @@ -292,6 +295,21 @@ public ShieldedTRC20Parameters build(boolean withAsk) throws ZksnarkException { SpendDescriptionInfo spend = spends.get(0); spendDescription = generateSpendProof(spend, ctx).getInstance(); builder.addSpendDescription(spendDescription); + + if (ovk == null && spend.expsk != null) { + ovk = spend.expsk.getOvk(); + } + byte[] nf = spendDescription.getNullifier().toByteArray(); + byte[] addr21 = new byte[21]; + addr21[0] = Wallet.getAddressPreFixByte(); + System.arraycopy(transparentToAddress, 0, addr21, 1, 20); + Optional cipherOpt = Encryption.encryptBurnMessageByOvk( + ovk, transparentToAmount, addr21, nf); + if (!cipherOpt.isPresent()) { + throw new ZksnarkException("encrypt burn message failed"); + } + burnCiphertext = cipherOpt.get(); + mergedBytes = ByteUtil.merge(shieldedTRC20Address, encodeSpendDescriptionWithoutSpendAuthSig(spendDescription)); if (receives.size() == 1) { @@ -492,7 +510,6 @@ private String burnParamsToHexString(GrpcAPI.ShieldedTRC20Parameters burnParams, } byte[] mergedBytes; - byte[] zeros = new byte[16]; mergedBytes = ByteUtil.merge( spendDesc.getNullifier().toByteArray(), spendDesc.getAnchor().toByteArray(), @@ -503,8 +520,7 @@ private String burnParamsToHexString(GrpcAPI.ShieldedTRC20Parameters burnParams, ByteUtil.bigIntegerToBytes(value, 32), burnParams.getBindingSignature().toByteArray(), payTo, - burnCiphertext, - zeros + burnCiphertext ); byte[] outputOffsetBytes; // 32 @@ -524,7 +540,7 @@ private String burnParamsToHexString(GrpcAPI.ShieldedTRC20Parameters burnParams, coffsetBytes = ByteUtil.longTo32Bytes(mergedBytes.length + 32 * 3 + 32L * 9); countBytes = ByteUtil.longTo32Bytes(1L); ReceiveDescription recvDesc = burnParams.getReceiveDescription(0); - zeros = new byte[12]; + byte[] zeros = new byte[12]; mergedBytes = ByteUtil .merge(mergedBytes, outputOffsetBytes, diff --git a/framework/src/main/java/org/tron/core/zen/note/NoteEncryption.java b/framework/src/main/java/org/tron/core/zen/note/NoteEncryption.java index 7d9de4ff596..33b413995b7 100644 --- a/framework/src/main/java/org/tron/core/zen/note/NoteEncryption.java +++ b/framework/src/main/java/org/tron/core/zen/note/NoteEncryption.java @@ -8,10 +8,12 @@ import static org.tron.core.zen.note.NoteEncryption.Encryption.NOTEENCRYPTION_CIPHER_KEYSIZE; import java.math.BigInteger; +import java.nio.charset.StandardCharsets; import java.util.Optional; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.Setter; +import org.tron.common.crypto.Hash; import org.tron.common.utils.ByteUtil; import org.tron.common.zksnark.JLibrustzcash; import org.tron.common.zksnark.JLibsodium; @@ -111,6 +113,11 @@ public OutCiphertext encryptToOurselves( public static class Encryption { public static final int NOTEENCRYPTION_CIPHER_KEYSIZE = 32; + public static final int BURN_CIPHER_LEN = 80; + public static final int BURN_NONCE_LEN = 12; + public static final int BURN_CIPHER_RECORD_SIZE = 96; + private static final byte[] BURN_NONCE_DOMAIN = + "Ztron_BurnNonce".getBytes(StandardCharsets.UTF_8); /** * generate ock by ovk, cv, cm, epk @@ -246,47 +253,113 @@ public static Optional attemptOutDecryption( } /** - * encrypt the message by ovk used for scanning + * encrypt burn message with nf-derived nonce, returns 96B record: + * cipher(80) + nonce(12) + reserved(4). */ public static Optional encryptBurnMessageByOvk(byte[] ovk, BigInteger toAmount, - byte[] transparentToAddress) + byte[] transparentToAddress, byte[] nf) throws ZksnarkException { + if (ovk == null || ovk.length != NOTEENCRYPTION_CIPHER_KEYSIZE) { + throw new ZksnarkException("invalid ovk length"); + } + if (transparentToAddress == null || transparentToAddress.length != 21) { + throw new ZksnarkException("invalid transparentToAddress length"); + } + if (nf == null || nf.length != 32) { + throw new ZksnarkException("invalid nullifier length"); + } byte[] plaintext = new byte[64]; byte[] amountArray = ByteUtil.bigIntegerToBytes(toAmount, 32); - byte[] cipherNonce = new byte[12]; - byte[] cipher = new byte[80]; + byte[] nonce = deriveNonceFromNf(nf); + byte[] cipher = new byte[BURN_CIPHER_LEN]; System.arraycopy(amountArray, 0, plaintext, 0, 32); - System.arraycopy(transparentToAddress, 0, plaintext, 32, - 21); + System.arraycopy(transparentToAddress, 0, plaintext, 32, 21); if (JLibsodium.cryptoAeadChacha20Poly1305IetfEncrypt(new Chacha20Poly1305IetfEncryptParams( cipher, null, plaintext, - 64, null, 0, null, cipherNonce, ovk)) != 0) { + 64, null, 0, null, nonce, ovk)) != 0) { return Optional.empty(); } - return Optional.of(cipher); + byte[] record = new byte[BURN_CIPHER_RECORD_SIZE]; + System.arraycopy(cipher, 0, record, 0, BURN_CIPHER_LEN); + System.arraycopy(nonce, 0, record, BURN_CIPHER_LEN, BURN_NONCE_LEN); + return Optional.of(record); } /** - * decrypt the message by ovk used for scanning + * Derive a 12-byte ChaCha20-Poly1305 nonce from the spend nullifier. + * The nullifier is prefixed with a fixed domain tag before hashing so the + * derivation cannot collide with any other SHA3-of-nf usage in the codebase. */ - public static Optional decryptBurnMessageByOvk(byte[] ovk, byte[] ciphertext) - throws ZksnarkException { + private static byte[] deriveNonceFromNf(byte[] nf) { + byte[] tagged = new byte[BURN_NONCE_DOMAIN.length + nf.length]; + System.arraycopy(BURN_NONCE_DOMAIN, 0, tagged, 0, BURN_NONCE_DOMAIN.length); + System.arraycopy(nf, 0, tagged, BURN_NONCE_DOMAIN.length, nf.length); + byte[] hash = Hash.sha3(tagged); + byte[] nonce = new byte[BURN_NONCE_LEN]; + System.arraycopy(hash, 0, nonce, 0, BURN_NONCE_LEN); + return nonce; + } + + /** + * decrypt burn message. Uses nonceFromLog to determine v1/v2: + * - all-zero nonce → v1 legacy path (pre-upgrade burn), decrypt with zero nonce. + * - non-zero nonce + nf present → v2 strict mode, verify nonce == sha3(domain || nf)[:12] + * before decrypting; rejects any cipher whose nonce was not bound to this nf. + * - non-zero nonce + nf absent → v2 relaxed mode, decrypt without re-deriving nonce. + * This path exists so a scanner that lost the matching NoteSpent log within the + * transaction can still recover the plaintext. It is sound because (a) the AEAD tag + * is keyed by ovk, so only the legitimate ovk holder can decrypt at all, and + * (b) any forged nonce would be rejected by the AEAD tag under the same key. + * In other words, omitting nf weakens key↔nonce binding to AEAD-tag binding — + * it never weakens authenticity. + */ + public static Optional decryptBurnMessageByOvk(byte[] ovk, byte[] ciphertext, + byte[] nonceFromLog, byte[] nf) throws ZksnarkException { + if (ovk == null || ovk.length != NOTEENCRYPTION_CIPHER_KEYSIZE) { + throw new ZksnarkException("invalid ovk length"); + } + if (ciphertext == null || ciphertext.length != BURN_CIPHER_LEN + || nonceFromLog == null || nonceFromLog.length != BURN_NONCE_LEN) { + return Optional.empty(); + } + + byte[] effectiveNonce; + if (isAllZero(nonceFromLog)) { + effectiveNonce = nonceFromLog; + } else { + if (nf != null) { + byte[] derived = deriveNonceFromNf(nf); + if (!java.util.Arrays.equals(nonceFromLog, derived)) { + return Optional.empty(); + } + } + effectiveNonce = nonceFromLog; + } + byte[] outPlaintext = new byte[64]; - byte[] cipherNonce = new byte[12]; if (JLibsodium.cryptoAeadChacha20poly1305IetfDecrypt(new Chacha20poly1305IetfDecryptParams( outPlaintext, null, null, - ciphertext, 80, + ciphertext, BURN_CIPHER_LEN, null, 0, - cipherNonce, ovk)) != 0) { + effectiveNonce, ovk)) != 0) { return Optional.empty(); } return Optional.of(outPlaintext); } + private static boolean isAllZero(byte[] data) { + for (byte b : data) { + if (b != 0) { + return false; + } + } + return true; + } + public static class EncCiphertext { @Getter diff --git a/framework/src/test/java/org/tron/core/zen/note/BurnCipherTest.java b/framework/src/test/java/org/tron/core/zen/note/BurnCipherTest.java new file mode 100644 index 00000000000..0ca6733f8cb --- /dev/null +++ b/framework/src/test/java/org/tron/core/zen/note/BurnCipherTest.java @@ -0,0 +1,208 @@ +package org.tron.core.zen.note; + +import java.math.BigInteger; +import java.util.Arrays; +import java.util.Optional; +import org.junit.Assert; +import org.junit.Test; +import org.tron.common.utils.ByteUtil; +import org.tron.core.exception.ZksnarkException; +import org.tron.core.zen.note.NoteEncryption.Encryption; + +public class BurnCipherTest { + + private static final byte[] OVK = buildTestBytes(32, 1); + private static final byte[] NF = buildTestBytes(32, 7); + private static final byte[] ADDR_21 = buildAddr21((byte) 0x41); + + private static byte[] buildTestBytes(int len, int seed) { + byte[] data = new byte[len]; + for (int i = 0; i < len; i++) { + data[i] = (byte) (i * 3 + seed); + } + return data; + } + + private static byte[] buildAddr21(byte prefix) { + byte[] addr = new byte[21]; + addr[0] = prefix; + for (int i = 1; i < 21; i++) { + addr[i] = (byte) (i * 2); + } + return addr; + } + + private static byte[] extractCipher(byte[] record) { + return Arrays.copyOf(record, Encryption.BURN_CIPHER_LEN); + } + + private static byte[] extractNonce(byte[] record) { + return Arrays.copyOfRange(record, + Encryption.BURN_CIPHER_LEN, + Encryption.BURN_CIPHER_LEN + Encryption.BURN_NONCE_LEN); + } + + // ---------- constants ---------- + + @Test + public void testBurnCipherSize() { + Assert.assertEquals(80, Encryption.BURN_CIPHER_LEN); + Assert.assertEquals(12, Encryption.BURN_NONCE_LEN); + Assert.assertEquals(96, Encryption.BURN_CIPHER_RECORD_SIZE); + } + + // ---------- encrypt ---------- + + @Test + public void testEncryptProduces96ByteRecord() throws ZksnarkException { + BigInteger amount = BigInteger.valueOf(1000000); + Optional recordOpt = Encryption.encryptBurnMessageByOvk( + OVK, amount, ADDR_21, NF); + Assert.assertTrue(recordOpt.isPresent()); + Assert.assertEquals(Encryption.BURN_CIPHER_RECORD_SIZE, recordOpt.get().length); + } + + @Test + public void testRecordLastFourBytesAreZero() throws ZksnarkException { + BigInteger amount = BigInteger.valueOf(1000000); + byte[] record = Encryption.encryptBurnMessageByOvk(OVK, amount, ADDR_21, NF).get(); + for (int i = 92; i < 96; i++) { + Assert.assertEquals(0, record[i]); + } + } + + @Test + public void testNonceEmbeddedInRecord() throws ZksnarkException { + BigInteger amount = BigInteger.valueOf(1000000); + byte[] record = Encryption.encryptBurnMessageByOvk(OVK, amount, ADDR_21, NF).get(); + byte[] nonce = extractNonce(record); + boolean allZero = true; + for (byte b : nonce) { + if (b != 0) { + allZero = false; + break; + } + } + Assert.assertFalse(allZero); + } + + @Test + public void testNonceDeterminism() throws ZksnarkException { + BigInteger amount = BigInteger.valueOf(1000000); + byte[] record1 = Encryption.encryptBurnMessageByOvk(OVK, amount, ADDR_21, NF).get(); + byte[] record2 = Encryption.encryptBurnMessageByOvk(OVK, amount, ADDR_21, NF).get(); + Assert.assertArrayEquals(record1, record2); + } + + @Test + public void testDifferentNfProducesDifferentRecord() throws ZksnarkException { + BigInteger amount = BigInteger.valueOf(1000000); + byte[] nf2 = new byte[32]; + nf2[0] = (byte) 0xFF; + + byte[] record1 = Encryption.encryptBurnMessageByOvk(OVK, amount, ADDR_21, NF).get(); + byte[] record2 = Encryption.encryptBurnMessageByOvk(OVK, amount, ADDR_21, nf2).get(); + Assert.assertFalse(Arrays.equals(record1, record2)); + } + + // ---------- encrypt input validation ---------- + + @Test(expected = ZksnarkException.class) + public void testEncryptRejectsNullNf() throws ZksnarkException { + Encryption.encryptBurnMessageByOvk(OVK, BigInteger.ONE, ADDR_21, null); + } + + @Test(expected = ZksnarkException.class) + public void testEncryptRejectsShortOvk() throws ZksnarkException { + Encryption.encryptBurnMessageByOvk(new byte[16], BigInteger.ONE, ADDR_21, NF); + } + + @Test(expected = ZksnarkException.class) + public void testEncryptRejectsBadAddrLength() throws ZksnarkException { + Encryption.encryptBurnMessageByOvk(OVK, BigInteger.ONE, new byte[20], NF); + } + + // ---------- decrypt round-trip ---------- + + @Test + public void testEncryptDecryptRoundTrip() throws ZksnarkException { + BigInteger amount = BigInteger.valueOf(1000000); + + byte[] record = Encryption.encryptBurnMessageByOvk(OVK, amount, ADDR_21, NF).get(); + byte[] cipher = extractCipher(record); + byte[] nonce = extractNonce(record); + + Optional plainOpt = Encryption.decryptBurnMessageByOvk( + OVK, cipher, nonce, NF); + Assert.assertTrue(plainOpt.isPresent()); + byte[] plaintext = plainOpt.get(); + + byte[] decryptedAmount = new byte[32]; + System.arraycopy(plaintext, 0, decryptedAmount, 0, 32); + Assert.assertEquals(amount, ByteUtil.bytesToBigInteger(decryptedAmount)); + + byte[] decryptedAddr = new byte[21]; + System.arraycopy(plaintext, 32, decryptedAddr, 0, 21); + Assert.assertArrayEquals(ADDR_21, decryptedAddr); + } + + @Test + public void testDecryptWithWrongNfFails() throws ZksnarkException { + BigInteger amount = BigInteger.valueOf(500000); + byte[] record = Encryption.encryptBurnMessageByOvk(OVK, amount, ADDR_21, NF).get(); + byte[] cipher = extractCipher(record); + byte[] nonce = extractNonce(record); + + byte[] wrongNf = new byte[32]; + wrongNf[0] = (byte) 0xFF; + Optional result = Encryption.decryptBurnMessageByOvk( + OVK, cipher, nonce, wrongNf); + Assert.assertFalse(result.isPresent()); + } + + @Test + public void testDecryptWithNullNfSucceeds() throws ZksnarkException { + BigInteger amount = BigInteger.valueOf(500000); + byte[] record = Encryption.encryptBurnMessageByOvk(OVK, amount, ADDR_21, NF).get(); + byte[] cipher = extractCipher(record); + byte[] nonce = extractNonce(record); + + Optional result = Encryption.decryptBurnMessageByOvk( + OVK, cipher, nonce, null); + Assert.assertTrue(result.isPresent()); + } + + @Test + public void testDecryptWithTamperedNonceFails() throws ZksnarkException { + BigInteger amount = BigInteger.valueOf(500000); + byte[] record = Encryption.encryptBurnMessageByOvk(OVK, amount, ADDR_21, NF).get(); + byte[] cipher = extractCipher(record); + + byte[] tamperedNonce = new byte[Encryption.BURN_NONCE_LEN]; + tamperedNonce[0] = (byte) 0xDE; + Optional result = Encryption.decryptBurnMessageByOvk( + OVK, cipher, tamperedNonce, NF); + Assert.assertFalse(result.isPresent()); + } + + // ---------- decrypt input validation ---------- + + @Test(expected = ZksnarkException.class) + public void testDecryptRejectsNullOvk() throws ZksnarkException { + Encryption.decryptBurnMessageByOvk(null, new byte[80], new byte[12], NF); + } + + @Test + public void testDecryptRejectsBadCipherLength() throws ZksnarkException { + Optional result = Encryption.decryptBurnMessageByOvk( + OVK, new byte[64], new byte[12], NF); + Assert.assertFalse(result.isPresent()); + } + + @Test + public void testDecryptRejectsNullNonce() throws ZksnarkException { + Optional result = Encryption.decryptBurnMessageByOvk( + OVK, new byte[80], null, NF); + Assert.assertFalse(result.isPresent()); + } +} diff --git a/framework/src/test/java/org/tron/core/zksnark/NoteEncDecryTest.java b/framework/src/test/java/org/tron/core/zksnark/NoteEncDecryTest.java index 3c3fb14b2b1..db1ee1eece6 100644 --- a/framework/src/test/java/org/tron/core/zksnark/NoteEncDecryTest.java +++ b/framework/src/test/java/org/tron/core/zksnark/NoteEncDecryTest.java @@ -1,14 +1,20 @@ package org.tron.core.zksnark; import com.google.protobuf.ByteString; +import java.lang.reflect.Method; +import java.math.BigInteger; import java.util.Optional; import javax.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.junit.Assert; import org.junit.Before; import org.junit.Test; +import org.tron.api.GrpcAPI; import org.tron.common.BaseTest; import org.tron.common.utils.ByteArray; +import org.tron.common.utils.ByteUtil; +import org.tron.common.zksnark.JLibsodium; +import org.tron.common.zksnark.JLibsodiumParam.Chacha20Poly1305IetfEncryptParams; import org.tron.core.Wallet; import org.tron.core.capsule.AssetIssueCapsule; import org.tron.core.config.args.Args; @@ -17,7 +23,9 @@ import org.tron.core.zen.note.NoteEncryption.Encryption; import org.tron.core.zen.note.NoteEncryption.Encryption.OutCiphertext; import org.tron.core.zen.note.OutgoingPlaintext; +import org.tron.protos.Protocol.TransactionInfo; import org.tron.protos.contract.AssetIssueContractOuterClass.AssetIssueContract; +import org.tron.protos.contract.ShieldContract; @Slf4j public class NoteEncDecryTest extends BaseTest { @@ -193,4 +201,202 @@ public void testDecryptEncWithEpk() throws ZksnarkException { Assert.assertArrayEquals(rcm, result2.getRcm()); Assert.assertEquals(4000, result2.getValue()); } + + @Test + public void testBurnMessageOvkLegacyZeroNonce() throws ZksnarkException { + byte[] ovk = new byte[]{ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, + 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32}; + byte[] toAddress = new byte[21]; + toAddress[0] = Wallet.getAddressPreFixByte(); + toAddress[20] = 0x42; + BigInteger amount = BigInteger.valueOf(99L); + + byte[] plaintext = new byte[64]; + byte[] amountArr = ByteUtil.bigIntegerToBytes(amount, 32); + System.arraycopy(amountArr, 0, plaintext, 0, 32); + System.arraycopy(toAddress, 0, plaintext, 32, 21); + byte[] zeroNonce = new byte[12]; + byte[] v1Cipher = new byte[Encryption.BURN_CIPHER_LEN]; + int rc = JLibsodium.cryptoAeadChacha20Poly1305IetfEncrypt( + new Chacha20Poly1305IetfEncryptParams( + v1Cipher, null, plaintext, 64, null, 0, null, zeroNonce, ovk)); + Assert.assertEquals(0, rc); + + Optional p1 = Encryption.decryptBurnMessageByOvk( + ovk, v1Cipher, new byte[12], null); + Assert.assertTrue(p1.isPresent()); + Assert.assertArrayEquals(plaintext, p1.get()); + + byte[] wrongNonce = new byte[12]; + wrongNonce[0] = 1; + Assert.assertFalse(Encryption.decryptBurnMessageByOvk( + ovk, v1Cipher, wrongNonce, null).isPresent()); + + Assert.assertFalse(Encryption.decryptBurnMessageByOvk( + ovk, v1Cipher, new byte[11], null).isPresent()); + Assert.assertFalse(Encryption.decryptBurnMessageByOvk( + ovk, v1Cipher, null, null).isPresent()); + } + + @Test + public void testGetTriggerInputBurn96ByteCipher() throws Exception { + byte[] burnCipher = new byte[Encryption.BURN_CIPHER_RECORD_SIZE]; + GrpcAPI.ShieldedTRC20Parameters trc20Params = buildBurnTrc20Params(burnCipher); + GrpcAPI.ShieldedTRC20TriggerContractParameters req = buildBurnTriggerRequest( + trc20Params, BigInteger.ONE); + GrpcAPI.BytesMessage out = wallet.getTriggerInputForShieldedTRC20Contract(req); + Assert.assertNotNull(out); + } + + @Test + public void testGetTriggerInputBurn80ByteCipherRejected() throws Exception { + byte[] legacyCipher = new byte[Encryption.BURN_CIPHER_LEN]; + GrpcAPI.ShieldedTRC20Parameters trc20Params = buildBurnTrc20Params(legacyCipher); + GrpcAPI.ShieldedTRC20TriggerContractParameters req = buildBurnTriggerRequest( + trc20Params, BigInteger.ONE); + try { + wallet.getTriggerInputForShieldedTRC20Contract(req); + Assert.fail("expected ZksnarkException for 80-byte burn cipher"); + } catch (ZksnarkException e) { + Assert.assertTrue(e.getMessage().contains("deprecated")); + } + } + + @Test + public void testGetNoteTxFromLogListByOvkBurnTooShort() throws Exception { + Wallet w = new Wallet(); + byte[] ovk = new byte[32]; + byte[] logData = new byte[64 + Encryption.BURN_CIPHER_RECORD_SIZE - 1]; + TransactionInfo.Log log = TransactionInfo.Log.newBuilder() + .setData(ByteString.copyFrom(logData)).build(); + GrpcAPI.DecryptNotesTRC20.NoteTx.Builder builder = + GrpcAPI.DecryptNotesTRC20.NoteTx.newBuilder(); + + Method m = Wallet.class.getDeclaredMethod("getNoteTxFromLogListByOvk", + GrpcAPI.DecryptNotesTRC20.NoteTx.Builder.class, + TransactionInfo.Log.class, byte[].class, int.class, byte[].class); + m.setAccessible(true); + Object result = m.invoke(w, builder, log, ovk, 4, null); + Assert.assertFalse(((Optional) result).isPresent()); + } + + @Test + public void testGetNoteTxFromLogListByOvkBurnRoundTrip() throws Exception { + Wallet w = new Wallet(); + byte[] ovk = new byte[32]; + for (int i = 0; i < 32; i++) { + ovk[i] = (byte) (i + 1); + } + BigInteger amount = BigInteger.valueOf(1000L); + byte[] toAddress = new byte[21]; + toAddress[0] = Wallet.getAddressPreFixByte(); + toAddress[20] = 0x42; + byte[] nf = new byte[32]; + nf[0] = (byte) 0xAB; + + Optional recordOpt = Encryption.encryptBurnMessageByOvk( + ovk, amount, toAddress, nf); + Assert.assertTrue(recordOpt.isPresent()); + byte[] record = recordOpt.get(); + + byte[] logData = new byte[64 + Encryption.BURN_CIPHER_RECORD_SIZE]; + System.arraycopy(toAddress, 1, logData, 12, 20); + byte[] valBytes = ByteUtil.bigIntegerToBytes(amount, 32); + System.arraycopy(valBytes, 0, logData, 32, 32); + System.arraycopy(record, 0, logData, 64, Encryption.BURN_CIPHER_RECORD_SIZE); + + TransactionInfo.Log log = TransactionInfo.Log.newBuilder() + .setData(ByteString.copyFrom(logData)).build(); + GrpcAPI.DecryptNotesTRC20.NoteTx.Builder builder = + GrpcAPI.DecryptNotesTRC20.NoteTx.newBuilder(); + + Method m = Wallet.class.getDeclaredMethod("getNoteTxFromLogListByOvk", + GrpcAPI.DecryptNotesTRC20.NoteTx.Builder.class, + TransactionInfo.Log.class, byte[].class, int.class, byte[].class); + m.setAccessible(true); + Object result = m.invoke(w, builder, log, ovk, 4, nf); + Assert.assertTrue(((Optional) result).isPresent()); + } + + @Test + public void testGetNoteTxFromLogListByOvkTwoBurnsCursorPairing() throws Exception { + Wallet w = new Wallet(); + byte[] ovk = new byte[32]; + for (int i = 0; i < 32; i++) { + ovk[i] = (byte) (i + 1); + } + byte[] toAddress = new byte[21]; + toAddress[0] = Wallet.getAddressPreFixByte(); + toAddress[20] = 0x42; + + byte[] nf1 = new byte[32]; + nf1[0] = (byte) 0xAA; + byte[] nf2 = new byte[32]; + nf2[0] = (byte) 0xBB; + BigInteger amount1 = BigInteger.valueOf(1000L); + BigInteger amount2 = BigInteger.valueOf(2000L); + + TransactionInfo.Log log1 = buildBurnLog(ovk, amount1, toAddress, nf1); + TransactionInfo.Log log2 = buildBurnLog(ovk, amount2, toAddress, nf2); + + Method m = Wallet.class.getDeclaredMethod("getNoteTxFromLogListByOvk", + GrpcAPI.DecryptNotesTRC20.NoteTx.Builder.class, + TransactionInfo.Log.class, byte[].class, int.class, byte[].class); + m.setAccessible(true); + + // correct cursor pairing: each log decrypted with its own nf + Optional r1 = (Optional) m.invoke( + w, GrpcAPI.DecryptNotesTRC20.NoteTx.newBuilder(), log1, ovk, 4, nf1); + Optional r2 = (Optional) m.invoke( + w, GrpcAPI.DecryptNotesTRC20.NoteTx.newBuilder(), log2, ovk, 4, nf2); + Assert.assertTrue("burn1 should decrypt with nf1", r1.isPresent()); + Assert.assertTrue("burn2 should decrypt with nf2", r2.isPresent()); + GrpcAPI.DecryptNotesTRC20.NoteTx tx1 = (GrpcAPI.DecryptNotesTRC20.NoteTx) r1.get(); + GrpcAPI.DecryptNotesTRC20.NoteTx tx2 = (GrpcAPI.DecryptNotesTRC20.NoteTx) r2.get(); + Assert.assertEquals(amount1.toString(10), tx1.getToAmount()); + Assert.assertEquals(amount2.toString(10), tx2.getToAmount()); + + // mis-paired cursor: nonce-from-log mismatches sha3(domain||nf), strict mode rejects + Optional bad1 = (Optional) m.invoke( + w, GrpcAPI.DecryptNotesTRC20.NoteTx.newBuilder(), log1, ovk, 4, nf2); + Optional bad2 = (Optional) m.invoke( + w, GrpcAPI.DecryptNotesTRC20.NoteTx.newBuilder(), log2, ovk, 4, nf1); + Assert.assertFalse("burn1 must not decrypt under nf2", bad1.isPresent()); + Assert.assertFalse("burn2 must not decrypt under nf1", bad2.isPresent()); + } + + private TransactionInfo.Log buildBurnLog(byte[] ovk, BigInteger amount, byte[] toAddress, + byte[] nf) throws ZksnarkException { + Optional recordOpt = Encryption.encryptBurnMessageByOvk(ovk, amount, toAddress, nf); + Assert.assertTrue(recordOpt.isPresent()); + byte[] record = recordOpt.get(); + byte[] logData = new byte[64 + Encryption.BURN_CIPHER_RECORD_SIZE]; + System.arraycopy(toAddress, 1, logData, 12, 20); + byte[] valBytes = ByteUtil.bigIntegerToBytes(amount, 32); + System.arraycopy(valBytes, 0, logData, 32, 32); + System.arraycopy(record, 0, logData, 64, Encryption.BURN_CIPHER_RECORD_SIZE); + return TransactionInfo.Log.newBuilder() + .setData(ByteString.copyFrom(logData)).build(); + } + + private GrpcAPI.ShieldedTRC20Parameters buildBurnTrc20Params(byte[] cipher) { + return GrpcAPI.ShieldedTRC20Parameters.newBuilder() + .setParameterType("burn") + .setTriggerContractInput(ByteArray.toHexString(cipher)) + .addSpendDescription(ShieldContract.SpendDescription.getDefaultInstance()) + .build(); + } + + private GrpcAPI.ShieldedTRC20TriggerContractParameters buildBurnTriggerRequest( + GrpcAPI.ShieldedTRC20Parameters trc20Params, BigInteger value) { + byte[] toAddress = new byte[21]; + toAddress[0] = Wallet.getAddressPreFixByte(); + return GrpcAPI.ShieldedTRC20TriggerContractParameters.newBuilder() + .setShieldedTRC20Parameters(trc20Params) + .addSpendAuthoritySignature(GrpcAPI.BytesMessage.getDefaultInstance()) + .setAmount(value.toString()) + .setTransparentToAddress(ByteString.copyFrom(toAddress)) + .build(); + } }