From 290cbd4b1036889cc8c177923c83e2e502ee3b3d Mon Sep 17 00:00:00 2001 From: federico Date: Sat, 9 May 2026 00:30:14 +0800 Subject: [PATCH 1/3] fix(crypto): avoid nonce reuse in burn ovk aead --- .../src/main/java/org/tron/core/Wallet.java | 23 +- .../zen/ShieldedTRC20ParametersBuilder.java | 11 +- .../tron/core/zen/note/NoteEncryption.java | 41 ++- .../tron/core/zksnark/NoteEncDecryTest.java | 238 ++++++++++++++++++ 4 files changed, 295 insertions(+), 18 deletions(-) diff --git a/framework/src/main/java/org/tron/core/Wallet.java b/framework/src/main/java/org/tron/core/Wallet.java index 279153115d9..48fa597cf97 100755 --- a/framework/src/main/java/org/tron/core/Wallet.java +++ b/framework/src/main/java/org/tron/core/Wallet.java @@ -4035,16 +4035,23 @@ private Optional getNoteTxFromLogListByOvk( } } } else if (logType == 4) { - //Data = toAddress(32) + value(32) + ciphertext(80) + padding(16) + //Data = toAddress(32) + value(32) + ciphertext(80) + nonce(12) + reserved(4) + // Pre-fix burns wrote 16 bytes of zero padding in place of nonce+reserved; those + // bytes are forwarded as-is and decrypt under the same AEAD path. + 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[] nonce = ByteArray.subArray(logData, 144, + 144 + NoteEncryption.Encryption.BURN_NONCE_LEN); 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, nonce); if (decryptedText.isPresent()) { plaintext = decryptedText.get(); System.arraycopy(plaintext, 0, amountArray, 0, 32); @@ -4278,8 +4285,18 @@ 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 == NoteEncryption.Encryption.BURN_CIPHER_LEN) { + // Legacy 80-byte cipher from clients built before the random-nonce upgrade. + // Pad to the 96-byte record with a zero nonce/reserved tail; on-chain layout + // is identical to the pre-upgrade burn calldata, so this remains backward + // compatible for callers that have not yet rebuilt against the new encoder. + byte[] record = new byte[NoteEncryption.Encryption.BURN_CIPHER_RECORD_SIZE]; + System.arraycopy(burnCiper, 0, record, 0, NoteEncryption.Encryption.BURN_CIPHER_LEN); + parametersBuilder.setBurnCiphertext(record); } 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..38b180a8c1f 100644 --- a/framework/src/main/java/org/tron/core/zen/ShieldedTRC20ParametersBuilder.java +++ b/framework/src/main/java/org/tron/core/zen/ShieldedTRC20ParametersBuilder.java @@ -61,7 +61,7 @@ public class ShieldedTRC20ParametersBuilder { @Setter private BigInteger transparentToAmount; @Setter - private byte[] burnCiphertext = new byte[80]; + private byte[] burnCiphertext = new byte[96]; public ShieldedTRC20ParametersBuilder() { @@ -492,7 +492,9 @@ private String burnParamsToHexString(GrpcAPI.ShieldedTRC20Parameters burnParams, } byte[] mergedBytes; - byte[] zeros = new byte[16]; + // burnCiphertext is a 96-byte record: cipher(80) || nonce(12) || reserved(4), + // occupying the same calldata slot as the previous cipher(80) || zeros(16) layout + // so total length is unchanged. mergedBytes = ByteUtil.merge( spendDesc.getNullifier().toByteArray(), spendDesc.getAnchor().toByteArray(), @@ -503,8 +505,7 @@ private String burnParamsToHexString(GrpcAPI.ShieldedTRC20Parameters burnParams, ByteUtil.bigIntegerToBytes(value, 32), burnParams.getBindingSignature().toByteArray(), payTo, - burnCiphertext, - zeros + burnCiphertext ); byte[] outputOffsetBytes; // 32 @@ -524,7 +525,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..d43e302b42b 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,6 +8,7 @@ import static org.tron.core.zen.note.NoteEncryption.Encryption.NOTEENCRYPTION_CIPHER_KEYSIZE; import java.math.BigInteger; +import java.security.SecureRandom; import java.util.Optional; import lombok.AllArgsConstructor; import lombok.Getter; @@ -246,15 +247,30 @@ public static Optional attemptOutDecryption( } /** - * encrypt the message by ovk used for scanning + * Burn ciphertext record layout: cipher(80) || nonce(12) || reserved(4) = 96 bytes. + * The 12-byte nonce is fresh-per-burn CSPRNG output to satisfy RFC 8439 ยง4 (key, nonce) + * uniqueness; the trailing 4 bytes are reserved zero, padding the record to fit the + * existing 96-byte slot in burn calldata / TokenBurn event data. + */ + public static final int BURN_CIPHER_RECORD_SIZE = 96; + public static final int BURN_CIPHER_LEN = 80; + public static final int BURN_NONCE_OFFSET = 80; + public static final int BURN_NONCE_LEN = CRYPTO_AEAD_CHACHA20POLY1305_IETF_NPUBBYTES; + + private static final SecureRandom BURN_NONCE_RNG = new SecureRandom(); + + /** + * encrypt the message by ovk used for scanning. Returns a 96-byte record: + * cipher(80) || nonce(12) || reserved zero(4). */ public static Optional encryptBurnMessageByOvk(byte[] ovk, BigInteger toAmount, byte[] transparentToAddress) throws ZksnarkException { byte[] plaintext = new byte[64]; byte[] amountArray = ByteUtil.bigIntegerToBytes(toAmount, 32); - byte[] cipherNonce = new byte[12]; - byte[] cipher = new byte[80]; + byte[] cipherNonce = new byte[BURN_NONCE_LEN]; + BURN_NONCE_RNG.nextBytes(cipherNonce); + byte[] cipher = new byte[BURN_CIPHER_LEN]; System.arraycopy(amountArray, 0, plaintext, 0, 32); System.arraycopy(transparentToAddress, 0, plaintext, 32, 21); @@ -265,23 +281,28 @@ public static Optional encryptBurnMessageByOvk(byte[] ovk, BigInteger to 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(cipherNonce, 0, record, BURN_NONCE_OFFSET, BURN_NONCE_LEN); + return Optional.of(record); } /** - * decrypt the message by ovk used for scanning + * decrypt the message by ovk used for scanning. {@code nonce} is the per-burn nonce + * recovered from the log and is required to be {@link #BURN_NONCE_LEN} bytes. Pre-upgrade + * burn logs carry an all-zero byte field at the nonce offset, which is forwarded as-is + * and decrypts via the same code path. */ - public static Optional decryptBurnMessageByOvk(byte[] ovk, byte[] ciphertext) - throws ZksnarkException { + public static Optional decryptBurnMessageByOvk(byte[] ovk, byte[] ciphertext, + byte[] nonce) throws ZksnarkException { 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) { + nonce, ovk)) != 0) { return Optional.empty(); } return Optional.of(outPlaintext); 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..a8cedd3db28 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,22 @@ package org.tron.core.zksnark; import com.google.protobuf.ByteString; +import java.lang.reflect.Method; +import java.math.BigInteger; +import java.util.HashSet; import java.util.Optional; +import java.util.Set; 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 +25,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 +203,232 @@ public void testDecryptEncWithEpk() throws ZksnarkException { Assert.assertArrayEquals(rcm, result2.getRcm()); Assert.assertEquals(4000, result2.getValue()); } + + /** + * Round-trip: encryptBurnMessageByOvk emits a 96-byte record (cipher(80) || nonce(12) + * || reserved(4)); decryptBurnMessageByOvk recovers the original (amount, address) plaintext + * when given the embedded nonce. + */ + @Test + public void testBurnMessageOvkRoundTrip() throws ZksnarkException { + byte[] ovk = new byte[]{ + -91, -41, -115, 8, -94, 69, 15, -49, -44, 69, -65, 38, 15, -115, 53, -47, + 48, 54, 106, -123, 126, -12, 3, -104, 18, 20, 57, -39, -114, -72, 74, -118}; + BigInteger amount = BigInteger.valueOf(1_234_567L); + byte[] toAddress = new byte[21]; + toAddress[0] = Wallet.getAddressPreFixByte(); + for (int i = 1; i < 21; i++) { + toAddress[i] = (byte) i; + } + + Optional recordOpt = Encryption.encryptBurnMessageByOvk(ovk, amount, toAddress); + Assert.assertTrue(recordOpt.isPresent()); + byte[] record = recordOpt.get(); + Assert.assertEquals(Encryption.BURN_CIPHER_RECORD_SIZE, record.length); + + byte[] cipher = new byte[Encryption.BURN_CIPHER_LEN]; + System.arraycopy(record, 0, cipher, 0, Encryption.BURN_CIPHER_LEN); + byte[] nonce = new byte[Encryption.BURN_NONCE_LEN]; + System.arraycopy(record, Encryption.BURN_NONCE_OFFSET, nonce, 0, Encryption.BURN_NONCE_LEN); + + // reserved bytes after the nonce are zero + for (int i = Encryption.BURN_NONCE_OFFSET + Encryption.BURN_NONCE_LEN; + i < Encryption.BURN_CIPHER_RECORD_SIZE; i++) { + Assert.assertEquals(0, record[i]); + } + + Optional plainOpt = Encryption.decryptBurnMessageByOvk(ovk, cipher, nonce); + Assert.assertTrue(plainOpt.isPresent()); + byte[] plain = plainOpt.get(); + byte[] amountBytes = new byte[32]; + System.arraycopy(plain, 0, amountBytes, 0, 32); + Assert.assertEquals(amount, new BigInteger(1, amountBytes)); + byte[] addrOut = new byte[21]; + System.arraycopy(plain, 32, addrOut, 0, 21); + Assert.assertArrayEquals(toAddress, addrOut); + } + + /** + * The fresh-per-burn nonce must not repeat across successive encryptions for the same + * (ovk, amount, address). Tests that encryptBurnMessageByOvk uses CSPRNG-derived nonces. + */ + @Test + public void testBurnMessageOvkNonceUniqueness() throws ZksnarkException { + 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(); + BigInteger amount = BigInteger.TEN; + + int n = 64; + Set seenNonces = new HashSet<>(); + Set seenCiphers = new HashSet<>(); + for (int i = 0; i < n; i++) { + Optional recordOpt = Encryption.encryptBurnMessageByOvk(ovk, amount, toAddress); + Assert.assertTrue(recordOpt.isPresent()); + byte[] record = recordOpt.get(); + String nonceHex = ByteArray.toHexString( + java.util.Arrays.copyOfRange(record, Encryption.BURN_NONCE_OFFSET, + Encryption.BURN_NONCE_OFFSET + Encryption.BURN_NONCE_LEN)); + String cipherHex = ByteArray.toHexString( + java.util.Arrays.copyOfRange(record, 0, Encryption.BURN_CIPHER_LEN)); + Assert.assertTrue("nonce repeated within " + n + " encryptions: " + nonceHex, + seenNonces.add(nonceHex)); + Assert.assertTrue("ciphertext repeated within " + n + " encryptions", + seenCiphers.add(cipherHex)); + } + } + + /** + * Backward compatibility: a v1 burn record (cipher emitted under the legacy zero-nonce + * encoder) decrypts cleanly via the new API when the caller passes the 12B zero nonce + * read from the pre-upgrade log padding. + */ + @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); + + // Build a v1 cipher exactly as the pre-upgrade encoder did: 80B output under a 12B + // zero nonce. Bypass the new encryptBurnMessageByOvk (which emits a random nonce). + 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); + + // 12B zero nonce (the bytes a pre-upgrade log carries at the nonce offset) โ†’ decrypts. + Optional p1 = Encryption.decryptBurnMessageByOvk(ovk, v1Cipher, new byte[12]); + Assert.assertTrue(p1.isPresent()); + Assert.assertArrayEquals(plaintext, p1.get()); + + // Wrong nonce on a v1 cipher โ†’ AEAD tag mismatch + byte[] wrongNonce = new byte[12]; + wrongNonce[0] = 1; + Assert.assertFalse(Encryption.decryptBurnMessageByOvk(ovk, v1Cipher, wrongNonce).isPresent()); + } + + /** + * The 96-byte triggerContractInput path of getTriggerInputForShieldedTRC20Contract is + * exercised when callers (re)build burn calldata against the new encoder. + */ + @Test + public void testGetTriggerInputBurn96ByteCipher() throws Exception { + byte[] burnCipher = new byte[Encryption.BURN_CIPHER_RECORD_SIZE]; + BigInteger value = BigInteger.ONE; + GrpcAPI.ShieldedTRC20Parameters trc20Params = buildBurnTrc20Params(burnCipher); + GrpcAPI.ShieldedTRC20TriggerContractParameters req = buildBurnTriggerRequest( + trc20Params, value); + GrpcAPI.BytesMessage out = wallet.getTriggerInputForShieldedTRC20Contract(req); + Assert.assertNotNull(out); + } + + /** + * The 80-byte triggerContractInput path stays valid: legacy clients that have not been + * rebuilt against the new encoder still produce burn calldata, and the wallet pads the + * cipher with a zero nonce/reserved tail to the 96-byte record layout. + */ + @Test + public void testGetTriggerInputBurn80ByteCipher() throws Exception { + byte[] legacyCipher = new byte[Encryption.BURN_CIPHER_LEN]; + BigInteger value = BigInteger.ONE; + GrpcAPI.ShieldedTRC20Parameters trc20Params = buildBurnTrc20Params(legacyCipher); + GrpcAPI.ShieldedTRC20TriggerContractParameters req = buildBurnTriggerRequest( + trc20Params, value); + GrpcAPI.BytesMessage out = wallet.getTriggerInputForShieldedTRC20Contract(req); + Assert.assertNotNull(out); + } + + /** + * A burn log shorter than the 64B prefix + 96B record is rejected without attempting to + * decrypt a partial nonce field. + */ + @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); + m.setAccessible(true); + Object result = m.invoke(w, builder, log, ovk, 4); + Assert.assertFalse(((Optional) result).isPresent()); + } + + /** + * End-to-end burn log decrypt: a record produced by the new random-nonce encoder is + * decoded back to the original (amount, address) tuple when the log carries the matching + * 12-byte nonce field. + */ + @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; + + Optional recordOpt = Encryption.encryptBurnMessageByOvk(ovk, amount, toAddress); + 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); + m.setAccessible(true); + Object result = m.invoke(w, builder, log, ovk, 4); + Assert.assertTrue(((Optional) result).isPresent()); + } + + 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(); + } } From 6923f1b27224a76af241557b450b4dce964ae9b4 Mon Sep 17 00:00:00 2001 From: federico Date: Sat, 9 May 2026 14:10:53 +0800 Subject: [PATCH 2/3] refactor(crypto): drop unreachable nonce fallback in burn ovk decrypt --- .../src/main/java/org/tron/core/zen/note/NoteEncryption.java | 3 +++ .../src/test/java/org/tron/core/zksnark/NoteEncDecryTest.java | 4 ++++ 2 files changed, 7 insertions(+) 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 d43e302b42b..2ad358d250c 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 @@ -295,6 +295,9 @@ public static Optional encryptBurnMessageByOvk(byte[] ovk, BigInteger to */ public static Optional decryptBurnMessageByOvk(byte[] ovk, byte[] ciphertext, byte[] nonce) throws ZksnarkException { + if (nonce == null || nonce.length != BURN_NONCE_LEN) { + return Optional.empty(); + } byte[] outPlaintext = new byte[64]; if (JLibsodium.cryptoAeadChacha20poly1305IetfDecrypt(new Chacha20poly1305IetfDecryptParams( outPlaintext, null, 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 a8cedd3db28..339e52f9d29 100644 --- a/framework/src/test/java/org/tron/core/zksnark/NoteEncDecryTest.java +++ b/framework/src/test/java/org/tron/core/zksnark/NoteEncDecryTest.java @@ -317,6 +317,10 @@ public void testBurnMessageOvkLegacyZeroNonce() throws ZksnarkException { byte[] wrongNonce = new byte[12]; wrongNonce[0] = 1; Assert.assertFalse(Encryption.decryptBurnMessageByOvk(ovk, v1Cipher, wrongNonce).isPresent()); + + // Malformed (wrong-length / null) nonce โ†’ fail-fast precondition guard + Assert.assertFalse(Encryption.decryptBurnMessageByOvk(ovk, v1Cipher, new byte[11]).isPresent()); + Assert.assertFalse(Encryption.decryptBurnMessageByOvk(ovk, v1Cipher, null).isPresent()); } /** From 59ec4015eff9d85fcdeb044393bd2d71e1175ba8 Mon Sep 17 00:00:00 2001 From: federico Date: Sat, 9 May 2026 14:12:00 +0800 Subject: [PATCH 3/3] fix(crypto): reject legacy 80B burn cipher and reroll all-zero nonce --- .../src/main/java/org/tron/core/Wallet.java | 10 +++------- .../org/tron/core/zen/note/NoteEncryption.java | 15 ++++++++++++++- .../org/tron/core/zksnark/NoteEncDecryTest.java | 17 +++++++++++------ 3 files changed, 28 insertions(+), 14 deletions(-) diff --git a/framework/src/main/java/org/tron/core/Wallet.java b/framework/src/main/java/org/tron/core/Wallet.java index 48fa597cf97..1f3a358d6aa 100755 --- a/framework/src/main/java/org/tron/core/Wallet.java +++ b/framework/src/main/java/org/tron/core/Wallet.java @@ -4290,13 +4290,9 @@ public BytesMessage getTriggerInputForShieldedTRC20Contract( parametersBuilder.setBurnCiphertext(burnCiper); } else if (!ArrayUtils.isEmpty(burnCiper) && burnCiper.length == NoteEncryption.Encryption.BURN_CIPHER_LEN) { - // Legacy 80-byte cipher from clients built before the random-nonce upgrade. - // Pad to the 96-byte record with a zero nonce/reserved tail; on-chain layout - // is identical to the pre-upgrade burn calldata, so this remains backward - // compatible for callers that have not yet rebuilt against the new encoder. - byte[] record = new byte[NoteEncryption.Encryption.BURN_CIPHER_RECORD_SIZE]; - System.arraycopy(burnCiper, 0, record, 0, NoteEncryption.Encryption.BURN_CIPHER_LEN); - parametersBuilder.setBurnCiphertext(record); + throw new ZksnarkException( + "legacy 80-byte burn cipher is no longer accepted; rebuild the request against " + + "encryptBurnMessageByOvk to emit a 96-byte record (cipher || nonce || reserved)"); } 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/note/NoteEncryption.java b/framework/src/main/java/org/tron/core/zen/note/NoteEncryption.java index 2ad358d250c..2f25d4b400d 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 @@ -259,6 +259,15 @@ public static Optional attemptOutDecryption( private static final SecureRandom BURN_NONCE_RNG = new SecureRandom(); + private static boolean isAllZero(byte[] bytes) { + for (byte b : bytes) { + if (b != 0) { + return false; + } + } + return true; + } + /** * encrypt the message by ovk used for scanning. Returns a 96-byte record: * cipher(80) || nonce(12) || reserved zero(4). @@ -269,7 +278,11 @@ public static Optional encryptBurnMessageByOvk(byte[] ovk, BigInteger to byte[] plaintext = new byte[64]; byte[] amountArray = ByteUtil.bigIntegerToBytes(toAmount, 32); byte[] cipherNonce = new byte[BURN_NONCE_LEN]; - BURN_NONCE_RNG.nextBytes(cipherNonce); + // Reroll the 2^-96 all-zero draw so the nonce field never collides with the + // pre-upgrade zero-padding that legacy v1 records carry at the same offset. + do { + BURN_NONCE_RNG.nextBytes(cipherNonce); + } while (isAllZero(cipherNonce)); byte[] cipher = new byte[BURN_CIPHER_LEN]; System.arraycopy(amountArray, 0, plaintext, 0, 32); System.arraycopy(transparentToAddress, 0, plaintext, 32, 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 339e52f9d29..3526fc2724f 100644 --- a/framework/src/test/java/org/tron/core/zksnark/NoteEncDecryTest.java +++ b/framework/src/test/java/org/tron/core/zksnark/NoteEncDecryTest.java @@ -339,19 +339,24 @@ public void testGetTriggerInputBurn96ByteCipher() throws Exception { } /** - * The 80-byte triggerContractInput path stays valid: legacy clients that have not been - * rebuilt against the new encoder still produce burn calldata, and the wallet pads the - * cipher with a zero nonce/reserved tail to the 96-byte record layout. + * Outbound burn calldata construction must reject the legacy 80-byte cipher: a stale + * client passing a pre-upgrade ciphertext would otherwise emit a fresh zero-nonce burn, + * extending the (ovk, zero-nonce) reuse surface forward in time. Forcing a hard error + * here makes the upgrade non-optional for callers using this API. */ @Test - public void testGetTriggerInputBurn80ByteCipher() throws Exception { + public void testGetTriggerInputBurn80ByteCipherRejected() throws Exception { byte[] legacyCipher = new byte[Encryption.BURN_CIPHER_LEN]; BigInteger value = BigInteger.ONE; GrpcAPI.ShieldedTRC20Parameters trc20Params = buildBurnTrc20Params(legacyCipher); GrpcAPI.ShieldedTRC20TriggerContractParameters req = buildBurnTriggerRequest( trc20Params, value); - GrpcAPI.BytesMessage out = wallet.getTriggerInputForShieldedTRC20Contract(req); - Assert.assertNotNull(out); + try { + wallet.getTriggerInputForShieldedTRC20Contract(req); + Assert.fail("expected ZksnarkException for 80-byte burn cipher"); + } catch (ZksnarkException e) { + Assert.assertTrue(e.getMessage().contains("legacy 80-byte burn cipher")); + } } /**