From 7f36a3b4c064d279dab01070fc2b90cde67d6144 Mon Sep 17 00:00:00 2001 From: federico Date: Sun, 10 May 2026 12:03:51 +0800 Subject: [PATCH 01/47] feat(crypto): add Falcon-512 post-quantum signature support --- .../AccountPermissionUpdateActuator.java | 4 +- .../org/tron/core/utils/ProposalUtil.java | 15 +- .../tron/core/vm/PrecompiledContracts.java | 398 +++++++++++++++ .../org/tron/core/vm/config/ConfigLoader.java | 1 + .../org/tron/common/utils/LocalWitnesses.java | 86 ++++ .../org/tron/core/capsule/BlockCapsule.java | 115 ++++- .../tron/core/capsule/TransactionCapsule.java | 116 ++++- .../org/tron/core/db/BandwidthProcessor.java | 11 +- .../core/store/DynamicPropertiesStore.java | 40 ++ .../common/parameter/CommonParameter.java | 5 + .../src/main/java/org/tron/core/Constant.java | 8 + .../org/tron/core/vm/config/VMConfig.java | 10 + .../java/org/tron/consensus/base/Param.java | 25 + .../org/tron/common/crypto/pqc/FNDSA.java | 259 ++++++++++ .../common/crypto/pqc/PQSchemeRegistry.java | 223 ++++++++ .../tron/common/crypto/pqc/PQSignature.java | 72 +++ .../src/main/java/org/tron/core/Wallet.java | 5 + .../java/org/tron/core/config/args/Args.java | 63 +++ .../org/tron/core/config/args/ConfigKey.java | 13 + .../core/config/args/WitnessInitializer.java | 39 ++ .../tron/core/consensus/ConsensusService.java | 73 +++ .../tron/core/consensus/ProposalService.java | 4 + .../main/java/org/tron/core/db/Manager.java | 59 ++- framework/src/main/resources/config.conf | 19 + .../tron/common/crypto/pqc/FNDSAKatTest.java | 246 +++++++++ .../org/tron/common/crypto/pqc/FNDSATest.java | 443 ++++++++++++++++ .../crypto/pqc/PQSchemeRegistryTest.java | 130 +++++ .../crypto/pqc/PQSignatureDefaultsTest.java | 132 +++++ .../pqc/SignatureSchemeBenchmarkTest.java | 132 +++++ .../common/crypto/pqc/program/PQClient.java | 147 ++++++ .../common/crypto/pqc/program/PQFullNode.java | 118 +++++ .../crypto/pqc/program/PQWitnessNode.java | 204 ++++++++ .../runtime/vm/BatchValidateSignPQTest.java | 346 +++++++++++++ .../runtime/vm/FnDsaPrecompileTest.java | 186 +++++++ .../runtime/vm/ValidateMultiSignPQTest.java | 464 +++++++++++++++++ .../tron/common/utils/LocalWitnessesTest.java | 152 ++++++ .../org/tron/core/BandwidthProcessorTest.java | 116 +++++ .../AccountPermissionUpdateActuatorTest.java | 2 +- .../actuator/CreateAccountActuatorTest.java | 4 + .../ShieldedTransferActuatorTest.java | 2 +- .../core/actuator/utils/ProposalUtilTest.java | 26 + .../tron/core/capsule/BlockCapsulePQTest.java | 194 +++++++ .../core/capsule/TransactionCapsuleTest.java | 475 ++++++++++++++++++ .../tron/core/exception/TronErrorTest.java | 12 +- .../core/services/ProposalServiceTest.java | 17 + .../org/tron/core/services/http/UtilTest.java | 39 ++ framework/src/test/resources/config-test.conf | 7 +- protocol/src/main/protos/core/Tron.proto | 36 +- 48 files changed, 5264 insertions(+), 29 deletions(-) create mode 100644 crypto/src/main/java/org/tron/common/crypto/pqc/FNDSA.java create mode 100644 crypto/src/main/java/org/tron/common/crypto/pqc/PQSchemeRegistry.java create mode 100644 crypto/src/main/java/org/tron/common/crypto/pqc/PQSignature.java create mode 100644 framework/src/main/java/org/tron/core/config/args/ConfigKey.java create mode 100644 framework/src/test/java/org/tron/common/crypto/pqc/FNDSAKatTest.java create mode 100644 framework/src/test/java/org/tron/common/crypto/pqc/FNDSATest.java create mode 100644 framework/src/test/java/org/tron/common/crypto/pqc/PQSchemeRegistryTest.java create mode 100644 framework/src/test/java/org/tron/common/crypto/pqc/PQSignatureDefaultsTest.java create mode 100644 framework/src/test/java/org/tron/common/crypto/pqc/SignatureSchemeBenchmarkTest.java create mode 100644 framework/src/test/java/org/tron/common/crypto/pqc/program/PQClient.java create mode 100644 framework/src/test/java/org/tron/common/crypto/pqc/program/PQFullNode.java create mode 100644 framework/src/test/java/org/tron/common/crypto/pqc/program/PQWitnessNode.java create mode 100644 framework/src/test/java/org/tron/common/runtime/vm/BatchValidateSignPQTest.java create mode 100644 framework/src/test/java/org/tron/common/runtime/vm/FnDsaPrecompileTest.java create mode 100644 framework/src/test/java/org/tron/common/runtime/vm/ValidateMultiSignPQTest.java create mode 100644 framework/src/test/java/org/tron/common/utils/LocalWitnessesTest.java create mode 100644 framework/src/test/java/org/tron/core/capsule/BlockCapsulePQTest.java diff --git a/actuator/src/main/java/org/tron/core/actuator/AccountPermissionUpdateActuator.java b/actuator/src/main/java/org/tron/core/actuator/AccountPermissionUpdateActuator.java index f2eafb20a5e..2fd5f75f8dd 100644 --- a/actuator/src/main/java/org/tron/core/actuator/AccountPermissionUpdateActuator.java +++ b/actuator/src/main/java/org/tron/core/actuator/AccountPermissionUpdateActuator.java @@ -95,13 +95,14 @@ private boolean checkPermission(Permission permission) throws ContractValidateEx long weightSum = 0; List addressList = permission.getKeysList() .stream() - .map(x -> x.getAddress()) + .map(Key::getAddress) .distinct() .collect(toList()); if (addressList.size() != permission.getKeysList().size()) { throw new ContractValidateException( "address should be distinct in permission " + permission.getType()); } + for (Key key : permission.getKeysList()) { if (!DecodeUtil.addressValid(key.getAddress().toByteArray())) { throw new ContractValidateException("key is not a validate address"); @@ -237,4 +238,5 @@ public ByteString getOwnerAddress() throws InvalidProtocolBufferException { public long calcFee() { return chainBaseManager.getDynamicPropertiesStore().getUpdateAccountPermissionFee(); } + } diff --git a/actuator/src/main/java/org/tron/core/utils/ProposalUtil.java b/actuator/src/main/java/org/tron/core/utils/ProposalUtil.java index 74d332c5611..259a1a60bde 100644 --- a/actuator/src/main/java/org/tron/core/utils/ProposalUtil.java +++ b/actuator/src/main/java/org/tron/core/utils/ProposalUtil.java @@ -941,6 +941,17 @@ public static void validator(DynamicPropertiesStore dynamicPropertiesStore, } break; } + case ALLOW_FN_DSA_512: { + if (dynamicPropertiesStore.getAllowFnDsa512() == 1) { + throw new ContractValidateException( + "[ALLOW_FN_DSA_512] has been valid, no need to propose again"); + } + if (value != 1) { + throw new ContractValidateException( + "This value[ALLOW_FN_DSA_512] is only allowed to be 1"); + } + break; + } default: break; } @@ -1029,7 +1040,9 @@ public enum ProposalType { // current value, value range ALLOW_TVM_PRAGUE(95), // 0, 1 ALLOW_TVM_OSAKA(96), // 0, 1 ALLOW_HARDEN_RESOURCE_CALCULATION(97), // 0, 1 - ALLOW_HARDEN_EXCHANGE_CALCULATION(98); // 0, 1 + ALLOW_HARDEN_EXCHANGE_CALCULATION(98), // 0, 1 + ALLOW_FN_DSA_512(100); // 0, 1 + private long code; ProposalType(long code) { diff --git a/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java b/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java index 1ac96b9d59d..27308c4412b 100644 --- a/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java +++ b/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java @@ -53,6 +53,8 @@ import org.tron.common.crypto.Rsv; import org.tron.common.crypto.SignUtils; import org.tron.common.crypto.SignatureInterface; +import org.tron.common.crypto.pqc.FNDSA; +import org.tron.common.crypto.pqc.PQSchemeRegistry; import org.tron.common.crypto.zksnark.BN128; import org.tron.common.crypto.zksnark.BN128Fp; import org.tron.common.crypto.zksnark.BN128G1; @@ -83,6 +85,7 @@ import org.tron.core.vm.utils.MUtil; import org.tron.core.vm.utils.VoteRewardUtil; import org.tron.protos.Protocol; +import org.tron.protos.Protocol.PQScheme; import org.tron.protos.Protocol.Permission; @Slf4j(topic = "VM") @@ -117,6 +120,11 @@ public class PrecompiledContracts { private static final Blake2F blake2F = new Blake2F(); private static final P256Verify p256Verify = new P256Verify(); + private static final VerifyFnDsa verifyFnDsa = new VerifyFnDsa(); + private static final ValidateMultiSignPQ validateMultiSignPQ = + new ValidateMultiSignPQ(); + private static final BatchValidateSignPQ batchValidateSignPQ = new BatchValidateSignPQ(); + // FreezeV2 PrecompileContracts private static final GetChainParameter getChainParameter = new GetChainParameter(); private static final AvailableUnfreezeV2Size availableUnfreezeV2Size = new AvailableUnfreezeV2Size(); @@ -212,6 +220,24 @@ public class PrecompiledContracts { private static final DataWord p256VerifyAddr = new DataWord( "0000000000000000000000000000000000000000000000000000000000000100"); + // EIP-8052 0x16: FN-DSA / Falcon-512 verify (FIPS-206 draft). Input layout: + // [msg 32B | sig_len 2B (big-endian) | sig sig_len B (1..752) | pk 896B]. + // Variable-length signature is prefixed with a 2-byte length field. + private static final DataWord verifyFnDsaAddr = new DataWord( + "0000000000000000000000000000000000000000000000000000000000000016"); + + // 0x17: algorithm-agnostic Permission multi-sign — accepts both ECDSA and + // Falcon-512 signatures against the same Permission.keys[] in one call, + // matching transaction-side §2.3.5 mixed-weight semantics. + private static final DataWord validateMultiSignPQAddr = new DataWord( + "0000000000000000000000000000000000000000000000000000000000000017"); + + // 0x18: batch independent Falcon-512 verify — bitmap of (sig, pk, addr) + // matches; mixed-algorithm contracts call 0x0A and 0x18 separately and OR + // the bitmaps client-side. + private static final DataWord batchValidateSignPqAddr = new DataWord( + "0000000000000000000000000000000000000000000000000000000000000018"); + public static PrecompiledContract getOptimizedContractForConstant(PrecompiledContract contract) { try { Constructor constructor = contract.getClass().getDeclaredConstructor(); @@ -297,6 +323,18 @@ public static PrecompiledContract getContractForAddress(DataWord address) { return p256Verify; } + if (VMConfig.allowFnDsa512() && address.equals(verifyFnDsaAddr)) { + return verifyFnDsa; + } + + if (VMConfig.allowFnDsa512() && address.equals(validateMultiSignPQAddr)) { + return validateMultiSignPQ; + } + + if (VMConfig.allowFnDsa512() && address.equals(batchValidateSignPqAddr)) { + return batchValidateSignPQ; + } + if (VMConfig.allowTvmFreezeV2()) { if (address.equals(getChainParameterAddr)) { return getChainParameter; @@ -2380,4 +2418,364 @@ public Pair execute(byte[] data) { } } + /** + * Verifies a FN-DSA / Falcon-512 signature (FIPS-206 draft). EIP-8052 / TRON extension. + * + *

Input layout (variable-length, EIP-8052-inspired): + *

+   *   [msg 32B | sig_len 2B (big-endian, 1..752) | sig sig_len B | pk 896B]
+   * 
+ * Minimum input: 32 + 2 + 1 + 896 = 931 bytes. + * + *

Returns a 32-byte word: 1 on valid signature, 0 otherwise. + * Malformed input (wrong lengths, out-of-range sig_len) returns 0 without error. + */ + public static class VerifyFnDsa extends PrecompiledContract { + + private static final int MSG_LEN = 32; + private static final int SIG_LEN_FIELD = 2; + private static final int PK_LEN = FNDSA.PUBLIC_KEY_LENGTH; + private static final int MAX_SIG_LEN = FNDSA.SIGNATURE_LENGTH; + private static final int MIN_INPUT_LEN = MSG_LEN + SIG_LEN_FIELD + 1 + PK_LEN; + + @Override + public long getEnergyForData(byte[] data) { + return 2500; + } + + @Override + public Pair execute(byte[] data) { + if (data == null || data.length < MIN_INPUT_LEN) { + return Pair.of(true, DataWord.ZERO().getData()); + } + try { + byte[] msg = copyOfRange(data, 0, MSG_LEN); + int sigLen = ((data[MSG_LEN] & 0xFF) << 8) | (data[MSG_LEN + 1] & 0xFF); + if (sigLen < 1 || sigLen > MAX_SIG_LEN) { + return Pair.of(true, DataWord.ZERO().getData()); + } + int pkOffset = MSG_LEN + SIG_LEN_FIELD + sigLen; + if (data.length < pkOffset + PK_LEN) { + return Pair.of(true, DataWord.ZERO().getData()); + } + byte[] sig = copyOfRange(data, MSG_LEN + SIG_LEN_FIELD, pkOffset); + byte[] pk = copyOfRange(data, pkOffset, pkOffset + PK_LEN); + boolean ok = FNDSA.verify(pk, msg, sig); + return Pair.of(true, ok ? DataWord.ONE().getData() : DataWord.ZERO().getData()); + } catch (Throwable t) { + return Pair.of(true, DataWord.ZERO().getData()); + } + } + } + + + /** + * 0x17 ValidateMultiSign — algorithm-agnostic Permission multi-sign. + *

Mirrors 0x09 hash construction ({@code SHA-256(address ‖ permissionId(int4B) ‖ data)}) + * and threshold/dedup semantics, while accepting Falcon-512 entries alongside ECDSA + * against the same {@code Permission.keys[]}. The {@code data} field stays {@code bytes32} + * so the hash is bit-identical to 0x09. + * + *

ABI: + *

+   *   validateMultiSign(
+   *       address account,           // word[0]
+   *       uint256 permissionId,      // word[1]
+   *       bytes32 data,              // word[2]
+   *       bytes[] ecdsaSignatures,   // word[3] = offset; each entry 65 B
+   *       bytes[] pqSignatures,      // word[4] = offset; each entry 1..752 B
+   *       bytes[] pqPublicKeys       // word[5] = offset; each entry 896 B
+   *   ) returns (bool)
+   * 
+ * + *

{@code MAX_SIZE = 5} applies to the total signature count + * ({@code ecdsaCnt + pqCnt}). Energy is split: {@code ecdsaCnt × 1500 + pqCnt × 15000}. + */ + public static class ValidateMultiSignPQ extends PrecompiledContract { + + private static final int ECDSA_ENERGY_PER_SIGN = 1500; + private static final int PQ_ENERGY_PER_SIGN = 15000; + private static final int MAX_SIZE = 5; + private static final int PK_LEN = FNDSA.PUBLIC_KEY_LENGTH; + private static final int MAX_SIG_LEN = FNDSA.SIGNATURE_LENGTH; + + @Override + public long getEnergyForData(byte[] data) { + try { + DataWord[] words = DataWord.parseArray(data); + int ecdsaCnt = words[words[3].intValueSafe() / WORD_SIZE].intValueSafe(); + int pqCnt = words[words[4].intValueSafe() / WORD_SIZE].intValueSafe(); + return (long) ecdsaCnt * ECDSA_ENERGY_PER_SIGN + + (long) pqCnt * PQ_ENERGY_PER_SIGN; + } catch (Throwable t) { + return (long) MAX_SIZE * PQ_ENERGY_PER_SIGN; + } + } + + @Override + public Pair execute(byte[] rawData) { + try { + DataWord[] words = DataWord.parseArray(rawData); + byte[] address = words[0].toTronAddress(); + int permissionId = words[1].intValueSafe(); + byte[] data = words[2].getData(); + + byte[] combine = ByteUtil.merge(address, ByteArray.fromInt(permissionId), data); + byte[] hash = Sha256Hash.hash(CommonParameter + .getInstance().isECKeyCryptoEngine(), combine); + + int ecdsaArrayWord = words[3].intValueSafe() / WORD_SIZE; + int pqSigArrayWord = words[4].intValueSafe() / WORD_SIZE; + int pqPkArrayWord = words[5].intValueSafe() / WORD_SIZE; + + int ecdsaCnt = words[ecdsaArrayWord].intValueSafe(); + int pqSigCnt = words[pqSigArrayWord].intValueSafe(); + int pqPkCnt = words[pqPkArrayWord].intValueSafe(); + + if (pqSigCnt != pqPkCnt + || ecdsaCnt + pqSigCnt == 0 + || ecdsaCnt + pqSigCnt > MAX_SIZE) { + return Pair.of(true, DATA_FALSE); + } + + byte[][] ecdsaSigs = extractSigArray(words, ecdsaArrayWord, rawData); + byte[][] pqSigs = extractBytesArray(words, pqSigArrayWord, rawData); + byte[][] pqPks = extractBytesArray(words, pqPkArrayWord, rawData); + + AccountCapsule account = this.getDeposit().getAccount(address); + if (account == null) { + return Pair.of(true, DATA_FALSE); + } + Permission permission = account.getPermissionById(permissionId); + if (permission == null) { + return Pair.of(true, DATA_FALSE); + } + + long totalWeight = 0L; + List executedSignList = new ArrayList<>(); + + for (byte[] sign : ecdsaSigs) { + byte[] recoveredAddr = recoverAddrBySign(sign, hash); + byte[] dedupKey = merge(recoveredAddr, sign); + if (ByteArray.matrixContains(executedSignList, recoveredAddr)) { + if (ByteArray.matrixContains(executedSignList, dedupKey)) { + continue; + } + MUtil.checkCPUTime(); + } + long weight = TransactionCapsule.getWeight(permission, recoveredAddr); + if (weight == 0) { + return Pair.of(true, DATA_FALSE); + } + totalWeight += weight; + executedSignList.add(dedupKey); + executedSignList.add(recoveredAddr); + } + + for (int i = 0; i < pqSigs.length; i++) { + byte[] sig = pqSigs[i]; + byte[] pk = pqPks[i]; + if (pk == null || pk.length != PK_LEN + || sig == null || sig.length < 1 || sig.length > MAX_SIG_LEN) { + return Pair.of(true, DATA_FALSE); + } + byte[] derivedAddr; + try { + derivedAddr = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pk); + } catch (Throwable t) { + return Pair.of(true, DATA_FALSE); + } + // Falcon-512 signing is randomized: the same key can produce many distinct + // valid signatures for the same hash. Dedup must therefore key on the + // derived address alone, otherwise an attacker could replay one key into + // the threshold N times via N different signatures. + if (ByteArray.matrixContains(executedSignList, derivedAddr)) { + continue; + } + long weight = TransactionCapsule.getWeight(permission, derivedAddr); + if (weight == 0) { + return Pair.of(true, DATA_FALSE); + } + if (!FNDSA.verify(pk, hash, sig)) { + return Pair.of(true, DATA_FALSE); + } + totalWeight += weight; + executedSignList.add(derivedAddr); + } + + if (totalWeight >= permission.getThreshold()) { + return Pair.of(true, dataOne()); + } + } catch (Throwable t) { + if (t instanceof OutOfTimeException) { + throw t; + } + logger.info("ValidateMultiSign(0x17) error:{}", t.getMessage()); + } + return Pair.of(true, DATA_FALSE); + } + } + + /** + * 0x18 BatchValidateSignPQ — independent per-element Falcon-512 verify. + *

Returns a 256-bit bitmap (matching 0x0A) where bit {@code i} is set iff + * {@code derive(pk_i) == expectedAddr_i} AND {@code FNDSA.verify(pk_i, hash, sig_i)}. + * + *

ABI: + *

+   *   batchValidateSignPQ(
+   *       bytes32   hash,                  // word[0]
+   *       bytes[]   signatures,            // word[1] = offset; each 1..752 B
+   *       bytes[]   publicKeys,            // word[2] = offset; each 896 B
+   *       bytes32[] expectedAddresses      // word[3] = offset; 21-byte addr in low 21 bytes
+   *   ) returns (bytes32)
+   * 
+ * + *

Reuses the {@code BatchValidateSign.workers} pool when not in a constant + * call and enforces {@code getCPUTimeLeftInNanoSecond()} timeout. {@code MAX_SIZE = 16}. + * Energy is {@code cnt × 15000}. + */ + public static class BatchValidateSignPQ extends PrecompiledContract { + + private static final int ENERGY_PER_SIGN = 15000; + private static final int MAX_SIZE = 16; + private static final int PK_LEN = FNDSA.PUBLIC_KEY_LENGTH; + private static final int MAX_SIG_LEN = FNDSA.SIGNATURE_LENGTH; + + @Override + public long getEnergyForData(byte[] data) { + try { + DataWord[] words = DataWord.parseArray(data); + int cnt = words[words[1].intValueSafe() / WORD_SIZE].intValueSafe(); + return (long) cnt * ENERGY_PER_SIGN; + } catch (Throwable t) { + return (long) MAX_SIZE * ENERGY_PER_SIGN; + } + } + + @Override + public Pair execute(byte[] data) { + try { + return doExecute(data); + } catch (Throwable t) { + if (t instanceof OutOfTimeException) { + throw (OutOfTimeException) t; + } + if (t instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + return Pair.of(true, new byte[WORD_SIZE]); + } + } + + private Pair doExecute(byte[] data) + throws InterruptedException, ExecutionException { + DataWord[] words = DataWord.parseArray(data); + byte[] hash = words[0].getData(); + + int sigArrayWord = words[1].intValueSafe() / WORD_SIZE; + int pkArrayWord = words[2].intValueSafe() / WORD_SIZE; + int addrArrayWord = words[3].intValueSafe() / WORD_SIZE; + + int sigArraySize = words[sigArrayWord].intValueSafe(); + int pkArraySize = words[pkArrayWord].intValueSafe(); + int addrArraySize = words[addrArrayWord].intValueSafe(); + + if (sigArraySize > MAX_SIZE || pkArraySize > MAX_SIZE + || addrArraySize > MAX_SIZE + || sigArraySize != pkArraySize || sigArraySize != addrArraySize) { + return Pair.of(true, DATA_FALSE); + } + + byte[][] signatures = extractBytesArray(words, sigArrayWord, data); + byte[][] publicKeys = extractBytesArray(words, pkArrayWord, data); + byte[][] addresses = extractBytes32Array(words, addrArrayWord); + + int cnt = signatures.length; + if (cnt == 0) { + return Pair.of(true, DATA_FALSE); + } + + byte[] res = new byte[WORD_SIZE]; + if (isConstantCall()) { + for (int i = 0; i < cnt; i++) { + if (verifyOne(signatures[i], publicKeys[i], hash, addresses[i])) { + res[i] = 1; + } + } + } else { + CountDownLatch countDownLatch = new CountDownLatch(cnt); + List> futures = new ArrayList<>(cnt); + + for (int i = 0; i < cnt; i++) { + Future future = BatchValidateSign.workers.submit( + new PqVerifyTask(countDownLatch, hash, signatures[i], + publicKeys[i], addresses[i], i)); + futures.add(future); + } + + boolean withNoTimeout = countDownLatch + .await(getCPUTimeLeftInNanoSecond(), TimeUnit.NANOSECONDS); + + if (!withNoTimeout) { + logger.info("BatchValidateSignPQ timeout"); + throw Program.Exception.notEnoughTime("call BatchValidateSignPQ precompile method"); + } + + for (Future future : futures) { + PqVerifyResult r = future.get(); + if (r.success) { + res[r.nonce] = 1; + } + } + } + return Pair.of(true, res); + } + + private static boolean verifyOne(byte[] sig, byte[] pk, byte[] hash, + byte[] expectedAddr) { + if (pk == null || pk.length != PK_LEN + || sig == null || sig.length < 1 || sig.length > MAX_SIG_LEN) { + return false; + } + try { + byte[] derived = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pk); + if (!DataWord.equalAddressByteArray(derived, expectedAddr)) { + return false; + } + return FNDSA.verify(pk, hash, sig); + } catch (Throwable t) { + return false; + } + } + + @AllArgsConstructor + private static class PqVerifyTask implements Callable { + + private CountDownLatch countDownLatch; + private byte[] hash; + private byte[] signature; + private byte[] publicKey; + private byte[] expectedAddr; + private int nonce; + + @Override + public PqVerifyResult call() { + try { + return new PqVerifyResult( + verifyOne(signature, publicKey, hash, expectedAddr), nonce); + } finally { + countDownLatch.countDown(); + } + } + } + + @AllArgsConstructor + private static class PqVerifyResult { + + private boolean success; + private int nonce; + } + } + } diff --git a/actuator/src/main/java/org/tron/core/vm/config/ConfigLoader.java b/actuator/src/main/java/org/tron/core/vm/config/ConfigLoader.java index 881eb861bea..6a992ae5f0d 100644 --- a/actuator/src/main/java/org/tron/core/vm/config/ConfigLoader.java +++ b/actuator/src/main/java/org/tron/core/vm/config/ConfigLoader.java @@ -47,6 +47,7 @@ public static void load(StoreFactory storeFactory) { VMConfig.initAllowTvmSelfdestructRestriction(ds.getAllowTvmSelfdestructRestriction()); VMConfig.initAllowTvmOsaka(ds.getAllowTvmOsaka()); VMConfig.initAllowHardenResourceCalculation(ds.getAllowHardenResourceCalculation()); + VMConfig.initAllowFnDsa512(ds.getAllowFnDsa512()); } } } diff --git a/chainbase/src/main/java/org/tron/common/utils/LocalWitnesses.java b/chainbase/src/main/java/org/tron/common/utils/LocalWitnesses.java index 7179045ea7e..dc8f4f0c4be 100644 --- a/chainbase/src/main/java/org/tron/common/utils/LocalWitnesses.java +++ b/chainbase/src/main/java/org/tron/common/utils/LocalWitnesses.java @@ -18,14 +18,17 @@ import com.google.common.collect.Lists; import java.util.List; import lombok.Getter; +import lombok.Setter; import lombok.extern.slf4j.Slf4j; import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.StringUtils; import org.tron.common.crypto.ECKey; import org.tron.common.crypto.SignInterface; import org.tron.common.crypto.SignUtils; +import org.tron.common.crypto.pqc.PQSchemeRegistry; import org.tron.core.config.Parameter.ChainConstant; import org.tron.core.exception.TronError; +import org.tron.protos.Protocol.PQScheme; @Slf4j(topic = "app") public class LocalWitnesses { @@ -33,6 +36,40 @@ public class LocalWitnesses { @Getter private List privateKeys = Lists.newArrayList(); + /** + * Pre-derived PQ private keys in hex format, one per witness. The expected + * byte length depends on {@link #pqScheme}: 1280 bytes (2560 hex chars) for + * FN-DSA-512. Index-aligned with {@link #pqPublicKeys}. + * + *

Configured directly (rather than derived from a seed on the node) so + * the runtime path is not exposed to potential cross-platform floating-point + * non-determinism in BC's Falcon keygen — operators generate the keypair + * off-line and ship both halves to the node. + */ + @Getter + private List pqPrivateKeys = Lists.newArrayList(); + + /** + * PQ public keys in hex format, one per witness. The expected byte length + * depends on {@link #pqScheme}: 896 bytes (1792 hex chars) for FN-DSA-512. + * Index-aligned with {@link #pqPrivateKeys}. + */ + @Getter + private List pqPublicKeys = Lists.newArrayList(); + + /** PQ signature scheme used by the configured {@link #pqPrivateKeys}. */ + @Getter + private PQScheme pqScheme = PQScheme.FN_DSA_512; + + public void setPqScheme(PQScheme pqScheme) { + if (pqScheme == null || !PQSchemeRegistry.contains(pqScheme)) { + throw new TronError("unsupported PQ signature scheme: " + pqScheme, + TronError.ErrCode.WITNESS_INIT); + } + this.pqScheme = pqScheme; + } + + @Setter @Getter private byte[] witnessAccountAddress; @@ -95,6 +132,55 @@ public void addPrivateKeys(String privateKey) { this.privateKeys.add(privateKey); } + /** + * Pre-derived PQ keypairs (priv + pub) used as signing keys under + * {@link #pqScheme}. The two lists must be the same length and index-aligned; + * each entry must be a hex string whose byte length matches the scheme's + * required private/public key size. Callers must therefore set the scheme + * via {@link #setPqScheme(PQScheme)} before calling this method when + * targeting a non-default scheme. + */ + public void setPqKeypairs(final List pqPrivateKeys, + final List pqPublicKeys) { + if (CollectionUtils.isEmpty(pqPrivateKeys) + && CollectionUtils.isEmpty(pqPublicKeys)) { + return; + } + int privCount = pqPrivateKeys == null ? 0 : pqPrivateKeys.size(); + int pubCount = pqPublicKeys == null ? 0 : pqPublicKeys.size(); + if (privCount != pubCount) { + throw new TronError(String.format( + "PQ keypair list size mismatch: priv=%d, pub=%d", privCount, pubCount), + TronError.ErrCode.WITNESS_INIT); + } + int expectedPrivLen = PQSchemeRegistry.getPrivateKeyLength(pqScheme); + int expectedPubLen = PQSchemeRegistry.getPublicKeyLength(pqScheme); + for (int i = 0; i < privCount; i++) { + validatePqKey(pqPrivateKeys.get(i), expectedPrivLen, "PQ private key"); + validatePqKey(pqPublicKeys.get(i), expectedPubLen, "PQ public key"); + } + this.pqPrivateKeys = pqPrivateKeys; + this.pqPublicKeys = pqPublicKeys; + } + + private static void validatePqKey(String key, int expectedLen, String label) { + String hex = key; + // Match downstream ByteArray.fromHexString, which only strips lowercase "0x". + if (StringUtils.startsWith(hex, "0x")) { + hex = hex.substring(2); + } + int expectedHexLen = expectedLen * 2; + if (StringUtils.isBlank(hex) || hex.length() != expectedHexLen) { + throw new TronError(String.format("%s must be %d hex chars, actual: %d", + label, expectedHexLen, StringUtils.isBlank(hex) ? 0 : hex.length()), + TronError.ErrCode.WITNESS_INIT); + } + if (!StringUtil.isHexadecimal(hex)) { + throw new TronError(label + " must be hex string", + TronError.ErrCode.WITNESS_INIT); + } + } + //get the first one recently public String getPrivateKey() { if (CollectionUtils.isEmpty(privateKeys)) { diff --git a/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java b/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java index 34b7853d4d1..dd0e2126bac 100755 --- a/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java +++ b/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java @@ -33,6 +33,7 @@ import org.tron.common.bloom.Bloom; import org.tron.common.crypto.SignInterface; import org.tron.common.crypto.SignUtils; +import org.tron.common.crypto.pqc.PQSchemeRegistry; import org.tron.common.parameter.CommonParameter; import org.tron.common.utils.ByteArray; import org.tron.common.utils.Sha256Hash; @@ -46,6 +47,10 @@ import org.tron.core.store.DynamicPropertiesStore; import org.tron.protos.Protocol.Block; import org.tron.protos.Protocol.BlockHeader; +import org.tron.protos.Protocol.Key; +import org.tron.protos.Protocol.PQScheme; +import org.tron.protos.Protocol.PQAuthSig; +import org.tron.protos.Protocol.Permission; import org.tron.protos.Protocol.Transaction; @Slf4j(topic = "capsule") @@ -177,6 +182,16 @@ public void sign(byte[] privateKey) { } + public void setPqAuthSig(PQAuthSig pqAuthSig) { + BlockHeader blockHeader = this.block.getBlockHeader().toBuilder() + .setPqAuthSig(pqAuthSig).build(); + this.block = this.block.toBuilder().setBlockHeader(blockHeader).build(); + } + + public byte[] getRawHashBytes() { + return getRawHash().getBytes(); + } + private Sha256Hash getRawHash() { return Sha256Hash.of(CommonParameter.getInstance().isECKeyCryptoEngine(), this.block.getBlockHeader().getRawData().toByteArray()); @@ -184,27 +199,104 @@ private Sha256Hash getRawHash() { public boolean validateSignature(DynamicPropertiesStore dynamicPropertiesStore, AccountStore accountStore) throws ValidateSignatureException { + BlockHeader header = block.getBlockHeader(); + boolean hasLegacy = !header.getWitnessSignature().isEmpty(); + PQAuthSig pqAuthSig = header.getPqAuthSig(); + boolean hasPq = pqAuthSig != null + && pqAuthSig.getSignature() != null + && !pqAuthSig.getSignature().isEmpty(); + + if (hasLegacy && hasPq) { + throw new ValidateSignatureException( + "witness_signature and pq_auth_sig are mutually exclusive"); + } + if (!hasLegacy && !hasPq) { + throw new ValidateSignatureException("missing witness signature"); + } + + byte[] witnessAccountAddress = header.getRawData().getWitnessAddress().toByteArray(); + if (hasPq) { + return validatePQSignature(dynamicPropertiesStore, accountStore, + witnessAccountAddress, pqAuthSig); + } + return validateLegacySignature(dynamicPropertiesStore, accountStore, witnessAccountAddress); + } + + private boolean validateLegacySignature(DynamicPropertiesStore dynamicPropertiesStore, + AccountStore accountStore, byte[] witnessAccountAddress) + throws ValidateSignatureException { try { byte[] sigAddress = SignUtils.signatureToAddress(getRawHash().getBytes(), TransactionCapsule.getBase64FromByteString( block.getBlockHeader().getWitnessSignature()), CommonParameter.getInstance().isECKeyCryptoEngine()); - byte[] witnessAccountAddress = block.getBlockHeader().getRawData().getWitnessAddress() - .toByteArray(); - if (dynamicPropertiesStore.getAllowMultiSign() != 1) { return Arrays.equals(sigAddress, witnessAccountAddress); - } else { - byte[] witnessPermissionAddress = accountStore.get(witnessAccountAddress) - .getWitnessPermissionAddress(); - return Arrays.equals(sigAddress, witnessPermissionAddress); } - + byte[] witnessPermissionAddress = accountStore.get(witnessAccountAddress) + .getWitnessPermissionAddress(); + return Arrays.equals(sigAddress, witnessPermissionAddress); } catch (SignatureException e) { throw new ValidateSignatureException(e.getMessage()); } } + /** + * Verify a PQ-signed block header. V2 binds the signing key by deriving its + * 21-byte address from the in-band {@code public_key} and matching against + * the witness account's Witness Permission keys[]. + */ + private boolean validatePQSignature(DynamicPropertiesStore dynamicPropertiesStore, + AccountStore accountStore, byte[] witnessAccountAddress, PQAuthSig pqAuthSig) + throws ValidateSignatureException { + PQScheme scheme = pqAuthSig.getScheme(); + if (!PQSchemeRegistry.contains(scheme)) { + throw new ValidateSignatureException( + "pq_auth_sig scheme " + scheme + " is not registered"); + } + if (!dynamicPropertiesStore.isPqSchemeAllowed(scheme)) { + throw new ValidateSignatureException( + "pq_auth_sig scheme " + scheme + " is not activated"); + } + + AccountCapsule accountCapsule = accountStore.get(witnessAccountAddress); + Permission witnessPermission = null; + if (accountCapsule != null && accountCapsule.getInstance().hasWitnessPermission()) { + witnessPermission = accountCapsule.getInstance().getWitnessPermission(); + } + if (witnessPermission == null || witnessPermission.getKeysCount() == 0) { + throw new ValidateSignatureException( + "pq_auth_sig present but witness permission is not configured"); + } + + byte[] publicKey = pqAuthSig.getPublicKey().toByteArray(); + if (publicKey.length != PQSchemeRegistry.getPublicKeyLength(scheme)) { + throw new ValidateSignatureException( + "pq_auth_sig public key length mismatch for scheme " + scheme); + } + byte[] signature = pqAuthSig.getSignature().toByteArray(); + if (!PQSchemeRegistry.isValidSignatureLength(scheme, signature.length)) { + throw new ValidateSignatureException( + "pq_auth_sig signature length mismatch for scheme " + scheme); + } + + byte[] derivedAddr = PQSchemeRegistry.computeAddress(scheme, publicKey); + Key matched = null; + for (Key k : witnessPermission.getKeysList()) { + if (Arrays.equals(k.getAddress().toByteArray(), derivedAddr)) { + matched = k; + break; + } + } + if (matched == null) { + throw new ValidateSignatureException( + "pq_auth_sig public key does not match any witness permission key"); + } + + byte[] digest = getRawHash().getBytes(); + return PQSchemeRegistry.verify(scheme, publicKey, digest, signature); + } + public BlockId getBlockId() { if (blockId.equals(Sha256Hash.ZERO_HASH)) { blockId = @@ -325,7 +417,12 @@ public long getTimeStamp() { } public boolean hasWitnessSignature() { - return !getInstance().getBlockHeader().getWitnessSignature().isEmpty(); + BlockHeader header = getInstance().getBlockHeader(); + if (!header.getWitnessSignature().isEmpty()) { + return true; + } + PQAuthSig auth = header.getPqAuthSig(); + return auth != null && !auth.getSignature().isEmpty(); } @Override diff --git a/chainbase/src/main/java/org/tron/core/capsule/TransactionCapsule.java b/chainbase/src/main/java/org/tron/core/capsule/TransactionCapsule.java index bb4b70cde1b..24fe9fd9964 100755 --- a/chainbase/src/main/java/org/tron/core/capsule/TransactionCapsule.java +++ b/chainbase/src/main/java/org/tron/core/capsule/TransactionCapsule.java @@ -45,7 +45,9 @@ import org.tron.common.crypto.Rsv; import org.tron.common.crypto.SignInterface; import org.tron.common.crypto.SignUtils; +import org.tron.common.crypto.pqc.PQSchemeRegistry; import org.tron.common.es.ExecutorServiceManager; +import org.tron.common.math.StrictMathWrapper; import org.tron.common.overlay.message.Message; import org.tron.common.parameter.CommonParameter; import org.tron.common.utils.ByteArray; @@ -66,6 +68,8 @@ import org.tron.core.store.AccountStore; import org.tron.core.store.DynamicPropertiesStore; import org.tron.protos.Protocol.Key; +import org.tron.protos.Protocol.PQScheme; +import org.tron.protos.Protocol.PQAuthSig; import org.tron.protos.Protocol.Permission; import org.tron.protos.Protocol.Permission.PermissionType; import org.tron.protos.Protocol.Transaction; @@ -487,11 +491,25 @@ public static boolean validateSignature(Transaction transaction, throw new PermissionException("permission isn't exit"); } checkPermission(permissionId, permission, contract); - long weight = checkWeight(permission, transaction.getSignatureList(), hash, null); - if (weight >= permission.getThreshold()) { - return true; + + // Hybrid weight: ECDSA signatures and PQ witnesses share one threshold + // check. The two domains derive distinct addresses (Keccak vs SHA-256 + // tagged with 0x41), so a key entry contributes to at most one path. + java.util.Set signedAddresses = new java.util.HashSet<>(); + List approveList = new ArrayList<>(); + long weight = checkWeight(permission, transaction.getSignatureList(), hash, approveList); + signedAddresses.addAll(approveList); + + if (transaction.getPqAuthSigCount() > 0) { + try { + weight = StrictMathWrapper.addExact(weight, + validatePQSignature(transaction, permission, signedAddresses, + dynamicPropertiesStore)); + } catch (ArithmeticException e) { + throw new PermissionException("weight overflow"); + } } - return false; + return weight >= permission.getThreshold(); } public void resetResult() { @@ -640,12 +658,20 @@ public boolean validatePubSignature(AccountStore accountStore, DynamicPropertiesStore dynamicPropertiesStore) throws ValidateSignatureException { if (!isVerified) { - if (this.transaction.getSignatureCount() <= 0 - || this.transaction.getRawData().getContractCount() <= 0) { - throw new ValidateSignatureException("miss sig or contract"); + int legacyCount = this.transaction.getSignatureCount(); + int pqCount = this.transaction.getPqAuthSigCount(); + + if (pqCount > 0 && !dynamicPropertiesStore.isAnyPqSchemeAllowed()) { + throw new ValidateSignatureException( + "pq_auth_sig not allowed: no post-quantum scheme is activated"); + } + if (legacyCount == 0 && pqCount == 0) { + throw new ValidateSignatureException("miss sig"); } - if (this.transaction.getSignatureCount() > dynamicPropertiesStore - .getTotalSignNum()) { + if (this.transaction.getRawData().getContractCount() <= 0) { + throw new ValidateSignatureException("miss contract"); + } + if (legacyCount + pqCount > dynamicPropertiesStore.getTotalSignNum()) { throw new ValidateSignatureException("too many signatures"); } @@ -681,6 +707,78 @@ void logSlowSigVerify(long startNs) { } } + /** + * Verify {@code transaction.pq_auth_sig[]} entries against {@code permission} + * and return the combined weight contributed by valid PQ witnesses. + * + *

V2 four-step verification per witness: + *

    + *
  1. Resolve the permission context (caller passes {@code permission}).
  2. + *
  3. Derive the 21-byte address from {@code witness.public_key} via the + * scheme's fingerprint hash.
  4. + *
  5. Match against {@code permission.keys[].address}; reject duplicates + * and addresses already counted by the legacy ECDSA path.
  6. + *
  7. Verify the signature over {@code txid} directly; the + * {@code permission_id} is already bound by {@code txid} since it is + * part of {@code raw_data}.
  8. + *
+ */ + static long validatePQSignature(Transaction transaction, Permission permission, + java.util.Set signedAddresses, + DynamicPropertiesStore dynamicPropertiesStore) + throws PermissionException { + byte[] digest = computeRawHash(transaction).getBytes(); + + long weight = 0L; + for (PQAuthSig witness : transaction.getPqAuthSigList()) { + PQScheme scheme = witness.getScheme(); + if (!PQSchemeRegistry.contains(scheme)) { + throw new PermissionException("unsupported pq scheme: " + scheme); + } + if (!dynamicPropertiesStore.isPqSchemeAllowed(scheme)) { + throw new PermissionException(scheme + " is not activated"); + } + byte[] pk = witness.getPublicKey().toByteArray(); + byte[] sig = witness.getSignature().toByteArray(); + if (pk.length != PQSchemeRegistry.getPublicKeyLength(scheme) + || !PQSchemeRegistry.isValidSignatureLength(scheme, sig.length)) { + throw new PermissionException("public key or signature length mismatch"); + } + byte[] derivedAddr = PQSchemeRegistry.computeAddress(scheme, pk); + ByteString addrBs = ByteString.copyFrom(derivedAddr); + if (!signedAddresses.add(addrBs)) { + throw new PermissionException( + encode58Check(derivedAddr) + " has signed twice!"); + } + Key matched = null; + for (Key k : permission.getKeysList()) { + if (k.getAddress().equals(addrBs)) { + matched = k; + break; + } + } + if (matched == null) { + throw new PermissionException( + "pq_auth_sig public key derives to " + encode58Check(derivedAddr) + + " but it is not contained of permission."); + } + if (!PQSchemeRegistry.verify(scheme, pk, digest, sig)) { + throw new PermissionException("pq sig invalid"); + } + try { + weight = StrictMathWrapper.addExact(weight, matched.getWeight()); + } catch (ArithmeticException e) { + throw new PermissionException("weight overflow"); + } + } + return weight; + } + + private static Sha256Hash computeRawHash(Transaction transaction) { + return Sha256Hash.of(CommonParameter.getInstance().isECKeyCryptoEngine(), + transaction.getRawData().toByteArray()); + } + /** * validate signature */ diff --git a/chainbase/src/main/java/org/tron/core/db/BandwidthProcessor.java b/chainbase/src/main/java/org/tron/core/db/BandwidthProcessor.java index ece16b25819..1d2987d4d44 100644 --- a/chainbase/src/main/java/org/tron/core/db/BandwidthProcessor.java +++ b/chainbase/src/main/java/org/tron/core/db/BandwidthProcessor.java @@ -140,8 +140,17 @@ public void consume(TransactionCapsule trx, TransactionTrace trace) if (optimizeTxs) { long maxCreateAccountTxSize = dynamicPropertiesStore.getMaxCreateAccountTxSize(); int signatureCount = trx.getInstance().getSignatureCount(); + long sigOverhead = signatureCount * PER_SIGN_LENGTH; + if (trx.getInstance().getPqAuthSigCount() > 0) { + long pqAuthSigBytes = 0L; + for (org.tron.protos.Protocol.PQAuthSig aw + : trx.getInstance().getPqAuthSigList()) { + pqAuthSigBytes += aw.getSerializedSize(); + } + sigOverhead += pqAuthSigBytes; + } long createAccountBytesSize = trx.getInstance().toBuilder().clearRet() - .build().getSerializedSize() - (signatureCount * PER_SIGN_LENGTH); + .build().getSerializedSize() - sigOverhead; if (createAccountBytesSize > maxCreateAccountTxSize) { throw new TooBigTransactionException(String.format( "Too big new account transaction, TxId %s, the size is %d bytes, maxTxSize %d", diff --git a/chainbase/src/main/java/org/tron/core/store/DynamicPropertiesStore.java b/chainbase/src/main/java/org/tron/core/store/DynamicPropertiesStore.java index 0f74f20d379..ea88128c54a 100644 --- a/chainbase/src/main/java/org/tron/core/store/DynamicPropertiesStore.java +++ b/chainbase/src/main/java/org/tron/core/store/DynamicPropertiesStore.java @@ -21,6 +21,7 @@ import org.tron.common.utils.Sha256Hash; import org.tron.core.capsule.BytesCapsule; import org.tron.core.config.Parameter.ChainConstant; +import org.tron.protos.Protocol.PQScheme; import org.tron.core.db.TronStoreWithRevoking; import org.tron.core.exception.BadItemException; import org.tron.core.exception.ItemNotFoundException; @@ -258,6 +259,8 @@ public class DynamicPropertiesStore extends TronStoreWithRevoking private static final byte[] TURKISH_KEY_MIGRATION_DONE = "TURKISH_KEY_MIGRATION_DONE".getBytes(); + private static final byte[] ALLOW_FN_DSA_512 = "ALLOW_FN_DSA_512".getBytes(); + @Autowired private DynamicPropertiesStore(@Value("properties") String dbName) { super(dbName); @@ -3083,6 +3086,43 @@ public long getTurkishKeyMigrationDone() { .orElse(0L); } + public long getAllowFnDsa512() { + return Optional.ofNullable(getUnchecked(ALLOW_FN_DSA_512)) + .map(BytesCapsule::getData) + .map(ByteArray::toLong) + .orElse(CommonParameter.getInstance().getAllowFnDsa512()); + } + + public void saveAllowFnDsa512(long value) { + this.put(ALLOW_FN_DSA_512, new BytesCapsule(ByteArray.fromLong(value))); + } + + public boolean allowFnDsa512() { + return getAllowFnDsa512() == 1L; + } + + /** Returns true iff at least one post-quantum signature scheme is currently activated. */ + public boolean isAnyPqSchemeAllowed() { + return allowFnDsa512(); + } + + /** + * Per-scheme governance check. V2 launches with FN-DSA-512 only. Future schemes will + * each get their own flag. + */ + public boolean isPqSchemeAllowed(PQScheme scheme) { + if (scheme == null) { + return false; + } + switch (scheme) { + case UNKNOWN_PQ_SCHEME: // proto3 default → Falcon-512 (see PQSchemeRegistry#resolve) + case FN_DSA_512: + return allowFnDsa512(); + default: + return false; + } + } + private static class DynamicResourceProperties { private static final byte[] ONE_DAY_NET_LIMIT = "ONE_DAY_NET_LIMIT".getBytes(); diff --git a/common/src/main/java/org/tron/common/parameter/CommonParameter.java b/common/src/main/java/org/tron/common/parameter/CommonParameter.java index f2831b4168f..d18f09a9ea7 100644 --- a/common/src/main/java/org/tron/common/parameter/CommonParameter.java +++ b/common/src/main/java/org/tron/common/parameter/CommonParameter.java @@ -671,6 +671,11 @@ public class CommonParameter { @Setter public long allowTvmBlob; + @Getter + @Setter + public long allowFnDsa512; + + private static double calcMaxTimeRatio() { return 5.0; } diff --git a/common/src/main/java/org/tron/core/Constant.java b/common/src/main/java/org/tron/core/Constant.java index 1437d319346..370a442fc06 100644 --- a/common/src/main/java/org/tron/core/Constant.java +++ b/common/src/main/java/org/tron/core/Constant.java @@ -60,6 +60,14 @@ public class Constant { // Crypto engine public static final String ECKey_ENGINE = "ECKey"; + // Post-quantum (FIPS 206 draft) FN-DSA / Falcon-512 signature constants. + // Falcon signatures are variable-length; SIGNATURE_MAX_LENGTH is the protocol-level + // upper bound, not an exact length. + public static final int FN_DSA_PUBLIC_KEY_LENGTH = 896; + public static final int FN_DSA_SIGNATURE_MAX_LENGTH = 752; + public static final String PQ_TX_AUTH_DOMAIN = "TRON_TX_AUTH_V1"; + public static final String PQ_BLOCK_AUTH_DOMAIN = "TRON_BLOCK_AUTH_V1"; + // Network public static final String LOCAL_HOST = "127.0.0.1"; diff --git a/common/src/main/java/org/tron/core/vm/config/VMConfig.java b/common/src/main/java/org/tron/core/vm/config/VMConfig.java index 94c1e50284e..2df06e2dd22 100644 --- a/common/src/main/java/org/tron/core/vm/config/VMConfig.java +++ b/common/src/main/java/org/tron/core/vm/config/VMConfig.java @@ -65,6 +65,8 @@ public class VMConfig { private static boolean ALLOW_HARDEN_RESOURCE_CALCULATION = false; + private static boolean ALLOW_FN_DSA_512 = false; + private VMConfig() { } @@ -184,6 +186,10 @@ public static void initAllowHardenResourceCalculation(long allow) { ALLOW_HARDEN_RESOURCE_CALCULATION = allow == 1; } + public static void initAllowFnDsa512(long allow) { + ALLOW_FN_DSA_512 = allow == 1; + } + public static boolean getEnergyLimitHardFork() { return CommonParameter.ENERGY_LIMIT_HARD_FORK; } @@ -291,4 +297,8 @@ public static boolean allowTvmOsaka() { public static boolean allowHardenResourceCalculation() { return ALLOW_HARDEN_RESOURCE_CALCULATION; } + + public static boolean allowFnDsa512() { + return ALLOW_FN_DSA_512; + } } diff --git a/consensus/src/main/java/org/tron/consensus/base/Param.java b/consensus/src/main/java/org/tron/consensus/base/Param.java index f7b7de3d084..a2692cf4c55 100644 --- a/consensus/src/main/java/org/tron/consensus/base/Param.java +++ b/consensus/src/main/java/org/tron/consensus/base/Param.java @@ -5,6 +5,7 @@ import lombok.Getter; import lombok.Setter; import org.tron.common.args.GenesisBlock; +import org.tron.protos.Protocol.PQScheme; public class Param { @@ -67,11 +68,35 @@ public class Miner { @Setter private ByteString witnessAddress; + private byte[] pqPrivateKey; + + private byte[] pqPublicKey; + + @Getter + @Setter + private PQScheme pqScheme; + public Miner(byte[] privateKey, ByteString privateKeyAddress, ByteString witnessAddress) { this.privateKey = privateKey; this.privateKeyAddress = privateKeyAddress; this.witnessAddress = witnessAddress; } + + public byte[] getPQPrivateKey() { + return pqPrivateKey == null ? null : pqPrivateKey.clone(); + } + + public void setPQPrivateKey(byte[] pqPrivateKey) { + this.pqPrivateKey = pqPrivateKey == null ? null : pqPrivateKey.clone(); + } + + public byte[] getPQPublicKey() { + return pqPublicKey == null ? null : pqPublicKey.clone(); + } + + public void setPQPublicKey(byte[] pqPublicKey) { + this.pqPublicKey = pqPublicKey == null ? null : pqPublicKey.clone(); + } } public Miner getMiner() { diff --git a/crypto/src/main/java/org/tron/common/crypto/pqc/FNDSA.java b/crypto/src/main/java/org/tron/common/crypto/pqc/FNDSA.java new file mode 100644 index 00000000000..3a9e22316c5 --- /dev/null +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/FNDSA.java @@ -0,0 +1,259 @@ +package org.tron.common.crypto.pqc; + +import java.security.SecureRandom; +import org.bouncycastle.crypto.AsymmetricCipherKeyPair; +import org.bouncycastle.crypto.params.ParametersWithRandom; +import org.bouncycastle.crypto.prng.FixedSecureRandom; +import org.bouncycastle.pqc.crypto.falcon.FalconKeyGenerationParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconKeyPairGenerator; +import org.bouncycastle.pqc.crypto.falcon.FalconParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconPrivateKeyParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconPublicKeyParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconSigner; +import org.tron.protos.Protocol.PQScheme; + +/** + * FIPS 206 (draft) FN-DSA / Falcon-512 keypair-bound signer/verifier. Instance + * methods sign/verify with the bound keypair, static {@link #sign(byte[], byte[])} + * / {@link #verify} provide stateless entry points used by + * {@link PQSchemeRegistry}. + * + *

Falcon signatures are variable-length: {@link #SIGNATURE_LENGTH} + * is the protocol-level upper bound, not an exact length. The + * {@link PQSignature#validateSignature} default treats this as + * {@code <= SIGNATURE_LENGTH}. BouncyCastle 1.79's {@code FalconNIST.CRYPTO_BYTES} + * for Falcon-512 is 690 bytes, well below the 752-byte protocol cap. + */ +public final class FNDSA implements PQSignature { + + /** + * Falcon-512 encoded private key from BC: f || g || F, where f and g are each + * {@link #F_G_ENCODED_LENGTH} bytes (6 bits per coefficient × N=512 / 8) and F is + * {@link #BIG_F_ENCODED_LENGTH} bytes (8 bits per coefficient × N=512 / 8). + */ + public static final int F_G_ENCODED_LENGTH = 384; + public static final int BIG_F_ENCODED_LENGTH = 512; + public static final int PRIVATE_KEY_LENGTH = + F_G_ENCODED_LENGTH + F_G_ENCODED_LENGTH + BIG_F_ENCODED_LENGTH; + /** + * Falcon-512 public key from BC: 14 * N / 8 = 896 bytes (the modq-encoded h polynomial). + * The 1-byte serialization header is stripped from {@code getH()}. + */ + public static final int PUBLIC_KEY_LENGTH = 896; + /** + * Extended private key encoding {@code f ‖ g ‖ F ‖ h}: the standard BC private key + * (1280 B) with the 896-byte public key {@code h} appended. Lets the holder recover + * the address without re-running keygen, since BC currently has no public API for + * deriving {@code h} from {@code (f, g)} alone (see bcgit/bc-java#2297). + */ + public static final int PRIVATE_KEY_WITH_PUBLIC_KEY_LENGTH = + PRIVATE_KEY_LENGTH + PUBLIC_KEY_LENGTH; + /** Protocol-level upper bound on Falcon-512 signature length (variable). */ + public static final int SIGNATURE_LENGTH = 752; + /** Falcon keygen seeds an internal SHAKE256 from 48 bytes of randomness. */ + public static final int SEED_LENGTH = 48; + + private static final FalconParameters PARAMS = FalconParameters.falcon_512; + + private final byte[] privateKey; + private final byte[] publicKey; + + public FNDSA() { + AsymmetricCipherKeyPair kp = generateKeyPair(new SecureRandom()); + this.privateKey = ((FalconPrivateKeyParameters) kp.getPrivate()).getEncoded(); + this.publicKey = ((FalconPublicKeyParameters) kp.getPublic()).getH(); + } + + public FNDSA(byte[] seed) { + if (seed == null || seed.length != SEED_LENGTH) { + throw new IllegalArgumentException("FN-DSA seed length must be " + SEED_LENGTH); + } + AsymmetricCipherKeyPair kp = generateKeyPair(new FixedSecureRandom(seed)); + this.privateKey = ((FalconPrivateKeyParameters) kp.getPrivate()).getEncoded(); + this.publicKey = ((FalconPublicKeyParameters) kp.getPublic()).getH(); + } + + public FNDSA(byte[] privateKey, byte[] publicKey) { + if (privateKey == null || privateKey.length != PRIVATE_KEY_LENGTH) { + throw new IllegalArgumentException( + "FN-DSA private key length must be " + PRIVATE_KEY_LENGTH); + } + if (publicKey == null || publicKey.length != PUBLIC_KEY_LENGTH) { + throw new IllegalArgumentException( + "FN-DSA public key length must be " + PUBLIC_KEY_LENGTH); + } + this.privateKey = privateKey.clone(); + this.publicKey = publicKey.clone(); + } + + /** + * Builds an instance from the extended private key encoding {@code f ‖ g ‖ F ‖ h} + * ({@link #PRIVATE_KEY_WITH_PUBLIC_KEY_LENGTH} bytes), as produced by + * {@link #getPrivateKeyWithPublicKey()}. Provided as a static factory rather + * than an additional {@code FNDSA(byte[])} constructor because Java cannot + * overload {@link #FNDSA(byte[]) the seed constructor} on length alone. + */ + public static FNDSA fromPrivateKeyWithPublicKey(byte[] extendedPrivateKey) { + if (extendedPrivateKey == null + || extendedPrivateKey.length != PRIVATE_KEY_WITH_PUBLIC_KEY_LENGTH) { + throw new IllegalArgumentException( + "FN-DSA extended private key length must be " + + PRIVATE_KEY_WITH_PUBLIC_KEY_LENGTH); + } + byte[] sk = new byte[PRIVATE_KEY_LENGTH]; + byte[] pk = new byte[PUBLIC_KEY_LENGTH]; + System.arraycopy(extendedPrivateKey, 0, sk, 0, PRIVATE_KEY_LENGTH); + System.arraycopy(extendedPrivateKey, PRIVATE_KEY_LENGTH, pk, 0, PUBLIC_KEY_LENGTH); + return new FNDSA(sk, pk); + } + + @Override + public PQScheme getScheme() { + return PQScheme.FN_DSA_512; + } + + @Override + public int getPrivateKeyLength() { + return PRIVATE_KEY_LENGTH; + } + + @Override + public int getPublicKeyLength() { + return PUBLIC_KEY_LENGTH; + } + + /** Returns the protocol-level signature length upper bound (signatures are variable-length). */ + @Override + public int getSignatureLength() { + return SIGNATURE_LENGTH; + } + + @Override + public byte[] getPrivateKey() { + return privateKey.clone(); + } + + /** + * Returns the private key with the 896-byte public key {@code h} appended: + * {@code f ‖ g ‖ F ‖ h} (total {@link #PRIVATE_KEY_WITH_PUBLIC_KEY_LENGTH} bytes). + * Use this format on disk / in config when the consumer needs to recover the + * address from the private key alone — neither BC's encoded private key nor + * the 48-byte keygen seed (without re-running keygen) suffice today. + */ + public byte[] getPrivateKeyWithPublicKey() { + byte[] out = new byte[PRIVATE_KEY_WITH_PUBLIC_KEY_LENGTH]; + System.arraycopy(privateKey, 0, out, 0, PRIVATE_KEY_LENGTH); + System.arraycopy(publicKey, 0, out, PRIVATE_KEY_LENGTH, PUBLIC_KEY_LENGTH); + return out; + } + + @Override + public byte[] getPublicKey() { + return publicKey.clone(); + } + + @Override + public byte[] getAddress() { + return PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, publicKey); + } + + @Override + public byte[] sign(byte[] message) { + return sign(privateKey, message); + } + + @Override + public boolean verify(byte[] message, byte[] signature) { + return verify(publicKey, message, signature); + } + + public static boolean verify(byte[] publicKey, byte[] message, byte[] signature) { + if (publicKey == null || publicKey.length != PUBLIC_KEY_LENGTH) { + throw new IllegalArgumentException( + "FN-DSA public key length must be " + PUBLIC_KEY_LENGTH); + } + if (signature == null || signature.length == 0 || signature.length > SIGNATURE_LENGTH) { + throw new IllegalArgumentException( + "FN-DSA signature length must be 1.." + SIGNATURE_LENGTH); + } + if (message == null) { + throw new IllegalArgumentException("message must not be null"); + } + FalconPublicKeyParameters pk = new FalconPublicKeyParameters(PARAMS, publicKey); + FalconSigner verifier = new FalconSigner(); + verifier.init(false, pk); + try { + return verifier.verifySignature(message, signature); + } catch (RuntimeException e) { + return false; + } + } + + /** + * Signs {@code message} using either the bare private key + * ({@link #PRIVATE_KEY_LENGTH} bytes, {@code f ‖ g ‖ F}) or the extended form + * ({@link #PRIVATE_KEY_WITH_PUBLIC_KEY_LENGTH} bytes, {@code f ‖ g ‖ F ‖ h}). + * The trailing {@code h} segment is ignored — only {@code (f, g, F)} feed BC's signer. + */ + public static byte[] sign(byte[] privateKey, byte[] message) { + validatePrivateKeyBytes(privateKey); + if (message == null) { + throw new IllegalArgumentException("message must not be null"); + } + byte[] f = new byte[F_G_ENCODED_LENGTH]; + byte[] g = new byte[F_G_ENCODED_LENGTH]; + byte[] bigF = new byte[BIG_F_ENCODED_LENGTH]; + System.arraycopy(privateKey, 0, f, 0, f.length); + System.arraycopy(privateKey, f.length, g, 0, g.length); + System.arraycopy(privateKey, f.length + g.length, bigF, 0, bigF.length); + FalconPrivateKeyParameters sk = new FalconPrivateKeyParameters(PARAMS, f, g, bigF, new byte[0]); + FalconSigner signer = new FalconSigner(); + signer.init(true, new ParametersWithRandom(sk, new SecureRandom())); + try { + return signer.generateSignature(message); + } catch (Exception e) { + throw new IllegalStateException("FN-DSA signing failed", e); + } + } + + /** + * Recovers the public key when the input is in the extended form + * {@code f ‖ g ‖ F ‖ h} ({@link #PRIVATE_KEY_WITH_PUBLIC_KEY_LENGTH} bytes). + * Throws {@link UnsupportedOperationException} for the bare {@code f ‖ g ‖ F} + * form: BouncyCastle currently has no public API to compute {@code h = g · f⁻¹} + * mod q, so callers must persist {@code h} alongside the private key (use + * {@link #getPrivateKeyWithPublicKey()}) or re-run keygen from a stored seed. + * See bcgit/bc-java#2297. + */ + public static byte[] derivePublicKey(byte[] privateKey) { + if (privateKey != null && privateKey.length == PRIVATE_KEY_WITH_PUBLIC_KEY_LENGTH) { + byte[] pk = new byte[PUBLIC_KEY_LENGTH]; + System.arraycopy(privateKey, PRIVATE_KEY_LENGTH, pk, 0, PUBLIC_KEY_LENGTH); + return pk; + } + throw new UnsupportedOperationException( + "FN-DSA public key cannot be derived from the bare encoded private key; " + + "supply the extended form (f ‖ g ‖ F ‖ h) or both halves to the " + + "(privateKey, publicKey) constructor"); + } + + public static byte[] computeAddress(byte[] publicKey) { + return PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, publicKey); + } + + private static AsymmetricCipherKeyPair generateKeyPair(SecureRandom random) { + FalconKeyPairGenerator generator = new FalconKeyPairGenerator(); + generator.init(new FalconKeyGenerationParameters(random, PARAMS)); + return generator.generateKeyPair(); + } + + private static void validatePrivateKeyBytes(byte[] privateKey) { + if (privateKey == null + || (privateKey.length != PRIVATE_KEY_LENGTH + && privateKey.length != PRIVATE_KEY_WITH_PUBLIC_KEY_LENGTH)) { + throw new IllegalArgumentException( + "FN-DSA private key length must be " + PRIVATE_KEY_LENGTH + + " or " + PRIVATE_KEY_WITH_PUBLIC_KEY_LENGTH); + } + } +} diff --git a/crypto/src/main/java/org/tron/common/crypto/pqc/PQSchemeRegistry.java b/crypto/src/main/java/org/tron/common/crypto/pqc/PQSchemeRegistry.java new file mode 100644 index 00000000000..b3965ac4cb8 --- /dev/null +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/PQSchemeRegistry.java @@ -0,0 +1,223 @@ +package org.tron.common.crypto.pqc; + +import java.util.Collections; +import java.util.EnumMap; +import java.util.Map; +import org.tron.common.crypto.Hash; +import org.tron.protos.Protocol.PQScheme; + +/** + * Static dispatch table for post-quantum signature schemes keyed by + * {@link PQScheme}. Each entry binds a scheme to its public-key length, + * signature length, seed length, fingerprint hash function, and stateless + * sign/verify/keygen operations. Legacy ECDSA secp256k1 / SM2 schemes are NOT + * registered — they flow through the existing {@code SignInterface} path. + * + *

Address binding (V2). A PQ-derived TRON address is + * {@code 0x41 ‖ deriveHash(scheme, public_key)[12..32]}, matching the ECDSA + * flow's {@code 0x41 ‖ Keccak-256(public_key)[12..32]} so PQ and ECDSA + * addresses share the same derivation shape. The hash function is scheme- + * specific (see {@link #deriveHash}); {@code FN_DSA_512} uses Keccak-256. + * + *

Wire-format default. {@code UNKNOWN_PQ_SCHEME = 0} is the proto3 + * default (reserved for the {@code UNKNOWN_} API-evolution slot); on the wire + * it is interpreted as {@code FN_DSA_512} so V2-launch witnesses pay zero + * bytes for the scheme tag. All public methods normalize via + * {@link #resolve(PQScheme)} before dispatch. + */ +public final class PQSchemeRegistry { + + /** Stateless sign/verify/keygen dispatch bound to a single PQ scheme. */ + public interface SignatureOps { + byte[] sign(byte[] privateKey, byte[] message); + + boolean verify(byte[] publicKey, byte[] message, byte[] signature); + + PQSignature fromSeed(byte[] seed); + + PQSignature fromKeypair(byte[] privateKey, byte[] publicKey); + } + + /** + * Fingerprint hash used to derive a 21-byte TRON address from a PQ public key. + * V2 first launch uses Keccak-256 for FN_DSA_512 to match the ECDSA address + * derivation; later schemes may bind to a different hash if the PQ scheme has + * its own canonical fingerprint. + */ + public interface FingerprintHash { + /** Returns the full digest of {@code data} (no truncation). */ + byte[] digest(byte[] data); + } + + private static final FingerprintHash KECCAK_256 = Hash::sha3; + + private static final class SchemeInfo { + final int privateKeyLength; + final int publicKeyLength; + final int signatureLength; + final int seedLength; + final FingerprintHash hash; + final SignatureOps ops; + + SchemeInfo(int privateKeyLength, int publicKeyLength, int signatureLength, + int seedLength, FingerprintHash hash, SignatureOps ops) { + this.privateKeyLength = privateKeyLength; + this.publicKeyLength = publicKeyLength; + this.signatureLength = signatureLength; + this.seedLength = seedLength; + this.hash = hash; + this.ops = ops; + } + } + + private static final Map SCHEMES; + + static { + EnumMap m = new EnumMap<>(PQScheme.class); + m.put(PQScheme.FN_DSA_512, new SchemeInfo( + FNDSA.PRIVATE_KEY_LENGTH, FNDSA.PUBLIC_KEY_LENGTH, + FNDSA.SIGNATURE_LENGTH, FNDSA.SEED_LENGTH, + KECCAK_256, + new SignatureOps() { + @Override + public byte[] sign(byte[] privateKey, byte[] message) { + return FNDSA.sign(privateKey, message); + } + + @Override + public boolean verify(byte[] publicKey, byte[] message, byte[] signature) { + return FNDSA.verify(publicKey, message, signature); + } + + @Override + public PQSignature fromSeed(byte[] seed) { + return new FNDSA(seed); + } + + @Override + public PQSignature fromKeypair(byte[] privateKey, byte[] publicKey) { + return new FNDSA(privateKey, publicKey); + } + })); + SCHEMES = Collections.unmodifiableMap(m); + } + + private PQSchemeRegistry() { + } + + /** + * Map a wire-format {@link PQScheme} to its registered scheme. The proto3 + * default {@code UNKNOWN_PQ_SCHEME} is normalized to {@code FN_DSA_512} so + * V2-launch witnesses that omit the scheme tag are decoded as Falcon-512. + * {@code null} and {@code UNRECOGNIZED} pass through unchanged so the + * caller-side {@code contains}/{@code require} checks reject them. + */ + public static PQScheme resolve(PQScheme scheme) { + if (scheme == PQScheme.UNKNOWN_PQ_SCHEME) { + return PQScheme.FN_DSA_512; + } + return scheme; + } + + public static boolean contains(PQScheme scheme) { + PQScheme resolved = resolve(scheme); + return resolved != null && SCHEMES.containsKey(resolved); + } + + public static int getPrivateKeyLength(PQScheme scheme) { + return require(scheme).privateKeyLength; + } + + public static int getPublicKeyLength(PQScheme scheme) { + return require(scheme).publicKeyLength; + } + + public static int getSignatureLength(PQScheme scheme) { + return require(scheme).signatureLength; + } + + public static int getSeedLength(PQScheme scheme) { + return require(scheme).seedLength; + } + + /** + * Per-scheme signature-length predicate. Fixed-length schemes require exact + * equality with {@link #getSignatureLength(PQScheme)}; variable-length + * schemes ({@code FN_DSA_512}) treat that value as an upper bound and accept + * any {@code 1..max}. + */ + public static boolean isValidSignatureLength(PQScheme scheme, int length) { + PQScheme resolved = resolve(scheme); + SchemeInfo info = require(resolved); + if (resolved == PQScheme.FN_DSA_512) { + return length > 0 && length <= info.signatureLength; + } + return length == info.signatureLength; + } + + public static byte[] sign(PQScheme scheme, byte[] privateKey, byte[] message) { + return require(scheme).ops.sign(privateKey, message); + } + + public static boolean verify( + PQScheme scheme, byte[] publicKey, byte[] message, byte[] signature) { + return require(scheme).ops.verify(publicKey, message, signature); + } + + public static PQSignature fromSeed(PQScheme scheme, byte[] seed) { + return require(scheme).ops.fromSeed(seed); + } + + /** + * Build a keypair-bound {@link PQSignature} from already-derived private and + * public key bytes. Used by the witness-config path when the operator has + * pre-computed the keypair off-line and wants to bypass on-node keygen. + * Validates {@code privateKey} and {@code publicKey} lengths against the + * scheme; cryptographic consistency between the two halves is the caller's + * responsibility. + */ + public static PQSignature fromKeypair( + PQScheme scheme, byte[] privateKey, byte[] publicKey) { + return require(scheme).ops.fromKeypair(privateKey, publicKey); + } + + /** + * Scheme-dispatched fingerprint hash of a PQ public key. Returns the full + * digest; callers truncate to 20 bytes when deriving the address suffix. + */ + public static byte[] deriveHash(PQScheme scheme, byte[] publicKey) { + SchemeInfo info = require(scheme); + if (publicKey == null || publicKey.length != info.publicKeyLength) { + throw new IllegalArgumentException( + "invalid public key length for " + scheme + ": " + + (publicKey == null ? -1 : publicKey.length)); + } + return info.hash.digest(publicKey); + } + + /** + * Derive the 21-byte TRON address from a PQ public key as + * {@code 0x41 ‖ deriveHash(scheme, public_key)[12..32]} — the rightmost 20 + * bytes of the digest, matching the ECDSA address derivation slice. + */ + public static byte[] computeAddress(PQScheme scheme, byte[] publicKey) { + byte[] h = deriveHash(scheme, publicKey); + byte[] addr = new byte[21]; + addr[0] = 0x41; + System.arraycopy(h, h.length - 20, addr, 1, 20); + return addr; + } + + private static SchemeInfo require(PQScheme scheme) { + if (scheme == null) { + throw new IllegalArgumentException("scheme must not be null"); + } + PQScheme resolved = resolve(scheme); + SchemeInfo info = SCHEMES.get(resolved); + if (info == null) { + throw new IllegalArgumentException( + "no PQSignature registered for scheme: " + scheme); + } + return info; + } +} diff --git a/crypto/src/main/java/org/tron/common/crypto/pqc/PQSignature.java b/crypto/src/main/java/org/tron/common/crypto/pqc/PQSignature.java new file mode 100644 index 00000000000..dbba2683ef0 --- /dev/null +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/PQSignature.java @@ -0,0 +1,72 @@ +package org.tron.common.crypto.pqc; + +import org.tron.protos.Protocol.PQScheme; + +/** + * Post-quantum signature scheme facade bound to a keypair. Instance methods + * (sign/verify/getAddress/getPublicKey/getPrivateKey) operate on the held + * keypair. Stateless dispatch by {@link PQScheme} is provided by + * {@link PQSchemeRegistry}. + */ +public interface PQSignature { + + PQScheme getScheme(); + + int getPrivateKeyLength(); + + int getPublicKeyLength(); + + int getSignatureLength(); + + byte[] getPrivateKey(); + + byte[] getPublicKey(); + + /** + * 21-byte TRON address derived from the held public key as + * {@code 0x41 ‖ deriveHash(scheme, public_key)[12..32]} (see + * {@link PQSchemeRegistry#computeAddress}). + */ + byte[] getAddress(); + + /** Sign {@code message} with the held private key; returns the raw signature. */ + byte[] sign(byte[] message); + + /** + * Verify {@code signature} over {@code message} against the held public key. + * + * @return true iff the signature is cryptographically valid for the bound keypair + */ + boolean verify(byte[] message, byte[] signature); + + default void validatePrivateKey(byte[] privateKey) { + if (privateKey == null || privateKey.length != getPrivateKeyLength()) { + throw new IllegalArgumentException( + "invalid " + getScheme() + " private key length: " + + (privateKey == null ? "null" : privateKey.length) + + ", expected " + getPrivateKeyLength()); + } + } + + default void validatePublicKey(byte[] publicKey) { + if (publicKey == null || publicKey.length != getPublicKeyLength()) { + throw new IllegalArgumentException( + "invalid " + getScheme() + " public key length: " + + (publicKey == null ? "null" : publicKey.length) + + ", expected " + getPublicKeyLength()); + } + } + + /** + * Default upper-bound check, sufficient for variable-length schemes (FN_DSA_512). + * Fixed-length schemes override this with strict equality. + */ + default void validateSignature(byte[] signature) { + if (signature == null || signature.length == 0 || signature.length > getSignatureLength()) { + throw new IllegalArgumentException( + "invalid " + getScheme() + " signature length: " + + (signature == null ? "null" : signature.length) + + ", expected 1.." + getSignatureLength()); + } + } +} diff --git a/framework/src/main/java/org/tron/core/Wallet.java b/framework/src/main/java/org/tron/core/Wallet.java index 0482643d8d0..3f8766f8ac4 100755 --- a/framework/src/main/java/org/tron/core/Wallet.java +++ b/framework/src/main/java/org/tron/core/Wallet.java @@ -1495,6 +1495,11 @@ public Protocol.ChainParameters getChainParameters() { .setValue(dbManager.getDynamicPropertiesStore().getAllowHardenExchangeCalculation()) .build()); + builder.addChainParameter(Protocol.ChainParameters.ChainParameter.newBuilder() + .setKey("getAllowFnDsa512") + .setValue(dbManager.getDynamicPropertiesStore().getAllowFnDsa512()) + .build()); + return builder.build(); } diff --git a/framework/src/main/java/org/tron/core/config/args/Args.java b/framework/src/main/java/org/tron/core/config/args/Args.java index de8b7dba1ad..d6ef74c6ef7 100644 --- a/framework/src/main/java/org/tron/core/config/args/Args.java +++ b/framework/src/main/java/org/tron/core/config/args/Args.java @@ -21,6 +21,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.EnumSet; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; @@ -43,6 +44,7 @@ import org.tron.common.args.GenesisBlock; import org.tron.common.args.Witness; import org.tron.common.cron.CronExpression; +import org.tron.common.crypto.pqc.PQSchemeRegistry; import org.tron.common.logsfilter.EventPluginConfig; import org.tron.common.logsfilter.FilterQuery; import org.tron.common.logsfilter.TriggerConfig; @@ -63,6 +65,7 @@ import org.tron.p2p.dns.update.PublishConfig; import org.tron.p2p.utils.NetUtil; import org.tron.program.Version; +import org.tron.protos.Protocol.PQScheme; @Slf4j(topic = "app") @NoArgsConstructor @@ -778,6 +781,10 @@ public static void applyConfigParams( eventConfig = EventConfig.fromConfig(config); applyEventConfig(eventConfig); + PARAMETER.allowFnDsa512 = + config.hasPath(ConfigKey.COMMITTEE_ALLOW_FN_DSA_512) ? config + .getInt(ConfigKey.COMMITTEE_ALLOW_FN_DSA_512) : 0; + logConfig(); } @@ -918,6 +925,9 @@ private static void applyCLIParams(CLIParameter cmd, JCommander jc) { } } + private static final EnumSet WITNESS_PQ_SCHEMES = EnumSet.of( + PQScheme.FN_DSA_512); + private static void initLocalWitnesses(Config config, CLIParameter cmd) { // not a witness node, skip if (!PARAMETER.isWitness()) { @@ -948,6 +958,59 @@ private static void initLocalWitnesses(Config config, CLIParameter cmd) { return; } + // path 4: PQ pre-derived keypair configuration + if (config.hasPath(ConfigKey.LOCAL_WITNESS_PQ_KEYS)) { + List pqEntries = config.getStringList(ConfigKey.LOCAL_WITNESS_PQ_KEYS); + if (!pqEntries.isEmpty()) { + localWitnesses = new LocalWitnesses(); + // Scheme must be applied before keypairs — key-length validation depends on it. + if (config.hasPath(ConfigKey.LOCAL_WITNESS_PQ_SCHEME)) { + String schemeName = config.getString(ConfigKey.LOCAL_WITNESS_PQ_SCHEME); + try { + PQScheme scheme = PQScheme.valueOf(schemeName); + if (!WITNESS_PQ_SCHEMES.contains(scheme)) { + throw new TronError("invalid " + ConfigKey.LOCAL_WITNESS_PQ_SCHEME + + ": " + schemeName + "; valid values: " + WITNESS_PQ_SCHEMES, + TronError.ErrCode.WITNESS_INIT); + } + localWitnesses.setPqScheme(scheme); + } catch (IllegalArgumentException e) { + throw new TronError("invalid " + ConfigKey.LOCAL_WITNESS_PQ_SCHEME + + ": " + schemeName, TronError.ErrCode.WITNESS_INIT); + } + } + // Each entry is the extended private key f‖g‖F‖h (priv ‖ pub) hex, + // sized (privLen + pubLen) bytes for the active scheme. We split here + // so downstream consumers (ConsensusService, LocalWitnesses) keep the + // same priv/pub split they already use — derivePublicKey(priv) replaces + // the previous explicit `pub` config field. + PQScheme scheme = localWitnesses.getPqScheme(); + int privHexLen = PQSchemeRegistry.getPrivateKeyLength(scheme) * 2; + int extHexLen = privHexLen + PQSchemeRegistry.getPublicKeyLength(scheme) * 2; + List pqPrivateKeys = new ArrayList<>(pqEntries.size()); + List pqPublicKeys = new ArrayList<>(pqEntries.size()); + for (int i = 0; i < pqEntries.size(); i++) { + String hex = pqEntries.get(i); + String stripped = hex != null && hex.startsWith("0x") ? hex.substring(2) : hex; + if (stripped == null || stripped.length() != extHexLen) { + throw new TronError(String.format( + "%s[%d] must be %d hex chars (extended priv‖pub for %s), actual: %d", + ConfigKey.LOCAL_WITNESS_PQ_KEYS, i, extHexLen, scheme, + stripped == null ? 0 : stripped.length()), + TronError.ErrCode.WITNESS_INIT); + } + pqPrivateKeys.add(stripped.substring(0, privHexLen)); + pqPublicKeys.add(stripped.substring(privHexLen)); + } + localWitnesses.setPqKeypairs(pqPrivateKeys, pqPublicKeys); + byte[] address = WitnessInitializer.resolvePqAuthSigAddress(lwConfig.getAccountAddress()); + if (address != null) { + localWitnesses.setWitnessAccountAddress(address); + } + return; + } + } + // no private key source configured throw new TronError("This is a witness node, but localWitnesses is null", TronError.ErrCode.WITNESS_INIT); diff --git a/framework/src/main/java/org/tron/core/config/args/ConfigKey.java b/framework/src/main/java/org/tron/core/config/args/ConfigKey.java new file mode 100644 index 00000000000..71fb1c907a6 --- /dev/null +++ b/framework/src/main/java/org/tron/core/config/args/ConfigKey.java @@ -0,0 +1,13 @@ +package org.tron.core.config.args; + +public final class ConfigKey { + + public static final String COMMITTEE_ALLOW_FN_DSA_512 = "committee.allowFnDsa512"; + + public static final String LOCAL_WITNESS_PQ_KEYS = "localwitness_pq_keys"; + + public static final String LOCAL_WITNESS_PQ_SCHEME = "localwitness_pq_scheme"; + + private ConfigKey() { + } +} diff --git a/framework/src/main/java/org/tron/core/config/args/WitnessInitializer.java b/framework/src/main/java/org/tron/core/config/args/WitnessInitializer.java index c2ce2ba0046..f8068511c0d 100644 --- a/framework/src/main/java/org/tron/core/config/args/WitnessInitializer.java +++ b/framework/src/main/java/org/tron/core/config/args/WitnessInitializer.java @@ -112,6 +112,45 @@ public static LocalWitnesses initFromKeystore( return witnesses; } + /** + * Init for PQ-only witness nodes (no legacy ECDSA key). The witness account + * address must be supplied explicitly because there is no ECDSA key to derive it from. + */ + public static LocalWitnesses initFromPQOnly(String witnessAccountAddress) { + if (StringUtils.isBlank(witnessAccountAddress)) { + throw new TronError( + "localWitnessAccountAddress must be set for PQ-only witness nodes", + TronError.ErrCode.WITNESS_INIT); + } + byte[] address = Commons.decodeFromBase58Check(witnessAccountAddress); + if (address == null) { + throw new TronError( + "LocalWitnessAccountAddress format is incorrect", + TronError.ErrCode.WITNESS_INIT); + } + LocalWitnesses witnesses = new LocalWitnesses(); + witnesses.initWitnessAccountAddress(address, false); + logger.debug("Initialised PQ-only witness with address {}", witnessAccountAddress); + return witnesses; + } + + /** + * Resolve witness address for PQ seed configuration. + */ + public static byte[] resolvePqAuthSigAddress(String witnessAccountAddress) { + if (StringUtils.isEmpty(witnessAccountAddress)) { + return null; + } + byte[] address = Commons.decodeFromBase58Check(witnessAccountAddress); + if (address != null) { + logger.debug("Got localWitnessAccountAddress from config.conf"); + } else { + throw new TronError("LocalWitnessAccountAddress format from config is incorrect", + TronError.ErrCode.WITNESS_INIT); + } + return address; + } + static byte[] resolveWitnessAddress( LocalWitnesses witnesses, String witnessAccountAddress) { if (StringUtils.isEmpty(witnessAccountAddress)) { diff --git a/framework/src/main/java/org/tron/core/consensus/ConsensusService.java b/framework/src/main/java/org/tron/core/consensus/ConsensusService.java index ef8f30ef498..5f54b62b955 100644 --- a/framework/src/main/java/org/tron/core/consensus/ConsensusService.java +++ b/framework/src/main/java/org/tron/core/consensus/ConsensusService.java @@ -10,13 +10,17 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.tron.common.crypto.SignUtils; +import org.tron.common.crypto.pqc.PQSchemeRegistry; +import org.tron.common.crypto.pqc.PQSignature; import org.tron.common.parameter.CommonParameter; import org.tron.consensus.Consensus; import org.tron.consensus.base.Param; import org.tron.consensus.base.Param.Miner; import org.tron.core.capsule.WitnessCapsule; import org.tron.core.config.args.Args; +import org.tron.core.exception.TronError; import org.tron.core.store.WitnessStore; +import org.tron.protos.Protocol.PQScheme; @Slf4j(topic = "consensus") @Component @@ -46,6 +50,14 @@ public void start() { param.setAgreeNodeCount(parameter.getAgreeNodeCount()); List miners = new ArrayList<>(); List privateKeys = Args.getLocalWitnesses().getPrivateKeys(); + List pqPrivateKeys = Args.getLocalWitnesses().getPqPrivateKeys(); + List pqPublicKeys = Args.getLocalWitnesses().getPqPublicKeys(); + if (pqPublicKeys.size() != pqPrivateKeys.size()) { + throw new TronError( + "localwitness_pq_keys size mismatch: " + pqPrivateKeys.size() + + " private vs " + pqPublicKeys.size() + " public", + TronError.ErrCode.WITNESS_INIT); + } if (privateKeys.size() > 1) { for (String key : privateKeys) { byte[] privateKey = fromHexString(key); @@ -76,6 +88,31 @@ public void start() { Miner miner = param.new Miner(privateKey, ByteString.copyFrom(privateKeyAddress), ByteString.copyFrom(witnessAddress)); miners.add(miner); + } else if (pqPrivateKeys.size() > 1) { + PQScheme scheme = Args.getLocalWitnesses().getPqScheme(); + requireSupportedPqScheme(scheme); + for (int i = 0; i < pqPrivateKeys.size(); i++) { + byte[] privBytes = fromHexString(pqPrivateKeys.get(i)); + byte[] pubBytes = fromHexString(pqPublicKeys.get(i)); + PQSignature keypair = PQSchemeRegistry.fromKeypair(scheme, privBytes, pubBytes); + byte[] sk = keypair.getPrivateKey(); + byte[] pk = keypair.getPublicKey(); + byte[] pqAddress = keypair.getAddress(); + WitnessCapsule witnessCapsule = witnessStore.get(pqAddress); + if (null == witnessCapsule) { + logger.warn("Witness {} is not in witnessStore.", Hex.toHexString(pqAddress)); + } + ByteString pqAddressBs = ByteString.copyFrom(pqAddress); + Miner miner = param.new Miner(null, pqAddressBs, pqAddressBs); + miner.setPQPrivateKey(sk); + miner.setPQPublicKey(pk); + miner.setPqScheme(scheme); + miners.add(miner); + logger.info("Add {} witness (from configured keypair): {}, size: {}", + scheme, Hex.toHexString(pqAddress), miners.size()); + } + } else if (pqPrivateKeys.size() == 1) { + miners.add(buildPQOnlyMinerFromKeypair(param, pqPrivateKeys.get(0), pqPublicKeys.get(0))); } param.setMiners(miners); @@ -85,6 +122,42 @@ public void start() { logger.info("consensus service start success"); } + private Miner buildPQOnlyMinerFromKeypair(Param param, String pqPrivateKey, + String pqPublicKey) { + PQScheme scheme = Args.getLocalWitnesses().getPqScheme(); + requireSupportedPqScheme(scheme); + byte[] privBytes = fromHexString(pqPrivateKey); + byte[] pubBytes = fromHexString(pqPublicKey); + PQSignature keypair = PQSchemeRegistry.fromKeypair(scheme, privBytes, pubBytes); + byte[] sk = keypair.getPrivateKey(); + byte[] pk = keypair.getPublicKey(); + byte[] pqAddress = keypair.getAddress(); + byte[] witnessAddress = Args.getLocalWitnesses().getWitnessAccountAddress(); + if (witnessAddress == null || witnessAddress.length == 0) { + witnessAddress = pqAddress; + } + WitnessCapsule witnessCapsule = witnessStore.get(witnessAddress); + if (null == witnessCapsule) { + logger.warn("Witness {} is not in witnessStore.", Hex.toHexString(witnessAddress)); + } + // In multi-signature mode, the address derived from the PQ key may differ from witnessAddress. + Miner miner = param.new Miner(null, ByteString.copyFrom(pqAddress), + ByteString.copyFrom(witnessAddress)); + miner.setPQPrivateKey(sk); + miner.setPQPublicKey(pk); + miner.setPqScheme(scheme); + logger.info("Add {} witness (from configured keypair): {}", + scheme, Hex.toHexString(witnessAddress)); + return miner; + } + + private static void requireSupportedPqScheme(PQScheme scheme) { + if (!PQSchemeRegistry.contains(scheme)) { + throw new TronError("unsupported PQ witness scheme: " + scheme, + TronError.ErrCode.WITNESS_INIT); + } + } + public void stop() { logger.info("consensus service closed start."); consensus.stop(); diff --git a/framework/src/main/java/org/tron/core/consensus/ProposalService.java b/framework/src/main/java/org/tron/core/consensus/ProposalService.java index 543deab2fc6..42e24a767be 100644 --- a/framework/src/main/java/org/tron/core/consensus/ProposalService.java +++ b/framework/src/main/java/org/tron/core/consensus/ProposalService.java @@ -412,6 +412,10 @@ public static boolean process(Manager manager, ProposalCapsule proposalCapsule) .saveAllowHardenExchangeCalculation(entry.getValue()); break; } + case ALLOW_FN_DSA_512: { + manager.getDynamicPropertiesStore().saveAllowFnDsa512(entry.getValue()); + break; + } default: find = false; break; diff --git a/framework/src/main/java/org/tron/core/db/Manager.java b/framework/src/main/java/org/tron/core/db/Manager.java index a534b9d1c5d..d6bdffb62ec 100644 --- a/framework/src/main/java/org/tron/core/db/Manager.java +++ b/framework/src/main/java/org/tron/core/db/Manager.java @@ -55,6 +55,7 @@ import org.tron.common.args.GenesisBlock; import org.tron.common.bloom.Bloom; import org.tron.common.cron.CronExpression; +import org.tron.common.crypto.pqc.PQSchemeRegistry; import org.tron.common.es.ExecutorServiceManager; import org.tron.common.exit.ExitManager; import org.tron.common.logsfilter.EventPluginLoader; @@ -171,6 +172,8 @@ import org.tron.core.utils.TransactionRegister; import org.tron.protos.Protocol; import org.tron.protos.Protocol.AccountType; +import org.tron.protos.Protocol.PQAuthSig; +import org.tron.protos.Protocol.PQScheme; import org.tron.protos.Protocol.Permission; import org.tron.protos.Protocol.Transaction; import org.tron.protos.Protocol.Transaction.Contract; @@ -1740,7 +1743,7 @@ public BlockCapsule generateBlock(Miner miner, long blockTime, long timeout) { session.reset(); blockCapsule.setMerkleRoot(); - blockCapsule.sign(miner.getPrivateKey()); + signBlockCapsule(blockCapsule, miner); BlockCapsule capsule = new BlockCapsule(blockCapsule.getInstance()); capsule.generatedByMyself = true; @@ -1756,6 +1759,60 @@ public BlockCapsule generateBlock(Miner miner, long blockTime, long timeout) { return capsule; } + private void signBlockCapsule(BlockCapsule blockCapsule, Miner miner) { + PQScheme scheme = resolveWitnessScheme(miner); + if (scheme != null && PQSchemeRegistry.contains(scheme)) { + signWitnessAuth(blockCapsule, miner, scheme); + } else { + blockCapsule.sign(miner.getPrivateKey()); + } + } + + private PQScheme resolveWitnessScheme(Miner miner) { + if (!chainBaseManager.getDynamicPropertiesStore().isAnyPqSchemeAllowed()) { + return null; + } + PQScheme scheme = miner.getPqScheme(); + if (scheme == null || !PQSchemeRegistry.contains(scheme)) { + return null; + } + if (!chainBaseManager.getDynamicPropertiesStore().isPqSchemeAllowed(scheme)) { + return null; + } + byte[] witnessAddress = miner.getWitnessAddress().toByteArray(); + AccountCapsule accountCapsule = chainBaseManager.getAccountStore().get(witnessAddress); + if (accountCapsule == null || !accountCapsule.getInstance().hasWitnessPermission()) { + return null; + } + Permission witnessPermission = accountCapsule.getInstance().getWitnessPermission(); + if (witnessPermission.getKeysCount() == 0) { + return null; + } + return scheme; + } + + private void signWitnessAuth(BlockCapsule blockCapsule, Miner miner, PQScheme scheme) { + byte[] pqPrivateKey = miner.getPQPrivateKey(); + byte[] pqPublicKey = miner.getPQPublicKey(); + if (pqPrivateKey == null || pqPublicKey == null) { + throw new IllegalStateException( + "witness permission requires " + scheme + + " but local PQ key material is not configured"); + } + byte[] digest = blockCapsule.getRawHashBytes(); + byte[] signature = PQSchemeRegistry.sign(scheme, pqPrivateKey, digest); + PQAuthSig.Builder builder = PQAuthSig.newBuilder() + .setPublicKey(ByteString.copyFrom(pqPublicKey)) + .setSignature(ByteString.copyFrom(signature)); + // FN_DSA_512 is the launch scheme: leave scheme at the proto3 default + // (UNKNOWN_PQ_SCHEME) and rely on PQSchemeRegistry.resolve() on the read + // path so the tag costs zero wire bytes per block. + if (scheme != PQScheme.FN_DSA_512) { + builder.setScheme(scheme); + } + blockCapsule.setPqAuthSig(builder.build()); + } + private void filterOwnerAddress(TransactionCapsule transactionCapsule, Set result) { byte[] owner = transactionCapsule.getOwnerAddress(); String ownerAddress = ByteArray.toHexString(owner); diff --git a/framework/src/main/resources/config.conf b/framework/src/main/resources/config.conf index b180ecd6d10..91eb905dbf4 100644 --- a/framework/src/main/resources/config.conf +++ b/framework/src/main/resources/config.conf @@ -702,6 +702,23 @@ localwitness = [ # "localwitnesskeystore.json" # ] +# Scheme used by localwitness_pq_keys. Defaults to FN_DSA_512. +# V2 first launch only allows FN_DSA_512 (Falcon-512, FIPS 206 draft). +# localwitness_pq_scheme = "FN_DSA_512" + +# Post-quantum witness signing keypairs, hex-encoded. Each entry is the +# extended private key f‖g‖F‖h (priv ‖ pub) as one hex string. For FN_DSA_512 +# the total is 2176 bytes (4352 hex chars): 1280 B Falcon-512 private key +# (f‖g‖F) followed by the 896 B public key h. Operators MUST generate the +# keypair off-line on a single platform and distribute the extended key; +# on-node keygen is intentionally bypassed because BouncyCastle's Falcon +# FFT/FPR code paths are not declared strictfp and could in theory diverge +# across JVMs/architectures. Used only after the ALLOW_FN_DSA_512 proposal +# is active and the witness Permission has been upgraded to FN_DSA_512. +# localwitness_pq_keys = [ +# "<4352 hex chars>" +# ] + block = { needSyncCheck = true maintenanceTimeInterval = 21600000 // 6 hours: 21600000(ms) @@ -800,6 +817,8 @@ committee = { # allowTvmBlob = 0 # consensusLogicOptimization = 0 # allowOptimizedReturnValueOfChainId = 0 + # allowTvmOsaka = 0 + # allowMlDsa = 0 } event.subscribe = { diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/FNDSAKatTest.java b/framework/src/test/java/org/tron/common/crypto/pqc/FNDSAKatTest.java new file mode 100644 index 00000000000..1c6656d8f38 --- /dev/null +++ b/framework/src/test/java/org/tron/common/crypto/pqc/FNDSAKatTest.java @@ -0,0 +1,246 @@ +package org.tron.common.crypto.pqc; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +import java.security.MessageDigest; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; +import org.junit.Test; +import org.tron.common.crypto.Hash; +import org.tron.protos.Protocol.PQScheme; + +/** + * Known-Answer Tests (KAT) for FN-DSA / Falcon-512. + * + *

Five seed vectors covering boundary patterns (incrementing, all-zero, + * all-ones, all-{@code 0xAA}, descending) lock in the deterministic + * seed → keypair derivation pinned by BouncyCastle 1.79's + * {@code FalconKeyPairGenerator}. Reference {@code pk}/{@code sk} digests and + * the V2 fingerprint address are captured from this same codebase / BC 1.79; + * the role of the test is regression detection — any change in seeding, + * encoding, or fingerprint derivation lights up. + * + *

Falcon signing is randomized so signature bytes cannot be pinned. Sign / + * verify is exercised per-vector and cross-vector to confirm signatures only + * verify under their own key. + */ +public class FNDSAKatTest { + + private static final class KatVector { + final String label; + final byte[] seed; + final String pkSha256; + final String skSha256; + + KatVector(String label, byte[] seed, String pkSha256, String skSha256) { + this.label = label; + this.seed = seed; + this.pkSha256 = pkSha256; + this.skSha256 = skSha256; + } + } + + private static byte[] seedIncrementing() { + byte[] s = new byte[FNDSA.SEED_LENGTH]; + for (int i = 0; i < s.length; i++) { + s[i] = (byte) i; + } + return s; + } + + private static byte[] seedDescending() { + byte[] s = new byte[FNDSA.SEED_LENGTH]; + for (int i = 0; i < s.length; i++) { + s[i] = (byte) (FNDSA.SEED_LENGTH - 1 - i); + } + return s; + } + + private static byte[] seedFilled(int b) { + byte[] s = new byte[FNDSA.SEED_LENGTH]; + Arrays.fill(s, (byte) b); + return s; + } + + private static final KatVector[] VECTORS = { + new KatVector("incrementing", seedIncrementing(), + "1cc09837c6931f9c5988e59ad0acd4e8bc5f13e274573d0edb444822cd4afc90", + "960a83b03e1a8a075002be97f7a92959a2b60c91184cabac06172d8821c32d6a"), + new KatVector("all_zero", seedFilled(0x00), + "708a446d675ee40027562aa2f853b9de0d9c876a08187133bb227c6d372aa1f2", + "fb05b4c139c8fd08b9ae3ecf3da9cc375623aeef38b20ecdb5bbd8c7c02e7324"), + new KatVector("all_ff", seedFilled(0xff), + "4744e8d541a208ae10f62f5175c6eda7b695f3fd32b2145a38f8b16665a350b0", + "e9adaa331dd9dc8d5881578e25bee75050105d7885bc7eac4e5e7f7fbba5612d"), + new KatVector("all_aa", seedFilled(0xaa), + "0894fd3551559bf8dbfd2ca828081c4f6998a16d65e63c595cf24178a2f952d3", + "b2c4678087cba90219fb590bf618a88eb663db96c1ad9c572ff86d38e8d78e1f"), + new KatVector("descending", seedDescending(), + "d2191201811bf061040a012d1799dcdacb055e844d99164e0ddc45c71007d829", + "dce0af30c51875158f3ea7c24b4ced289f49ce6123148994dc2a79548e678c2f"), + }; + + private static byte[] sha256(byte[] in) { + try { + return MessageDigest.getInstance("SHA-256").digest(in); + } catch (Exception e) { + throw new AssertionError("SHA-256 unavailable", e); + } + } + + private static String hex(byte[] b) { + StringBuilder sb = new StringBuilder(b.length * 2); + for (byte x : b) { + sb.append(String.format("%02x", x)); + } + return sb.toString(); + } + + @Test + public void allVectorsDeriveExpectedPublicAndPrivateKey() { + for (KatVector v : VECTORS) { + FNDSA k = new FNDSA(v.seed); + assertEquals(v.label + ": pk length", + FNDSA.PUBLIC_KEY_LENGTH, k.getPublicKey().length); + assertEquals(v.label + ": sk length", + FNDSA.PRIVATE_KEY_LENGTH, k.getPrivateKey().length); + assertEquals(v.label + ": pk SHA-256 must match KAT vector", + v.pkSha256, hex(sha256(k.getPublicKey()))); + assertEquals(v.label + ": sk SHA-256 must match KAT vector", + v.skSha256, hex(sha256(k.getPrivateKey()))); + } + } + + @Test + public void allVectorsDeriveExpectedAddress() { + for (KatVector v : VECTORS) { + FNDSA k = new FNDSA(v.seed); + byte[] addr = k.getAddress(); + assertEquals(v.label + ": address length", + 21, addr.length); + + byte[] viaRegistry = + PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, k.getPublicKey()); + assertArrayEquals(v.label + ": registry dispatch must match instance", + addr, viaRegistry); + } + } + + @Test + public void addressIsExactly0x41PlusKeccak256RightmostBytesOfPublicKey() { + for (KatVector v : VECTORS) { + FNDSA k = new FNDSA(v.seed); + byte[] pk = k.getPublicKey(); + byte[] hash = Hash.sha3(pk); + byte[] expected = new byte[21]; + expected[0] = 0x41; + System.arraycopy(hash, hash.length - 20, expected, 1, 20); + assertArrayEquals(v.label + ": address must be 0x41 ‖ Keccak-256(pk)[12..32]", + expected, k.getAddress()); + } + } + + @Test + public void allVectorsAreReproducibleAcrossInstances() { + for (KatVector v : VECTORS) { + FNDSA a = new FNDSA(v.seed); + FNDSA b = new FNDSA(v.seed); + assertArrayEquals(v.label + ": pk reproducible", a.getPublicKey(), b.getPublicKey()); + assertArrayEquals(v.label + ": sk reproducible", a.getPrivateKey(), b.getPrivateKey()); + assertArrayEquals(v.label + ": addr reproducible", a.getAddress(), b.getAddress()); + } + } + + @Test + public void distinctSeedsProduceDistinctKeysAndAddresses() { + Set pkDigests = new HashSet<>(); + Set skDigests = new HashSet<>(); + Set addresses = new HashSet<>(); + for (KatVector v : VECTORS) { + pkDigests.add(v.pkSha256); + skDigests.add(v.skSha256); + addresses.add(hex(new FNDSA(v.seed).getAddress())); + } + assertEquals("KAT pk digests must be pairwise distinct", + VECTORS.length, pkDigests.size()); + assertEquals("KAT sk digests must be pairwise distinct", + VECTORS.length, skDigests.size()); + assertEquals("KAT addresses must be pairwise distinct", + VECTORS.length, addresses.size()); + } + + @Test + public void signaturesFromKatKeysVerifyUnderTheirOwnPublicKey() { + byte[][] messages = { + new byte[0], + "x".getBytes(), + "tron-fn-dsa-kat-message".getBytes(), + new byte[1024], + }; + for (KatVector v : VECTORS) { + FNDSA k = new FNDSA(v.seed); + for (byte[] msg : messages) { + byte[] sig = k.sign(msg); + assertTrue(v.label + ": signature must be non-empty", + sig.length > 0); + assertTrue(v.label + ": signature must respect 752-byte upper bound", + sig.length <= FNDSA.SIGNATURE_LENGTH); + assertTrue(v.label + ": signature must verify under its own pk", + FNDSA.verify(k.getPublicKey(), msg, sig)); + assertTrue(v.label + ": registry verify must accept own signature", + PQSchemeRegistry.verify( + PQScheme.FN_DSA_512, k.getPublicKey(), msg, sig)); + } + } + } + + @Test + public void signatureFromVectorAFailsUnderVectorBPublicKey() { + byte[] msg = "tron-fn-dsa-kat-cross".getBytes(); + FNDSA[] keys = new FNDSA[VECTORS.length]; + byte[][] sigs = new byte[VECTORS.length][]; + for (int i = 0; i < VECTORS.length; i++) { + keys[i] = new FNDSA(VECTORS[i].seed); + sigs[i] = keys[i].sign(msg); + } + for (int i = 0; i < VECTORS.length; i++) { + for (int j = 0; j < VECTORS.length; j++) { + if (i == j) { + assertTrue(VECTORS[i].label + ": self-verify must succeed", + FNDSA.verify(keys[i].getPublicKey(), msg, sigs[i])); + } else { + assertFalse("signature from " + VECTORS[i].label + + " must NOT verify under " + VECTORS[j].label, + FNDSA.verify(keys[j].getPublicKey(), msg, sigs[i])); + } + } + } + } + + @Test + public void distinctSeedsAtRuntimeAlsoProduceDistinctRuntimePublicKeys() { + // Belt-and-braces: the sanity check above only compared hard-coded digests. + // Re-derive at runtime and confirm they're still pairwise distinct. + byte[][] pks = new byte[VECTORS.length][]; + for (int i = 0; i < VECTORS.length; i++) { + pks[i] = new FNDSA(VECTORS[i].seed).getPublicKey(); + } + for (int i = 0; i < VECTORS.length; i++) { + for (int j = i + 1; j < VECTORS.length; j++) { + assertFalse( + VECTORS[i].label + " and " + VECTORS[j].label + + " produced identical pk bytes", + Arrays.equals(pks[i], pks[j])); + assertNotEquals( + VECTORS[i].label + " and " + VECTORS[j].label + + " produced identical pk digests", + hex(sha256(pks[i])), hex(sha256(pks[j]))); + } + } + } +} diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/FNDSATest.java b/framework/src/test/java/org/tron/common/crypto/pqc/FNDSATest.java new file mode 100644 index 00000000000..6298b1d251b --- /dev/null +++ b/framework/src/test/java/org/tron/common/crypto/pqc/FNDSATest.java @@ -0,0 +1,443 @@ +package org.tron.common.crypto.pqc; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.security.SecureRandom; +import org.bouncycastle.crypto.AsymmetricCipherKeyPair; +import org.bouncycastle.crypto.params.ParametersWithRandom; +import org.bouncycastle.pqc.crypto.falcon.FalconKeyGenerationParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconKeyPairGenerator; +import org.bouncycastle.pqc.crypto.falcon.FalconParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconPrivateKeyParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconPublicKeyParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconSigner; +import org.junit.Before; +import org.junit.Test; +import org.tron.protos.Protocol.PQScheme; + +public class FNDSATest { + + private static final FalconParameters PARAMS = FalconParameters.falcon_512; + + private FNDSA keypair; + private FalconPublicKeyParameters pk; + private FalconPrivateKeyParameters sk; + + @Before + public void setUp() { + AsymmetricCipherKeyPair kp = freshKeyPair(); + pk = (FalconPublicKeyParameters) kp.getPublic(); + sk = (FalconPrivateKeyParameters) kp.getPrivate(); + keypair = new FNDSA(sk.getEncoded(), pk.getH()); + } + + private static AsymmetricCipherKeyPair freshKeyPair() { + FalconKeyPairGenerator gen = new FalconKeyPairGenerator(); + gen.init(new FalconKeyGenerationParameters(new SecureRandom(), PARAMS)); + return gen.generateKeyPair(); + } + + private byte[] rawSign(byte[] message) { + FalconSigner signer = new FalconSigner(); + signer.init(true, new ParametersWithRandom(sk, new SecureRandom())); + try { + return signer.generateSignature(message); + } catch (Exception e) { + throw new AssertionError("failed to sign in test setup", e); + } + } + + @Test + public void schemeAndLengthsMatchFips206Draft() { + assertEquals(PQScheme.FN_DSA_512, keypair.getScheme()); + assertEquals(FNDSA.PUBLIC_KEY_LENGTH, keypair.getPublicKeyLength()); + assertEquals(FNDSA.SIGNATURE_LENGTH, keypair.getSignatureLength()); + assertEquals(FNDSA.PRIVATE_KEY_LENGTH, keypair.getPrivateKeyLength()); + assertEquals(FNDSA.PUBLIC_KEY_LENGTH, pk.getH().length); + } + + @Test + public void publicKeyHasFixedLength() { + for (int i = 0; i < 4; i++) { + AsymmetricCipherKeyPair kp = freshKeyPair(); + byte[] pkBytes = ((FalconPublicKeyParameters) kp.getPublic()).getH(); + assertEquals(FNDSA.PUBLIC_KEY_LENGTH, pkBytes.length); + } + } + + @Test + public void privateKeyEncodingHasFixedLength() { + for (int i = 0; i < 4; i++) { + AsymmetricCipherKeyPair kp = freshKeyPair(); + byte[] skBytes = ((FalconPrivateKeyParameters) kp.getPrivate()).getEncoded(); + assertEquals(FNDSA.PRIVATE_KEY_LENGTH, skBytes.length); + } + } + + @Test + public void signProducesVerifiableSignatureWithinBound() { + byte[] msg = "hello, fn-dsa".getBytes(); + byte[] sig = FNDSA.sign(sk.getEncoded(), msg); + assertTrue("signature must be non-empty", sig.length > 0); + assertTrue( + "signature must respect protocol-level upper bound", + sig.length <= FNDSA.SIGNATURE_LENGTH); + assertTrue(FNDSA.verify(pk.getH(), msg, sig)); + } + + @Test + public void signatureBoundaryAtMaxAcceptedByLengthCheck() { + byte[] sig = new byte[FNDSA.SIGNATURE_LENGTH]; + keypair.validateSignature(sig); + } + + @Test + public void signatureBoundaryAboveMaxRejected() { + byte[] sig = new byte[FNDSA.SIGNATURE_LENGTH + 1]; + try { + keypair.validateSignature(sig); + fail("signature longer than upper bound should be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("signature length")); + } + } + + @Test + public void minimalValidLengthAcceptedByLengthCheck() { + byte[] sig = new byte[1]; + keypair.validateSignature(sig); + } + + @Test + public void emptySignatureRejectedByLengthCheck() { + byte[] sig = new byte[0]; + try { + keypair.validateSignature(sig); + fail("empty signature should be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("signature length")); + } + } + + @Test + public void verifyRejectsSignatureLongerThanUpperBound() { + byte[] msg = new byte[] {1, 2, 3}; + byte[] tooLong = new byte[FNDSA.SIGNATURE_LENGTH + 1]; + try { + FNDSA.verify(pk.getH(), msg, tooLong); + fail("signature exceeding upper bound should be rejected at static verify"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("signature length")); + } + } + + @Test + public void verifyRejectsEmptySignature() { + byte[] msg = new byte[] {1, 2, 3}; + byte[] empty = new byte[0]; + try { + FNDSA.verify(pk.getH(), msg, empty); + fail("empty signature should be rejected at static verify"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("signature length")); + } + } + + @Test + public void invalidPublicKeyLengthRejected() { + byte[] badPk = new byte[FNDSA.PUBLIC_KEY_LENGTH - 1]; + byte[] msg = new byte[] {1}; + byte[] sig = new byte[16]; + try { + FNDSA.verify(badPk, msg, sig); + fail("short public key should be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("public key length")); + } + } + + @Test + public void nullMessageRejected() { + byte[] sig = new byte[16]; + try { + FNDSA.verify(pk.getH(), null, sig); + fail("null message should be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("message")); + } + } + + @Test + public void signatureBoundToMessage() { + byte[] msg = "hello".getBytes(); + byte[] sig = rawSign(msg); + byte[] tamperedMsg = "hellp".getBytes(); + assertFalse(keypair.verify(tamperedMsg, sig)); + } + + @Test + public void tamperedSignatureFailsVerification() { + byte[] msg = "payload".getBytes(); + byte[] sig = rawSign(msg); + sig[0] ^= 0x01; + assertFalse(keypair.verify(msg, sig)); + } + + @Test + public void wrongPublicKeyFailsVerification() { + byte[] msg = "payload".getBytes(); + byte[] sig = rawSign(msg); + AsymmetricCipherKeyPair other = freshKeyPair(); + byte[] otherPk = ((FalconPublicKeyParameters) other.getPublic()).getH(); + assertFalse(FNDSA.verify(otherPk, msg, sig)); + } + + @Test + public void crossAlgoSignatureRejected() { + // FN-DSA upper bound is 752 bytes; ML-DSA-44 (2420), ML-DSA-65 (3309), + // SLH-DSA (7856) all exceed it and must be rejected at the length check. + byte[] msg = "cross-algo".getBytes(); + int[] foreignLengths = {2420, 3309, 7856}; + for (int len : foreignLengths) { + byte[] foreign = new byte[len]; + try { + FNDSA.verify(pk.getH(), msg, foreign); + fail("foreign-scheme signature length " + len + " should be rejected for FN-DSA"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("signature length")); + } + } + } + + @Test + public void emptyMessageVerifiesConsistently() { + byte[] msg = new byte[0]; + byte[] sig = rawSign(msg); + assertTrue(keypair.verify(msg, sig)); + } + + @Test + public void keypairBoundInstanceSignsAndVerifies() { + FNDSA signer = new FNDSA(); + byte[] msg = "keypair-bound".getBytes(); + byte[] sig = signer.sign(msg); + assertTrue(sig.length > 0 && sig.length <= FNDSA.SIGNATURE_LENGTH); + assertTrue(signer.verify(msg, sig)); + } + + @Test + public void fromSeedIsDeterministic() { + byte[] seed = new byte[FNDSA.SEED_LENGTH]; + for (int i = 0; i < seed.length; i++) { + seed[i] = (byte) i; + } + FNDSA a = new FNDSA(seed); + FNDSA b = new FNDSA(seed); + assertArrayEquals(a.getPublicKey(), b.getPublicKey()); + assertArrayEquals(a.getPrivateKey(), b.getPrivateKey()); + } + + @Test(expected = IllegalArgumentException.class) + public void invalidSeedLengthRejected() { + new FNDSA(new byte[FNDSA.SEED_LENGTH - 1]); + } + + @Test(expected = UnsupportedOperationException.class) + public void derivePublicKeyFromEncodedPrivateKeyUnsupported() { + FNDSA.derivePublicKey(sk.getEncoded()); + } + + @Test + public void computeAddressIs21Bytes() { + assertEquals(21, FNDSA.computeAddress(pk.getH()).length); + } + + @Test + public void registryDispatchMatchesDirectCalls() { + byte[] msg = "registry-dispatch".getBytes(); + byte[] sigDirect = FNDSA.sign(sk.getEncoded(), msg); + assertTrue(PQSchemeRegistry.verify( + PQScheme.FN_DSA_512, pk.getH(), msg, sigDirect)); + byte[] sigViaRegistry = PQSchemeRegistry.sign( + PQScheme.FN_DSA_512, sk.getEncoded(), msg); + assertTrue(FNDSA.verify(pk.getH(), msg, sigViaRegistry)); + assertEquals(FNDSA.PUBLIC_KEY_LENGTH, + PQSchemeRegistry.getPublicKeyLength(PQScheme.FN_DSA_512)); + assertEquals(FNDSA.SIGNATURE_LENGTH, + PQSchemeRegistry.getSignatureLength(PQScheme.FN_DSA_512)); + } + + @Test + public void registryIsValidSignatureLengthRespectsUpperBound() { + assertTrue(PQSchemeRegistry.isValidSignatureLength(PQScheme.FN_DSA_512, 1)); + assertTrue(PQSchemeRegistry.isValidSignatureLength( + PQScheme.FN_DSA_512, FNDSA.SIGNATURE_LENGTH)); + assertFalse(PQSchemeRegistry.isValidSignatureLength(PQScheme.FN_DSA_512, 0)); + assertFalse(PQSchemeRegistry.isValidSignatureLength( + PQScheme.FN_DSA_512, FNDSA.SIGNATURE_LENGTH + 1)); + } + + @Test + public void registryComputeAddressMatchesDirect() { + assertArrayEquals( + FNDSA.computeAddress(pk.getH()), + PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pk.getH())); + } + + @Test(expected = IllegalArgumentException.class) + public void seedConstructorRejectsNull() { + new FNDSA((byte[]) null); + } + + @Test + public void keypairConstructorRejectsNullPrivateKey() { + try { + new FNDSA(null, pk.getH()); + fail("null private key must be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("private key length")); + } + } + + @Test + public void keypairConstructorRejectsWrongPrivateKeyLength() { + try { + new FNDSA(new byte[FNDSA.PRIVATE_KEY_LENGTH - 1], pk.getH()); + fail("short private key must be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("private key length")); + } + } + + @Test + public void keypairConstructorRejectsNullPublicKey() { + try { + new FNDSA(sk.getEncoded(), null); + fail("null public key must be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("public key length")); + } + } + + @Test + public void keypairConstructorRejectsWrongPublicKeyLength() { + try { + new FNDSA(sk.getEncoded(), new byte[FNDSA.PUBLIC_KEY_LENGTH + 1]); + fail("over-long public key must be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("public key length")); + } + } + + @Test + public void extendedPrivateKeyRoundTripsThroughFromAndGetters() { + byte[] extended = keypair.getPrivateKeyWithPublicKey(); + assertEquals(FNDSA.PRIVATE_KEY_WITH_PUBLIC_KEY_LENGTH, extended.length); + FNDSA restored = FNDSA.fromPrivateKeyWithPublicKey(extended); + assertArrayEquals(keypair.getPrivateKey(), restored.getPrivateKey()); + assertArrayEquals(keypair.getPublicKey(), restored.getPublicKey()); + // The recovered keypair must produce verifiable signatures and recover its address. + byte[] msg = "extended-key-roundtrip".getBytes(); + byte[] sig = restored.sign(msg); + assertTrue(restored.verify(msg, sig)); + assertArrayEquals(keypair.getAddress(), restored.getAddress()); + } + + @Test(expected = IllegalArgumentException.class) + public void fromExtendedPrivateKeyRejectsNull() { + FNDSA.fromPrivateKeyWithPublicKey(null); + } + + @Test(expected = IllegalArgumentException.class) + public void fromExtendedPrivateKeyRejectsWrongLength() { + FNDSA.fromPrivateKeyWithPublicKey(new byte[FNDSA.PRIVATE_KEY_LENGTH]); + } + + @Test + public void derivePublicKeyFromExtendedFormReturnsAppendedPublicKey() { + byte[] extended = keypair.getPrivateKeyWithPublicKey(); + byte[] derived = FNDSA.derivePublicKey(extended); + assertArrayEquals(keypair.getPublicKey(), derived); + } + + @Test(expected = UnsupportedOperationException.class) + public void derivePublicKeyRejectsNull() { + FNDSA.derivePublicKey(null); + } + + @Test + public void staticSignAcceptsExtendedPrivateKey() { + byte[] extended = keypair.getPrivateKeyWithPublicKey(); + byte[] msg = "static-sign-extended".getBytes(); + byte[] sig = FNDSA.sign(extended, msg); + assertTrue(sig.length > 0 && sig.length <= FNDSA.SIGNATURE_LENGTH); + assertTrue(FNDSA.verify(pk.getH(), msg, sig)); + } + + @Test + public void staticSignRejectsNullPrivateKey() { + try { + FNDSA.sign(null, new byte[] {1}); + fail("null private key must be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("private key length")); + } + } + + @Test + public void staticSignRejectsWrongPrivateKeyLength() { + try { + FNDSA.sign(new byte[FNDSA.PRIVATE_KEY_LENGTH - 1], new byte[] {1}); + fail("short private key must be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("private key length")); + } + } + + @Test + public void staticSignRejectsNullMessage() { + try { + FNDSA.sign(sk.getEncoded(), null); + fail("null message must be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("message")); + } + } + + @Test + public void staticVerifyRejectsNullPublicKey() { + try { + FNDSA.verify(null, new byte[] {1}, new byte[16]); + fail("null public key must be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("public key length")); + } + } + + @Test + public void unknownPqSchemeResolvesToFnDsa512() { + assertEquals(PQScheme.FN_DSA_512, + PQSchemeRegistry.resolve(PQScheme.UNKNOWN_PQ_SCHEME)); + assertTrue(PQSchemeRegistry.contains(PQScheme.UNKNOWN_PQ_SCHEME)); + assertEquals(FNDSA.PUBLIC_KEY_LENGTH, + PQSchemeRegistry.getPublicKeyLength(PQScheme.UNKNOWN_PQ_SCHEME)); + assertEquals(FNDSA.SIGNATURE_LENGTH, + PQSchemeRegistry.getSignatureLength(PQScheme.UNKNOWN_PQ_SCHEME)); + assertTrue(PQSchemeRegistry.isValidSignatureLength( + PQScheme.UNKNOWN_PQ_SCHEME, FNDSA.SIGNATURE_LENGTH)); + assertArrayEquals( + FNDSA.computeAddress(pk.getH()), + PQSchemeRegistry.computeAddress(PQScheme.UNKNOWN_PQ_SCHEME, pk.getH())); + + byte[] msg = "unknown-resolves-to-falcon".getBytes(); + byte[] sig = PQSchemeRegistry.sign( + PQScheme.UNKNOWN_PQ_SCHEME, sk.getEncoded(), msg); + assertTrue(PQSchemeRegistry.verify( + PQScheme.UNKNOWN_PQ_SCHEME, pk.getH(), msg, sig)); + assertTrue(FNDSA.verify(pk.getH(), msg, sig)); + } +} diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/PQSchemeRegistryTest.java b/framework/src/test/java/org/tron/common/crypto/pqc/PQSchemeRegistryTest.java new file mode 100644 index 00000000000..203d4625ca8 --- /dev/null +++ b/framework/src/test/java/org/tron/common/crypto/pqc/PQSchemeRegistryTest.java @@ -0,0 +1,130 @@ +package org.tron.common.crypto.pqc; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.util.Arrays; +import org.junit.Test; +import org.tron.protos.Protocol.PQScheme; + +/** + * Covers the static dispatch helpers of {@link PQSchemeRegistry} and the + * defensive paths exercised by callers passing {@code null}, {@code UNRECOGNIZED} + * or wrong-shaped public keys. + */ +public class PQSchemeRegistryTest { + + @Test + public void containsRejectsNullScheme() { + assertFalse(PQSchemeRegistry.contains(null)); + } + + @Test + public void containsRejectsUnrecognized() { + assertFalse(PQSchemeRegistry.contains(PQScheme.UNRECOGNIZED)); + } + + @Test + public void containsAcceptsRegisteredScheme() { + assertTrue(PQSchemeRegistry.contains(PQScheme.FN_DSA_512)); + } + + @Test + public void getSeedLengthReturnsRegisteredValue() { + assertEquals(FNDSA.SEED_LENGTH, + PQSchemeRegistry.getSeedLength(PQScheme.FN_DSA_512)); + // UNKNOWN_PQ_SCHEME normalizes to FN_DSA_512. + assertEquals(FNDSA.SEED_LENGTH, + PQSchemeRegistry.getSeedLength(PQScheme.UNKNOWN_PQ_SCHEME)); + } + + @Test + public void getPrivateKeyLengthReturnsRegisteredValue() { + assertEquals(FNDSA.PRIVATE_KEY_LENGTH, + PQSchemeRegistry.getPrivateKeyLength(PQScheme.FN_DSA_512)); + } + + @Test + public void fromSeedDispatchesToFalcon() { + byte[] seed = new byte[FNDSA.SEED_LENGTH]; + Arrays.fill(seed, (byte) 0x07); + PQSignature sig = PQSchemeRegistry.fromSeed(PQScheme.FN_DSA_512, seed); + assertNotNull(sig); + assertEquals(PQScheme.FN_DSA_512, sig.getScheme()); + // Same seed must yield deterministic keypair across direct and dispatched paths. + FNDSA direct = new FNDSA(seed); + assertArrayEquals(direct.getPublicKey(), sig.getPublicKey()); + assertArrayEquals(direct.getPrivateKey(), sig.getPrivateKey()); + } + + @Test + public void fromKeypairDispatchesAndPreservesAddress() { + byte[] seed = new byte[FNDSA.SEED_LENGTH]; + Arrays.fill(seed, (byte) 0x09); + FNDSA src = new FNDSA(seed); + PQSignature sig = PQSchemeRegistry.fromKeypair( + PQScheme.FN_DSA_512, src.getPrivateKey(), src.getPublicKey()); + assertArrayEquals(src.getAddress(), sig.getAddress()); + byte[] msg = "from-keypair".getBytes(); + byte[] s = sig.sign(msg); + assertTrue(sig.verify(msg, s)); + } + + @Test + public void deriveHashRejectsNullPublicKey() { + try { + PQSchemeRegistry.deriveHash(PQScheme.FN_DSA_512, null); + fail("null public key must be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("public key length")); + } + } + + @Test + public void deriveHashRejectsWrongLengthPublicKey() { + try { + PQSchemeRegistry.deriveHash( + PQScheme.FN_DSA_512, new byte[FNDSA.PUBLIC_KEY_LENGTH - 1]); + fail("short public key must be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("public key length")); + } + } + + @Test + public void requireRejectsNullScheme() { + try { + PQSchemeRegistry.getPublicKeyLength(null); + fail("null scheme must be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("scheme")); + } + } + + @Test + public void requireRejectsUnrecognizedScheme() { + try { + PQSchemeRegistry.getPublicKeyLength(PQScheme.UNRECOGNIZED); + fail("UNRECOGNIZED scheme must be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("PQSignature registered")); + } + } + + @Test + public void resolvePassesThroughNonDefaultSchemes() { + assertEquals(PQScheme.FN_DSA_512, + PQSchemeRegistry.resolve(PQScheme.FN_DSA_512)); + // null should pass through so contains/require can decide. + assertTrue(PQSchemeRegistry.resolve(null) == null); + } + + @Test + public void isValidSignatureLengthRejectsZero() { + assertFalse(PQSchemeRegistry.isValidSignatureLength(PQScheme.FN_DSA_512, 0)); + } +} diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/PQSignatureDefaultsTest.java b/framework/src/test/java/org/tron/common/crypto/pqc/PQSignatureDefaultsTest.java new file mode 100644 index 00000000000..748885e998f --- /dev/null +++ b/framework/src/test/java/org/tron/common/crypto/pqc/PQSignatureDefaultsTest.java @@ -0,0 +1,132 @@ +package org.tron.common.crypto.pqc; + +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import org.junit.Before; +import org.junit.Test; +import org.tron.protos.Protocol.PQScheme; + +/** + * Drives the {@link PQSignature} default validator branches (null and + * length-mismatch) via a minimal in-test implementation. {@link FNDSA} + * exposes these defaults but the cryptographic instances exercise mostly the + * happy paths; the explicit fixture here forces the error legs. + */ +public class PQSignatureDefaultsTest { + + private PQSignature stub; + + @Before + public void setUp() { + stub = new PQSignature() { + @Override + public PQScheme getScheme() { + return PQScheme.FN_DSA_512; + } + + @Override + public int getPrivateKeyLength() { + return 16; + } + + @Override + public int getPublicKeyLength() { + return 8; + } + + @Override + public int getSignatureLength() { + return 32; + } + + @Override + public byte[] getPrivateKey() { + return new byte[getPrivateKeyLength()]; + } + + @Override + public byte[] getPublicKey() { + return new byte[getPublicKeyLength()]; + } + + @Override + public byte[] getAddress() { + return new byte[21]; + } + + @Override + public byte[] sign(byte[] message) { + return new byte[1]; + } + + @Override + public boolean verify(byte[] message, byte[] signature) { + return false; + } + }; + } + + @Test + public void validatePrivateKeyRejectsNull() { + try { + stub.validatePrivateKey(null); + fail("null private key must be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("private key length")); + assertTrue(expected.getMessage().contains("null")); + } + } + + @Test + public void validatePrivateKeyRejectsWrongLength() { + try { + stub.validatePrivateKey(new byte[stub.getPrivateKeyLength() - 1]); + fail("short private key must be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("private key length")); + } + } + + @Test + public void validatePrivateKeyAcceptsExactLength() { + stub.validatePrivateKey(new byte[stub.getPrivateKeyLength()]); + } + + @Test + public void validatePublicKeyRejectsNull() { + try { + stub.validatePublicKey(null); + fail("null public key must be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("public key length")); + assertTrue(expected.getMessage().contains("null")); + } + } + + @Test + public void validatePublicKeyRejectsWrongLength() { + try { + stub.validatePublicKey(new byte[stub.getPublicKeyLength() + 1]); + fail("over-long public key must be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("public key length")); + } + } + + @Test + public void validatePublicKeyAcceptsExactLength() { + stub.validatePublicKey(new byte[stub.getPublicKeyLength()]); + } + + @Test + public void validateSignatureRejectsNull() { + try { + stub.validateSignature(null); + fail("null signature must be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("signature length")); + assertTrue(expected.getMessage().contains("null")); + } + } +} diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/SignatureSchemeBenchmarkTest.java b/framework/src/test/java/org/tron/common/crypto/pqc/SignatureSchemeBenchmarkTest.java new file mode 100644 index 00000000000..f7fdb8d7876 --- /dev/null +++ b/framework/src/test/java/org/tron/common/crypto/pqc/SignatureSchemeBenchmarkTest.java @@ -0,0 +1,132 @@ +package org.tron.common.crypto.pqc; + +import java.security.SignatureException; +import java.util.Locale; +import org.junit.Test; +import org.tron.common.crypto.ECKey; +import org.tron.common.crypto.ECKey.ECDSASignature; +import org.tron.common.crypto.Hash; + +/** + * Micro-benchmark comparing key generation, signing and verification latency for + * secp256k1 ECDSA (ECKey) and FN-DSA / Falcon-512. Numbers are reported + * in microseconds (avg of {@link #ITERATIONS} iterations after {@link #WARMUP} warm-up rounds). + */ +public class SignatureSchemeBenchmarkTest { + + private static final int WARMUP = 20; + private static final int ITERATIONS = 500; + private static final byte[] MESSAGE = "tron-pq-benchmark-message".getBytes(); + private static final byte[] MESSAGE_HASH = Hash.sha3(MESSAGE); + + @Test + public void benchmarkAllSchemes() { + Result eckey = benchEcKey(); + Result fndsa = benchFnDsa(); + + System.out.println(String.format(Locale.ROOT, + "=== Signature scheme benchmark (avg over %d iterations, warmup %d) ===", + ITERATIONS, WARMUP)); + System.out.println(String.format(Locale.ROOT, + "%-12s | %12s | %12s | %12s", + "scheme", "keygen (us)", "sign (us)", "verify (us)")); + System.out.println("-------------+--------------+--------------+--------------"); + printResult(eckey); + printResult(fndsa); + } + + private Result benchEcKey() { + for (int i = 0; i < WARMUP; i++) { + ECKey k = new ECKey(); + ECDSASignature s = k.sign(MESSAGE_HASH); + try { + ECKey.signatureToAddress(MESSAGE_HASH, s); + } catch (SignatureException e) { + throw new AssertionError(e); + } + } + + long keygenNs = 0; + ECKey[] keys = new ECKey[ITERATIONS]; + for (int i = 0; i < ITERATIONS; i++) { + long t0 = System.nanoTime(); + keys[i] = new ECKey(); + keygenNs += System.nanoTime() - t0; + } + + long signNs = 0; + ECDSASignature[] sigs = new ECDSASignature[ITERATIONS]; + for (int i = 0; i < ITERATIONS; i++) { + long t0 = System.nanoTime(); + sigs[i] = keys[i].sign(MESSAGE_HASH); + signNs += System.nanoTime() - t0; + } + + long verifyNs = 0; + for (int i = 0; i < ITERATIONS; i++) { + long t0 = System.nanoTime(); + try { + ECKey.signatureToAddress(MESSAGE_HASH, sigs[i]); + } catch (SignatureException e) { + throw new AssertionError(e); + } + verifyNs += System.nanoTime() - t0; + } + return new Result("ECKey(secp)", keygenNs, signNs, verifyNs); + } + + private Result benchFnDsa() { + for (int i = 0; i < WARMUP; i++) { + FNDSA k = new FNDSA(); + byte[] sig = k.sign(MESSAGE); + k.verify(MESSAGE, sig); + } + + long keygenNs = 0; + FNDSA[] keys = new FNDSA[ITERATIONS]; + for (int i = 0; i < ITERATIONS; i++) { + long t0 = System.nanoTime(); + keys[i] = new FNDSA(); + keygenNs += System.nanoTime() - t0; + } + + long signNs = 0; + byte[][] sigs = new byte[ITERATIONS][]; + for (int i = 0; i < ITERATIONS; i++) { + long t0 = System.nanoTime(); + sigs[i] = keys[i].sign(MESSAGE); + signNs += System.nanoTime() - t0; + } + + long verifyNs = 0; + for (int i = 0; i < ITERATIONS; i++) { + long t0 = System.nanoTime(); + keys[i].verify(MESSAGE, sigs[i]); + verifyNs += System.nanoTime() - t0; + } + return new Result("FN-DSA-512", keygenNs, signNs, verifyNs); + } + + private static void printResult(Result r) { + System.out.println(String.format(Locale.ROOT, + "%-12s | %12.2f | %12.2f | %12.2f", + r.name, + r.keygenNs / 1_000.0 / ITERATIONS, + r.signNs / 1_000.0 / ITERATIONS, + r.verifyNs / 1_000.0 / ITERATIONS)); + } + + private static final class Result { + final String name; + final long keygenNs; + final long signNs; + final long verifyNs; + + Result(String name, long keygenNs, long signNs, long verifyNs) { + this.name = name; + this.keygenNs = keygenNs; + this.signNs = signNs; + this.verifyNs = verifyNs; + } + } +} diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/program/PQClient.java b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQClient.java new file mode 100644 index 00000000000..6522611946c --- /dev/null +++ b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQClient.java @@ -0,0 +1,147 @@ +package org.tron.common.crypto.pqc.program; + +import com.google.protobuf.Any; +import com.google.protobuf.ByteString; +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; +import java.nio.ByteBuffer; +import java.security.MessageDigest; +import java.util.Arrays; +import java.util.concurrent.TimeUnit; +import org.tron.api.GrpcAPI.EmptyMessage; +import org.tron.api.GrpcAPI.Return; +import org.tron.api.WalletGrpc; +import org.tron.api.WalletGrpc.WalletBlockingStub; +import org.tron.common.crypto.pqc.FNDSA; +import org.tron.common.utils.ByteArray; +import org.tron.protos.Protocol.Block; +import org.tron.protos.Protocol.PQAuthSig; +import org.tron.protos.Protocol.Transaction; +import org.tron.protos.Protocol.Transaction.Contract.ContractType; +import org.tron.protos.contract.BalanceContract.TransferContract; + +/** + * Demo client that connects to {@link PQWitnessNode} and broadcasts an FN-DSA-512 + * signed transfer transaction. + * + * The keypair is derived from the same fixed seed used by PQWitnessNode, so no + * out-of-band key exchange is needed. + * + * Usage: + * Terminal 1 — start the witness node first: + * ./gradlew :framework:run -PmainClass=org.tron.common.crypto.pqc.program.PQWitnessNode + * Terminal 2 — broadcast a PQC transaction: + * ./gradlew :framework:run -PmainClass=org.tron.common.crypto.pqc.program.PQClient + * + * Optional JVM args: + * -Dpqc.host=localhost (default: localhost) + * -Dpqc.port=50051 (default: 50051) + */ +public class PQClient { + + private static final String HOST = + System.getProperty("pqc.host", "localhost"); + private static final int PORT = + Integer.parseInt(System.getProperty("pqc.port", "50051")); + + /** Recipient of the demo transfer. */ + private static final byte[] TO_ADDR = + ByteArray.fromHexString("41f522cc20ca18b636bdd93b4fb15ea84cc2b4e001"); + + public static void main(String[] args) throws Exception { + // Force INFO level: logback-test.xml (on the test classpath) sets root=DEBUG + // which is far too noisy for a demo run. + ((ch.qos.logback.classic.Logger) org.slf4j.LoggerFactory + .getLogger(ch.qos.logback.classic.Logger.ROOT_LOGGER_NAME)) + .setLevel(ch.qos.logback.classic.Level.INFO); + + // ── 1. Derive user keypair from same fixed seed as PQWitnessNode ───── + byte[] userSeed = new byte[FNDSA.SEED_LENGTH]; + Arrays.fill(userSeed, (byte) 0x02); + FNDSA userKp = new FNDSA(userSeed); + + byte[] userPub = userKp.getPublicKey(); + byte[] userPriv = userKp.getPrivateKey(); + byte[] signerAddr = FNDSA.computeAddress(userPub); + byte[] ownerAddr = PQWitnessNode.USER_ADDR; + + System.out.println("=== PQC Client ==="); + System.out.println("Connecting to " + HOST + ":" + PORT); + System.out.println("Owner address: " + ByteArray.toHexString(ownerAddr)); + System.out.println("Signer address: " + ByteArray.toHexString(signerAddr)); + + // ── 2. Connect via gRPC ─────────────────────────────────────────────── + ManagedChannel channel = ManagedChannelBuilder + .forAddress(HOST, PORT) + .usePlaintext() + .build(); + WalletBlockingStub stub = WalletGrpc.newBlockingStub(channel) + .withDeadlineAfter(10, TimeUnit.SECONDS); + + try { + // ── 3. Fetch reference block for TaPoS ─────────────────────────── + Block head = stub.getNowBlock(EmptyMessage.getDefaultInstance()); + byte[] headerRaw = head.getBlockHeader().getRawData().toByteArray(); + long refNum = head.getBlockHeader().getRawData().getNumber(); + byte[] blockHash = sha256(headerRaw); + + System.out.println("Reference block: #" + refNum + + " hash=" + ByteArray.toHexString(Arrays.copyOfRange(blockHash, 0, 8)) + "..."); + + // ── 4. Build the transfer transaction ───────────────────────────── + Transaction.raw rawData = Transaction.raw.newBuilder() + .addContract(Transaction.Contract.newBuilder() + .setType(ContractType.TransferContract) + .setParameter(Any.pack(TransferContract.newBuilder() + .setOwnerAddress(ByteString.copyFrom(ownerAddr)) + .setToAddress(ByteString.copyFrom(TO_ADDR)) + .setAmount(1_000_000L) // 1 TRX + .build())) + .setPermissionId(0)) + // TaPoS fields + .setRefBlockHash(ByteString.copyFrom(Arrays.copyOfRange(blockHash, 8, 16))) + .setRefBlockBytes(ByteString.copyFrom(longToBytes(refNum), 6, 2)) + .setExpiration(System.currentTimeMillis() + 60_000L) + .build(); + + Transaction tx = Transaction.newBuilder().setRawData(rawData).build(); + + // ── 5. Sign with FN-DSA-512 pq_auth_sig ───────────────────────────── + byte[] txId = sha256(rawData.toByteArray()); + byte[] sig = FNDSA.sign(userPriv, txId); + + // FN_DSA_512 is the launch scheme → leave scheme at proto3 default and + // let PQSchemeRegistry.resolve() normalize it on the verifier side. + Transaction signedTx = tx.toBuilder() + .addPqAuthSig(PQAuthSig.newBuilder() + .setPublicKey(ByteString.copyFrom(userPub)) + .setSignature(ByteString.copyFrom(sig))) + .build(); + + System.out.println("TX id: " + ByteArray.toHexString(txId)); + + // ── 6. Broadcast ────────────────────────────────────────────────── + Return result = stub.broadcastTransaction(signedTx); + System.out.println("Broadcast result: " + result.getCode() + + " — " + result.getMessage().toStringUtf8()); + + if (result.getResult()) { + System.out.println("SUCCESS: PQC-signed transaction accepted by the node."); + } else { + System.out.println("REJECTED: " + result.getCode()); + } + + } finally { + channel.shutdown(); + channel.awaitTermination(5, TimeUnit.SECONDS); + } + } + + private static byte[] sha256(byte[] data) throws Exception { + return MessageDigest.getInstance("SHA-256").digest(data); + } + + private static byte[] longToBytes(long value) { + return ByteBuffer.allocate(8).putLong(value).array(); + } +} diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/program/PQFullNode.java b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQFullNode.java new file mode 100644 index 00000000000..7f628f84b70 --- /dev/null +++ b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQFullNode.java @@ -0,0 +1,118 @@ +package org.tron.common.crypto.pqc.program; + +import java.io.File; +import java.net.InetSocketAddress; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Collections; +import org.tron.common.application.Application; +import org.tron.common.application.ApplicationFactory; +import org.tron.common.application.TronApplicationContext; +import org.tron.common.crypto.pqc.FNDSA; +import org.tron.common.utils.ByteArray; +import org.tron.core.ChainBaseManager; +import org.tron.core.config.DefaultConfig; +import org.tron.core.config.args.Args; +import org.tron.core.db.Manager; + +/** + * Demo fullnode that dials {@link PQWitnessNode} via P2P and syncs PQ-signed blocks. + * + * Both nodes share the same deterministic PQ genesis pre-state (witness account with an + * FN-DSA-512 witness permission + demo user account with an FN-DSA-512 owner permission), + * installed via {@link PQWitnessNode#installPQGenesisState}. Once the witness produces + * a block it is broadcast over P2P; this node validates {@code BlockHeader.pq_auth_sig} + * against the same on-chain public key and applies the block. + * + * Usage: + * Terminal 1 — start the witness node first: + * ./gradlew :framework:run -PmainClass=org.tron.common.crypto.pqc.program.PQWitnessNode + * Terminal 2 — start a fullnode that syncs from it: + * ./gradlew :framework:run -PmainClass=org.tron.common.crypto.pqc.program.PQFullNode + * + * Optional JVM args: + * -Dpqc.witness.host=127.0.0.1 (default: 127.0.0.1) + * -Dpqc.witness.p2p.port=18888 (default: PQWitnessNode.P2P_PORT) + */ +public class PQFullNode { + + /** gRPC port (different from PQWitnessNode so both can run on one host). */ + static final int GRPC_PORT = 50052; + /** Full-node HTTP port (different from PQWitnessNode). */ + static final int HTTP_PORT = 8091; + /** P2P listen port (different from PQWitnessNode). */ + static final int P2P_PORT = 18889; + + private static final String WITNESS_HOST = + System.getProperty("pqc.witness.host", "127.0.0.1"); + private static final int WITNESS_P2P_PORT = Integer.parseInt( + System.getProperty("pqc.witness.p2p.port", String.valueOf(PQWitnessNode.P2P_PORT))); + + public static void main(String[] args) throws Exception { + // Force INFO level: logback-test.xml (on the test classpath) sets root=DEBUG + // which is far too noisy for a demo run. + ((ch.qos.logback.classic.Logger) org.slf4j.LoggerFactory + .getLogger(ch.qos.logback.classic.Logger.ROOT_LOGGER_NAME)) + .setLevel(ch.qos.logback.classic.Level.INFO); + + // ── 1. Derive the same deterministic keys used by PQWitnessNode ────── + FNDSA witnessKp = new FNDSA(PQWitnessNode.WITNESS_SEED); + FNDSA userKp = new FNDSA(PQWitnessNode.USER_SEED); + + byte[] witnessPub = witnessKp.getPublicKey(); + byte[] userPub = userKp.getPublicKey(); + + System.out.println("=== PQC Full Node ==="); + System.out.println("Peer (witness): " + WITNESS_HOST + ":" + WITNESS_P2P_PORT); + System.out.println("gRPC port: " + GRPC_PORT); + System.out.println("HTTP port: " + HTTP_PORT); + System.out.println("P2P port: " + P2P_PORT); + System.out.println("Witness address (expected): " + + ByteArray.toHexString(FNDSA.computeAddress(witnessPub))); + + // ── 2. Configure node (no -w: this is a pure fullnode) ──────────────── + File dbDir = Files.createTempDirectory("pqc-fullnode-").toFile(); + dbDir.deleteOnExit(); + + Args.setParam(new String[]{"--output-directory", dbDir.getAbsolutePath()}, + "config-test.conf"); + Args.getInstance().setRpcEnable(true); + Args.getInstance().setFullNodeHttpEnable(true); + Args.getInstance().setFullNodeHttpPort(HTTP_PORT); + Args.getInstance().setSolidityNodeHttpEnable(false); + Args.getInstance().setRpcPort(GRPC_PORT); + Args.getInstance().setNodeListenPort(P2P_PORT); + Args.getInstance().setNeedSyncCheck(false); + Args.getInstance().setMinEffectiveConnection(0); + Args.getInstance().genesisBlock.setWitnesses(new ArrayList<>()); + + // Point to the witness node as the only seed peer. + // Mutable list — startup appends persisted peers to it. + Args.getInstance().getSeedNode().setAddressList(new ArrayList<>( + Collections.singletonList(new InetSocketAddress(WITNESS_HOST, WITNESS_P2P_PORT)))); + + // ── 3. Start Spring context ─────────────────────────────────────────── + TronApplicationContext context = new TronApplicationContext(DefaultConfig.class); + Application app = ApplicationFactory.create(context); + Manager db = context.getBean(Manager.class); + ChainBaseManager chain = context.getBean(ChainBaseManager.class); + + // ── 4. Install matching PQ genesis pre-state ────────────────────────── + // Without this the incoming pq_auth_sig would fail to validate because + // this node wouldn't know the witness's FN-DSA-512 public key. + PQWitnessNode.installPQGenesisState(db, chain, witnessPub, userPub); + + // ── 5. Start P2P + gRPC (no ConsensusService.start — we don't produce) ─ + app.startup(); + + System.out.println("\nFull node running, syncing from witness. Send Ctrl-C to stop.\n"); + + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + System.out.println("Shutting down..."); + context.close(); + Args.clearParam(); + })); + + Thread.currentThread().join(); + } +} diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/program/PQWitnessNode.java b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQWitnessNode.java new file mode 100644 index 00000000000..f677a44cf01 --- /dev/null +++ b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQWitnessNode.java @@ -0,0 +1,204 @@ +package org.tron.common.crypto.pqc.program; + +import com.google.protobuf.ByteString; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import org.bouncycastle.util.encoders.Hex; +import org.tron.common.application.Application; +import org.tron.common.application.ApplicationFactory; +import org.tron.common.application.TronApplicationContext; +import org.tron.common.crypto.pqc.FNDSA; +import org.tron.common.crypto.pqc.PQSchemeRegistry; +import org.tron.common.utils.ByteArray; +import org.tron.core.ChainBaseManager; +import org.tron.core.capsule.AccountCapsule; +import org.tron.core.capsule.WitnessCapsule; +import org.tron.core.config.DefaultConfig; +import org.tron.core.config.args.Args; +import org.tron.core.consensus.ConsensusService; +import org.tron.core.db.Manager; +import org.tron.protos.Protocol.Account; +import org.tron.protos.Protocol.AccountType; +import org.tron.protos.Protocol.Key; +import org.tron.protos.Protocol.PQScheme; +import org.tron.protos.Protocol.Permission; +import org.tron.protos.Protocol.Permission.PermissionType; + +/** + * Demo witness node with FN-DSA-512 block production. + * + * Starts an in-process TRON node configured with a PQC witness keypair and + * a user account that holds an FN-DSA-512 owner permission — ready to receive + * transactions from {@link PQClient}. + * + * Keypairs are derived from fixed seeds so PQClient can derive matching keys + * without any out-of-band coordination. + * + * Usage: + * Terminal 1 — start this node: + * ./gradlew :framework:run -PmainClass=org.tron.common.crypto.pqc.program.PQWitnessNode + * Terminal 2 — broadcast a PQC transaction: + * ./gradlew :framework:run -PmainClass=org.tron.common.crypto.pqc.program.PQClient + */ +public class PQWitnessNode { + + /** Fixed seed for the FN-DSA-512 witness keypair (shared with PQClient for derivation). */ + static final byte[] WITNESS_SEED = filledSeed(0x01); + /** Fixed seed for the FN-DSA-512 user keypair (shared with PQClient for derivation). */ + static final byte[] USER_SEED = filledSeed(0x02); + + /** gRPC port the node listens on. */ + static final int GRPC_PORT = 50051; + + /** Full-node HTTP port. */ + static final int HTTP_PORT = 8090; + + /** P2P listen port (shared with PQFullNode so it can dial in as a seed peer). */ + static final int P2P_PORT = 18888; + + /** Fixed on-chain address for the demo user account. */ + static final byte[] USER_ADDR = + ByteArray.fromHexString("41abd4b9367799eaa3197fecb144eb71de1e049abc"); + + public static void main(String[] args) throws Exception { + // Force INFO level: logback-test.xml (on the test classpath) sets root=DEBUG + // which is far too noisy for a demo run. + ((ch.qos.logback.classic.Logger) org.slf4j.LoggerFactory + .getLogger(ch.qos.logback.classic.Logger.ROOT_LOGGER_NAME)) + .setLevel(ch.qos.logback.classic.Level.INFO); + + // ── 1. Derive deterministic keypairs ────────────────────────────────── + FNDSA witnessKp = new FNDSA(WITNESS_SEED); + FNDSA userKp = new FNDSA(USER_SEED); + + byte[] witnessPub = witnessKp.getPublicKey(); + byte[] witnessAddr = FNDSA.computeAddress(witnessPub); + byte[] userPub = userKp.getPublicKey(); + byte[] signerAddr = FNDSA.computeAddress(userPub); + + System.out.println("=== PQC Witness Node ==="); + System.out.println("Witness address (FN-DSA-512): " + ByteArray.toHexString(witnessAddr)); + System.out.println("User address: " + ByteArray.toHexString(USER_ADDR)); + System.out.println("User signer address: " + ByteArray.toHexString(signerAddr)); + System.out.println("gRPC port: " + GRPC_PORT); + System.out.println("HTTP port: " + HTTP_PORT); + System.out.println("P2P port: " + P2P_PORT); + + // ── 2. Configure node ───────────────────────────────────────────────── + File dbDir = Files.createTempDirectory("pqc-node-").toFile(); + dbDir.deleteOnExit(); + + // Inject the witness keypair via a temp HOCON config that includes + // config-test.conf and overrides localwitness_pq_keys with the extended + // priv‖pub hex derived from WITNESS_SEED (matches what PQClient derives). + Path conf = writeWitnessConfig(witnessKp); + + Args.setParam(new String[]{"--output-directory", dbDir.getAbsolutePath(), "-w"}, + conf.toString()); + Args.getInstance().setRpcEnable(true); + Args.getInstance().setFullNodeHttpEnable(true); + Args.getInstance().setFullNodeHttpPort(HTTP_PORT); + Args.getInstance().setRpcPort(GRPC_PORT); + Args.getInstance().setNodeListenPort(P2P_PORT); + Args.getInstance().setNeedSyncCheck(false); + Args.getInstance().setMinEffectiveConnection(0); + Args.getInstance().genesisBlock.setWitnesses(new ArrayList<>()); + + // ── 3. Start Spring context ─────────────────────────────────────────── + TronApplicationContext context = new TronApplicationContext(DefaultConfig.class); + Application app = ApplicationFactory.create(context); + Manager db = context.getBean(Manager.class); + ChainBaseManager chain = context.getBean(ChainBaseManager.class); + + // ── 4. Install PQ genesis pre-state (shared with PQFullNode) ───────── + installPQGenesisState(db, chain, witnessPub, userPub); + + // ── 5. Start consensus (DposTask auto-produces blocks) ─────────────── + context.getBean(ConsensusService.class).start(); + + // ── 6. Start gRPC / P2P server ─────────────────────────────────────── + app.startup(); + + System.out.println("\nNode is running. Send Ctrl-C to stop."); + System.out.println("Run PQClient or PQFullNode in another terminal.\n"); + + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + System.out.println("Shutting down..."); + context.close(); + Args.clearParam(); + })); + + Thread.currentThread().join(); // block until Ctrl-C + } + + /** + * Apply the PQ-specific pre-state that must exist on every node participating + * in the demo network. Both PQWitnessNode and PQFullNode call this so their + * genesis state matches before the first PQ block is produced / received. + */ + static void installPQGenesisState(Manager db, ChainBaseManager chain, + byte[] witnessPub, byte[] userPub) { + byte[] witnessAddr = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, witnessPub); + ByteString witnessAddrBs = ByteString.copyFrom(witnessAddr); + byte[] signerAddr = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, userPub); + ByteString signerAddrBs = ByteString.copyFrom(signerAddr); + + // Activate FN-DSA on the local chain params. + db.getDynamicPropertiesStore().saveAllowFnDsa512(1L); + db.getDynamicPropertiesStore().saveAllowMultiSign(1L); + + // Witness account with FN-DSA-512 witness permission. Address-as-fingerprint + // binds the public key in-band; no separate pq_key field is stored. + Permission witnessPerm = Permission.newBuilder() + .setType(PermissionType.Witness) + .setId(1).setPermissionName("witness").setThreshold(1) + .addKeys(Key.newBuilder() + .setAddress(witnessAddrBs).setWeight(1)) + .build(); + db.getAccountStore().put(witnessAddr, new AccountCapsule(Account.newBuilder() + .setAddress(witnessAddrBs).setType(AccountType.Normal) + .setBalance(1_000_000_000L).setIsWitness(true) + .setWitnessPermission(witnessPerm).build())); + + // The witness must be in the witness store BEFORE consensus starts so that + // DposService.start() includes it in the active-witness schedule. + chain.getWitnessStore().put(witnessAddr, new WitnessCapsule(witnessAddrBs)); + chain.getWitnessScheduleStore().saveActiveWitnesses(new ArrayList<>()); + chain.addWitness(witnessAddrBs); + + // User account with FN-DSA-512 owner permission. + Permission userOwnerPerm = Permission.newBuilder() + .setType(PermissionType.Owner).setPermissionName("owner").setThreshold(1) + .addKeys(Key.newBuilder() + .setAddress(signerAddrBs).setWeight(1)) + .build(); + AccountCapsule userCapsule = new AccountCapsule( + ByteString.copyFrom(USER_ADDR), ByteString.copyFromUtf8("pquser"), AccountType.Normal); + userCapsule.setBalance(100_000_000L); // 100 TRX + userCapsule.updatePermissions(userOwnerPerm, null, Collections.emptyList()); + db.getAccountStore().put(USER_ADDR, userCapsule); + } + + private static byte[] filledSeed(int value) { + byte[] seed = new byte[FNDSA.SEED_LENGTH]; + Arrays.fill(seed, (byte) value); + return seed; + } + + private static Path writeWitnessConfig(FNDSA witnessKp) throws java.io.IOException { + Path conf = Files.createTempFile("pqc-witness-", ".conf"); + conf.toFile().deleteOnExit(); + String body = "include classpath(\"config-test.conf\")\n" + + "localwitness_pq_scheme = \"FN_DSA_512\"\n" + + "localwitness_pq_keys = [\n" + + " \"" + Hex.toHexString(witnessKp.getPrivateKeyWithPublicKey()) + "\"\n" + + "]\n"; + Files.write(conf, body.getBytes(StandardCharsets.UTF_8)); + return conf; + } +} diff --git a/framework/src/test/java/org/tron/common/runtime/vm/BatchValidateSignPQTest.java b/framework/src/test/java/org/tron/common/runtime/vm/BatchValidateSignPQTest.java new file mode 100644 index 00000000000..aae387ad2b7 --- /dev/null +++ b/framework/src/test/java/org/tron/common/runtime/vm/BatchValidateSignPQTest.java @@ -0,0 +1,346 @@ +package org.tron.common.runtime.vm; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.tuple.Pair; +import org.bouncycastle.util.encoders.Hex; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.tron.common.crypto.pqc.FNDSA; +import org.tron.common.crypto.pqc.PQSchemeRegistry; +import org.tron.common.utils.client.utils.AbiUtil; +import org.tron.core.vm.PrecompiledContracts; +import org.tron.core.vm.PrecompiledContracts.BatchValidateSignPQ; +import org.tron.core.vm.PrecompiledContracts.PrecompiledContract; +import org.tron.core.vm.config.VMConfig; +import org.tron.protos.Protocol.PQScheme; + +/** + * Unit tests for the 0x18 batch independent Falcon-512 verify precompile. + * Returns a 256-bit bitmap where bit i is set iff + * {@code derive(pk_i) == expectedAddr_i && FNDSA.verify(pk_i, hash, sig_i)}. + * Stateless — no chain DB. + */ +@Slf4j +public class BatchValidateSignPQTest { + + private static final DataWord ADDR_0X18 = new DataWord( + "0000000000000000000000000000000000000000000000000000000000000018"); + + private static final String METHOD_SIGN = + "batchvalidatesignpq(bytes32,bytes[],bytes[],bytes32[])"; + + private static final byte[] HASH; + + static { + HASH = new byte[32]; + for (int i = 0; i < 32; i++) { + HASH[i] = (byte) (i + 1); + } + } + + private final BatchValidateSignPQ contract = new BatchValidateSignPQ(); + + @Before + public void enableProposal() { + VMConfig.initAllowFnDsa512(1L); + } + + @After + public void disableProposal() { + VMConfig.initAllowFnDsa512(0L); + } + + @Test + public void switchOff_returnsNull() { + VMConfig.initAllowFnDsa512(0L); + Assert.assertNull(PrecompiledContracts.getContractForAddress(ADDR_0X18)); + } + + @Test + public void switchOn_returnsContract() { + PrecompiledContract pc = PrecompiledContracts.getContractForAddress(ADDR_0X18); + Assert.assertNotNull(pc); + Assert.assertTrue(pc instanceof BatchValidateSignPQ); + } + + @Test + public void constantCall_allValid_setsAllBits() { + contract.setConstantCall(true); + int n = 4; + List sigs = new ArrayList<>(n); + List pks = new ArrayList<>(n); + List addrs = new ArrayList<>(n); + for (int i = 0; i < n; i++) { + FNDSA k = new FNDSA(); + sigs.add(Hex.toHexString(k.sign(HASH))); + pks.add(Hex.toHexString(k.getPublicKey())); + addrs.add(addrAsBytes32Hex(k.getPublicKey())); + } + byte[] res = run(HASH, sigs, pks, addrs).getRight(); + for (int i = 0; i < n; i++) { + Assert.assertEquals("bit " + i, 1, res[i]); + } + for (int i = n; i < 32; i++) { + Assert.assertEquals("padding bit " + i, 0, res[i]); + } + } + + @Test + public void constantCall_mismatchedAddress_clearsBit() { + contract.setConstantCall(true); + FNDSA k1 = new FNDSA(); + FNDSA k2 = new FNDSA(); + List sigs = Arrays.asList( + Hex.toHexString(k1.sign(HASH)), + Hex.toHexString(k2.sign(HASH))); + List pks = Arrays.asList( + Hex.toHexString(k1.getPublicKey()), + Hex.toHexString(k2.getPublicKey())); + // entry 1's address is wrong + List addrs = Arrays.asList( + addrAsBytes32Hex(k1.getPublicKey()), + addrAsBytes32Hex(k1.getPublicKey())); + + byte[] res = run(HASH, sigs, pks, addrs).getRight(); + Assert.assertEquals(1, res[0]); + Assert.assertEquals(0, res[1]); + } + + @Test + public void constantCall_tamperedSignature_clearsBit() { + contract.setConstantCall(true); + FNDSA k = new FNDSA(); + byte[] sig = k.sign(HASH); + sig[0] ^= 0x01; + List sigs = Collections1(Hex.toHexString(sig)); + List pks = Collections1(Hex.toHexString(k.getPublicKey())); + List addrs = Collections1(addrAsBytes32Hex(k.getPublicKey())); + + byte[] res = run(HASH, sigs, pks, addrs).getRight(); + Assert.assertEquals(0, res[0]); + } + + @Test + public void constantCall_wrongPkLength_clearsBit() { + contract.setConstantCall(true); + FNDSA k = new FNDSA(); + byte[] truncatedPk = Arrays.copyOf(k.getPublicKey(), k.getPublicKey().length - 1); + List sigs = Collections1(Hex.toHexString(k.sign(HASH))); + List pks = Collections1(Hex.toHexString(truncatedPk)); + List addrs = Collections1(addrAsBytes32Hex(k.getPublicKey())); + + byte[] res = run(HASH, sigs, pks, addrs).getRight(); + Assert.assertEquals(0, res[0]); + } + + @Test + public void asyncPath_allValid_setsAllBits() { + contract.setConstantCall(false); + contract.setVmShouldEndInUs(System.nanoTime() / 1000 + 5_000_000L); + int n = 4; + List sigs = new ArrayList<>(n); + List pks = new ArrayList<>(n); + List addrs = new ArrayList<>(n); + for (int i = 0; i < n; i++) { + FNDSA k = new FNDSA(); + sigs.add(Hex.toHexString(k.sign(HASH))); + pks.add(Hex.toHexString(k.getPublicKey())); + addrs.add(addrAsBytes32Hex(k.getPublicKey())); + } + byte[] res = run(HASH, sigs, pks, addrs).getRight(); + for (int i = 0; i < n; i++) { + Assert.assertEquals("bit " + i, 1, res[i]); + } + } + + @Test + public void mismatchedArrayLengths_returnsZero() { + contract.setConstantCall(true); + FNDSA k = new FNDSA(); + List sigs = Collections1(Hex.toHexString(k.sign(HASH))); + List pks = Arrays.asList( + Hex.toHexString(k.getPublicKey()), Hex.toHexString(k.getPublicKey())); + List addrs = Collections1(addrAsBytes32Hex(k.getPublicKey())); + + byte[] res = run(HASH, sigs, pks, addrs).getRight(); + Assert.assertArrayEquals(new byte[32], res); + } + + @Test + public void overMaxSize_returnsZero() { + contract.setConstantCall(true); + contract.setVmShouldEndInUs(System.nanoTime() / 1000 + 30_000_000L); + int n = 17; + List sigs = new ArrayList<>(n); + List pks = new ArrayList<>(n); + List addrs = new ArrayList<>(n); + for (int i = 0; i < n; i++) { + FNDSA k = new FNDSA(); + sigs.add(Hex.toHexString(k.sign(HASH))); + pks.add(Hex.toHexString(k.getPublicKey())); + addrs.add(addrAsBytes32Hex(k.getPublicKey())); + } + byte[] res = run(HASH, sigs, pks, addrs).getRight(); + Assert.assertArrayEquals(new byte[32], res); + } + + @Test + public void energyScalesWithCount() { + contract.setConstantCall(true); + int n = 3; + List sigs = new ArrayList<>(n); + List pks = new ArrayList<>(n); + List addrs = new ArrayList<>(n); + for (int i = 0; i < n; i++) { + FNDSA k = new FNDSA(); + sigs.add(Hex.toHexString(k.sign(HASH))); + pks.add(Hex.toHexString(k.getPublicKey())); + addrs.add(addrAsBytes32Hex(k.getPublicKey())); + } + byte[] input = encode(HASH, sigs, pks, addrs); + Assert.assertEquals(3L * 15000L, contract.getEnergyForData(input)); + } + + @Test + public void emptyArrays_returnsAllZero() { + contract.setConstantCall(true); + byte[] res = run(HASH, new ArrayList<>(), new ArrayList<>(), new ArrayList<>()).getRight(); + Assert.assertArrayEquals(new byte[32], res); + } + + @Test + public void differentHash_clearsAllBits() { + contract.setConstantCall(true); + int n = 3; + List sigs = new ArrayList<>(n); + List pks = new ArrayList<>(n); + List addrs = new ArrayList<>(n); + for (int i = 0; i < n; i++) { + FNDSA k = new FNDSA(); + // Sign HASH... + sigs.add(Hex.toHexString(k.sign(HASH))); + pks.add(Hex.toHexString(k.getPublicKey())); + addrs.add(addrAsBytes32Hex(k.getPublicKey())); + } + // ...but verify against a different hash. + byte[] otherHash = new byte[32]; + Arrays.fill(otherHash, (byte) 0xAA); + + byte[] res = run(otherHash, sigs, pks, addrs).getRight(); + Assert.assertArrayEquals(new byte[32], res); + } + + @Test + public void atMaxSize16_setsAllBits() { + contract.setConstantCall(true); + int n = 16; + List sigs = new ArrayList<>(n); + List pks = new ArrayList<>(n); + List addrs = new ArrayList<>(n); + for (int i = 0; i < n; i++) { + FNDSA k = new FNDSA(); + sigs.add(Hex.toHexString(k.sign(HASH))); + pks.add(Hex.toHexString(k.getPublicKey())); + addrs.add(addrAsBytes32Hex(k.getPublicKey())); + } + contract.setVmShouldEndInUs(System.nanoTime() / 1000 + 30_000_000L); + byte[] res = run(HASH, sigs, pks, addrs).getRight(); + for (int i = 0; i < n; i++) { + Assert.assertEquals("bit " + i, 1, res[i]); + } + for (int i = n; i < 32; i++) { + Assert.assertEquals("padding bit " + i, 0, res[i]); + } + } + + @Test + public void asyncPath_mixedValidInvalid() { + contract.setConstantCall(false); + contract.setVmShouldEndInUs(System.nanoTime() / 1000 + 10_000_000L); + int n = 4; + List sigs = new ArrayList<>(n); + List pks = new ArrayList<>(n); + List addrs = new ArrayList<>(n); + for (int i = 0; i < n; i++) { + FNDSA k = new FNDSA(); + byte[] sig = k.sign(HASH); + // Tamper entries 1 and 3. + if (i == 1 || i == 3) { + sig[0] ^= 0x01; + } + sigs.add(Hex.toHexString(sig)); + pks.add(Hex.toHexString(k.getPublicKey())); + addrs.add(addrAsBytes32Hex(k.getPublicKey())); + } + byte[] res = run(HASH, sigs, pks, addrs).getRight(); + Assert.assertEquals(1, res[0]); + Assert.assertEquals(0, res[1]); + Assert.assertEquals(1, res[2]); + Assert.assertEquals(0, res[3]); + } + + @Test + public void sigTooLong_clearsBit() { + contract.setConstantCall(true); + FNDSA k = new FNDSA(); + byte[] oversized = new byte[800]; + Arrays.fill(oversized, (byte) 0x99); + List sigs = Collections1(Hex.toHexString(oversized)); + List pks = Collections1(Hex.toHexString(k.getPublicKey())); + List addrs = Collections1(addrAsBytes32Hex(k.getPublicKey())); + + byte[] res = run(HASH, sigs, pks, addrs).getRight(); + Assert.assertEquals(0, res[0]); + } + + // -------- helpers -------- + + private Pair run(byte[] hash, List sigs, + List pks, List addrs) { + byte[] input = encode(hash, sigs, pks, addrs); + contract.setVmShouldEndInUs(System.nanoTime() / 1000 + 5_000_000L); + Pair ret = contract.execute(input); + logger.info("0x18 bitmap: {}", Hex.toHexString(ret.getRight())); + return ret; + } + + private byte[] encode(byte[] hash, List sigs, List pks, List addrs) { + List parameters = Arrays.asList( + "0x" + Hex.toHexString(hash), + toHexList(sigs), + toHexList(pks), + toHexList(addrs)); + return Hex.decode(AbiUtil.parseParameters(METHOD_SIGN, parameters)); + } + + private static List toHexList(List hexes) { + List out = new ArrayList<>(hexes.size()); + for (String h : hexes) { + out.add(h.startsWith("0x") ? h : ("0x" + h)); + } + return out; + } + + private static List Collections1(String s) { + List l = new ArrayList<>(1); + l.add(s); + return l; + } + + /** + * Build a bytes32 hex string whose low 21 bytes hold the derived TRON address + * (high 11 bytes left zero). Matches {@code DataWord.equalAddressByteArray}'s + * "compare last 20 bytes" semantics. + */ + private static String addrAsBytes32Hex(byte[] pk) { + byte[] addr21 = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pk); + byte[] padded = new byte[32]; + System.arraycopy(addr21, 0, padded, 32 - addr21.length, addr21.length); + return "0x" + Hex.toHexString(padded); + } +} diff --git a/framework/src/test/java/org/tron/common/runtime/vm/FnDsaPrecompileTest.java b/framework/src/test/java/org/tron/common/runtime/vm/FnDsaPrecompileTest.java new file mode 100644 index 00000000000..4e21409e2ce --- /dev/null +++ b/framework/src/test/java/org/tron/common/runtime/vm/FnDsaPrecompileTest.java @@ -0,0 +1,186 @@ +package org.tron.common.runtime.vm; + +import org.apache.commons.lang3.tuple.Pair; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.tron.common.crypto.pqc.FNDSA; +import org.tron.core.vm.PrecompiledContracts; +import org.tron.core.vm.PrecompiledContracts.PrecompiledContract; +import org.tron.core.vm.config.VMConfig; + +/** + * Unit tests for the FN-DSA / Falcon-512 (0x16) verify precompile (EIP-8052 / TRON extension). + * Input layout: [msg 32B | sig_len 2B | sig sig_len B | pk 896B]. Stateless — no chain DB. + */ +public class FnDsaPrecompileTest { + + private static final DataWord FNDSA_ADDR = new DataWord( + "0000000000000000000000000000000000000000000000000000000000000016"); + + private static final byte[] MESSAGE_HASH = new byte[32]; + + static { + for (int i = 0; i < 32; i++) { + MESSAGE_HASH[i] = (byte) i; + } + } + + @Before + public void enableProposal() { + VMConfig.initAllowFnDsa512(1L); + } + + @After + public void disableProposal() { + VMConfig.initAllowFnDsa512(0L); + } + + @Test + public void switchOff_returnsNull() { + VMConfig.initAllowFnDsa512(0L); + Assert.assertNull(PrecompiledContracts.getContractForAddress(FNDSA_ADDR)); + } + + @Test + public void switchOn_returnsContract() { + Assert.assertNotNull(PrecompiledContracts.getContractForAddress(FNDSA_ADDR)); + } + + @Test + public void validSignature_returnsOne() { + FNDSA key = new FNDSA(); + byte[] sig = key.sign(MESSAGE_HASH); + byte[] input = buildInput(MESSAGE_HASH, sig, key.getPublicKey()); + + PrecompiledContract pc = PrecompiledContracts.getContractForAddress(FNDSA_ADDR); + Pair result = pc.execute(input); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ONE().getData(), result.getRight()); + Assert.assertEquals(2500, pc.getEnergyForData(input)); + } + + @Test + public void tamperedMessage_returnsZero() { + FNDSA key = new FNDSA(); + byte[] sig = key.sign(MESSAGE_HASH); + byte[] tampered = MESSAGE_HASH.clone(); + tampered[0] ^= 0x01; + byte[] input = buildInput(tampered, sig, key.getPublicKey()); + + Pair result = + PrecompiledContracts.getContractForAddress(FNDSA_ADDR).execute(input); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + @Test + public void tamperedSignature_returnsZero() { + FNDSA key = new FNDSA(); + byte[] sig = key.sign(MESSAGE_HASH); + sig[0] ^= 0x01; + byte[] input = buildInput(MESSAGE_HASH, sig, key.getPublicKey()); + + Pair result = + PrecompiledContracts.getContractForAddress(FNDSA_ADDR).execute(input); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + @Test + public void wrongPublicKey_returnsZero() { + FNDSA signer = new FNDSA(); + FNDSA other = new FNDSA(); + byte[] sig = signer.sign(MESSAGE_HASH); + byte[] input = buildInput(MESSAGE_HASH, sig, other.getPublicKey()); + + Pair result = + PrecompiledContracts.getContractForAddress(FNDSA_ADDR).execute(input); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + @Test + public void nullInput_returnsZero() { + Pair result = + PrecompiledContracts.getContractForAddress(FNDSA_ADDR).execute(null); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + @Test + public void shortInput_returnsZero() { + Pair result = + PrecompiledContracts.getContractForAddress(FNDSA_ADDR).execute(new byte[100]); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + @Test + public void zeroSigLen_returnsZero() { + FNDSA key = new FNDSA(); + byte[] pk = key.getPublicKey(); + // sig_len = 0 is invalid (must be >= 1) + // input must be >= MIN_INPUT_LEN (931 = 32 + 2 + 1 + 896) to reach the sigLen check + byte[] input = new byte[32 + 2 + pk.length + 1]; + System.arraycopy(MESSAGE_HASH, 0, input, 0, 32); + // sig_len bytes = 0x00 0x00 → sigLen = 0 + System.arraycopy(pk, 0, input, 34, pk.length); + + Pair result = + PrecompiledContracts.getContractForAddress(FNDSA_ADDR).execute(input); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + @Test + public void oversizedSigLen_returnsZero() { + // sig_len = 753, which exceeds FNDSA.SIGNATURE_LENGTH (752) + byte[] input = new byte[32 + 2 + 753 + FNDSA.PUBLIC_KEY_LENGTH]; + input[32] = 0x02; // high byte + input[33] = (byte) 0xF1; // low byte → 0x02F1 = 753 + Pair result = + PrecompiledContracts.getContractForAddress(FNDSA_ADDR).execute(input); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + @Test + public void sigLenLargerThanActualData_returnsZero() { + FNDSA key = new FNDSA(); + byte[] sig = key.sign(MESSAGE_HASH); + // claim sig is 100 bytes longer than it is + byte[] input = buildInput(MESSAGE_HASH, sig, key.getPublicKey()); + // corrupt sig_len field to claim a larger sig + int claimedLen = sig.length + 100; + input[32] = (byte) ((claimedLen >> 8) & 0xFF); + input[33] = (byte) (claimedLen & 0xFF); + + Pair result = + PrecompiledContracts.getContractForAddress(FNDSA_ADDR).execute(input); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + /** Encodes input as [msg 32B | sig_len 2B | sig | pk]. */ + private static byte[] buildInput(byte[] msg, byte[] sig, byte[] pk) { + int sigLen = sig.length; + byte[] out = new byte[32 + 2 + sigLen + pk.length]; + System.arraycopy(msg, 0, out, 0, 32); + out[32] = (byte) ((sigLen >> 8) & 0xFF); + out[33] = (byte) (sigLen & 0xFF); + System.arraycopy(sig, 0, out, 34, sigLen); + System.arraycopy(pk, 0, out, 34 + sigLen, pk.length); + return out; + } +} diff --git a/framework/src/test/java/org/tron/common/runtime/vm/ValidateMultiSignPQTest.java b/framework/src/test/java/org/tron/common/runtime/vm/ValidateMultiSignPQTest.java new file mode 100644 index 00000000000..37a7cd7aa02 --- /dev/null +++ b/framework/src/test/java/org/tron/common/runtime/vm/ValidateMultiSignPQTest.java @@ -0,0 +1,464 @@ +package org.tron.common.runtime.vm; + +import com.google.protobuf.ByteString; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.tuple.Pair; +import org.bouncycastle.util.encoders.Hex; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.tron.common.BaseTest; +import org.tron.common.TestConstants; +import org.tron.common.crypto.ECKey; +import org.tron.common.crypto.pqc.FNDSA; +import org.tron.common.crypto.pqc.PQSchemeRegistry; +import org.tron.common.parameter.CommonParameter; +import org.tron.common.utils.ByteArray; +import org.tron.common.utils.ByteUtil; +import org.tron.common.utils.Sha256Hash; +import org.tron.common.utils.StringUtil; +import org.tron.common.utils.client.utils.AbiUtil; +import org.tron.core.capsule.AccountCapsule; +import org.tron.core.config.args.Args; +import org.tron.core.store.StoreFactory; +import org.tron.core.vm.PrecompiledContracts; +import org.tron.core.vm.PrecompiledContracts.PrecompiledContract; +import org.tron.core.vm.PrecompiledContracts.ValidateMultiSignPQ; +import org.tron.core.vm.config.VMConfig; +import org.tron.core.vm.repository.Repository; +import org.tron.core.vm.repository.RepositoryImpl; +import org.tron.protos.Protocol; +import org.tron.protos.Protocol.PQScheme; + +/** + * Unit tests for the 0x17 algorithm-agnostic Permission multi-sign precompile. + * Mirrors 0x09 hash construction and threshold semantics, while supporting + * Falcon-512 entries alongside ECDSA against the same Permission.keys[]. + */ +@Slf4j +public class ValidateMultiSignPQTest extends BaseTest { + + private static final DataWord ADDR_0X17 = new DataWord( + "0000000000000000000000000000000000000000000000000000000000000017"); + + private static final String METHOD_SIGN = + "validatemultisign(address,uint256,bytes32,bytes[],bytes[],bytes[])"; + + private static final byte[] longData; + + static { + Args.setParam(new String[]{"--output-directory", dbPath(), "--debug"}, TestConstants.TEST_CONF); + longData = new byte[1000]; + Arrays.fill(longData, (byte) 7); + } + + private final ValidateMultiSignPQ contract = new ValidateMultiSignPQ(); + + @Before + public void before() { + dbManager.getDynamicPropertiesStore().saveAllowMultiSign(1); + dbManager.getDynamicPropertiesStore().saveTotalSignNum(5); + VMConfig.initAllowFnDsa512(1L); + } + + @Test + public void switchOff_returnsNull() { + VMConfig.initAllowFnDsa512(0L); + Assert.assertNull(PrecompiledContracts.getContractForAddress(ADDR_0X17)); + } + + @Test + public void switchOn_returnsContract() { + PrecompiledContract pc = PrecompiledContracts.getContractForAddress(ADDR_0X17); + Assert.assertNotNull(pc); + Assert.assertTrue(pc instanceof ValidateMultiSignPQ); + } + + @Test + public void unknownAccount_returnsZero() { + ECKey owner = new ECKey(); + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + List ecdsaSigs = Collections.singletonList( + Hex.toHexString(new ECKey().sign(toSign).toByteArray())); + Assert.assertArrayEquals(DataWord.ZERO().getData(), + runContract(owner.getAddress(), 2, data, ecdsaSigs, + Collections.emptyList(), Collections.emptyList()).getRight()); + } + + @Test + public void pureEcdsaThresholdReached_returnsOne() { + ECKey k1 = new ECKey(); + ECKey k2 = new ECKey(); + ECKey owner = new ECKey(); + setupPermission(owner, Arrays.asList(k1.getAddress(), k2.getAddress()), + Arrays.asList(1, 1), 2, Collections.emptyList(), Collections.emptyList()); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + List ecdsaSigs = Arrays.asList( + Hex.toHexString(k1.sign(toSign).toByteArray()), + Hex.toHexString(k2.sign(toSign).toByteArray())); + + Assert.assertArrayEquals(DataWord.ONE().getData(), + runContract(owner.getAddress(), 2, data, ecdsaSigs, + Collections.emptyList(), Collections.emptyList()).getRight()); + } + + @Test + public void purePqThresholdReached_returnsOne() { + FNDSA pq1 = new FNDSA(); + FNDSA pq2 = new FNDSA(); + ECKey owner = new ECKey(); + byte[] addr1 = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pq1.getPublicKey()); + byte[] addr2 = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pq2.getPublicKey()); + setupPermission(owner, Collections.emptyList(), Collections.emptyList(), + 2, Arrays.asList(addr1, addr2), Arrays.asList(1, 1)); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + + List pqSigs = Arrays.asList( + Hex.toHexString(pq1.sign(toSign)), + Hex.toHexString(pq2.sign(toSign))); + List pqPks = Arrays.asList( + Hex.toHexString(pq1.getPublicKey()), + Hex.toHexString(pq2.getPublicKey())); + + Assert.assertArrayEquals(DataWord.ONE().getData(), + runContract(owner.getAddress(), 2, data, + Collections.emptyList(), pqSigs, pqPks).getRight()); + } + + @Test + public void mixedEcdsaAndPq_returnsOne() { + ECKey k1 = new ECKey(); + FNDSA pq1 = new FNDSA(); + ECKey owner = new ECKey(); + byte[] pqAddr = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pq1.getPublicKey()); + setupPermission(owner, Collections.singletonList(k1.getAddress()), Collections.singletonList(1), + 2, Collections.singletonList(pqAddr), Collections.singletonList(1)); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + + List ecdsaSigs = Collections.singletonList( + Hex.toHexString(k1.sign(toSign).toByteArray())); + List pqSigs = Collections.singletonList(Hex.toHexString(pq1.sign(toSign))); + List pqPks = Collections.singletonList(Hex.toHexString(pq1.getPublicKey())); + + Assert.assertArrayEquals(DataWord.ONE().getData(), + runContract(owner.getAddress(), 2, data, ecdsaSigs, pqSigs, pqPks).getRight()); + } + + @Test + public void pqSignatureForgery_returnsZero() { + FNDSA pq1 = new FNDSA(); + ECKey owner = new ECKey(); + byte[] pqAddr = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pq1.getPublicKey()); + setupPermission(owner, Collections.emptyList(), Collections.emptyList(), + 1, Collections.singletonList(pqAddr), Collections.singletonList(1)); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + byte[] forgedSig = pq1.sign(toSign); + forgedSig[10] ^= 0x01; + + List pqSigs = Collections.singletonList(Hex.toHexString(forgedSig)); + List pqPks = Collections.singletonList(Hex.toHexString(pq1.getPublicKey())); + + Assert.assertArrayEquals(DataWord.ZERO().getData(), + runContract(owner.getAddress(), 2, data, + Collections.emptyList(), pqSigs, pqPks).getRight()); + } + + @Test + public void wrongPqPublicKeyLength_returnsZero() { + FNDSA pq1 = new FNDSA(); + ECKey owner = new ECKey(); + byte[] pqAddr = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pq1.getPublicKey()); + setupPermission(owner, Collections.emptyList(), Collections.emptyList(), + 1, Collections.singletonList(pqAddr), Collections.singletonList(1)); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + byte[] truncatedPk = Arrays.copyOf(pq1.getPublicKey(), pq1.getPublicKey().length - 1); + + List pqSigs = Collections.singletonList(Hex.toHexString(pq1.sign(toSign))); + List pqPks = Collections.singletonList(Hex.toHexString(truncatedPk)); + + Assert.assertArrayEquals(DataWord.ZERO().getData(), + runContract(owner.getAddress(), 2, data, + Collections.emptyList(), pqSigs, pqPks).getRight()); + } + + @Test + public void mismatchedPqArrayLengths_returnsZero() { + FNDSA pq1 = new FNDSA(); + FNDSA pq2 = new FNDSA(); + ECKey owner = new ECKey(); + byte[] addr1 = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pq1.getPublicKey()); + setupPermission(owner, Collections.emptyList(), Collections.emptyList(), + 1, Collections.singletonList(addr1), Collections.singletonList(1)); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + + List pqSigs = Arrays.asList( + Hex.toHexString(pq1.sign(toSign)), + Hex.toHexString(pq2.sign(toSign))); + List pqPks = Collections.singletonList(Hex.toHexString(pq1.getPublicKey())); + + Assert.assertArrayEquals(DataWord.ZERO().getData(), + runContract(owner.getAddress(), 2, data, + Collections.emptyList(), pqSigs, pqPks).getRight()); + } + + @Test + public void totalCountOverMaxSize_returnsZero() { + ECKey owner = new ECKey(); + List ecdsaAddrs = new ArrayList<>(); + List ecdsaWeights = new ArrayList<>(); + List ecdsaKeys = new ArrayList<>(); + for (int i = 0; i < 6; i++) { + ECKey k = new ECKey(); + ecdsaKeys.add(k); + ecdsaAddrs.add(k.getAddress()); + ecdsaWeights.add(1); + } + setupPermission(owner, ecdsaAddrs, ecdsaWeights, 6, + Collections.emptyList(), Collections.emptyList()); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + List ecdsaSigs = new ArrayList<>(); + for (ECKey k : ecdsaKeys) { + ecdsaSigs.add(Hex.toHexString(k.sign(toSign).toByteArray())); + } + + Assert.assertArrayEquals(DataWord.ZERO().getData(), + runContract(owner.getAddress(), 2, data, ecdsaSigs, + Collections.emptyList(), Collections.emptyList()).getRight()); + } + + @Test + public void duplicatePqSig_doesNotDoubleCount() { + FNDSA pq1 = new FNDSA(); + ECKey owner = new ECKey(); + byte[] pqAddr = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pq1.getPublicKey()); + setupPermission(owner, Collections.emptyList(), Collections.emptyList(), + 2, Collections.singletonList(pqAddr), Collections.singletonList(1)); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + byte[] sig = pq1.sign(toSign); + + List pqSigs = Arrays.asList(Hex.toHexString(sig), Hex.toHexString(sig)); + List pqPks = Arrays.asList( + Hex.toHexString(pq1.getPublicKey()), Hex.toHexString(pq1.getPublicKey())); + + Assert.assertArrayEquals(DataWord.ZERO().getData(), + runContract(owner.getAddress(), 2, data, + Collections.emptyList(), pqSigs, pqPks).getRight()); + } + + @Test + public void energyChargesEcdsaAndPqSeparately() { + FNDSA pq1 = new FNDSA(); + ECKey k1 = new ECKey(); + ECKey owner = new ECKey(); + byte[] pqAddr = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pq1.getPublicKey()); + setupPermission(owner, Collections.singletonList(k1.getAddress()), Collections.singletonList(1), + 2, Collections.singletonList(pqAddr), Collections.singletonList(1)); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + List ecdsaSigs = Collections.singletonList( + Hex.toHexString(k1.sign(toSign).toByteArray())); + List pqSigs = Collections.singletonList(Hex.toHexString(pq1.sign(toSign))); + List pqPks = Collections.singletonList(Hex.toHexString(pq1.getPublicKey())); + + byte[] input = encodeInput(owner.getAddress(), 2, data, ecdsaSigs, pqSigs, pqPks); + // 1 ECDSA × 1500 + 1 PQ × 15000 = 16500 + Assert.assertEquals(16500L, contract.getEnergyForData(input)); + } + + @Test + public void thresholdNotReached_returnsZero() { + ECKey k1 = new ECKey(); + ECKey k2 = new ECKey(); + ECKey owner = new ECKey(); + setupPermission(owner, Arrays.asList(k1.getAddress(), k2.getAddress()), + Arrays.asList(1, 1), 2, Collections.emptyList(), Collections.emptyList()); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + // Only one valid signature; threshold is 2. + List ecdsaSigs = Collections.singletonList( + Hex.toHexString(k1.sign(toSign).toByteArray())); + + Assert.assertArrayEquals(DataWord.ZERO().getData(), + runContract(owner.getAddress(), 2, data, ecdsaSigs, + Collections.emptyList(), Collections.emptyList()).getRight()); + } + + @Test + public void pqKeyNotInPermission_returnsZero() { + FNDSA inPerm = new FNDSA(); + FNDSA outsider = new FNDSA(); + ECKey owner = new ECKey(); + byte[] inAddr = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, inPerm.getPublicKey()); + setupPermission(owner, Collections.emptyList(), Collections.emptyList(), + 1, Collections.singletonList(inAddr), Collections.singletonList(1)); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + + // Outsider produces a perfectly valid Falcon signature, but its derived + // address is not in Permission.keys[] → weight 0 → not counted. + List pqSigs = Collections.singletonList(Hex.toHexString(outsider.sign(toSign))); + List pqPks = Collections.singletonList(Hex.toHexString(outsider.getPublicKey())); + + Assert.assertArrayEquals(DataWord.ZERO().getData(), + runContract(owner.getAddress(), 2, data, + Collections.emptyList(), pqSigs, pqPks).getRight()); + } + + @Test + public void pqSigTooLong_returnsZero() { + FNDSA pq1 = new FNDSA(); + ECKey owner = new ECKey(); + byte[] pqAddr = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pq1.getPublicKey()); + setupPermission(owner, Collections.emptyList(), Collections.emptyList(), + 1, Collections.singletonList(pqAddr), Collections.singletonList(1)); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + + // Pad sig past the 752-byte cap. + byte[] oversized = new byte[800]; + Arrays.fill(oversized, (byte) 0x42); + List pqSigs = Collections.singletonList(Hex.toHexString(oversized)); + List pqPks = Collections.singletonList(Hex.toHexString(pq1.getPublicKey())); + + Assert.assertArrayEquals(DataWord.ZERO().getData(), + runContract(owner.getAddress(), 2, data, + Collections.emptyList(), pqSigs, pqPks).getRight()); + } + + @Test + public void bothArraysEmpty_returnsZero() { + ECKey k1 = new ECKey(); + ECKey owner = new ECKey(); + setupPermission(owner, Collections.singletonList(k1.getAddress()), + Collections.singletonList(1), 1, + Collections.emptyList(), Collections.emptyList()); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + + Assert.assertArrayEquals(DataWord.ZERO().getData(), + runContract(owner.getAddress(), 2, data, + Collections.emptyList(), Collections.emptyList(), + Collections.emptyList()).getRight()); + } + + @Test + public void mixedFailingPqAborts_returnsZero() { + // Mirrors 0x09 semantics: a verify failure on any submitted entry aborts + // the whole call with DATA_FALSE — even if other entries would alone meet + // threshold. Verifies 0x17 does not silently skip a forged PQ signature. + ECKey k1 = new ECKey(); + ECKey k2 = new ECKey(); + FNDSA pq1 = new FNDSA(); + ECKey owner = new ECKey(); + byte[] pqAddr = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pq1.getPublicKey()); + setupPermission(owner, + Arrays.asList(k1.getAddress(), k2.getAddress()), Arrays.asList(1, 1), + 2, Collections.singletonList(pqAddr), Collections.singletonList(1)); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + + List ecdsaSigs = Arrays.asList( + Hex.toHexString(k1.sign(toSign).toByteArray()), + Hex.toHexString(k2.sign(toSign).toByteArray())); + byte[] forged = pq1.sign(toSign); + forged[0] ^= 0x55; + List pqSigs = Collections.singletonList(Hex.toHexString(forged)); + List pqPks = Collections.singletonList(Hex.toHexString(pq1.getPublicKey())); + + Assert.assertArrayEquals(DataWord.ZERO().getData(), + runContract(owner.getAddress(), 2, data, ecdsaSigs, pqSigs, pqPks).getRight()); + } + + // -------- helpers -------- + + private void setupPermission(ECKey owner, + List ecdsaKeyAddrs, List ecdsaWeights, + int threshold, + List pqKeyAddrs, List pqWeights) { + AccountCapsule account = new AccountCapsule(ByteString.copyFrom(owner.getAddress()), + Protocol.AccountType.Normal, System.currentTimeMillis(), true, + dbManager.getDynamicPropertiesStore()); + + Protocol.Permission.Builder perm = Protocol.Permission.newBuilder() + .setType(Protocol.Permission.PermissionType.Active) + .setId(2) + .setPermissionName("active") + .setThreshold(threshold) + .setOperations(ByteString.copyFrom(ByteArray.fromHexString( + "0000000000000000000000000000000000000000000000000000000000000000"))); + for (int i = 0; i < ecdsaKeyAddrs.size(); i++) { + perm.addKeys(Protocol.Key.newBuilder() + .setAddress(ByteString.copyFrom(ecdsaKeyAddrs.get(i))) + .setWeight(ecdsaWeights.get(i)).build()); + } + for (int i = 0; i < pqKeyAddrs.size(); i++) { + perm.addKeys(Protocol.Key.newBuilder() + .setAddress(ByteString.copyFrom(pqKeyAddrs.get(i))) + .setWeight(pqWeights.get(i)).build()); + } + account.updatePermissions(account.getPermissionById(0), null, + Collections.singletonList(perm.build())); + dbManager.getAccountStore().put(owner.getAddress(), account); + } + + private byte[] computeHash(byte[] address, int permissionId, byte[] data) { + byte[] combined = ByteUtil.merge(address, ByteArray.fromInt(permissionId), data); + return Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), combined); + } + + private byte[] encodeInput(byte[] ownerAddr, int permissionId, byte[] data, + List ecdsaSigs, List pqSigs, List pqPks) { + List parameters = Arrays.asList( + StringUtil.encode58Check(ownerAddr), + permissionId, + "0x" + Hex.toHexString(data), + toHexList(ecdsaSigs), + toHexList(pqSigs), + toHexList(pqPks)); + return Hex.decode(AbiUtil.parseParameters(METHOD_SIGN, parameters)); + } + + private Pair runContract(byte[] ownerAddr, int permissionId, byte[] data, + List ecdsaSigs, List pqSigs, + List pqPks) { + byte[] input = encodeInput(ownerAddr, permissionId, data, ecdsaSigs, pqSigs, pqPks); + Repository deposit = RepositoryImpl.createRoot(StoreFactory.getInstance()); + contract.setRepository(deposit); + Pair ret = contract.execute(input); + logger.info("0x17 result: {}", Hex.toHexString(ret.getRight())); + return ret; + } + + private static List toHexList(List hexes) { + List out = new ArrayList<>(hexes.size()); + for (String h : hexes) { + out.add(h.startsWith("0x") ? h : ("0x" + h)); + } + return out; + } +} diff --git a/framework/src/test/java/org/tron/common/utils/LocalWitnessesTest.java b/framework/src/test/java/org/tron/common/utils/LocalWitnessesTest.java new file mode 100644 index 00000000000..22e99ef4571 --- /dev/null +++ b/framework/src/test/java/org/tron/common/utils/LocalWitnessesTest.java @@ -0,0 +1,152 @@ +package org.tron.common.utils; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.util.Arrays; +import java.util.Collections; +import org.bouncycastle.util.encoders.Hex; +import org.junit.BeforeClass; +import org.junit.Test; +import org.tron.common.crypto.pqc.FNDSA; +import org.tron.core.exception.TronError; +import org.tron.protos.Protocol.PQScheme; + +public class LocalWitnessesTest { + + // Real Falcon-512 keypair generated once per test class. We exercise the + // (priv, pub) keypair config path with bytes that satisfy the BC ops, so the + // tests below never hit cross-platform FFT determinism concerns. + private static String priv; + private static String pub; + private static String priv2; + private static String pub2; + + @BeforeClass + public static void generateKeypairs() { + FNDSA k1 = new FNDSA(); + FNDSA k2 = new FNDSA(); + priv = Hex.toHexString(k1.getPrivateKey()); + pub = Hex.toHexString(k1.getPublicKey()); + priv2 = Hex.toHexString(k2.getPrivateKey()); + pub2 = Hex.toHexString(k2.getPublicKey()); + } + + @Test + public void fnDsa512AcceptsValidKeypair() { + LocalWitnesses lw = new LocalWitnesses(); + lw.setPqKeypairs(Collections.singletonList(priv), Collections.singletonList(pub)); + assertEquals(PQScheme.FN_DSA_512, lw.getPqScheme()); + assertEquals(1, lw.getPqPrivateKeys().size()); + assertEquals(1, lw.getPqPublicKeys().size()); + } + + @Test + public void fnDsa512AcceptsMultipleKeypairs() { + LocalWitnesses lw = new LocalWitnesses(); + lw.setPqKeypairs(Arrays.asList(priv, priv2), Arrays.asList(pub, pub2)); + assertEquals(2, lw.getPqPrivateKeys().size()); + assertEquals(2, lw.getPqPublicKeys().size()); + } + + @Test + public void mismatchedListSizesRejected() { + LocalWitnesses lw = new LocalWitnesses(); + TronError err = assertThrows(TronError.class, + () -> lw.setPqKeypairs(Arrays.asList(priv, priv2), Collections.singletonList(pub))); + assertEquals(TronError.ErrCode.WITNESS_INIT, err.getErrCode()); + assertTrue(err.getMessage().contains("size mismatch")); + } + + @Test + public void wrongLengthPrivateKeyRejected() { + LocalWitnesses lw = new LocalWitnesses(); + String shortPriv = priv.substring(2); + TronError err = assertThrows(TronError.class, + () -> lw.setPqKeypairs(Collections.singletonList(shortPriv), + Collections.singletonList(pub))); + assertEquals(TronError.ErrCode.WITNESS_INIT, err.getErrCode()); + assertTrue(err.getMessage().contains("PQ private key")); + // FN-DSA-512 private key is 1280 bytes = 2560 hex chars. + assertTrue(err.getMessage().contains("2560")); + } + + @Test + public void wrongLengthPublicKeyRejected() { + LocalWitnesses lw = new LocalWitnesses(); + String shortPub = pub.substring(2); + TronError err = assertThrows(TronError.class, + () -> lw.setPqKeypairs(Collections.singletonList(priv), + Collections.singletonList(shortPub))); + assertEquals(TronError.ErrCode.WITNESS_INIT, err.getErrCode()); + assertTrue(err.getMessage().contains("PQ public key")); + // FN-DSA-512 public key is 896 bytes = 1792 hex chars. + assertTrue(err.getMessage().contains("1792")); + } + + @Test + public void nonHexPrivateKeyRejected() { + LocalWitnesses lw = new LocalWitnesses(); + String badPriv = "zz" + priv.substring(2); + TronError err = assertThrows(TronError.class, + () -> lw.setPqKeypairs(Collections.singletonList(badPriv), + Collections.singletonList(pub))); + assertEquals(TronError.ErrCode.WITNESS_INIT, err.getErrCode()); + assertTrue(err.getMessage().contains("hex")); + } + + @Test + public void unsupportedSchemeRejected() { + LocalWitnesses lw = new LocalWitnesses(); + TronError err = assertThrows(TronError.class, + () -> lw.setPqScheme(PQScheme.UNRECOGNIZED)); + assertEquals(TronError.ErrCode.WITNESS_INIT, err.getErrCode()); + assertTrue(err.getMessage().contains("unsupported PQ signature scheme")); + } + + @Test + public void nullSchemeRejected() { + LocalWitnesses lw = new LocalWitnesses(); + TronError err = assertThrows(TronError.class, () -> lw.setPqScheme(null)); + assertEquals(TronError.ErrCode.WITNESS_INIT, err.getErrCode()); + assertTrue(err.getMessage().contains("unsupported PQ signature scheme")); + } + + @Test + public void supportedSchemeAccepted() { + LocalWitnesses lw = new LocalWitnesses(); + lw.setPqScheme(PQScheme.FN_DSA_512); + assertEquals(PQScheme.FN_DSA_512, lw.getPqScheme()); + } + + @Test + public void emptyKeypairsAreNoop() { + LocalWitnesses lw = new LocalWitnesses(); + lw.setPqKeypairs(Collections.emptyList(), Collections.emptyList()); + lw.setPqKeypairs(null, null); + assertEquals(0, lw.getPqPrivateKeys().size()); + assertEquals(0, lw.getPqPublicKeys().size()); + } + + @Test + public void zeroXPrefixedHexAccepted() { + // validatePqKey strips a leading "0x" before measuring the length, so + // hex strings with the prefix must be accepted. + LocalWitnesses lw = new LocalWitnesses(); + lw.setPqKeypairs( + Collections.singletonList("0x" + priv), + Collections.singletonList("0x" + pub)); + assertEquals(1, lw.getPqPrivateKeys().size()); + } + + @Test + public void blankKeyRejected() { + LocalWitnesses lw = new LocalWitnesses(); + TronError err = assertThrows(TronError.class, + () -> lw.setPqKeypairs(Collections.singletonList(""), + Collections.singletonList(pub))); + assertEquals(TronError.ErrCode.WITNESS_INIT, err.getErrCode()); + assertTrue(err.getMessage().contains("PQ private key")); + } +} diff --git a/framework/src/test/java/org/tron/core/BandwidthProcessorTest.java b/framework/src/test/java/org/tron/core/BandwidthProcessorTest.java index cf652af3650..f6fede77523 100755 --- a/framework/src/test/java/org/tron/core/BandwidthProcessorTest.java +++ b/framework/src/test/java/org/tron/core/BandwidthProcessorTest.java @@ -881,4 +881,120 @@ public void testCalculateGlobalNetLimit() { .calculateGlobalNetLimitV2(accountCapsule.getAllFrozenBalanceForBandwidth()); Assert.assertTrue(netLimitV2 > 0); } + + @Test + public void pqPQAuthWitnessBytesSubtractedInCreateAccountCap() throws Exception { + chainBaseManager.getDynamicPropertiesStore().saveLatestBlockHeaderTimestamp(1526647838000L); + chainBaseManager.getDynamicPropertiesStore().saveTotalNetWeight(10_000_000L); + + AccountCapsule ownerCapsule = new AccountCapsule( + ByteString.copyFromUtf8("owner"), + ByteString.copyFrom(ByteArray.fromHexString(OWNER_ADDRESS)), + AccountType.Normal, + chainBaseManager.getDynamicPropertiesStore().getAssetIssueFee()); + ownerCapsule.setBalance(10_000_000L); + long expireTime = DateTime.now().getMillis() + 6 * 86_400_000; + ownerCapsule.setFrozenForBandwidth(2_000_000L, expireTime); + chainBaseManager.getAccountStore().put(ownerCapsule.getAddress().toByteArray(), ownerCapsule); + + chainBaseManager.getAccountStore().delete(ByteArray.fromHexString(TO_ADDRESS)); + + TransferContract contract = TransferContract.newBuilder() + .setOwnerAddress(ByteString.copyFrom(ByteArray.fromHexString(OWNER_ADDRESS))) + .setToAddress(ByteString.copyFrom(ByteArray.fromHexString(TO_ADDRESS))) + .setAmount(100L) + .build(); + + byte[] fakeSig = new byte[752]; + byte[] fakePub = new byte[897]; + Protocol.PQAuthSig pqAuthSig = Protocol.PQAuthSig.newBuilder() + .setScheme(Protocol.PQScheme.FN_DSA_512) + .setPublicKey(ByteString.copyFrom(fakePub)) + .setSignature(ByteString.copyFrom(fakeSig)) + .build(); + + TransactionCapsule baseTrx = new TransactionCapsule(contract, + chainBaseManager.getAccountStore()); + Transaction withAuth = baseTrx.getInstance().toBuilder() + .addPqAuthSig(pqAuthSig) + .build(); + TransactionCapsule trx = new TransactionCapsule(withAuth); + TransactionTrace trace = new TransactionTrace(trx, StoreFactory.getInstance(), + new RuntimeImpl()); + + long cap = chainBaseManager.getDynamicPropertiesStore().getMaxCreateAccountTxSize(); + long rawSize = trx.getInstance().toBuilder().clearRet().build().getSerializedSize(); + Assert.assertTrue("test precondition: raw tx must exceed cap with pq_auth_sig", + rawSize > cap); + + BandwidthProcessor processor = new BandwidthProcessor(chainBaseManager); + try { + processor.consume(trx, trace); + } catch (TooBigTransactionException e) { + Assert.fail("PQ pq_auth_sig bytes should be deducted from create-account cap check"); + } finally { + chainBaseManager.getAccountStore().delete(ByteArray.fromHexString(OWNER_ADDRESS)); + chainBaseManager.getAccountStore().delete(ByteArray.fromHexString(TO_ADDRESS)); + } + } + + @Test + public void pqPQAuthWitnessCountedInBandwidthUsage() throws Exception { + chainBaseManager.getDynamicPropertiesStore().saveLatestBlockHeaderTimestamp(1526647838000L); + chainBaseManager.getDynamicPropertiesStore().saveTotalNetWeight(10_000_000L); + + AccountCapsule ownerCapsule = new AccountCapsule( + ByteString.copyFromUtf8("owner"), + ByteString.copyFrom(ByteArray.fromHexString(OWNER_ADDRESS)), + AccountType.Normal, + chainBaseManager.getDynamicPropertiesStore().getAssetIssueFee()); + ownerCapsule.setBalance(10_000_000L); + long expireTime = DateTime.now().getMillis() + 6 * 86_400_000; + ownerCapsule.setFrozenForBandwidth(2_000_000L, expireTime); + chainBaseManager.getAccountStore().put(ownerCapsule.getAddress().toByteArray(), ownerCapsule); + + AccountCapsule toAddressCapsule = new AccountCapsule( + ByteString.copyFromUtf8("to"), + ByteString.copyFrom(ByteArray.fromHexString(TO_ADDRESS)), + AccountType.Normal, + 0L); + chainBaseManager.getAccountStore().put(toAddressCapsule.getAddress().toByteArray(), + toAddressCapsule); + + TransferContract contract = TransferContract.newBuilder() + .setOwnerAddress(ByteString.copyFrom(ByteArray.fromHexString(OWNER_ADDRESS))) + .setToAddress(ByteString.copyFrom(ByteArray.fromHexString(TO_ADDRESS))) + .setAmount(100L) + .build(); + + byte[] fakeSig = new byte[752]; + byte[] fakePub = new byte[897]; + Protocol.PQAuthSig pqAuthSig = Protocol.PQAuthSig.newBuilder() + .setScheme(Protocol.PQScheme.FN_DSA_512) + .setPublicKey(ByteString.copyFrom(fakePub)) + .setSignature(ByteString.copyFrom(fakeSig)) + .build(); + + TransactionCapsule baseTrx = new TransactionCapsule(contract, + chainBaseManager.getAccountStore()); + Transaction withAuth = baseTrx.getInstance().toBuilder() + .addPqAuthSig(pqAuthSig) + .build(); + TransactionCapsule trx = new TransactionCapsule(withAuth); + TransactionTrace trace = new TransactionTrace(trx, StoreFactory.getInstance(), + new RuntimeImpl()); + + long expectedBytes = trx.getInstance().toBuilder().clearRet().build().getSerializedSize() + + (chainBaseManager.getDynamicPropertiesStore().supportVM() + ? Constant.MAX_RESULT_SIZE_IN_TX : 0); + + BandwidthProcessor processor = new BandwidthProcessor(chainBaseManager); + try { + processor.consume(trx, trace); + Assert.assertEquals(expectedBytes, trace.getReceipt().getNetUsage()); + } finally { + chainBaseManager.getAccountStore().delete(ByteArray.fromHexString(OWNER_ADDRESS)); + chainBaseManager.getAccountStore().delete(ByteArray.fromHexString(TO_ADDRESS)); + } + } } diff --git a/framework/src/test/java/org/tron/core/actuator/AccountPermissionUpdateActuatorTest.java b/framework/src/test/java/org/tron/core/actuator/AccountPermissionUpdateActuatorTest.java index 250f7b9dc01..1fa2ef9d16f 100644 --- a/framework/src/test/java/org/tron/core/actuator/AccountPermissionUpdateActuatorTest.java +++ b/framework/src/test/java/org/tron/core/actuator/AccountPermissionUpdateActuatorTest.java @@ -1019,4 +1019,4 @@ public void checkActiveDefaultOperations() { } -} \ No newline at end of file +} diff --git a/framework/src/test/java/org/tron/core/actuator/CreateAccountActuatorTest.java b/framework/src/test/java/org/tron/core/actuator/CreateAccountActuatorTest.java index 4cb8e639089..6c1923a9ce4 100755 --- a/framework/src/test/java/org/tron/core/actuator/CreateAccountActuatorTest.java +++ b/framework/src/test/java/org/tron/core/actuator/CreateAccountActuatorTest.java @@ -220,6 +220,10 @@ public void commonErrorCheck() { } + // PQ-native account creation is deferred per V2 scope: AccountCreateContract.pq_key + // has been removed (reserved 4) and CreateAccountActuator no longer carries any PQ + // validation logic. Tests for that path were dropped along with the field. + private void processAndCheckInvalid(CreateAccountActuator actuator, TransactionResultCapsule ret, String failMsg, String expectedMsg) { diff --git a/framework/src/test/java/org/tron/core/actuator/ShieldedTransferActuatorTest.java b/framework/src/test/java/org/tron/core/actuator/ShieldedTransferActuatorTest.java index 578f9f5ebed..1d08ab53b77 100755 --- a/framework/src/test/java/org/tron/core/actuator/ShieldedTransferActuatorTest.java +++ b/framework/src/test/java/org/tron/core/actuator/ShieldedTransferActuatorTest.java @@ -275,7 +275,7 @@ public void publicAddressToPublicAddressNoPublicSign() { Assert.assertTrue(dbManager.pushTransaction(transactionCap)); } catch (ValidateSignatureException e) { Assert.assertTrue(e instanceof ValidateSignatureException); - Assert.assertEquals("miss sig or contract", e.getMessage()); + Assert.assertEquals("miss sig", e.getMessage()); } catch (Exception e) { Assert.assertTrue(false); } diff --git a/framework/src/test/java/org/tron/core/actuator/utils/ProposalUtilTest.java b/framework/src/test/java/org/tron/core/actuator/utils/ProposalUtilTest.java index 16a3cb3a5bb..92f35d7b79f 100644 --- a/framework/src/test/java/org/tron/core/actuator/utils/ProposalUtilTest.java +++ b/framework/src/test/java/org/tron/core/actuator/utils/ProposalUtilTest.java @@ -797,4 +797,30 @@ public void blockVersionCheck() { } } } + + @Test + public void validateAllowFnDsa512() { + long code = ProposalType.ALLOW_FN_DSA_512.getCode(); + + ContractValidateException thrown = assertThrows(ContractValidateException.class, + () -> ProposalUtil.validator(dynamicPropertiesStore, forkUtils, code, 0)); + assertEquals("This value[ALLOW_FN_DSA_512] is only allowed to be 1", thrown.getMessage()); + + thrown = assertThrows(ContractValidateException.class, + () -> ProposalUtil.validator(dynamicPropertiesStore, forkUtils, code, 2)); + assertEquals("This value[ALLOW_FN_DSA_512] is only allowed to be 1", thrown.getMessage()); + + try { + ProposalUtil.validator(dynamicPropertiesStore, forkUtils, code, 1); + } catch (ContractValidateException e) { + Assert.fail("value=1 should be accepted: " + e.getMessage()); + } + + dynamicPropertiesStore.saveAllowFnDsa512(1L); + thrown = assertThrows(ContractValidateException.class, + () -> ProposalUtil.validator(dynamicPropertiesStore, forkUtils, code, 1)); + assertEquals("[ALLOW_FN_DSA_512] has been valid, no need to propose again", + thrown.getMessage()); + dynamicPropertiesStore.saveAllowFnDsa512(0L); + } } diff --git a/framework/src/test/java/org/tron/core/capsule/BlockCapsulePQTest.java b/framework/src/test/java/org/tron/core/capsule/BlockCapsulePQTest.java new file mode 100644 index 00000000000..f224652046e --- /dev/null +++ b/framework/src/test/java/org/tron/core/capsule/BlockCapsulePQTest.java @@ -0,0 +1,194 @@ +package org.tron.core.capsule; + +import com.google.protobuf.ByteString; +import org.junit.Assert; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.tron.common.BaseTest; +import org.tron.common.TestConstants; +import org.tron.common.crypto.ECKey; +import org.tron.common.crypto.pqc.FNDSA; +import org.tron.common.crypto.pqc.PQSchemeRegistry; +import org.tron.common.utils.Sha256Hash; +import org.tron.core.config.args.Args; +import org.tron.core.exception.ValidateSignatureException; +import org.tron.protos.Protocol.Account; +import org.tron.protos.Protocol.AccountType; +import org.tron.protos.Protocol.Key; +import org.tron.protos.Protocol.PQAuthSig; +import org.tron.protos.Protocol.PQScheme; +import org.tron.protos.Protocol.Permission; +import org.tron.protos.Protocol.Permission.PermissionType; + +public class BlockCapsulePQTest extends BaseTest { + + private ECKey witnessKey; + private byte[] witnessAddress; + private FNDSA pqKeypair; + private byte[] pqAddress; + + @BeforeClass + public static void init() { + Args.setParam(new String[] {"-d", dbPath()}, TestConstants.TEST_CONF); + } + + @Before + public void setUp() { + witnessKey = new ECKey(); + witnessAddress = witnessKey.getAddress(); + pqKeypair = new FNDSA(); + pqAddress = PQSchemeRegistry.computeAddress( + PQScheme.FN_DSA_512, pqKeypair.getPublicKey()); + } + + /** + * Build a witness account whose witness permission key is bound to the + * given address. For PQ scenarios, pass {@link #pqAddress}; for legacy ECDSA + * scenarios, pass {@link #witnessAddress}. + */ + private AccountCapsule buildWitnessAccount(byte[] keyAddress) { + Key kb = Key.newBuilder() + .setAddress(ByteString.copyFrom(keyAddress)) + .setWeight(1) + .build(); + Permission witnessPerm = Permission.newBuilder() + .setType(PermissionType.Witness) + .setId(1) + .setPermissionName("witness") + .setThreshold(1) + .addKeys(kb) + .build(); + Account account = Account.newBuilder() + .setAccountName(ByteString.copyFromUtf8("w")) + .setAddress(ByteString.copyFrom(witnessAddress)) + .setType(AccountType.Normal) + .setBalance(1_000_000_000L) + .setIsWitness(true) + .setWitnessPermission(witnessPerm) + .build(); + return new AccountCapsule(account); + } + + private BlockCapsule buildSignedBlock(byte[] parentHash) { + BlockCapsule block = new BlockCapsule( + 1L, + Sha256Hash.wrap(ByteString.copyFrom(parentHash)), + System.currentTimeMillis(), + ByteString.copyFrom(witnessAddress)); + block.sign(witnessKey.getPrivKeyBytes()); + return block; + } + + private BlockCapsule buildUnsignedBlock(byte[] parentHash) { + return new BlockCapsule( + 1L, + Sha256Hash.wrap(ByteString.copyFrom(parentHash)), + System.currentTimeMillis(), + ByteString.copyFrom(witnessAddress)); + } + + private byte[] signPQ(byte[] message) { + return FNDSA.sign(pqKeypair.getPrivateKey(), message); + } + + private PQAuthSig buildPQAuthSig(byte[] signature) { + return PQAuthSig.newBuilder() + .setScheme(PQScheme.FN_DSA_512) + .setPublicKey(ByteString.copyFrom(pqKeypair.getPublicKey())) + .setSignature(ByteString.copyFrom(signature)) + .build(); + } + + @Test + public void legacyValidateWithoutPQAuthSigAcceptedBeforeActivation() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowMultiSign(1L); + dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(0L); + AccountCapsule witness = buildWitnessAccount(witnessAddress); + dbManager.getAccountStore().put(witnessAddress, witness); + + byte[] parentHash = new byte[32]; + BlockCapsule block = buildSignedBlock(parentHash); + Assert.assertTrue(block.validateSignature( + dbManager.getDynamicPropertiesStore(), dbManager.getAccountStore())); + } + + @Test(expected = ValidateSignatureException.class) + public void pqAuthSigBeforeActivationRejected() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowMultiSign(1L); + dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(0L); + AccountCapsule witness = buildWitnessAccount(pqAddress); + dbManager.getAccountStore().put(witnessAddress, witness); + + byte[] parentHash = new byte[32]; + BlockCapsule block = buildUnsignedBlock(parentHash); + byte[] digest = block.getRawHashBytes(); + block.setPqAuthSig(buildPQAuthSig(signPQ(digest))); + block.validateSignature( + dbManager.getDynamicPropertiesStore(), dbManager.getAccountStore()); + } + + @Test(expected = ValidateSignatureException.class) + public void bothLegacyAndPQAuthSigRejected() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowMultiSign(1L); + dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(1L); + AccountCapsule witness = buildWitnessAccount(pqAddress); + dbManager.getAccountStore().put(witnessAddress, witness); + + byte[] parentHash = new byte[32]; + BlockCapsule block = buildSignedBlock(parentHash); + byte[] digest = block.getRawHashBytes(); + block.setPqAuthSig(buildPQAuthSig(signPQ(digest))); + block.validateSignature( + dbManager.getDynamicPropertiesStore(), dbManager.getAccountStore()); + } + + @Test + public void pqOnlyAccepted() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowMultiSign(1L); + dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(1L); + AccountCapsule witness = buildWitnessAccount(pqAddress); + dbManager.getAccountStore().put(witnessAddress, witness); + + byte[] parentHash = new byte[32]; + BlockCapsule block = buildUnsignedBlock(parentHash); + byte[] digest = block.getRawHashBytes(); + block.setPqAuthSig(buildPQAuthSig(signPQ(digest))); + Assert.assertTrue(block.validateSignature( + dbManager.getDynamicPropertiesStore(), dbManager.getAccountStore())); + } + + @Test + public void tamperedPQAuthSigFails() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowMultiSign(1L); + dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(1L); + AccountCapsule witness = buildWitnessAccount(pqAddress); + dbManager.getAccountStore().put(witnessAddress, witness); + + byte[] parentHash = new byte[32]; + BlockCapsule block = buildUnsignedBlock(parentHash); + byte[] digest = block.getRawHashBytes(); + byte[] pqSig = signPQ(digest); + pqSig[pqSig.length - 1] ^= 0x01; + block.setPqAuthSig(buildPQAuthSig(pqSig)); + Assert.assertFalse(block.validateSignature( + dbManager.getDynamicPropertiesStore(), dbManager.getAccountStore())); + } + + @Test(expected = ValidateSignatureException.class) + public void signerNotInWitnessPermissionRejected() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowMultiSign(1L); + dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(1L); + // Witness permission key bound to a different address (the legacy ECDSA + // address), so the PQ signer's derived address won't match. + AccountCapsule witness = buildWitnessAccount(witnessAddress); + dbManager.getAccountStore().put(witnessAddress, witness); + + byte[] parentHash = new byte[32]; + BlockCapsule block = buildUnsignedBlock(parentHash); + byte[] digest = block.getRawHashBytes(); + block.setPqAuthSig(buildPQAuthSig(signPQ(digest))); + block.validateSignature( + dbManager.getDynamicPropertiesStore(), dbManager.getAccountStore()); + } +} diff --git a/framework/src/test/java/org/tron/core/capsule/TransactionCapsuleTest.java b/framework/src/test/java/org/tron/core/capsule/TransactionCapsuleTest.java index 9c2e004931e..078e6153f39 100644 --- a/framework/src/test/java/org/tron/core/capsule/TransactionCapsuleTest.java +++ b/framework/src/test/java/org/tron/core/capsule/TransactionCapsuleTest.java @@ -8,6 +8,7 @@ import ch.qos.logback.classic.Logger; import ch.qos.logback.classic.spi.ILoggingEvent; import ch.qos.logback.core.read.ListAppender; +import com.google.protobuf.Any; import com.google.protobuf.ByteString; import java.util.List; import java.util.concurrent.TimeUnit; @@ -20,15 +21,28 @@ import org.slf4j.LoggerFactory; import org.tron.common.BaseTest; import org.tron.common.TestConstants; +import org.tron.common.crypto.ECKey; +import org.tron.common.crypto.pqc.FNDSA; +import org.tron.common.crypto.pqc.PQSchemeRegistry; +import org.tron.common.utils.ByteArray; +import org.tron.common.utils.Sha256Hash; import org.tron.common.utils.StringUtil; import org.tron.core.Wallet; import org.tron.core.config.args.Args; +import org.tron.core.exception.ValidateSignatureException; import org.tron.protos.Protocol.AccountType; +import org.tron.protos.Protocol.Key; +import org.tron.protos.Protocol.PQAuthSig; +import org.tron.protos.Protocol.PQScheme; +import org.tron.protos.Protocol.Permission; +import org.tron.protos.Protocol.Permission.PermissionType; import org.tron.protos.Protocol.Transaction; import org.tron.protos.Protocol.Transaction.Contract.ContractType; import org.tron.protos.Protocol.Transaction.Result; import org.tron.protos.Protocol.Transaction.Result.contractResult; import org.tron.protos.Protocol.Transaction.raw; +import org.tron.protos.contract.BalanceContract.TransferContract; +import org.tron.protos.contract.SmartContractOuterClass.TriggerSmartContract; @Slf4j public class TransactionCapsuleTest extends BaseTest { @@ -113,6 +127,73 @@ public void slowVerify() { } } + // --------------------- FN-DSA pq_auth_sig verification (V2) --------------------- + + private static final String PQ_OWNER_HEX = + "41abd4b9367799eaa3197fecb144eb71de1e049abc"; + private static final String PQ_TO_HEX = + "41548794500882809695a8a687866e76d4271a1abc"; + + private Transaction buildTransferTx(String ownerHex, int permissionId) { + TransferContract transfer = TransferContract.newBuilder() + .setOwnerAddress(ByteString.copyFrom(ByteArray.fromHexString(ownerHex))) + .setToAddress(ByteString.copyFrom(ByteArray.fromHexString(PQ_TO_HEX))) + .setAmount(1L) + .build(); + Transaction.Contract c = Transaction.Contract.newBuilder() + .setType(ContractType.TransferContract) + .setParameter(Any.pack(transfer)) + .setPermissionId(permissionId) + .build(); + raw rawData = raw.newBuilder().addContract(c).build(); + return Transaction.newBuilder().setRawData(rawData).build(); + } + + /** + * V2: bind the PQ public key to the permission via address-as-fingerprint. + * The signer address is derived from the public key by the scheme's + * fingerprint hash (see {@link PQSchemeRegistry#computeAddress}). + */ + private void putAccountWithPQPermission( + String ownerHex, byte[] pqPublicKey, PQScheme scheme) { + byte[] addr = ByteArray.fromHexString(ownerHex); + byte[] signerAddr = PQSchemeRegistry.computeAddress(scheme, pqPublicKey); + Key pqKey = Key.newBuilder() + .setAddress(ByteString.copyFrom(signerAddr)) + .setWeight(1L) + .build(); + Permission owner = Permission.newBuilder() + .setType(PermissionType.Owner) + .setPermissionName("owner") + .setThreshold(1) + .addKeys(pqKey) + .build(); + AccountCapsule acc = new AccountCapsule(ByteString.copyFrom(addr), + ByteString.copyFromUtf8("pqowner"), AccountType.Normal); + acc.updatePermissions(owner, null, java.util.Collections.emptyList()); + dbManager.getAccountStore().put(addr, acc); + } + + @Test + public void pqAuthSigBeforeActivationRejected() { + dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(0L); + Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0).toBuilder() + .addPqAuthSig(PQAuthSig.newBuilder() + .setScheme(PQScheme.FN_DSA_512) + .setPublicKey(ByteString.copyFrom(new byte[FNDSA.PUBLIC_KEY_LENGTH])) + .setSignature(ByteString.copyFrom(new byte[FNDSA.SIGNATURE_LENGTH])) + .build()) + .build(); + TransactionCapsule cap = new TransactionCapsule(tx); + try { + cap.validatePubSignature(dbManager.getAccountStore(), + dbManager.getDynamicPropertiesStore()); + Assert.fail("should reject pq_auth_sig before activation"); + } catch (ValidateSignatureException e) { + Assert.assertTrue(e.getMessage().contains("no post-quantum scheme is activated")); + } + } + @Test public void fastVerify() { Logger capsuleLogger = (Logger) LoggerFactory.getLogger("capsule"); @@ -134,4 +215,398 @@ public void fastVerify() { capsuleLogger.setLevel(originalLevel); } } + + @Test + public void validPQAuthSigAccepted() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(1L); + FNDSA kp = new FNDSA(); + putAccountWithPQPermission(PQ_OWNER_HEX, kp.getPublicKey(), PQScheme.FN_DSA_512); + + Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0); + byte[] txid = Sha256Hash.of(true, tx.getRawData().toByteArray()).getBytes(); + + byte[] sig = FNDSA.sign(kp.getPrivateKey(), txid); + + Transaction signed = tx.toBuilder() + .addPqAuthSig(PQAuthSig.newBuilder() + .setScheme(PQScheme.FN_DSA_512) + .setPublicKey(ByteString.copyFrom(kp.getPublicKey())) + .setSignature(ByteString.copyFrom(sig)) + .build()) + .build(); + TransactionCapsule cap = new TransactionCapsule(signed); + Assert.assertTrue(cap.validatePubSignature(dbManager.getAccountStore(), + dbManager.getDynamicPropertiesStore())); + } + + @Test + public void duplicateSignerRejected() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(1L); + FNDSA kp = new FNDSA(); + putAccountWithPQPermission(PQ_OWNER_HEX, kp.getPublicKey(), PQScheme.FN_DSA_512); + + Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0); + byte[] txid = Sha256Hash.of(true, tx.getRawData().toByteArray()).getBytes(); + + byte[] sig = FNDSA.sign(kp.getPrivateKey(), txid); + PQAuthSig w = PQAuthSig.newBuilder() + .setScheme(PQScheme.FN_DSA_512) + .setPublicKey(ByteString.copyFrom(kp.getPublicKey())) + .setSignature(ByteString.copyFrom(sig)) + .build(); + Transaction signed = tx.toBuilder().addPqAuthSig(w).addPqAuthSig(w).build(); + + TransactionCapsule cap = new TransactionCapsule(signed); + try { + cap.validatePubSignature(dbManager.getAccountStore(), + dbManager.getDynamicPropertiesStore()); + Assert.fail("duplicate signer should be rejected"); + } catch (ValidateSignatureException e) { + Assert.assertTrue(e.getMessage().contains("has signed twice")); + } + } + + @Test + public void tamperedPQAuthSigRejected() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(1L); + FNDSA kp = new FNDSA(); + putAccountWithPQPermission(PQ_OWNER_HEX, kp.getPublicKey(), PQScheme.FN_DSA_512); + + Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0); + byte[] txid = Sha256Hash.of(true, tx.getRawData().toByteArray()).getBytes(); + + byte[] sig = FNDSA.sign(kp.getPrivateKey(), txid); + sig[0] ^= 0x01; + + Transaction signed = tx.toBuilder() + .addPqAuthSig(PQAuthSig.newBuilder() + .setScheme(PQScheme.FN_DSA_512) + .setPublicKey(ByteString.copyFrom(kp.getPublicKey())) + .setSignature(ByteString.copyFrom(sig)) + .build()) + .build(); + TransactionCapsule cap = new TransactionCapsule(signed); + try { + cap.validatePubSignature(dbManager.getAccountStore(), + dbManager.getDynamicPropertiesStore()); + Assert.fail("tampered signature should be rejected"); + } catch (ValidateSignatureException e) { + Assert.assertTrue(e.getMessage().contains("pq sig invalid")); + } + } + + @Test + public void signerNotInPermissionRejected() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(1L); + FNDSA known = new FNDSA(); + putAccountWithPQPermission(PQ_OWNER_HEX, known.getPublicKey(), PQScheme.FN_DSA_512); + + // Sign with a *different* keypair → derived address is not in the permission. + FNDSA stranger = new FNDSA(); + Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0); + byte[] txid = Sha256Hash.of(true, tx.getRawData().toByteArray()).getBytes(); + + byte[] sig = FNDSA.sign(stranger.getPrivateKey(), txid); + + Transaction signed = tx.toBuilder() + .addPqAuthSig(PQAuthSig.newBuilder() + .setScheme(PQScheme.FN_DSA_512) + .setPublicKey(ByteString.copyFrom(stranger.getPublicKey())) + .setSignature(ByteString.copyFrom(sig)) + .build()) + .build(); + TransactionCapsule cap = new TransactionCapsule(signed); + try { + cap.validatePubSignature(dbManager.getAccountStore(), + dbManager.getDynamicPropertiesStore()); + Assert.fail("signer outside permission should be rejected"); + } catch (ValidateSignatureException e) { + Assert.assertTrue(e.getMessage().contains("not contained of permission")); + } + } + + /** + * TRC20 transfer(address,uint256) call data: 4-byte selector + 32-byte address + 32-byte amount. + */ + private Transaction buildTrc20TransferTx(String ownerHex, int permissionId) { + byte[] selector = ByteArray.fromHexString("a9059cbb"); + byte[] toAddrPadded = new byte[32]; + byte[] toRaw = ByteArray.fromHexString(PQ_TO_HEX.substring(2)); // strip "41" + System.arraycopy(toRaw, 0, toAddrPadded, 12, 20); + byte[] amountPadded = new byte[32]; + amountPadded[31] = (byte) 100; // 100 tokens + byte[] callData = new byte[selector.length + toAddrPadded.length + amountPadded.length]; + System.arraycopy(selector, 0, callData, 0, 4); + System.arraycopy(toAddrPadded, 0, callData, 4, 32); + System.arraycopy(amountPadded, 0, callData, 36, 32); + + byte[] contractAddr = ByteArray.fromHexString("41a614f803b6fd780986a42c78ec9c7f77e6ded13c"); + TriggerSmartContract trigger = TriggerSmartContract.newBuilder() + .setOwnerAddress(ByteString.copyFrom(ByteArray.fromHexString(ownerHex))) + .setContractAddress(ByteString.copyFrom(contractAddr)) + .setData(ByteString.copyFrom(callData)) + .build(); + Transaction.Contract c = Transaction.Contract.newBuilder() + .setType(ContractType.TriggerSmartContract) + .setParameter(Any.pack(trigger)) + .setPermissionId(permissionId) + .build(); + raw rawData = raw.newBuilder() + .addContract(c) + .setFeeLimit(150_000_000L) + .build(); + return Transaction.newBuilder().setRawData(rawData).build(); + } + + /** + * Returns [serializedSize, packSize, maxTxPerBlock] rows ordered by signature size: + * ECKey, FN-DSA-512. + */ + private long[][] measureSizes(Transaction baseTx) { + final long blockLimit = 2_000_000L; + + // ECKey (ECDSA): 65-byte signature in `signature` field + ECKey ecKey = new ECKey(); + TransactionCapsule ecCap = new TransactionCapsule(baseTx); + ecCap.sign(ecKey.getPrivKeyBytes()); + long ecSerial = ecCap.getInstance().toByteArray().length; + long ecPack = ecCap.computeTrxSizeForBlockMessage(); + + // FN-DSA-512: variable-length signature (<= 752 bytes) + 897-byte public key + FNDSA kpFn = new FNDSA(); + byte[] txidFn = Sha256Hash.of(true, baseTx.getRawData().toByteArray()).getBytes(); + byte[] sigFn = FNDSA.sign(kpFn.getPrivateKey(), txidFn); + Transaction txFn = baseTx.toBuilder() + .addPqAuthSig(PQAuthSig.newBuilder() + .setScheme(PQScheme.FN_DSA_512) + .setPublicKey(ByteString.copyFrom(kpFn.getPublicKey())) + .setSignature(ByteString.copyFrom(sigFn)) + .build()) + .build(); + TransactionCapsule capFn = new TransactionCapsule(txFn); + long dFnSerial = txFn.toByteArray().length; + long dFnPack = capFn.computeTrxSizeForBlockMessage(); + + return new long[][]{ + {ecSerial, ecPack, blockLimit / ecPack}, + {dFnSerial, dFnPack, blockLimit / dFnPack}, + }; + } + + @Test + public void transactionSizeComparisonByScheme() { + long[][] trx = measureSizes(buildTransferTx(PQ_OWNER_HEX, 0)); + long[][] trc20 = measureSizes(buildTrc20TransferTx(PQ_OWNER_HEX, 0)); + + String[] labels = {"ECKey (ECDSA)", "FN-DSA-512"}; + System.out.println("=== TRX transfer ==="); + for (int i = 0; i < labels.length; i++) { + System.out.printf(" %s: serial=%d B pack=%d B maxTx/block=%d%n", + labels[i], trx[i][0], trx[i][1], trx[i][2]); + } + System.out.println("=== TRC20 transfer ==="); + for (int i = 0; i < labels.length; i++) { + System.out.printf(" %s: serial=%d B pack=%d B maxTx/block=%d%n", + labels[i], trc20[i][0], trc20[i][1], trc20[i][2]); + } + + // FN-DSA-512 envelope is larger than ECKey, so it fits fewer txs per block. + Assert.assertTrue(trx[1][0] > trx[0][0]); + Assert.assertTrue(trc20[1][0] > trc20[0][0]); + Assert.assertTrue(trx[1][2] < trx[0][2]); + Assert.assertTrue(trc20[1][2] < trc20[0][2]); + } + + @Test + public void pqAuthSigWrongPublicKeyLengthRejected() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(1L); + FNDSA kp = new FNDSA(); + putAccountWithPQPermission(PQ_OWNER_HEX, kp.getPublicKey(), PQScheme.FN_DSA_512); + + Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0); + byte[] txid = Sha256Hash.of(true, tx.getRawData().toByteArray()).getBytes(); + byte[] sig = FNDSA.sign(kp.getPrivateKey(), txid); + + // Truncate public key by one byte to force the length-mismatch branch. + byte[] shortPub = new byte[FNDSA.PUBLIC_KEY_LENGTH - 1]; + System.arraycopy(kp.getPublicKey(), 0, shortPub, 0, shortPub.length); + + Transaction signed = tx.toBuilder() + .addPqAuthSig(PQAuthSig.newBuilder() + .setScheme(PQScheme.FN_DSA_512) + .setPublicKey(ByteString.copyFrom(shortPub)) + .setSignature(ByteString.copyFrom(sig)) + .build()) + .build(); + TransactionCapsule cap = new TransactionCapsule(signed); + try { + cap.validatePubSignature(dbManager.getAccountStore(), + dbManager.getDynamicPropertiesStore()); + Assert.fail("wrong public key length must be rejected"); + } catch (ValidateSignatureException e) { + Assert.assertTrue(e.getMessage().contains("public key or signature length mismatch")); + } + } + + @Test + public void pqAuthSigWrongSignatureLengthRejected() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(1L); + FNDSA kp = new FNDSA(); + putAccountWithPQPermission(PQ_OWNER_HEX, kp.getPublicKey(), PQScheme.FN_DSA_512); + + Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0); + + // Empty signature is not a valid FN-DSA-512 length, hits the same branch. + Transaction signed = tx.toBuilder() + .addPqAuthSig(PQAuthSig.newBuilder() + .setScheme(PQScheme.FN_DSA_512) + .setPublicKey(ByteString.copyFrom(kp.getPublicKey())) + .setSignature(ByteString.EMPTY) + .build()) + .build(); + TransactionCapsule cap = new TransactionCapsule(signed); + try { + cap.validatePubSignature(dbManager.getAccountStore(), + dbManager.getDynamicPropertiesStore()); + Assert.fail("wrong signature length must be rejected"); + } catch (ValidateSignatureException e) { + Assert.assertTrue(e.getMessage().contains("public key or signature length mismatch")); + } + } + + @Test + public void pqAuthSigUnsupportedSchemeRejected() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(1L); + FNDSA kp = new FNDSA(); + putAccountWithPQPermission(PQ_OWNER_HEX, kp.getPublicKey(), PQScheme.FN_DSA_512); + + Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0); + byte[] txid = Sha256Hash.of(true, tx.getRawData().toByteArray()).getBytes(); + byte[] sig = FNDSA.sign(kp.getPrivateKey(), txid); + + // setSchemeValue(99) sets an unknown numeric tag; reading back yields + // PQScheme.UNRECOGNIZED, which PQSchemeRegistry.contains() rejects. + Transaction signed = tx.toBuilder() + .addPqAuthSig(PQAuthSig.newBuilder() + .setSchemeValue(99) + .setPublicKey(ByteString.copyFrom(kp.getPublicKey())) + .setSignature(ByteString.copyFrom(sig)) + .build()) + .build(); + TransactionCapsule cap = new TransactionCapsule(signed); + try { + cap.validatePubSignature(dbManager.getAccountStore(), + dbManager.getDynamicPropertiesStore()); + Assert.fail("unsupported scheme must be rejected"); + } catch (ValidateSignatureException e) { + Assert.assertTrue(e.getMessage().contains("unsupported pq scheme")); + } + } + + @Test + public void validatePubSignatureRejectsMissingSig() { + Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0); + TransactionCapsule cap = new TransactionCapsule(tx); + try { + cap.validatePubSignature(dbManager.getAccountStore(), + dbManager.getDynamicPropertiesStore()); + Assert.fail("transaction with no signatures must be rejected"); + } catch (ValidateSignatureException e) { + Assert.assertTrue(e.getMessage().contains("miss sig")); + } + } + + @Test + public void validatePubSignatureRejectsMissingContract() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(1L); + FNDSA kp = new FNDSA(); + byte[] sig = FNDSA.sign(kp.getPrivateKey(), new byte[32]); + + // No contracts in raw_data, but a pq_auth_sig is attached so we get past + // the "miss sig" guard and into the "miss contract" branch. + Transaction tx = Transaction.newBuilder() + .setRawData(raw.newBuilder().build()) + .addPqAuthSig(PQAuthSig.newBuilder() + .setScheme(PQScheme.FN_DSA_512) + .setPublicKey(ByteString.copyFrom(kp.getPublicKey())) + .setSignature(ByteString.copyFrom(sig)) + .build()) + .build(); + TransactionCapsule cap = new TransactionCapsule(tx); + try { + cap.validatePubSignature(dbManager.getAccountStore(), + dbManager.getDynamicPropertiesStore()); + Assert.fail("transaction with no contracts must be rejected"); + } catch (ValidateSignatureException e) { + Assert.assertTrue(e.getMessage().contains("miss contract")); + } + } + + @Test + public void validatePubSignatureRejectsTooManySignatures() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(1L); + int original = dbManager.getDynamicPropertiesStore().getTotalSignNum(); + try { + dbManager.getDynamicPropertiesStore().saveTotalSignNum(1); + FNDSA a = new FNDSA(); + FNDSA b = new FNDSA(); + putAccountWithPQPermission(PQ_OWNER_HEX, a.getPublicKey(), PQScheme.FN_DSA_512); + + Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0); + byte[] txid = Sha256Hash.of(true, tx.getRawData().toByteArray()).getBytes(); + byte[] sigA = FNDSA.sign(a.getPrivateKey(), txid); + byte[] sigB = FNDSA.sign(b.getPrivateKey(), txid); + + Transaction signed = tx.toBuilder() + .addPqAuthSig(PQAuthSig.newBuilder() + .setScheme(PQScheme.FN_DSA_512) + .setPublicKey(ByteString.copyFrom(a.getPublicKey())) + .setSignature(ByteString.copyFrom(sigA)) + .build()) + .addPqAuthSig(PQAuthSig.newBuilder() + .setScheme(PQScheme.FN_DSA_512) + .setPublicKey(ByteString.copyFrom(b.getPublicKey())) + .setSignature(ByteString.copyFrom(sigB)) + .build()) + .build(); + TransactionCapsule cap = new TransactionCapsule(signed); + try { + cap.validatePubSignature(dbManager.getAccountStore(), + dbManager.getDynamicPropertiesStore()); + Assert.fail("more sigs than totalSignNum must be rejected"); + } catch (ValidateSignatureException e) { + Assert.assertTrue(e.getMessage().contains("too many signatures")); + } + } finally { + dbManager.getDynamicPropertiesStore().saveTotalSignNum(original); + } + } + + @Test + public void fnDsaPQAuthSigRejectedWhenNotActivated() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(0L); + FNDSA kp = new FNDSA(); + putAccountWithPQPermission(PQ_OWNER_HEX, kp.getPublicKey(), PQScheme.FN_DSA_512); + + Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0); + byte[] txid = Sha256Hash.of(true, tx.getRawData().toByteArray()).getBytes(); + + byte[] sig = FNDSA.sign(kp.getPrivateKey(), txid); + + Transaction signed = tx.toBuilder() + .addPqAuthSig(PQAuthSig.newBuilder() + .setScheme(PQScheme.FN_DSA_512) + .setPublicKey(ByteString.copyFrom(kp.getPublicKey())) + .setSignature(ByteString.copyFrom(sig)) + .build()) + .build(); + TransactionCapsule cap = new TransactionCapsule(signed); + try { + cap.validatePubSignature(dbManager.getAccountStore(), + dbManager.getDynamicPropertiesStore()); + Assert.fail("FN-DSA must be rejected when ALLOW_FN_DSA_512 is 0"); + } catch (ValidateSignatureException expected) { + Assert.assertTrue(expected.getMessage().contains("no post-quantum scheme is activated")); + } + } } diff --git a/framework/src/test/java/org/tron/core/exception/TronErrorTest.java b/framework/src/test/java/org/tron/core/exception/TronErrorTest.java index 91559d86362..11538bd967e 100644 --- a/framework/src/test/java/org/tron/core/exception/TronErrorTest.java +++ b/framework/src/test/java/org/tron/core/exception/TronErrorTest.java @@ -16,6 +16,7 @@ import com.typesafe.config.ConfigObject; import java.io.IOException; import java.lang.reflect.Field; +import java.nio.file.Files; import java.nio.file.Path; import java.util.Collections; import java.util.HashMap; @@ -116,9 +117,16 @@ public void LogLoadTest() throws IOException { } @Test - public void witnessInitTest() { + public void witnessInitTest() throws IOException { + // Inherit config-test.conf and override every witness-key source so that + // --witness has nothing to initialize from. + Path conf = temporaryFolder.newFile("no-witness.conf").toPath(); + String content = "include classpath(\"" + TestConstants.TEST_CONF + "\")\n" + + "localwitness = []\n" + + "localwitness_pq_keys = []\n"; + Files.write(conf, content.getBytes()); TronError thrown = assertThrows(TronError.class, () -> { - Args.setParam(new String[]{"--witness"}, TestConstants.TEST_CONF); + Args.setParam(new String[]{"--witness"}, conf.toString()); }); assertEquals(TronError.ErrCode.WITNESS_INIT, thrown.getErrCode()); } diff --git a/framework/src/test/java/org/tron/core/services/ProposalServiceTest.java b/framework/src/test/java/org/tron/core/services/ProposalServiceTest.java index 5732e6f1cde..070a49bdd85 100644 --- a/framework/src/test/java/org/tron/core/services/ProposalServiceTest.java +++ b/framework/src/test/java/org/tron/core/services/ProposalServiceTest.java @@ -1,6 +1,7 @@ package org.tron.core.services; import static org.tron.core.Constant.MAX_PROPOSAL_EXPIRE_TIME; +import static org.tron.core.utils.ProposalUtil.ProposalType.ALLOW_FN_DSA_512; import static org.tron.core.utils.ProposalUtil.ProposalType.CONSENSUS_LOGIC_OPTIMIZATION; import static org.tron.core.utils.ProposalUtil.ProposalType.ENERGY_FEE; import static org.tron.core.utils.ProposalUtil.ProposalType.PROPOSAL_EXPIRE_TIME; @@ -151,4 +152,20 @@ public void testProposalExpireTime() { Assert.assertEquals(MAX_PROPOSAL_EXPIRE_TIME - 3000, window); } + @Test + public void testProcessAllowFnDsa512() { + dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(0L); + Assert.assertFalse(dbManager.getDynamicPropertiesStore().allowFnDsa512()); + + Proposal proposal = Proposal.newBuilder() + .putParameters(ALLOW_FN_DSA_512.getCode(), 1L).build(); + ProposalCapsule proposalCapsule = new ProposalCapsule(proposal); + boolean result = ProposalService.process(dbManager, proposalCapsule); + Assert.assertTrue(result); + + Assert.assertEquals(1L, dbManager.getDynamicPropertiesStore().getAllowFnDsa512()); + Assert.assertTrue(dbManager.getDynamicPropertiesStore().allowFnDsa512()); + + dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(0L); + } } \ No newline at end of file diff --git a/framework/src/test/java/org/tron/core/services/http/UtilTest.java b/framework/src/test/java/org/tron/core/services/http/UtilTest.java index ebcb530bca3..95110f2b267 100644 --- a/framework/src/test/java/org/tron/core/services/http/UtilTest.java +++ b/framework/src/test/java/org/tron/core/services/http/UtilTest.java @@ -15,6 +15,8 @@ import org.tron.core.config.args.Args; import org.tron.core.utils.TransactionUtil; import org.tron.protos.Protocol; +import org.tron.protos.Protocol.PQAuthSig; +import org.tron.protos.Protocol.PQScheme; import org.tron.protos.Protocol.Transaction; public class UtilTest extends BaseTest { @@ -189,4 +191,41 @@ public void testPackTransaction() { TransactionSignWeight txSignWeight = transactionUtil.getTransactionSignWeight(transaction); Assert.assertNotNull(txSignWeight); } + + @Test + public void roundtripPQAuthSigJson() throws Exception { + byte[] sig = new byte[752]; + byte[] pubKey = new byte[897]; + for (int i = 0; i < sig.length; i++) { + sig[i] = (byte) (i & 0xff); + } + for (int i = 0; i < pubKey.length; i++) { + pubKey[i] = (byte) ((i * 7) & 0xff); + } + PQAuthSig pqAuthSig = PQAuthSig.newBuilder() + .setScheme(PQScheme.FN_DSA_512) + .setPublicKey(ByteString.copyFrom(pubKey)) + .setSignature(ByteString.copyFrom(sig)) + .build(); + Transaction original = Transaction.newBuilder() + .setRawData(Transaction.raw.newBuilder().setTimestamp(1L).build()) + .addPqAuthSig(pqAuthSig) + .build(); + + String json = Util.printTransactionToJSON(original, false).toJSONString(); + Assert.assertTrue("JSON output should contain pq_auth_sig field", + json.contains("pq_auth_sig")); + + Transaction.Builder rebuilt = Transaction.newBuilder(); + JsonFormat.merge(json, rebuilt, false); + Transaction decoded = rebuilt.build(); + + Assert.assertEquals(1, decoded.getPqAuthSigCount()); + Assert.assertEquals(pqAuthSig.getScheme(), + decoded.getPqAuthSig(0).getScheme()); + Assert.assertEquals(pqAuthSig.getPublicKey(), + decoded.getPqAuthSig(0).getPublicKey()); + Assert.assertEquals(pqAuthSig.getSignature(), + decoded.getPqAuthSig(0).getSignature()); + } } diff --git a/framework/src/test/resources/config-test.conf b/framework/src/test/resources/config-test.conf index 21cebbfeef4..99afce48263 100644 --- a/framework/src/test/resources/config-test.conf +++ b/framework/src/test/resources/config-test.conf @@ -349,10 +349,13 @@ genesis.block = { // and the localwitness is configured with the private key of the witnessPermissionAddress in the witness account. // When it is empty,the localwitness is configured with the private key of the witness account. -//localWitnessAccountAddress = +// localWitnessAccountAddress = localwitness = [ +] +localwitness_pq_scheme = "FN_DSA_512" +localwitness_pq_keys = [ ] block = { @@ -387,4 +390,4 @@ node.dynamicConfig.enable = true event.subscribe = { enable = false } -node.dynamicConfig.checkInterval = 0 \ No newline at end of file +node.dynamicConfig.checkInterval = 0 diff --git a/protocol/src/main/protos/core/Tron.proto b/protocol/src/main/protos/core/Tron.proto index 6a294c32b0c..d9ca33f7f53 100644 --- a/protocol/src/main/protos/core/Tron.proto +++ b/protocol/src/main/protos/core/Tron.proto @@ -16,6 +16,18 @@ enum AccountType { Contract = 2; } +// Post-quantum signature scheme identifier used by PQAuthSig. +// UNKNOWN_PQ_SCHEME = 0 is the proto3 default and is reserved per the +// java-tron API evolution standard (issue #6515) so unset / unrecognized +// values are detectable by JSON consumers; it MUST never be registered +// in PQSchemeRegistry. FN_DSA_512 = 1 is the V2 launch scheme. +// New schemes are reserved for future activation (see PQSchemeRegistry). +enum PQScheme { + UNKNOWN_PQ_SCHEME = 0; + FN_DSA_512 = 1; + reserved 2 to 15; +} + // AccountId, (name, address) use name, (null, address) use address, (name, null) use name, message AccountId { bytes name = 1; @@ -241,7 +253,17 @@ message Account { message Key { bytes address = 1; - int64 weight = 2; + int64 weight = 2; +} + +// Per-signer post-quantum authentication witness for a transaction or block. +// The signing public key is carried in-band; node verifies binding via +// derived_addr = 0x41 ‖ deriveHash(scheme, public_key)[0:20] +// and matches against Permission.keys[].address. +message PQAuthSig { + PQScheme scheme = 1; + bytes public_key = 2; + bytes signature = 3; } message DelegatedResource { @@ -448,6 +470,12 @@ message Transaction { // only support size = 1, repeated list here for muti-sig extension repeated bytes signature = 2; repeated Result ret = 5; + // Post-quantum authentication signatures. Each entry binds a signing + // public key to its derived address and the corresponding signature. + // ECDSA signatures (`signature` above) and PQAuthSig entries may co-exist + // on multi-sig transactions, contributing weight independently to the + // permission's threshold. + repeated PQAuthSig pq_auth_sig = 6; } message TransactionInfo { @@ -514,6 +542,12 @@ message BlockHeader { } raw raw_data = 1; bytes witness_signature = 2; + // Post-quantum block signature. Exactly one of {witness_signature, + // pq_auth_sig} SHALL be present per block: SRs with an ECDSA-only Witness + // Permission set witness_signature; SRs whose Witness Permission carries a + // PQ-derived Key set pq_auth_sig instead. The verifier dispatches by which + // field is populated. + PQAuthSig pq_auth_sig = 3; } // block From 4b45b4db1b6a486fdeed6223ecb84df50abb1164 Mon Sep 17 00:00:00 2001 From: federico Date: Mon, 11 May 2026 02:16:37 +0800 Subject: [PATCH 02/47] refactor(pqc): harden ALLOW_FN_DSA_512 proposal validation and PQ-only witness init --- .../org/tron/core/utils/ProposalUtil.java | 15 +++-- .../java/org/tron/core/config/args/Args.java | 24 +++----- .../core/config/args/WitnessInitializer.java | 55 +++++++++-------- .../core/actuator/utils/ProposalUtilTest.java | 60 +++++++++++++++---- 4 files changed, 94 insertions(+), 60 deletions(-) diff --git a/actuator/src/main/java/org/tron/core/utils/ProposalUtil.java b/actuator/src/main/java/org/tron/core/utils/ProposalUtil.java index 259a1a60bde..b7954165e4c 100644 --- a/actuator/src/main/java/org/tron/core/utils/ProposalUtil.java +++ b/actuator/src/main/java/org/tron/core/utils/ProposalUtil.java @@ -942,13 +942,18 @@ public static void validator(DynamicPropertiesStore dynamicPropertiesStore, break; } case ALLOW_FN_DSA_512: { - if (dynamicPropertiesStore.getAllowFnDsa512() == 1) { + if (!forkController.pass(ForkBlockVersionEnum.VERSION_4_8_2)) { throw new ContractValidateException( - "[ALLOW_FN_DSA_512] has been valid, no need to propose again"); + "Bad chain parameter id [ALLOW_FN_DSA_512]"); } - if (value != 1) { + if (value != 0 && value != 1) { throw new ContractValidateException( - "This value[ALLOW_FN_DSA_512] is only allowed to be 1"); + "This value[ALLOW_FN_DSA_512] is only allowed to be 0 or 1"); + } + if (dynamicPropertiesStore.getAllowFnDsa512() == value) { + throw new ContractValidateException( + "[ALLOW_FN_DSA_512] has been set to " + value + + ", no need to propose again"); } break; } @@ -1041,7 +1046,7 @@ public enum ProposalType { // current value, value range ALLOW_TVM_OSAKA(96), // 0, 1 ALLOW_HARDEN_RESOURCE_CALCULATION(97), // 0, 1 ALLOW_HARDEN_EXCHANGE_CALCULATION(98), // 0, 1 - ALLOW_FN_DSA_512(100); // 0, 1 + ALLOW_FN_DSA_512(99); // 0, 1 private long code; diff --git a/framework/src/main/java/org/tron/core/config/args/Args.java b/framework/src/main/java/org/tron/core/config/args/Args.java index d6ef74c6ef7..de874f90dde 100644 --- a/framework/src/main/java/org/tron/core/config/args/Args.java +++ b/framework/src/main/java/org/tron/core/config/args/Args.java @@ -962,29 +962,26 @@ private static void initLocalWitnesses(Config config, CLIParameter cmd) { if (config.hasPath(ConfigKey.LOCAL_WITNESS_PQ_KEYS)) { List pqEntries = config.getStringList(ConfigKey.LOCAL_WITNESS_PQ_KEYS); if (!pqEntries.isEmpty()) { - localWitnesses = new LocalWitnesses(); - // Scheme must be applied before keypairs — key-length validation depends on it. + PQScheme scheme = PQScheme.FN_DSA_512; if (config.hasPath(ConfigKey.LOCAL_WITNESS_PQ_SCHEME)) { String schemeName = config.getString(ConfigKey.LOCAL_WITNESS_PQ_SCHEME); try { - PQScheme scheme = PQScheme.valueOf(schemeName); - if (!WITNESS_PQ_SCHEMES.contains(scheme)) { - throw new TronError("invalid " + ConfigKey.LOCAL_WITNESS_PQ_SCHEME - + ": " + schemeName + "; valid values: " + WITNESS_PQ_SCHEMES, - TronError.ErrCode.WITNESS_INIT); - } - localWitnesses.setPqScheme(scheme); + scheme = PQScheme.valueOf(schemeName); } catch (IllegalArgumentException e) { throw new TronError("invalid " + ConfigKey.LOCAL_WITNESS_PQ_SCHEME + ": " + schemeName, TronError.ErrCode.WITNESS_INIT); } + if (!WITNESS_PQ_SCHEMES.contains(scheme)) { + throw new TronError("invalid " + ConfigKey.LOCAL_WITNESS_PQ_SCHEME + + ": " + schemeName + "; valid values: " + WITNESS_PQ_SCHEMES, + TronError.ErrCode.WITNESS_INIT); + } } // Each entry is the extended private key f‖g‖F‖h (priv ‖ pub) hex, // sized (privLen + pubLen) bytes for the active scheme. We split here // so downstream consumers (ConsensusService, LocalWitnesses) keep the // same priv/pub split they already use — derivePublicKey(priv) replaces // the previous explicit `pub` config field. - PQScheme scheme = localWitnesses.getPqScheme(); int privHexLen = PQSchemeRegistry.getPrivateKeyLength(scheme) * 2; int extHexLen = privHexLen + PQSchemeRegistry.getPublicKeyLength(scheme) * 2; List pqPrivateKeys = new ArrayList<>(pqEntries.size()); @@ -1002,11 +999,8 @@ private static void initLocalWitnesses(Config config, CLIParameter cmd) { pqPrivateKeys.add(stripped.substring(0, privHexLen)); pqPublicKeys.add(stripped.substring(privHexLen)); } - localWitnesses.setPqKeypairs(pqPrivateKeys, pqPublicKeys); - byte[] address = WitnessInitializer.resolvePqAuthSigAddress(lwConfig.getAccountAddress()); - if (address != null) { - localWitnesses.setWitnessAccountAddress(address); - } + localWitnesses = WitnessInitializer.initFromPQOnly( + scheme, pqPrivateKeys, pqPublicKeys, lwConfig.getAccountAddress()); return; } } diff --git a/framework/src/main/java/org/tron/core/config/args/WitnessInitializer.java b/framework/src/main/java/org/tron/core/config/args/WitnessInitializer.java index f8068511c0d..9251f2151c7 100644 --- a/framework/src/main/java/org/tron/core/config/args/WitnessInitializer.java +++ b/framework/src/main/java/org/tron/core/config/args/WitnessInitializer.java @@ -7,6 +7,7 @@ import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.tron.common.crypto.SignInterface; +import org.tron.common.crypto.pqc.PQSchemeRegistry; import org.tron.common.utils.ByteArray; import org.tron.common.utils.Commons; import org.tron.common.utils.LocalWitnesses; @@ -14,6 +15,7 @@ import org.tron.core.exception.TronError; import org.tron.keystore.Credentials; import org.tron.keystore.WalletUtils; +import org.tron.protos.Protocol.PQScheme; @Slf4j public class WitnessInitializer { @@ -113,42 +115,39 @@ public static LocalWitnesses initFromKeystore( } /** - * Init for PQ-only witness nodes (no legacy ECDSA key). The witness account - * address must be supplied explicitly because there is no ECDSA key to derive it from. + * Init for PQ-only witness nodes (no legacy ECDSA key). When + * {@code witnessAccountAddress} is blank, the address is derived from the + * first PQ public key via {@link PQSchemeRegistry#computeAddress(PQScheme, + * byte[])}. */ - public static LocalWitnesses initFromPQOnly(String witnessAccountAddress) { - if (StringUtils.isBlank(witnessAccountAddress)) { - throw new TronError( - "localWitnessAccountAddress must be set for PQ-only witness nodes", - TronError.ErrCode.WITNESS_INIT); - } - byte[] address = Commons.decodeFromBase58Check(witnessAccountAddress); - if (address == null) { + public static LocalWitnesses initFromPQOnly(PQScheme scheme, + List pqPrivateKeys, List pqPublicKeys, + String witnessAccountAddress) { + if (pqPublicKeys == null || pqPublicKeys.isEmpty()) { throw new TronError( - "LocalWitnessAccountAddress format is incorrect", + "PQ public keys must be set for PQ-only witness nodes", TronError.ErrCode.WITNESS_INIT); } LocalWitnesses witnesses = new LocalWitnesses(); - witnesses.initWitnessAccountAddress(address, false); - logger.debug("Initialised PQ-only witness with address {}", witnessAccountAddress); - return witnesses; - } + witnesses.setPqScheme(scheme); + witnesses.setPqKeypairs(pqPrivateKeys, pqPublicKeys); - /** - * Resolve witness address for PQ seed configuration. - */ - public static byte[] resolvePqAuthSigAddress(String witnessAccountAddress) { - if (StringUtils.isEmpty(witnessAccountAddress)) { - return null; - } - byte[] address = Commons.decodeFromBase58Check(witnessAccountAddress); - if (address != null) { - logger.debug("Got localWitnessAccountAddress from config.conf"); + byte[] address; + if (StringUtils.isBlank(witnessAccountAddress)) { + byte[] firstPubKey = ByteArray.fromHexString(pqPublicKeys.get(0)); + address = PQSchemeRegistry.computeAddress(scheme, firstPubKey); + logger.debug("Derived PQ-only witness address from public key"); } else { - throw new TronError("LocalWitnessAccountAddress format from config is incorrect", - TronError.ErrCode.WITNESS_INIT); + address = Commons.decodeFromBase58Check(witnessAccountAddress); + if (address == null) { + throw new TronError( + "LocalWitnessAccountAddress format is incorrect", + TronError.ErrCode.WITNESS_INIT); + } + logger.debug("Got localWitnessAccountAddress from config.conf"); } - return address; + witnesses.initWitnessAccountAddress(address, false); + return witnesses; } static byte[] resolveWitnessAddress( diff --git a/framework/src/test/java/org/tron/core/actuator/utils/ProposalUtilTest.java b/framework/src/test/java/org/tron/core/actuator/utils/ProposalUtilTest.java index 92f35d7b79f..1e7be605104 100644 --- a/framework/src/test/java/org/tron/core/actuator/utils/ProposalUtilTest.java +++ b/framework/src/test/java/org/tron/core/actuator/utils/ProposalUtilTest.java @@ -801,26 +801,62 @@ public void blockVersionCheck() { @Test public void validateAllowFnDsa512() { long code = ProposalType.ALLOW_FN_DSA_512.getCode(); + ThrowingRunnable proposeZero = () -> ProposalUtil.validator(dynamicPropertiesStore, forkUtils, + code, 0); + ThrowingRunnable proposeOne = () -> ProposalUtil.validator(dynamicPropertiesStore, forkUtils, + code, 1); + ThrowingRunnable proposeTwo = () -> ProposalUtil.validator(dynamicPropertiesStore, forkUtils, + code, 2); - ContractValidateException thrown = assertThrows(ContractValidateException.class, - () -> ProposalUtil.validator(dynamicPropertiesStore, forkUtils, code, 0)); - assertEquals("This value[ALLOW_FN_DSA_512] is only allowed to be 1", thrown.getMessage()); + byte[] stats = new byte[27]; + forkUtils.getManager().getDynamicPropertiesStore() + .statsByVersion(ForkBlockVersionEnum.VERSION_4_8_1.getValue(), stats); + long maintenanceTimeInterval = forkUtils.getManager().getDynamicPropertiesStore() + .getMaintenanceTimeInterval(); + long hardForkTime = + ((ForkBlockVersionEnum.VERSION_4_8_2.getHardForkTime() - 1) / maintenanceTimeInterval + 1) + * maintenanceTimeInterval; + forkUtils.getManager().getDynamicPropertiesStore() + .saveLatestBlockHeaderTimestamp(hardForkTime - 1); - thrown = assertThrows(ContractValidateException.class, - () -> ProposalUtil.validator(dynamicPropertiesStore, forkUtils, code, 2)); - assertEquals("This value[ALLOW_FN_DSA_512] is only allowed to be 1", thrown.getMessage()); + // 1) before fork 4.8.2 -> rejected + ContractValidateException thrown = assertThrows(ContractValidateException.class, proposeOne); + assertEquals("Bad chain parameter id [ALLOW_FN_DSA_512]", thrown.getMessage()); + forkUtils.getManager().getDynamicPropertiesStore() + .saveLatestBlockHeaderTimestamp(hardForkTime + 1); + Arrays.fill(stats, (byte) 1); + forkUtils.getManager().getDynamicPropertiesStore() + .statsByVersion(ForkBlockVersionEnum.VERSION_4_8_2.getValue(), stats); + + // 2) value not in {0, 1} -> rejected + thrown = assertThrows(ContractValidateException.class, proposeTwo); + assertEquals("This value[ALLOW_FN_DSA_512] is only allowed to be 0 or 1", thrown.getMessage()); + + // 3) current value is 0 (default), proposing 0 again -> rejected + thrown = assertThrows(ContractValidateException.class, proposeZero); + assertEquals("[ALLOW_FN_DSA_512] has been set to 0, no need to propose again", + thrown.getMessage()); + + // 4) value=1 to enable -> ok try { - ProposalUtil.validator(dynamicPropertiesStore, forkUtils, code, 1); - } catch (ContractValidateException e) { - Assert.fail("value=1 should be accepted: " + e.getMessage()); + proposeOne.run(); + } catch (Throwable e) { + Assert.fail("Should pass when toggling 0 -> 1: " + e.getMessage()); } + // 5) after activation, proposing 1 again -> rejected dynamicPropertiesStore.saveAllowFnDsa512(1L); - thrown = assertThrows(ContractValidateException.class, - () -> ProposalUtil.validator(dynamicPropertiesStore, forkUtils, code, 1)); - assertEquals("[ALLOW_FN_DSA_512] has been valid, no need to propose again", + thrown = assertThrows(ContractValidateException.class, proposeOne); + assertEquals("[ALLOW_FN_DSA_512] has been set to 1, no need to propose again", thrown.getMessage()); + + // 6) value=0 to disable -> ok (toggle back off) + try { + proposeZero.run(); + } catch (Throwable e) { + Assert.fail("Should pass when toggling 1 -> 0: " + e.getMessage()); + } dynamicPropertiesStore.saveAllowFnDsa512(0L); } } From c32015b50bd2a568f57e1e6fb78e2feef4a5c956 Mon Sep 17 00:00:00 2001 From: federico Date: Wed, 13 May 2026 18:38:53 +0800 Subject: [PATCH 03/47] fix(crypto): address PQ review feedback --- .../tron/core/vm/PrecompiledContracts.java | 139 +++++--- .../org/tron/core/capsule/BlockCapsule.java | 19 +- .../tron/core/capsule/TransactionCapsule.java | 3 +- .../java/org/tron/consensus/base/Param.java | 17 + .../crypto/pqc/{FNDSA.java => FNDSA512.java} | 55 +++- .../common/crypto/pqc/PQSchemeRegistry.java | 12 +- .../java/org/tron/core/config/args/Args.java | 26 +- .../core/config/args/WitnessInitializer.java | 17 +- .../tron/core/consensus/ConsensusService.java | 7 + .../main/java/org/tron/core/db/Manager.java | 33 +- framework/src/main/resources/config.conf | 2 +- ...FNDSAKatTest.java => FNDSA512KatTest.java} | 42 +-- .../pqc/{FNDSATest.java => FNDSA512Test.java} | 132 ++++---- .../crypto/pqc/PQSchemeRegistryTest.java | 16 +- .../crypto/pqc/PQSignatureDefaultsTest.java | 2 +- .../pqc/SignatureSchemeBenchmarkTest.java | 6 +- .../common/crypto/pqc/program/PQClient.java | 24 +- .../common/crypto/pqc/program/PQFullNode.java | 8 +- .../common/crypto/pqc/program/PQTxSender.java | 299 ++++++++++++++++++ .../crypto/pqc/program/PQWitnessNode.java | 14 +- ...st.java => BatchValidateFnDsa512Test.java} | 48 +-- .../runtime/vm/FnDsaPrecompileTest.java | 39 ++- ...st.java => ValidateMultiFnDsa512Test.java} | 40 +-- .../tron/common/utils/LocalWitnessesTest.java | 6 +- .../org/tron/core/BandwidthProcessorTest.java | 9 +- .../core/actuator/utils/ProposalUtilTest.java | 1 + .../tron/core/capsule/BlockCapsulePQTest.java | 30 +- .../core/capsule/TransactionCapsuleTest.java | 81 ++--- protocol/src/main/protos/core/Tron.proto | 2 +- 29 files changed, 830 insertions(+), 299 deletions(-) rename crypto/src/main/java/org/tron/common/crypto/pqc/{FNDSA.java => FNDSA512.java} (83%) rename framework/src/test/java/org/tron/common/crypto/pqc/{FNDSAKatTest.java => FNDSA512KatTest.java} (88%) rename framework/src/test/java/org/tron/common/crypto/pqc/{FNDSATest.java => FNDSA512Test.java} (76%) create mode 100644 framework/src/test/java/org/tron/common/crypto/pqc/program/PQTxSender.java rename framework/src/test/java/org/tron/common/runtime/vm/{BatchValidateSignPQTest.java => BatchValidateFnDsa512Test.java} (89%) rename framework/src/test/java/org/tron/common/runtime/vm/{ValidateMultiSignPQTest.java => ValidateMultiFnDsa512Test.java} (95%) diff --git a/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java b/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java index 27308c4412b..f5e0f63dc59 100644 --- a/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java +++ b/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java @@ -53,7 +53,7 @@ import org.tron.common.crypto.Rsv; import org.tron.common.crypto.SignUtils; import org.tron.common.crypto.SignatureInterface; -import org.tron.common.crypto.pqc.FNDSA; +import org.tron.common.crypto.pqc.FNDSA512; import org.tron.common.crypto.pqc.PQSchemeRegistry; import org.tron.common.crypto.zksnark.BN128; import org.tron.common.crypto.zksnark.BN128Fp; @@ -120,10 +120,10 @@ public class PrecompiledContracts { private static final Blake2F blake2F = new Blake2F(); private static final P256Verify p256Verify = new P256Verify(); - private static final VerifyFnDsa verifyFnDsa = new VerifyFnDsa(); - private static final ValidateMultiSignPQ validateMultiSignPQ = - new ValidateMultiSignPQ(); - private static final BatchValidateSignPQ batchValidateSignPQ = new BatchValidateSignPQ(); + private static final VerifyFnDsa512 verifyFnDsa512 = new VerifyFnDsa512(); + private static final ValidateMultiFnDsa512 validateMultiFnDsa512 = + new ValidateMultiFnDsa512(); + private static final BatchValidateFnDsa512 batchValidateFnDsa512 = new BatchValidateFnDsa512(); // FreezeV2 PrecompileContracts private static final GetChainParameter getChainParameter = new GetChainParameter(); @@ -223,19 +223,19 @@ public class PrecompiledContracts { // EIP-8052 0x16: FN-DSA / Falcon-512 verify (FIPS-206 draft). Input layout: // [msg 32B | sig_len 2B (big-endian) | sig sig_len B (1..752) | pk 896B]. // Variable-length signature is prefixed with a 2-byte length field. - private static final DataWord verifyFnDsaAddr = new DataWord( + private static final DataWord verifyFnDsa512Addr = new DataWord( "0000000000000000000000000000000000000000000000000000000000000016"); // 0x17: algorithm-agnostic Permission multi-sign — accepts both ECDSA and // Falcon-512 signatures against the same Permission.keys[] in one call, // matching transaction-side §2.3.5 mixed-weight semantics. - private static final DataWord validateMultiSignPQAddr = new DataWord( + private static final DataWord validateMultiFnDsa512Addr = new DataWord( "0000000000000000000000000000000000000000000000000000000000000017"); // 0x18: batch independent Falcon-512 verify — bitmap of (sig, pk, addr) // matches; mixed-algorithm contracts call 0x0A and 0x18 separately and OR // the bitmaps client-side. - private static final DataWord batchValidateSignPqAddr = new DataWord( + private static final DataWord batchValidateFnDsa512Addr = new DataWord( "0000000000000000000000000000000000000000000000000000000000000018"); public static PrecompiledContract getOptimizedContractForConstant(PrecompiledContract contract) { @@ -323,16 +323,18 @@ public static PrecompiledContract getContractForAddress(DataWord address) { return p256Verify; } - if (VMConfig.allowFnDsa512() && address.equals(verifyFnDsaAddr)) { - return verifyFnDsa; - } - - if (VMConfig.allowFnDsa512() && address.equals(validateMultiSignPQAddr)) { - return validateMultiSignPQ; - } - - if (VMConfig.allowFnDsa512() && address.equals(batchValidateSignPqAddr)) { - return batchValidateSignPQ; + // FN-DSA-512 is the first PQ signature scheme supported by TRON, so its proposal flag + // gates every PQ-related precompile (single verify, multisig verify, batch verify). + if (VMConfig.allowFnDsa512()) { + if (address.equals(verifyFnDsa512Addr)) { + return verifyFnDsa512; + } + if (address.equals(validateMultiFnDsa512Addr)) { + return validateMultiFnDsa512; + } + if (address.equals(batchValidateFnDsa512Addr)) { + return batchValidateFnDsa512; + } } if (VMConfig.allowTvmFreezeV2()) { @@ -475,6 +477,35 @@ private static boolean isValidAbiEncoding(byte[] data, int headerWords, int item return tail > 0 && tail % multiplyExact(itemWords, WORD_SIZE) == 0; } + /** + * Structural pre-check for ABI head: word-aligned length and room for the + * fixed head. The PQ precompiles cannot reuse {@link #isValidAbiEncoding} + * because their {@code bytes[]} entries (PQ signatures, 1..752 bytes) are + * variable-length, so the trailing divisibility check does not apply. + */ + private static boolean isValidAbiHead(byte[] data, int headWords) { + return data != null + && data.length % WORD_SIZE == 0 + && data.length >= multiplyExact(headWords, WORD_SIZE); + } + + /** + * Verifies that the array offset stored at {@code words[offsetWordIndex]} is + * word-aligned, falls inside the dynamic data region (≥ head), and points to + * a length word that still fits inside {@code words}. Sister check to + * {@link #isValidAbiEncoding} for ABIs whose items are not uniform width. + */ + private static boolean isValidArrayOffset(DataWord[] words, int offsetWordIndex, + int headWords) { + long offsetBytes = words[offsetWordIndex].longValueSafe(); + if (offsetBytes < (long) headWords * WORD_SIZE + || offsetBytes % WORD_SIZE != 0) { + return false; + } + long lengthWordIdx = offsetBytes / WORD_SIZE; + return lengthWordIdx < words.length; + } + public abstract static class PrecompiledContract { protected static final byte[] DATA_FALSE = new byte[WORD_SIZE]; @@ -2425,22 +2456,23 @@ public Pair execute(byte[] data) { *
    *   [msg 32B | sig_len 2B (big-endian, 1..752) | sig sig_len B | pk 896B]
    * 
- * Minimum input: 32 + 2 + 1 + 896 = 931 bytes. + * Total length must equal exactly {@code 32 + 2 + sig_len + 896} (no trailing + * bytes; matches 0x100 P256Verify / EIP-7951 strictness). * *

Returns a 32-byte word: 1 on valid signature, 0 otherwise. * Malformed input (wrong lengths, out-of-range sig_len) returns 0 without error. */ - public static class VerifyFnDsa extends PrecompiledContract { + public static class VerifyFnDsa512 extends PrecompiledContract { private static final int MSG_LEN = 32; private static final int SIG_LEN_FIELD = 2; - private static final int PK_LEN = FNDSA.PUBLIC_KEY_LENGTH; - private static final int MAX_SIG_LEN = FNDSA.SIGNATURE_LENGTH; + private static final int PK_LEN = FNDSA512.PUBLIC_KEY_LENGTH; + private static final int MAX_SIG_LEN = FNDSA512.SIGNATURE_LENGTH; private static final int MIN_INPUT_LEN = MSG_LEN + SIG_LEN_FIELD + 1 + PK_LEN; @Override public long getEnergyForData(byte[] data) { - return 2500; + return 4000; } @Override @@ -2455,14 +2487,17 @@ public Pair execute(byte[] data) { return Pair.of(true, DataWord.ZERO().getData()); } int pkOffset = MSG_LEN + SIG_LEN_FIELD + sigLen; - if (data.length < pkOffset + PK_LEN) { + // Strict equality (cf. 0x100 P256Verify): one logical input ↔ one encoding, + // leaves room for future EIP-8052 trailing fields. + if (data.length != pkOffset + PK_LEN) { return Pair.of(true, DataWord.ZERO().getData()); } byte[] sig = copyOfRange(data, MSG_LEN + SIG_LEN_FIELD, pkOffset); byte[] pk = copyOfRange(data, pkOffset, pkOffset + PK_LEN); - boolean ok = FNDSA.verify(pk, msg, sig); + boolean ok = FNDSA512.verify(pk, msg, sig); return Pair.of(true, ok ? DataWord.ONE().getData() : DataWord.ZERO().getData()); } catch (Throwable t) { + logger.info("VerifyFnDsa512 error:{}", t.getMessage()); return Pair.of(true, DataWord.ZERO().getData()); } } @@ -2489,15 +2524,17 @@ public Pair execute(byte[] data) { * * *

{@code MAX_SIZE = 5} applies to the total signature count - * ({@code ecdsaCnt + pqCnt}). Energy is split: {@code ecdsaCnt × 1500 + pqCnt × 15000}. + * ({@code ecdsaCnt + pqCnt}). Energy is split: {@code ecdsaCnt × 1500 + pqCnt × 2000}. */ - public static class ValidateMultiSignPQ extends PrecompiledContract { + public static class ValidateMultiFnDsa512 extends PrecompiledContract { private static final int ECDSA_ENERGY_PER_SIGN = 1500; - private static final int PQ_ENERGY_PER_SIGN = 15000; + private static final int PQ_ENERGY_PER_SIGN = 2000; private static final int MAX_SIZE = 5; - private static final int PK_LEN = FNDSA.PUBLIC_KEY_LENGTH; - private static final int MAX_SIG_LEN = FNDSA.SIGNATURE_LENGTH; + private static final int PK_LEN = FNDSA512.PUBLIC_KEY_LENGTH; + private static final int MAX_SIG_LEN = FNDSA512.SIGNATURE_LENGTH; + // address, permissionId, data, ecdsaOffset, pqSigOffset, pqPkOffset. + private static final int ABI_HEAD_WORDS = 6; @Override public long getEnergyForData(byte[] data) { @@ -2514,8 +2551,16 @@ public long getEnergyForData(byte[] data) { @Override public Pair execute(byte[] rawData) { + if (!isValidAbiHead(rawData, ABI_HEAD_WORDS)) { + return Pair.of(false, EMPTY_BYTE_ARRAY); + } try { DataWord[] words = DataWord.parseArray(rawData); + if (!isValidArrayOffset(words, 3, ABI_HEAD_WORDS) + || !isValidArrayOffset(words, 4, ABI_HEAD_WORDS) + || !isValidArrayOffset(words, 5, ABI_HEAD_WORDS)) { + return Pair.of(false, EMPTY_BYTE_ARRAY); + } byte[] address = words[0].toTronAddress(); int permissionId = words[1].intValueSafe(); byte[] data = words[2].getData(); @@ -2596,7 +2641,7 @@ public Pair execute(byte[] rawData) { if (weight == 0) { return Pair.of(true, DATA_FALSE); } - if (!FNDSA.verify(pk, hash, sig)) { + if (!FNDSA512.verify(pk, hash, sig)) { return Pair.of(true, DATA_FALSE); } totalWeight += weight; @@ -2617,13 +2662,13 @@ public Pair execute(byte[] rawData) { } /** - * 0x18 BatchValidateSignPQ — independent per-element Falcon-512 verify. + * 0x18 BatchValidateFnDsa512 — independent per-element Falcon-512 verify. *

Returns a 256-bit bitmap (matching 0x0A) where bit {@code i} is set iff - * {@code derive(pk_i) == expectedAddr_i} AND {@code FNDSA.verify(pk_i, hash, sig_i)}. + * {@code derive(pk_i) == expectedAddr_i} AND {@code FNDSA512.verify(pk_i, hash, sig_i)}. * *

ABI: *

-   *   batchValidateSignPQ(
+   *   batchValidateFnDsa512(
    *       bytes32   hash,                  // word[0]
    *       bytes[]   signatures,            // word[1] = offset; each 1..752 B
    *       bytes[]   publicKeys,            // word[2] = offset; each 896 B
@@ -2633,14 +2678,16 @@ public Pair execute(byte[] rawData) {
    *
    * 

Reuses the {@code BatchValidateSign.workers} pool when not in a constant * call and enforces {@code getCPUTimeLeftInNanoSecond()} timeout. {@code MAX_SIZE = 16}. - * Energy is {@code cnt × 15000}. + * Energy is {@code cnt × 2000}. */ - public static class BatchValidateSignPQ extends PrecompiledContract { + public static class BatchValidateFnDsa512 extends PrecompiledContract { - private static final int ENERGY_PER_SIGN = 15000; + private static final int ENERGY_PER_SIGN = 2000; private static final int MAX_SIZE = 16; - private static final int PK_LEN = FNDSA.PUBLIC_KEY_LENGTH; - private static final int MAX_SIG_LEN = FNDSA.SIGNATURE_LENGTH; + private static final int PK_LEN = FNDSA512.PUBLIC_KEY_LENGTH; + private static final int MAX_SIG_LEN = FNDSA512.SIGNATURE_LENGTH; + // hash, sigArrayOffset, pkArrayOffset, addrArrayOffset. + private static final int ABI_HEAD_WORDS = 4; @Override public long getEnergyForData(byte[] data) { @@ -2670,7 +2717,15 @@ public Pair execute(byte[] data) { private Pair doExecute(byte[] data) throws InterruptedException, ExecutionException { + if (!isValidAbiHead(data, ABI_HEAD_WORDS)) { + return Pair.of(false, EMPTY_BYTE_ARRAY); + } DataWord[] words = DataWord.parseArray(data); + if (!isValidArrayOffset(words, 1, ABI_HEAD_WORDS) + || !isValidArrayOffset(words, 2, ABI_HEAD_WORDS) + || !isValidArrayOffset(words, 3, ABI_HEAD_WORDS)) { + return Pair.of(false, EMPTY_BYTE_ARRAY); + } byte[] hash = words[0].getData(); int sigArrayWord = words[1].intValueSafe() / WORD_SIZE; @@ -2718,8 +2773,8 @@ private Pair doExecute(byte[] data) .await(getCPUTimeLeftInNanoSecond(), TimeUnit.NANOSECONDS); if (!withNoTimeout) { - logger.info("BatchValidateSignPQ timeout"); - throw Program.Exception.notEnoughTime("call BatchValidateSignPQ precompile method"); + logger.info("BatchValidateFnDsa512 timeout"); + throw Program.Exception.notEnoughTime("call BatchValidateFnDsa512 precompile method"); } for (Future future : futures) { @@ -2743,7 +2798,7 @@ private static boolean verifyOne(byte[] sig, byte[] pk, byte[] hash, if (!DataWord.equalAddressByteArray(derived, expectedAddr)) { return false; } - return FNDSA.verify(pk, hash, sig); + return FNDSA512.verify(pk, hash, sig); } catch (Throwable t) { return false; } diff --git a/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java b/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java index dd0e2126bac..6c781d419aa 100755 --- a/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java +++ b/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java @@ -175,7 +175,9 @@ public void sign(byte[] privateKey) { ByteString sig = ByteString.copyFrom(ecKeyEngine.Base64toBytes(ecKeyEngine.signHash(getRawHash() .getBytes()))); - BlockHeader blockHeader = this.block.getBlockHeader().toBuilder().setWitnessSignature(sig) + BlockHeader blockHeader = this.block.getBlockHeader().toBuilder() + .clearPqAuthSig() + .setWitnessSignature(sig) .build(); this.block = this.block.toBuilder().setBlockHeader(blockHeader).build(); @@ -184,6 +186,7 @@ public void sign(byte[] privateKey) { public void setPqAuthSig(PQAuthSig pqAuthSig) { BlockHeader blockHeader = this.block.getBlockHeader().toBuilder() + .clearWitnessSignature() .setPqAuthSig(pqAuthSig).build(); this.block = this.block.toBuilder().setBlockHeader(blockHeader).build(); } @@ -201,10 +204,7 @@ public boolean validateSignature(DynamicPropertiesStore dynamicPropertiesStore, AccountStore accountStore) throws ValidateSignatureException { BlockHeader header = block.getBlockHeader(); boolean hasLegacy = !header.getWitnessSignature().isEmpty(); - PQAuthSig pqAuthSig = header.getPqAuthSig(); - boolean hasPq = pqAuthSig != null - && pqAuthSig.getSignature() != null - && !pqAuthSig.getSignature().isEmpty(); + boolean hasPq = header.hasPqAuthSig(); if (hasLegacy && hasPq) { throw new ValidateSignatureException( @@ -217,7 +217,7 @@ public boolean validateSignature(DynamicPropertiesStore dynamicPropertiesStore, byte[] witnessAccountAddress = header.getRawData().getWitnessAddress().toByteArray(); if (hasPq) { return validatePQSignature(dynamicPropertiesStore, accountStore, - witnessAccountAddress, pqAuthSig); + witnessAccountAddress, header.getPqAuthSig()); } return validateLegacySignature(dynamicPropertiesStore, accountStore, witnessAccountAddress); } @@ -233,8 +233,11 @@ private boolean validateLegacySignature(DynamicPropertiesStore dynamicProperties if (dynamicPropertiesStore.getAllowMultiSign() != 1) { return Arrays.equals(sigAddress, witnessAccountAddress); } - byte[] witnessPermissionAddress = accountStore.get(witnessAccountAddress) - .getWitnessPermissionAddress(); + AccountCapsule witnessAccount = accountStore.get(witnessAccountAddress); + if (witnessAccount == null) { + throw new ValidateSignatureException("witness account does not exist"); + } + byte[] witnessPermissionAddress = witnessAccount.getWitnessPermissionAddress(); return Arrays.equals(sigAddress, witnessPermissionAddress); } catch (SignatureException e) { throw new ValidateSignatureException(e.getMessage()); diff --git a/chainbase/src/main/java/org/tron/core/capsule/TransactionCapsule.java b/chainbase/src/main/java/org/tron/core/capsule/TransactionCapsule.java index 24fe9fd9964..21f0f1adc59 100755 --- a/chainbase/src/main/java/org/tron/core/capsule/TransactionCapsule.java +++ b/chainbase/src/main/java/org/tron/core/capsule/TransactionCapsule.java @@ -794,7 +794,8 @@ public boolean validateSignature(AccountStore accountStore, if (!ArrayUtils.isEmpty(owner)) { //transfer from transparent address validatePubSignature(accountStore, dynamicPropertiesStore); } else { //transfer from shielded address - if (this.transaction.getSignatureCount() > 0) { + if (this.transaction.getSignatureCount() > 0 + || this.transaction.getPqAuthSigCount() > 0) { throw new ValidateSignatureException("there should be no signatures signed by " + "transparent address when transfer from shielded address"); } diff --git a/consensus/src/main/java/org/tron/consensus/base/Param.java b/consensus/src/main/java/org/tron/consensus/base/Param.java index a2692cf4c55..71f23ce23ae 100644 --- a/consensus/src/main/java/org/tron/consensus/base/Param.java +++ b/consensus/src/main/java/org/tron/consensus/base/Param.java @@ -54,6 +54,14 @@ public static Param getInstance() { return param; } + /** Signing key family carried by a {@link Miner}. */ + public enum MinerType { + /** Legacy ECDSA / SM2 witness; signs blocks via {@code BlockCapsule.sign}. */ + ECDSA, + /** Post-quantum witness; signs blocks via {@code signWitnessAuth}. */ + PQ + } + public class Miner { @Getter @@ -76,6 +84,15 @@ public class Miner { @Setter private PQScheme pqScheme; + /** + * Explicit signing-family marker so the block producer doesn't have to infer + * key type from {@code privateKey == null}. Defaults to {@link MinerType#ECDSA}; + * PQ-only miners must call {@link #setType(MinerType)} with {@link MinerType#PQ}. + */ + @Getter + @Setter + private MinerType type = MinerType.ECDSA; + public Miner(byte[] privateKey, ByteString privateKeyAddress, ByteString witnessAddress) { this.privateKey = privateKey; this.privateKeyAddress = privateKeyAddress; diff --git a/crypto/src/main/java/org/tron/common/crypto/pqc/FNDSA.java b/crypto/src/main/java/org/tron/common/crypto/pqc/FNDSA512.java similarity index 83% rename from crypto/src/main/java/org/tron/common/crypto/pqc/FNDSA.java rename to crypto/src/main/java/org/tron/common/crypto/pqc/FNDSA512.java index 3a9e22316c5..080eaa3a8c3 100644 --- a/crypto/src/main/java/org/tron/common/crypto/pqc/FNDSA.java +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/FNDSA512.java @@ -1,5 +1,6 @@ package org.tron.common.crypto.pqc; +import java.nio.charset.StandardCharsets; import java.security.SecureRandom; import org.bouncycastle.crypto.AsymmetricCipherKeyPair; import org.bouncycastle.crypto.params.ParametersWithRandom; @@ -24,7 +25,7 @@ * {@code <= SIGNATURE_LENGTH}. BouncyCastle 1.79's {@code FalconNIST.CRYPTO_BYTES} * for Falcon-512 is 690 bytes, well below the 752-byte protocol cap. */ -public final class FNDSA implements PQSignature { +public final class FNDSA512 implements PQSignature { /** * Falcon-512 encoded private key from BC: f || g || F, where f and g are each @@ -58,13 +59,13 @@ public final class FNDSA implements PQSignature { private final byte[] privateKey; private final byte[] publicKey; - public FNDSA() { + public FNDSA512() { AsymmetricCipherKeyPair kp = generateKeyPair(new SecureRandom()); this.privateKey = ((FalconPrivateKeyParameters) kp.getPrivate()).getEncoded(); this.publicKey = ((FalconPublicKeyParameters) kp.getPublic()).getH(); } - public FNDSA(byte[] seed) { + public FNDSA512(byte[] seed) { if (seed == null || seed.length != SEED_LENGTH) { throw new IllegalArgumentException("FN-DSA seed length must be " + SEED_LENGTH); } @@ -73,7 +74,7 @@ public FNDSA(byte[] seed) { this.publicKey = ((FalconPublicKeyParameters) kp.getPublic()).getH(); } - public FNDSA(byte[] privateKey, byte[] publicKey) { + public FNDSA512(byte[] privateKey, byte[] publicKey) { if (privateKey == null || privateKey.length != PRIVATE_KEY_LENGTH) { throw new IllegalArgumentException( "FN-DSA private key length must be " + PRIVATE_KEY_LENGTH); @@ -82,6 +83,7 @@ public FNDSA(byte[] privateKey, byte[] publicKey) { throw new IllegalArgumentException( "FN-DSA public key length must be " + PUBLIC_KEY_LENGTH); } + requireConsistent(privateKey, publicKey); this.privateKey = privateKey.clone(); this.publicKey = publicKey.clone(); } @@ -90,10 +92,10 @@ public FNDSA(byte[] privateKey, byte[] publicKey) { * Builds an instance from the extended private key encoding {@code f ‖ g ‖ F ‖ h} * ({@link #PRIVATE_KEY_WITH_PUBLIC_KEY_LENGTH} bytes), as produced by * {@link #getPrivateKeyWithPublicKey()}. Provided as a static factory rather - * than an additional {@code FNDSA(byte[])} constructor because Java cannot - * overload {@link #FNDSA(byte[]) the seed constructor} on length alone. + * than an additional {@code FNDSA512(byte[])} constructor because Java cannot + * overload {@link #FNDSA512(byte[]) the seed constructor} on length alone. */ - public static FNDSA fromPrivateKeyWithPublicKey(byte[] extendedPrivateKey) { + public static FNDSA512 fromPrivateKeyWithPublicKey(byte[] extendedPrivateKey) { if (extendedPrivateKey == null || extendedPrivateKey.length != PRIVATE_KEY_WITH_PUBLIC_KEY_LENGTH) { throw new IllegalArgumentException( @@ -104,7 +106,7 @@ public static FNDSA fromPrivateKeyWithPublicKey(byte[] extendedPrivateKey) { byte[] pk = new byte[PUBLIC_KEY_LENGTH]; System.arraycopy(extendedPrivateKey, 0, sk, 0, PRIVATE_KEY_LENGTH); System.arraycopy(extendedPrivateKey, PRIVATE_KEY_LENGTH, pk, 0, PUBLIC_KEY_LENGTH); - return new FNDSA(sk, pk); + return new FNDSA512(sk, pk); } @Override @@ -133,6 +135,16 @@ public byte[] getPrivateKey() { return privateKey.clone(); } + /** + * FN-DSA accepts the bare {@link #PRIVATE_KEY_LENGTH} form as well as the + * extended {@link #PRIVATE_KEY_WITH_PUBLIC_KEY_LENGTH} form used for local + * witness config. Override of {@link PQSignature#validatePrivateKey}. + */ + @Override + public void validatePrivateKey(byte[] privateKey) { + validatePrivateKeyBytes(privateKey); + } + /** * Returns the private key with the 896-byte public key {@code h} appended: * {@code f ‖ g ‖ F ‖ h} (total {@link #PRIVATE_KEY_WITH_PUBLIC_KEY_LENGTH} bytes). @@ -247,6 +259,33 @@ private static AsymmetricCipherKeyPair generateKeyPair(SecureRandom random) { return generator.generateKeyPair(); } + /** + * Domain-separated probe used by {@link #requireConsistent}; not a security + * boundary (Falcon hashes the message internally), the constant just makes the + * keypair self-check searchable in logs/stack traces. + */ + private static final byte[] CONSISTENCY_PROBE = + "tron:FN-DSA-512:keypair-consistency-probe".getBytes(StandardCharsets.UTF_8); + + /** + * Probe that the supplied (sk, pk) actually form a keypair. Falcon has no + * public API to derive {@code h} from {@code (f, g)} alone (bcgit/bc-java#2297), + * so we sign and verify a fixed probe message. Runs once per witness load and + * costs a few ms on Falcon-512 — acceptable for a startup-time misconfiguration + * check, and avoids advertising an address that signatures will never satisfy. + */ + private static void requireConsistent(byte[] privateKey, byte[] publicKey) { + byte[] sig; + try { + sig = sign(privateKey, CONSISTENCY_PROBE); + } catch (RuntimeException e) { + throw new IllegalArgumentException("FN-DSA private/public key mismatch", e); + } + if (!verify(publicKey, CONSISTENCY_PROBE, sig)) { + throw new IllegalArgumentException("FN-DSA private/public key mismatch"); + } + } + private static void validatePrivateKeyBytes(byte[] privateKey) { if (privateKey == null || (privateKey.length != PRIVATE_KEY_LENGTH diff --git a/crypto/src/main/java/org/tron/common/crypto/pqc/PQSchemeRegistry.java b/crypto/src/main/java/org/tron/common/crypto/pqc/PQSchemeRegistry.java index b3965ac4cb8..c57d7e702f2 100644 --- a/crypto/src/main/java/org/tron/common/crypto/pqc/PQSchemeRegistry.java +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/PQSchemeRegistry.java @@ -75,28 +75,28 @@ private static final class SchemeInfo { static { EnumMap m = new EnumMap<>(PQScheme.class); m.put(PQScheme.FN_DSA_512, new SchemeInfo( - FNDSA.PRIVATE_KEY_LENGTH, FNDSA.PUBLIC_KEY_LENGTH, - FNDSA.SIGNATURE_LENGTH, FNDSA.SEED_LENGTH, + FNDSA512.PRIVATE_KEY_LENGTH, FNDSA512.PUBLIC_KEY_LENGTH, + FNDSA512.SIGNATURE_LENGTH, FNDSA512.SEED_LENGTH, KECCAK_256, new SignatureOps() { @Override public byte[] sign(byte[] privateKey, byte[] message) { - return FNDSA.sign(privateKey, message); + return FNDSA512.sign(privateKey, message); } @Override public boolean verify(byte[] publicKey, byte[] message, byte[] signature) { - return FNDSA.verify(publicKey, message, signature); + return FNDSA512.verify(publicKey, message, signature); } @Override public PQSignature fromSeed(byte[] seed) { - return new FNDSA(seed); + return new FNDSA512(seed); } @Override public PQSignature fromKeypair(byte[] privateKey, byte[] publicKey) { - return new FNDSA(privateKey, publicKey); + return new FNDSA512(privateKey, publicKey); } })); SCHEMES = Collections.unmodifiableMap(m); diff --git a/framework/src/main/java/org/tron/core/config/args/Args.java b/framework/src/main/java/org/tron/core/config/args/Args.java index de874f90dde..a4b6e9a4998 100644 --- a/framework/src/main/java/org/tron/core/config/args/Args.java +++ b/framework/src/main/java/org/tron/core/config/args/Args.java @@ -935,24 +935,35 @@ private static void initLocalWitnesses(Config config, CLIParameter cmd) { return; } + LocalWitnessConfig lwConfig = LocalWitnessConfig.fromConfig(config); + boolean hasCliPriv = StringUtils.isNotBlank(cmd.privateKey); + boolean hasCfgPriv = !lwConfig.getPrivateKeys().isEmpty(); + boolean hasKeystore = !lwConfig.getKeystores().isEmpty(); + boolean hasPqKeys = config.hasPath(ConfigKey.LOCAL_WITNESS_PQ_KEYS) + && !config.getStringList(ConfigKey.LOCAL_WITNESS_PQ_KEYS).isEmpty(); + if (hasPqKeys && (hasCliPriv || hasCfgPriv || hasKeystore)) { + throw new TronError( + "legacy witness keys (CLI --private-key, localwitness, localwitnesskeystore) " + + "and " + ConfigKey.LOCAL_WITNESS_PQ_KEYS + " are mutually exclusive", + TronError.ErrCode.WITNESS_INIT); + } + // path 1: CLI --private-key - if (StringUtils.isNotBlank(cmd.privateKey)) { + if (hasCliPriv) { localWitnesses = WitnessInitializer.initFromCLIPrivateKey( cmd.privateKey, cmd.witnessAddress); return; } - LocalWitnessConfig lwConfig = LocalWitnessConfig.fromConfig(config); - // path 2: config localwitness (private key list) - if (!lwConfig.getPrivateKeys().isEmpty()) { + if (hasCfgPriv) { localWitnesses = WitnessInitializer.initFromCFGPrivateKey( lwConfig.getPrivateKeys(), lwConfig.getAccountAddress()); return; } // path 3: config localwitnesskeystore + password - if (!lwConfig.getKeystores().isEmpty()) { + if (hasKeystore) { localWitnesses = WitnessInitializer.initFromKeystore( lwConfig.getKeystores(), cmd.password, lwConfig.getAccountAddress()); return; @@ -988,7 +999,10 @@ private static void initLocalWitnesses(Config config, CLIParameter cmd) { List pqPublicKeys = new ArrayList<>(pqEntries.size()); for (int i = 0; i < pqEntries.size(); i++) { String hex = pqEntries.get(i); - String stripped = hex != null && hex.startsWith("0x") ? hex.substring(2) : hex; + String stripped = hex; + if (hex != null && (hex.startsWith("0x") || hex.startsWith("0X"))) { + stripped = hex.substring(2); + } if (stripped == null || stripped.length() != extHexLen) { throw new TronError(String.format( "%s[%d] must be %d hex chars (extended priv‖pub for %s), actual: %d", diff --git a/framework/src/main/java/org/tron/core/config/args/WitnessInitializer.java b/framework/src/main/java/org/tron/core/config/args/WitnessInitializer.java index 9251f2151c7..52819df0843 100644 --- a/framework/src/main/java/org/tron/core/config/args/WitnessInitializer.java +++ b/framework/src/main/java/org/tron/core/config/args/WitnessInitializer.java @@ -128,6 +128,16 @@ public static LocalWitnesses initFromPQOnly(PQScheme scheme, "PQ public keys must be set for PQ-only witness nodes", TronError.ErrCode.WITNESS_INIT); } + if (pqPrivateKeys == null || pqPrivateKeys.isEmpty()) { + throw new TronError( + "PQ private keys must be set for PQ-only witness nodes", + TronError.ErrCode.WITNESS_INIT); + } + if (pqPrivateKeys.size() != pqPublicKeys.size()) { + throw new TronError( + "PQ private/public key count mismatch", + TronError.ErrCode.WITNESS_INIT); + } LocalWitnesses witnesses = new LocalWitnesses(); witnesses.setPqScheme(scheme); witnesses.setPqKeypairs(pqPrivateKeys, pqPublicKeys); @@ -138,6 +148,11 @@ public static LocalWitnesses initFromPQOnly(PQScheme scheme, address = PQSchemeRegistry.computeAddress(scheme, firstPubKey); logger.debug("Derived PQ-only witness address from public key"); } else { + if (pqPublicKeys.size() != 1) { + throw new TronError( + "LocalWitnessAccountAddress can only be set when there is only one PQ keypair", + TronError.ErrCode.WITNESS_INIT); + } address = Commons.decodeFromBase58Check(witnessAccountAddress); if (address == null) { throw new TronError( @@ -146,7 +161,7 @@ public static LocalWitnesses initFromPQOnly(PQScheme scheme, } logger.debug("Got localWitnessAccountAddress from config.conf"); } - witnesses.initWitnessAccountAddress(address, false); + witnesses.setWitnessAccountAddress(address); return witnesses; } diff --git a/framework/src/main/java/org/tron/core/consensus/ConsensusService.java b/framework/src/main/java/org/tron/core/consensus/ConsensusService.java index 5f54b62b955..23cbe28e3e3 100644 --- a/framework/src/main/java/org/tron/core/consensus/ConsensusService.java +++ b/framework/src/main/java/org/tron/core/consensus/ConsensusService.java @@ -58,6 +58,11 @@ public void start() { + " private vs " + pqPublicKeys.size() + " public", TronError.ErrCode.WITNESS_INIT); } + if (!privateKeys.isEmpty() && !pqPrivateKeys.isEmpty()) { + throw new TronError( + "legacy localwitness keys and localwitness_pq_keys are mutually exclusive", + TronError.ErrCode.WITNESS_INIT); + } if (privateKeys.size() > 1) { for (String key : privateKeys) { byte[] privateKey = fromHexString(key); @@ -107,6 +112,7 @@ public void start() { miner.setPQPrivateKey(sk); miner.setPQPublicKey(pk); miner.setPqScheme(scheme); + miner.setType(Param.MinerType.PQ); miners.add(miner); logger.info("Add {} witness (from configured keypair): {}, size: {}", scheme, Hex.toHexString(pqAddress), miners.size()); @@ -146,6 +152,7 @@ private Miner buildPQOnlyMinerFromKeypair(Param param, String pqPrivateKey, miner.setPQPrivateKey(sk); miner.setPQPublicKey(pk); miner.setPqScheme(scheme); + miner.setType(Param.MinerType.PQ); logger.info("Add {} witness (from configured keypair): {}", scheme, Hex.toHexString(witnessAddress)); return miner; diff --git a/framework/src/main/java/org/tron/core/db/Manager.java b/framework/src/main/java/org/tron/core/db/Manager.java index d6bdffb62ec..7dd5a9b7a21 100644 --- a/framework/src/main/java/org/tron/core/db/Manager.java +++ b/framework/src/main/java/org/tron/core/db/Manager.java @@ -1760,11 +1760,29 @@ public BlockCapsule generateBlock(Miner miner, long blockTime, long timeout) { } private void signBlockCapsule(BlockCapsule blockCapsule, Miner miner) { - PQScheme scheme = resolveWitnessScheme(miner); - if (scheme != null && PQSchemeRegistry.contains(scheme)) { - signWitnessAuth(blockCapsule, miner, scheme); - } else { - blockCapsule.sign(miner.getPrivateKey()); + switch (miner.getType()) { + case PQ: + PQScheme scheme = resolveWitnessScheme(miner); + if (scheme == null) { + // PQ-only miner whose configured scheme is not currently usable + // (proposal not activated, scheme allow flag flipped, witness + // permission missing, etc.). Surface a clear cause; DposTask's + // Throwable handler will log and the witness will miss this slot, + // but the producer thread keeps running. + throw new IllegalStateException( + "PQ-only miner " + Hex.toHexString(miner.getWitnessAddress().toByteArray()) + + " has scheme " + miner.getPqScheme() + + " configured but it is not currently usable " + + "(scheme not allowed by dynamic properties, " + + "or witness permission is missing/empty)"); + } + signWitnessAuth(blockCapsule, miner, scheme); + break; + case ECDSA: + blockCapsule.sign(miner.getPrivateKey()); + break; + default: + throw new IllegalStateException("unknown miner type: " + miner.getType()); } } @@ -1796,8 +1814,9 @@ private void signWitnessAuth(BlockCapsule blockCapsule, Miner miner, PQScheme sc byte[] pqPublicKey = miner.getPQPublicKey(); if (pqPrivateKey == null || pqPublicKey == null) { throw new IllegalStateException( - "witness permission requires " + scheme - + " but local PQ key material is not configured"); + "miner " + Hex.toHexString(miner.getWitnessAddress().toByteArray()) + + " has scheme " + scheme + + " set but local PQ key material is missing"); } byte[] digest = blockCapsule.getRawHashBytes(); byte[] signature = PQSchemeRegistry.sign(scheme, pqPrivateKey, digest); diff --git a/framework/src/main/resources/config.conf b/framework/src/main/resources/config.conf index 91eb905dbf4..a0ed0877b74 100644 --- a/framework/src/main/resources/config.conf +++ b/framework/src/main/resources/config.conf @@ -818,7 +818,7 @@ committee = { # consensusLogicOptimization = 0 # allowOptimizedReturnValueOfChainId = 0 # allowTvmOsaka = 0 - # allowMlDsa = 0 + # allowFnDsa512 = 0 } event.subscribe = { diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/FNDSAKatTest.java b/framework/src/test/java/org/tron/common/crypto/pqc/FNDSA512KatTest.java similarity index 88% rename from framework/src/test/java/org/tron/common/crypto/pqc/FNDSAKatTest.java rename to framework/src/test/java/org/tron/common/crypto/pqc/FNDSA512KatTest.java index 1c6656d8f38..14b5f6eb60a 100644 --- a/framework/src/test/java/org/tron/common/crypto/pqc/FNDSAKatTest.java +++ b/framework/src/test/java/org/tron/common/crypto/pqc/FNDSA512KatTest.java @@ -29,7 +29,7 @@ * verify is exercised per-vector and cross-vector to confirm signatures only * verify under their own key. */ -public class FNDSAKatTest { +public class FNDSA512KatTest { private static final class KatVector { final String label; @@ -46,7 +46,7 @@ private static final class KatVector { } private static byte[] seedIncrementing() { - byte[] s = new byte[FNDSA.SEED_LENGTH]; + byte[] s = new byte[FNDSA512.SEED_LENGTH]; for (int i = 0; i < s.length; i++) { s[i] = (byte) i; } @@ -54,15 +54,15 @@ private static byte[] seedIncrementing() { } private static byte[] seedDescending() { - byte[] s = new byte[FNDSA.SEED_LENGTH]; + byte[] s = new byte[FNDSA512.SEED_LENGTH]; for (int i = 0; i < s.length; i++) { - s[i] = (byte) (FNDSA.SEED_LENGTH - 1 - i); + s[i] = (byte) (FNDSA512.SEED_LENGTH - 1 - i); } return s; } private static byte[] seedFilled(int b) { - byte[] s = new byte[FNDSA.SEED_LENGTH]; + byte[] s = new byte[FNDSA512.SEED_LENGTH]; Arrays.fill(s, (byte) b); return s; } @@ -104,11 +104,11 @@ private static String hex(byte[] b) { @Test public void allVectorsDeriveExpectedPublicAndPrivateKey() { for (KatVector v : VECTORS) { - FNDSA k = new FNDSA(v.seed); + FNDSA512 k = new FNDSA512(v.seed); assertEquals(v.label + ": pk length", - FNDSA.PUBLIC_KEY_LENGTH, k.getPublicKey().length); + FNDSA512.PUBLIC_KEY_LENGTH, k.getPublicKey().length); assertEquals(v.label + ": sk length", - FNDSA.PRIVATE_KEY_LENGTH, k.getPrivateKey().length); + FNDSA512.PRIVATE_KEY_LENGTH, k.getPrivateKey().length); assertEquals(v.label + ": pk SHA-256 must match KAT vector", v.pkSha256, hex(sha256(k.getPublicKey()))); assertEquals(v.label + ": sk SHA-256 must match KAT vector", @@ -119,7 +119,7 @@ public void allVectorsDeriveExpectedPublicAndPrivateKey() { @Test public void allVectorsDeriveExpectedAddress() { for (KatVector v : VECTORS) { - FNDSA k = new FNDSA(v.seed); + FNDSA512 k = new FNDSA512(v.seed); byte[] addr = k.getAddress(); assertEquals(v.label + ": address length", 21, addr.length); @@ -134,7 +134,7 @@ public void allVectorsDeriveExpectedAddress() { @Test public void addressIsExactly0x41PlusKeccak256RightmostBytesOfPublicKey() { for (KatVector v : VECTORS) { - FNDSA k = new FNDSA(v.seed); + FNDSA512 k = new FNDSA512(v.seed); byte[] pk = k.getPublicKey(); byte[] hash = Hash.sha3(pk); byte[] expected = new byte[21]; @@ -148,8 +148,8 @@ public void addressIsExactly0x41PlusKeccak256RightmostBytesOfPublicKey() { @Test public void allVectorsAreReproducibleAcrossInstances() { for (KatVector v : VECTORS) { - FNDSA a = new FNDSA(v.seed); - FNDSA b = new FNDSA(v.seed); + FNDSA512 a = new FNDSA512(v.seed); + FNDSA512 b = new FNDSA512(v.seed); assertArrayEquals(v.label + ": pk reproducible", a.getPublicKey(), b.getPublicKey()); assertArrayEquals(v.label + ": sk reproducible", a.getPrivateKey(), b.getPrivateKey()); assertArrayEquals(v.label + ": addr reproducible", a.getAddress(), b.getAddress()); @@ -164,7 +164,7 @@ public void distinctSeedsProduceDistinctKeysAndAddresses() { for (KatVector v : VECTORS) { pkDigests.add(v.pkSha256); skDigests.add(v.skSha256); - addresses.add(hex(new FNDSA(v.seed).getAddress())); + addresses.add(hex(new FNDSA512(v.seed).getAddress())); } assertEquals("KAT pk digests must be pairwise distinct", VECTORS.length, pkDigests.size()); @@ -183,15 +183,15 @@ public void signaturesFromKatKeysVerifyUnderTheirOwnPublicKey() { new byte[1024], }; for (KatVector v : VECTORS) { - FNDSA k = new FNDSA(v.seed); + FNDSA512 k = new FNDSA512(v.seed); for (byte[] msg : messages) { byte[] sig = k.sign(msg); assertTrue(v.label + ": signature must be non-empty", sig.length > 0); assertTrue(v.label + ": signature must respect 752-byte upper bound", - sig.length <= FNDSA.SIGNATURE_LENGTH); + sig.length <= FNDSA512.SIGNATURE_LENGTH); assertTrue(v.label + ": signature must verify under its own pk", - FNDSA.verify(k.getPublicKey(), msg, sig)); + FNDSA512.verify(k.getPublicKey(), msg, sig)); assertTrue(v.label + ": registry verify must accept own signature", PQSchemeRegistry.verify( PQScheme.FN_DSA_512, k.getPublicKey(), msg, sig)); @@ -202,21 +202,21 @@ public void signaturesFromKatKeysVerifyUnderTheirOwnPublicKey() { @Test public void signatureFromVectorAFailsUnderVectorBPublicKey() { byte[] msg = "tron-fn-dsa-kat-cross".getBytes(); - FNDSA[] keys = new FNDSA[VECTORS.length]; + FNDSA512[] keys = new FNDSA512[VECTORS.length]; byte[][] sigs = new byte[VECTORS.length][]; for (int i = 0; i < VECTORS.length; i++) { - keys[i] = new FNDSA(VECTORS[i].seed); + keys[i] = new FNDSA512(VECTORS[i].seed); sigs[i] = keys[i].sign(msg); } for (int i = 0; i < VECTORS.length; i++) { for (int j = 0; j < VECTORS.length; j++) { if (i == j) { assertTrue(VECTORS[i].label + ": self-verify must succeed", - FNDSA.verify(keys[i].getPublicKey(), msg, sigs[i])); + FNDSA512.verify(keys[i].getPublicKey(), msg, sigs[i])); } else { assertFalse("signature from " + VECTORS[i].label + " must NOT verify under " + VECTORS[j].label, - FNDSA.verify(keys[j].getPublicKey(), msg, sigs[i])); + FNDSA512.verify(keys[j].getPublicKey(), msg, sigs[i])); } } } @@ -228,7 +228,7 @@ public void distinctSeedsAtRuntimeAlsoProduceDistinctRuntimePublicKeys() { // Re-derive at runtime and confirm they're still pairwise distinct. byte[][] pks = new byte[VECTORS.length][]; for (int i = 0; i < VECTORS.length; i++) { - pks[i] = new FNDSA(VECTORS[i].seed).getPublicKey(); + pks[i] = new FNDSA512(VECTORS[i].seed).getPublicKey(); } for (int i = 0; i < VECTORS.length; i++) { for (int j = i + 1; j < VECTORS.length; j++) { diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/FNDSATest.java b/framework/src/test/java/org/tron/common/crypto/pqc/FNDSA512Test.java similarity index 76% rename from framework/src/test/java/org/tron/common/crypto/pqc/FNDSATest.java rename to framework/src/test/java/org/tron/common/crypto/pqc/FNDSA512Test.java index 6298b1d251b..fce3565f2ca 100644 --- a/framework/src/test/java/org/tron/common/crypto/pqc/FNDSATest.java +++ b/framework/src/test/java/org/tron/common/crypto/pqc/FNDSA512Test.java @@ -19,11 +19,11 @@ import org.junit.Test; import org.tron.protos.Protocol.PQScheme; -public class FNDSATest { +public class FNDSA512Test { private static final FalconParameters PARAMS = FalconParameters.falcon_512; - private FNDSA keypair; + private FNDSA512 keypair; private FalconPublicKeyParameters pk; private FalconPrivateKeyParameters sk; @@ -32,7 +32,7 @@ public void setUp() { AsymmetricCipherKeyPair kp = freshKeyPair(); pk = (FalconPublicKeyParameters) kp.getPublic(); sk = (FalconPrivateKeyParameters) kp.getPrivate(); - keypair = new FNDSA(sk.getEncoded(), pk.getH()); + keypair = new FNDSA512(sk.getEncoded(), pk.getH()); } private static AsymmetricCipherKeyPair freshKeyPair() { @@ -54,10 +54,10 @@ private byte[] rawSign(byte[] message) { @Test public void schemeAndLengthsMatchFips206Draft() { assertEquals(PQScheme.FN_DSA_512, keypair.getScheme()); - assertEquals(FNDSA.PUBLIC_KEY_LENGTH, keypair.getPublicKeyLength()); - assertEquals(FNDSA.SIGNATURE_LENGTH, keypair.getSignatureLength()); - assertEquals(FNDSA.PRIVATE_KEY_LENGTH, keypair.getPrivateKeyLength()); - assertEquals(FNDSA.PUBLIC_KEY_LENGTH, pk.getH().length); + assertEquals(FNDSA512.PUBLIC_KEY_LENGTH, keypair.getPublicKeyLength()); + assertEquals(FNDSA512.SIGNATURE_LENGTH, keypair.getSignatureLength()); + assertEquals(FNDSA512.PRIVATE_KEY_LENGTH, keypair.getPrivateKeyLength()); + assertEquals(FNDSA512.PUBLIC_KEY_LENGTH, pk.getH().length); } @Test @@ -65,7 +65,7 @@ public void publicKeyHasFixedLength() { for (int i = 0; i < 4; i++) { AsymmetricCipherKeyPair kp = freshKeyPair(); byte[] pkBytes = ((FalconPublicKeyParameters) kp.getPublic()).getH(); - assertEquals(FNDSA.PUBLIC_KEY_LENGTH, pkBytes.length); + assertEquals(FNDSA512.PUBLIC_KEY_LENGTH, pkBytes.length); } } @@ -74,30 +74,30 @@ public void privateKeyEncodingHasFixedLength() { for (int i = 0; i < 4; i++) { AsymmetricCipherKeyPair kp = freshKeyPair(); byte[] skBytes = ((FalconPrivateKeyParameters) kp.getPrivate()).getEncoded(); - assertEquals(FNDSA.PRIVATE_KEY_LENGTH, skBytes.length); + assertEquals(FNDSA512.PRIVATE_KEY_LENGTH, skBytes.length); } } @Test public void signProducesVerifiableSignatureWithinBound() { byte[] msg = "hello, fn-dsa".getBytes(); - byte[] sig = FNDSA.sign(sk.getEncoded(), msg); + byte[] sig = FNDSA512.sign(sk.getEncoded(), msg); assertTrue("signature must be non-empty", sig.length > 0); assertTrue( "signature must respect protocol-level upper bound", - sig.length <= FNDSA.SIGNATURE_LENGTH); - assertTrue(FNDSA.verify(pk.getH(), msg, sig)); + sig.length <= FNDSA512.SIGNATURE_LENGTH); + assertTrue(FNDSA512.verify(pk.getH(), msg, sig)); } @Test public void signatureBoundaryAtMaxAcceptedByLengthCheck() { - byte[] sig = new byte[FNDSA.SIGNATURE_LENGTH]; + byte[] sig = new byte[FNDSA512.SIGNATURE_LENGTH]; keypair.validateSignature(sig); } @Test public void signatureBoundaryAboveMaxRejected() { - byte[] sig = new byte[FNDSA.SIGNATURE_LENGTH + 1]; + byte[] sig = new byte[FNDSA512.SIGNATURE_LENGTH + 1]; try { keypair.validateSignature(sig); fail("signature longer than upper bound should be rejected"); @@ -126,9 +126,9 @@ public void emptySignatureRejectedByLengthCheck() { @Test public void verifyRejectsSignatureLongerThanUpperBound() { byte[] msg = new byte[] {1, 2, 3}; - byte[] tooLong = new byte[FNDSA.SIGNATURE_LENGTH + 1]; + byte[] tooLong = new byte[FNDSA512.SIGNATURE_LENGTH + 1]; try { - FNDSA.verify(pk.getH(), msg, tooLong); + FNDSA512.verify(pk.getH(), msg, tooLong); fail("signature exceeding upper bound should be rejected at static verify"); } catch (IllegalArgumentException expected) { assertTrue(expected.getMessage().contains("signature length")); @@ -140,7 +140,7 @@ public void verifyRejectsEmptySignature() { byte[] msg = new byte[] {1, 2, 3}; byte[] empty = new byte[0]; try { - FNDSA.verify(pk.getH(), msg, empty); + FNDSA512.verify(pk.getH(), msg, empty); fail("empty signature should be rejected at static verify"); } catch (IllegalArgumentException expected) { assertTrue(expected.getMessage().contains("signature length")); @@ -149,11 +149,11 @@ public void verifyRejectsEmptySignature() { @Test public void invalidPublicKeyLengthRejected() { - byte[] badPk = new byte[FNDSA.PUBLIC_KEY_LENGTH - 1]; + byte[] badPk = new byte[FNDSA512.PUBLIC_KEY_LENGTH - 1]; byte[] msg = new byte[] {1}; byte[] sig = new byte[16]; try { - FNDSA.verify(badPk, msg, sig); + FNDSA512.verify(badPk, msg, sig); fail("short public key should be rejected"); } catch (IllegalArgumentException expected) { assertTrue(expected.getMessage().contains("public key length")); @@ -164,7 +164,7 @@ public void invalidPublicKeyLengthRejected() { public void nullMessageRejected() { byte[] sig = new byte[16]; try { - FNDSA.verify(pk.getH(), null, sig); + FNDSA512.verify(pk.getH(), null, sig); fail("null message should be rejected"); } catch (IllegalArgumentException expected) { assertTrue(expected.getMessage().contains("message")); @@ -193,7 +193,7 @@ public void wrongPublicKeyFailsVerification() { byte[] sig = rawSign(msg); AsymmetricCipherKeyPair other = freshKeyPair(); byte[] otherPk = ((FalconPublicKeyParameters) other.getPublic()).getH(); - assertFalse(FNDSA.verify(otherPk, msg, sig)); + assertFalse(FNDSA512.verify(otherPk, msg, sig)); } @Test @@ -205,7 +205,7 @@ public void crossAlgoSignatureRejected() { for (int len : foreignLengths) { byte[] foreign = new byte[len]; try { - FNDSA.verify(pk.getH(), msg, foreign); + FNDSA512.verify(pk.getH(), msg, foreign); fail("foreign-scheme signature length " + len + " should be rejected for FN-DSA"); } catch (IllegalArgumentException expected) { assertTrue(expected.getMessage().contains("signature length")); @@ -222,52 +222,52 @@ public void emptyMessageVerifiesConsistently() { @Test public void keypairBoundInstanceSignsAndVerifies() { - FNDSA signer = new FNDSA(); + FNDSA512 signer = new FNDSA512(); byte[] msg = "keypair-bound".getBytes(); byte[] sig = signer.sign(msg); - assertTrue(sig.length > 0 && sig.length <= FNDSA.SIGNATURE_LENGTH); + assertTrue(sig.length > 0 && sig.length <= FNDSA512.SIGNATURE_LENGTH); assertTrue(signer.verify(msg, sig)); } @Test public void fromSeedIsDeterministic() { - byte[] seed = new byte[FNDSA.SEED_LENGTH]; + byte[] seed = new byte[FNDSA512.SEED_LENGTH]; for (int i = 0; i < seed.length; i++) { seed[i] = (byte) i; } - FNDSA a = new FNDSA(seed); - FNDSA b = new FNDSA(seed); + FNDSA512 a = new FNDSA512(seed); + FNDSA512 b = new FNDSA512(seed); assertArrayEquals(a.getPublicKey(), b.getPublicKey()); assertArrayEquals(a.getPrivateKey(), b.getPrivateKey()); } @Test(expected = IllegalArgumentException.class) public void invalidSeedLengthRejected() { - new FNDSA(new byte[FNDSA.SEED_LENGTH - 1]); + new FNDSA512(new byte[FNDSA512.SEED_LENGTH - 1]); } @Test(expected = UnsupportedOperationException.class) public void derivePublicKeyFromEncodedPrivateKeyUnsupported() { - FNDSA.derivePublicKey(sk.getEncoded()); + FNDSA512.derivePublicKey(sk.getEncoded()); } @Test public void computeAddressIs21Bytes() { - assertEquals(21, FNDSA.computeAddress(pk.getH()).length); + assertEquals(21, FNDSA512.computeAddress(pk.getH()).length); } @Test public void registryDispatchMatchesDirectCalls() { byte[] msg = "registry-dispatch".getBytes(); - byte[] sigDirect = FNDSA.sign(sk.getEncoded(), msg); + byte[] sigDirect = FNDSA512.sign(sk.getEncoded(), msg); assertTrue(PQSchemeRegistry.verify( PQScheme.FN_DSA_512, pk.getH(), msg, sigDirect)); byte[] sigViaRegistry = PQSchemeRegistry.sign( PQScheme.FN_DSA_512, sk.getEncoded(), msg); - assertTrue(FNDSA.verify(pk.getH(), msg, sigViaRegistry)); - assertEquals(FNDSA.PUBLIC_KEY_LENGTH, + assertTrue(FNDSA512.verify(pk.getH(), msg, sigViaRegistry)); + assertEquals(FNDSA512.PUBLIC_KEY_LENGTH, PQSchemeRegistry.getPublicKeyLength(PQScheme.FN_DSA_512)); - assertEquals(FNDSA.SIGNATURE_LENGTH, + assertEquals(FNDSA512.SIGNATURE_LENGTH, PQSchemeRegistry.getSignatureLength(PQScheme.FN_DSA_512)); } @@ -275,28 +275,28 @@ public void registryDispatchMatchesDirectCalls() { public void registryIsValidSignatureLengthRespectsUpperBound() { assertTrue(PQSchemeRegistry.isValidSignatureLength(PQScheme.FN_DSA_512, 1)); assertTrue(PQSchemeRegistry.isValidSignatureLength( - PQScheme.FN_DSA_512, FNDSA.SIGNATURE_LENGTH)); + PQScheme.FN_DSA_512, FNDSA512.SIGNATURE_LENGTH)); assertFalse(PQSchemeRegistry.isValidSignatureLength(PQScheme.FN_DSA_512, 0)); assertFalse(PQSchemeRegistry.isValidSignatureLength( - PQScheme.FN_DSA_512, FNDSA.SIGNATURE_LENGTH + 1)); + PQScheme.FN_DSA_512, FNDSA512.SIGNATURE_LENGTH + 1)); } @Test public void registryComputeAddressMatchesDirect() { assertArrayEquals( - FNDSA.computeAddress(pk.getH()), + FNDSA512.computeAddress(pk.getH()), PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pk.getH())); } @Test(expected = IllegalArgumentException.class) public void seedConstructorRejectsNull() { - new FNDSA((byte[]) null); + new FNDSA512((byte[]) null); } @Test public void keypairConstructorRejectsNullPrivateKey() { try { - new FNDSA(null, pk.getH()); + new FNDSA512(null, pk.getH()); fail("null private key must be rejected"); } catch (IllegalArgumentException expected) { assertTrue(expected.getMessage().contains("private key length")); @@ -306,7 +306,7 @@ public void keypairConstructorRejectsNullPrivateKey() { @Test public void keypairConstructorRejectsWrongPrivateKeyLength() { try { - new FNDSA(new byte[FNDSA.PRIVATE_KEY_LENGTH - 1], pk.getH()); + new FNDSA512(new byte[FNDSA512.PRIVATE_KEY_LENGTH - 1], pk.getH()); fail("short private key must be rejected"); } catch (IllegalArgumentException expected) { assertTrue(expected.getMessage().contains("private key length")); @@ -316,7 +316,7 @@ public void keypairConstructorRejectsWrongPrivateKeyLength() { @Test public void keypairConstructorRejectsNullPublicKey() { try { - new FNDSA(sk.getEncoded(), null); + new FNDSA512(sk.getEncoded(), null); fail("null public key must be rejected"); } catch (IllegalArgumentException expected) { assertTrue(expected.getMessage().contains("public key length")); @@ -326,18 +326,30 @@ public void keypairConstructorRejectsNullPublicKey() { @Test public void keypairConstructorRejectsWrongPublicKeyLength() { try { - new FNDSA(sk.getEncoded(), new byte[FNDSA.PUBLIC_KEY_LENGTH + 1]); + new FNDSA512(sk.getEncoded(), new byte[FNDSA512.PUBLIC_KEY_LENGTH + 1]); fail("over-long public key must be rejected"); } catch (IllegalArgumentException expected) { assertTrue(expected.getMessage().contains("public key length")); } } + @Test + public void keypairConstructorRejectsMismatchedHalves() { + FalconPublicKeyParameters strangerPk = + (FalconPublicKeyParameters) freshKeyPair().getPublic(); + try { + new FNDSA512(sk.getEncoded(), strangerPk.getH()); + fail("mismatched private/public key pair must be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("mismatch")); + } + } + @Test public void extendedPrivateKeyRoundTripsThroughFromAndGetters() { byte[] extended = keypair.getPrivateKeyWithPublicKey(); - assertEquals(FNDSA.PRIVATE_KEY_WITH_PUBLIC_KEY_LENGTH, extended.length); - FNDSA restored = FNDSA.fromPrivateKeyWithPublicKey(extended); + assertEquals(FNDSA512.PRIVATE_KEY_WITH_PUBLIC_KEY_LENGTH, extended.length); + FNDSA512 restored = FNDSA512.fromPrivateKeyWithPublicKey(extended); assertArrayEquals(keypair.getPrivateKey(), restored.getPrivateKey()); assertArrayEquals(keypair.getPublicKey(), restored.getPublicKey()); // The recovered keypair must produce verifiable signatures and recover its address. @@ -349,39 +361,39 @@ public void extendedPrivateKeyRoundTripsThroughFromAndGetters() { @Test(expected = IllegalArgumentException.class) public void fromExtendedPrivateKeyRejectsNull() { - FNDSA.fromPrivateKeyWithPublicKey(null); + FNDSA512.fromPrivateKeyWithPublicKey(null); } @Test(expected = IllegalArgumentException.class) public void fromExtendedPrivateKeyRejectsWrongLength() { - FNDSA.fromPrivateKeyWithPublicKey(new byte[FNDSA.PRIVATE_KEY_LENGTH]); + FNDSA512.fromPrivateKeyWithPublicKey(new byte[FNDSA512.PRIVATE_KEY_LENGTH]); } @Test public void derivePublicKeyFromExtendedFormReturnsAppendedPublicKey() { byte[] extended = keypair.getPrivateKeyWithPublicKey(); - byte[] derived = FNDSA.derivePublicKey(extended); + byte[] derived = FNDSA512.derivePublicKey(extended); assertArrayEquals(keypair.getPublicKey(), derived); } @Test(expected = UnsupportedOperationException.class) public void derivePublicKeyRejectsNull() { - FNDSA.derivePublicKey(null); + FNDSA512.derivePublicKey(null); } @Test public void staticSignAcceptsExtendedPrivateKey() { byte[] extended = keypair.getPrivateKeyWithPublicKey(); byte[] msg = "static-sign-extended".getBytes(); - byte[] sig = FNDSA.sign(extended, msg); - assertTrue(sig.length > 0 && sig.length <= FNDSA.SIGNATURE_LENGTH); - assertTrue(FNDSA.verify(pk.getH(), msg, sig)); + byte[] sig = FNDSA512.sign(extended, msg); + assertTrue(sig.length > 0 && sig.length <= FNDSA512.SIGNATURE_LENGTH); + assertTrue(FNDSA512.verify(pk.getH(), msg, sig)); } @Test public void staticSignRejectsNullPrivateKey() { try { - FNDSA.sign(null, new byte[] {1}); + FNDSA512.sign(null, new byte[] {1}); fail("null private key must be rejected"); } catch (IllegalArgumentException expected) { assertTrue(expected.getMessage().contains("private key length")); @@ -391,7 +403,7 @@ public void staticSignRejectsNullPrivateKey() { @Test public void staticSignRejectsWrongPrivateKeyLength() { try { - FNDSA.sign(new byte[FNDSA.PRIVATE_KEY_LENGTH - 1], new byte[] {1}); + FNDSA512.sign(new byte[FNDSA512.PRIVATE_KEY_LENGTH - 1], new byte[] {1}); fail("short private key must be rejected"); } catch (IllegalArgumentException expected) { assertTrue(expected.getMessage().contains("private key length")); @@ -401,7 +413,7 @@ public void staticSignRejectsWrongPrivateKeyLength() { @Test public void staticSignRejectsNullMessage() { try { - FNDSA.sign(sk.getEncoded(), null); + FNDSA512.sign(sk.getEncoded(), null); fail("null message must be rejected"); } catch (IllegalArgumentException expected) { assertTrue(expected.getMessage().contains("message")); @@ -411,7 +423,7 @@ public void staticSignRejectsNullMessage() { @Test public void staticVerifyRejectsNullPublicKey() { try { - FNDSA.verify(null, new byte[] {1}, new byte[16]); + FNDSA512.verify(null, new byte[] {1}, new byte[16]); fail("null public key must be rejected"); } catch (IllegalArgumentException expected) { assertTrue(expected.getMessage().contains("public key length")); @@ -423,14 +435,14 @@ public void unknownPqSchemeResolvesToFnDsa512() { assertEquals(PQScheme.FN_DSA_512, PQSchemeRegistry.resolve(PQScheme.UNKNOWN_PQ_SCHEME)); assertTrue(PQSchemeRegistry.contains(PQScheme.UNKNOWN_PQ_SCHEME)); - assertEquals(FNDSA.PUBLIC_KEY_LENGTH, + assertEquals(FNDSA512.PUBLIC_KEY_LENGTH, PQSchemeRegistry.getPublicKeyLength(PQScheme.UNKNOWN_PQ_SCHEME)); - assertEquals(FNDSA.SIGNATURE_LENGTH, + assertEquals(FNDSA512.SIGNATURE_LENGTH, PQSchemeRegistry.getSignatureLength(PQScheme.UNKNOWN_PQ_SCHEME)); assertTrue(PQSchemeRegistry.isValidSignatureLength( - PQScheme.UNKNOWN_PQ_SCHEME, FNDSA.SIGNATURE_LENGTH)); + PQScheme.UNKNOWN_PQ_SCHEME, FNDSA512.SIGNATURE_LENGTH)); assertArrayEquals( - FNDSA.computeAddress(pk.getH()), + FNDSA512.computeAddress(pk.getH()), PQSchemeRegistry.computeAddress(PQScheme.UNKNOWN_PQ_SCHEME, pk.getH())); byte[] msg = "unknown-resolves-to-falcon".getBytes(); @@ -438,6 +450,6 @@ public void unknownPqSchemeResolvesToFnDsa512() { PQScheme.UNKNOWN_PQ_SCHEME, sk.getEncoded(), msg); assertTrue(PQSchemeRegistry.verify( PQScheme.UNKNOWN_PQ_SCHEME, pk.getH(), msg, sig)); - assertTrue(FNDSA.verify(pk.getH(), msg, sig)); + assertTrue(FNDSA512.verify(pk.getH(), msg, sig)); } } diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/PQSchemeRegistryTest.java b/framework/src/test/java/org/tron/common/crypto/pqc/PQSchemeRegistryTest.java index 203d4625ca8..817d1dc1f07 100644 --- a/framework/src/test/java/org/tron/common/crypto/pqc/PQSchemeRegistryTest.java +++ b/framework/src/test/java/org/tron/common/crypto/pqc/PQSchemeRegistryTest.java @@ -35,37 +35,37 @@ public void containsAcceptsRegisteredScheme() { @Test public void getSeedLengthReturnsRegisteredValue() { - assertEquals(FNDSA.SEED_LENGTH, + assertEquals(FNDSA512.SEED_LENGTH, PQSchemeRegistry.getSeedLength(PQScheme.FN_DSA_512)); // UNKNOWN_PQ_SCHEME normalizes to FN_DSA_512. - assertEquals(FNDSA.SEED_LENGTH, + assertEquals(FNDSA512.SEED_LENGTH, PQSchemeRegistry.getSeedLength(PQScheme.UNKNOWN_PQ_SCHEME)); } @Test public void getPrivateKeyLengthReturnsRegisteredValue() { - assertEquals(FNDSA.PRIVATE_KEY_LENGTH, + assertEquals(FNDSA512.PRIVATE_KEY_LENGTH, PQSchemeRegistry.getPrivateKeyLength(PQScheme.FN_DSA_512)); } @Test public void fromSeedDispatchesToFalcon() { - byte[] seed = new byte[FNDSA.SEED_LENGTH]; + byte[] seed = new byte[FNDSA512.SEED_LENGTH]; Arrays.fill(seed, (byte) 0x07); PQSignature sig = PQSchemeRegistry.fromSeed(PQScheme.FN_DSA_512, seed); assertNotNull(sig); assertEquals(PQScheme.FN_DSA_512, sig.getScheme()); // Same seed must yield deterministic keypair across direct and dispatched paths. - FNDSA direct = new FNDSA(seed); + FNDSA512 direct = new FNDSA512(seed); assertArrayEquals(direct.getPublicKey(), sig.getPublicKey()); assertArrayEquals(direct.getPrivateKey(), sig.getPrivateKey()); } @Test public void fromKeypairDispatchesAndPreservesAddress() { - byte[] seed = new byte[FNDSA.SEED_LENGTH]; + byte[] seed = new byte[FNDSA512.SEED_LENGTH]; Arrays.fill(seed, (byte) 0x09); - FNDSA src = new FNDSA(seed); + FNDSA512 src = new FNDSA512(seed); PQSignature sig = PQSchemeRegistry.fromKeypair( PQScheme.FN_DSA_512, src.getPrivateKey(), src.getPublicKey()); assertArrayEquals(src.getAddress(), sig.getAddress()); @@ -88,7 +88,7 @@ public void deriveHashRejectsNullPublicKey() { public void deriveHashRejectsWrongLengthPublicKey() { try { PQSchemeRegistry.deriveHash( - PQScheme.FN_DSA_512, new byte[FNDSA.PUBLIC_KEY_LENGTH - 1]); + PQScheme.FN_DSA_512, new byte[FNDSA512.PUBLIC_KEY_LENGTH - 1]); fail("short public key must be rejected"); } catch (IllegalArgumentException expected) { assertTrue(expected.getMessage().contains("public key length")); diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/PQSignatureDefaultsTest.java b/framework/src/test/java/org/tron/common/crypto/pqc/PQSignatureDefaultsTest.java index 748885e998f..03eb0b8a0fa 100644 --- a/framework/src/test/java/org/tron/common/crypto/pqc/PQSignatureDefaultsTest.java +++ b/framework/src/test/java/org/tron/common/crypto/pqc/PQSignatureDefaultsTest.java @@ -9,7 +9,7 @@ /** * Drives the {@link PQSignature} default validator branches (null and - * length-mismatch) via a minimal in-test implementation. {@link FNDSA} + * length-mismatch) via a minimal in-test implementation. {@link FNDSA512} * exposes these defaults but the cryptographic instances exercise mostly the * happy paths; the explicit fixture here forces the error legs. */ diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/SignatureSchemeBenchmarkTest.java b/framework/src/test/java/org/tron/common/crypto/pqc/SignatureSchemeBenchmarkTest.java index f7fdb8d7876..7b99e7d7796 100644 --- a/framework/src/test/java/org/tron/common/crypto/pqc/SignatureSchemeBenchmarkTest.java +++ b/framework/src/test/java/org/tron/common/crypto/pqc/SignatureSchemeBenchmarkTest.java @@ -77,16 +77,16 @@ private Result benchEcKey() { private Result benchFnDsa() { for (int i = 0; i < WARMUP; i++) { - FNDSA k = new FNDSA(); + FNDSA512 k = new FNDSA512(); byte[] sig = k.sign(MESSAGE); k.verify(MESSAGE, sig); } long keygenNs = 0; - FNDSA[] keys = new FNDSA[ITERATIONS]; + FNDSA512[] keys = new FNDSA512[ITERATIONS]; for (int i = 0; i < ITERATIONS; i++) { long t0 = System.nanoTime(); - keys[i] = new FNDSA(); + keys[i] = new FNDSA512(); keygenNs += System.nanoTime() - t0; } diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/program/PQClient.java b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQClient.java index 6522611946c..192e4abc23b 100644 --- a/framework/src/test/java/org/tron/common/crypto/pqc/program/PQClient.java +++ b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQClient.java @@ -5,15 +5,16 @@ import io.grpc.ManagedChannel; import io.grpc.ManagedChannelBuilder; import java.nio.ByteBuffer; -import java.security.MessageDigest; import java.util.Arrays; import java.util.concurrent.TimeUnit; import org.tron.api.GrpcAPI.EmptyMessage; import org.tron.api.GrpcAPI.Return; import org.tron.api.WalletGrpc; import org.tron.api.WalletGrpc.WalletBlockingStub; -import org.tron.common.crypto.pqc.FNDSA; +import org.tron.common.crypto.pqc.FNDSA512; +import org.tron.common.parameter.CommonParameter; import org.tron.common.utils.ByteArray; +import org.tron.common.utils.Sha256Hash; import org.tron.protos.Protocol.Block; import org.tron.protos.Protocol.PQAuthSig; import org.tron.protos.Protocol.Transaction; @@ -56,13 +57,13 @@ public static void main(String[] args) throws Exception { .setLevel(ch.qos.logback.classic.Level.INFO); // ── 1. Derive user keypair from same fixed seed as PQWitnessNode ───── - byte[] userSeed = new byte[FNDSA.SEED_LENGTH]; + byte[] userSeed = new byte[FNDSA512.SEED_LENGTH]; Arrays.fill(userSeed, (byte) 0x02); - FNDSA userKp = new FNDSA(userSeed); + FNDSA512 userKp = new FNDSA512(userSeed); byte[] userPub = userKp.getPublicKey(); byte[] userPriv = userKp.getPrivateKey(); - byte[] signerAddr = FNDSA.computeAddress(userPub); + byte[] signerAddr = FNDSA512.computeAddress(userPub); byte[] ownerAddr = PQWitnessNode.USER_ADDR; System.out.println("=== PQC Client ==="); @@ -83,7 +84,8 @@ public static void main(String[] args) throws Exception { Block head = stub.getNowBlock(EmptyMessage.getDefaultInstance()); byte[] headerRaw = head.getBlockHeader().getRawData().toByteArray(); long refNum = head.getBlockHeader().getRawData().getNumber(); - byte[] blockHash = sha256(headerRaw); + byte[] blockHash = Sha256Hash.of( + CommonParameter.getInstance().isECKeyCryptoEngine(), headerRaw).getBytes(); System.out.println("Reference block: #" + refNum + " hash=" + ByteArray.toHexString(Arrays.copyOfRange(blockHash, 0, 8)) + "..."); @@ -107,8 +109,10 @@ public static void main(String[] args) throws Exception { Transaction tx = Transaction.newBuilder().setRawData(rawData).build(); // ── 5. Sign with FN-DSA-512 pq_auth_sig ───────────────────────────── - byte[] txId = sha256(rawData.toByteArray()); - byte[] sig = FNDSA.sign(userPriv, txId); + byte[] txId = Sha256Hash.of( + CommonParameter.getInstance().isECKeyCryptoEngine(), + rawData.toByteArray()).getBytes(); + byte[] sig = FNDSA512.sign(userPriv, txId); // FN_DSA_512 is the launch scheme → leave scheme at proto3 default and // let PQSchemeRegistry.resolve() normalize it on the verifier side. @@ -137,10 +141,6 @@ public static void main(String[] args) throws Exception { } } - private static byte[] sha256(byte[] data) throws Exception { - return MessageDigest.getInstance("SHA-256").digest(data); - } - private static byte[] longToBytes(long value) { return ByteBuffer.allocate(8).putLong(value).array(); } diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/program/PQFullNode.java b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQFullNode.java index 7f628f84b70..d6bf351c772 100644 --- a/framework/src/test/java/org/tron/common/crypto/pqc/program/PQFullNode.java +++ b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQFullNode.java @@ -8,7 +8,7 @@ import org.tron.common.application.Application; import org.tron.common.application.ApplicationFactory; import org.tron.common.application.TronApplicationContext; -import org.tron.common.crypto.pqc.FNDSA; +import org.tron.common.crypto.pqc.FNDSA512; import org.tron.common.utils.ByteArray; import org.tron.core.ChainBaseManager; import org.tron.core.config.DefaultConfig; @@ -56,8 +56,8 @@ public static void main(String[] args) throws Exception { .setLevel(ch.qos.logback.classic.Level.INFO); // ── 1. Derive the same deterministic keys used by PQWitnessNode ────── - FNDSA witnessKp = new FNDSA(PQWitnessNode.WITNESS_SEED); - FNDSA userKp = new FNDSA(PQWitnessNode.USER_SEED); + FNDSA512 witnessKp = new FNDSA512(PQWitnessNode.WITNESS_SEED); + FNDSA512 userKp = new FNDSA512(PQWitnessNode.USER_SEED); byte[] witnessPub = witnessKp.getPublicKey(); byte[] userPub = userKp.getPublicKey(); @@ -68,7 +68,7 @@ public static void main(String[] args) throws Exception { System.out.println("HTTP port: " + HTTP_PORT); System.out.println("P2P port: " + P2P_PORT); System.out.println("Witness address (expected): " - + ByteArray.toHexString(FNDSA.computeAddress(witnessPub))); + + ByteArray.toHexString(FNDSA512.computeAddress(witnessPub))); // ── 2. Configure node (no -w: this is a pure fullnode) ──────────────── File dbDir = Files.createTempDirectory("pqc-fullnode-").toFile(); diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/program/PQTxSender.java b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQTxSender.java new file mode 100644 index 00000000000..cdcaf3e72a5 --- /dev/null +++ b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQTxSender.java @@ -0,0 +1,299 @@ +package org.tron.common.crypto.pqc.program; + +import com.google.protobuf.Any; +import com.google.protobuf.ByteString; +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; +import java.nio.ByteBuffer; +import java.security.MessageDigest; +import java.util.Arrays; +import java.util.concurrent.TimeUnit; +import org.tron.api.GrpcAPI.EmptyMessage; +import org.tron.api.GrpcAPI.Return; +import org.tron.api.WalletGrpc; +import org.tron.api.WalletGrpc.WalletBlockingStub; +import org.tron.common.crypto.pqc.FNDSA512; +import org.tron.common.math.StrictMathWrapper; +import org.tron.common.utils.ByteArray; +import org.tron.common.utils.Commons; +import org.tron.common.utils.StringUtil; +import org.tron.common.utils.client.utils.AbiUtil; +import org.tron.core.capsule.TransactionCapsule; +import org.tron.protos.Protocol.Block; +import org.tron.protos.Protocol.PQAuthSig; +import org.tron.protos.Protocol.Transaction; +import org.tron.protos.Protocol.Transaction.Contract.ContractType; +import org.tron.protos.contract.BalanceContract.TransferContract; +import org.tron.protos.contract.SmartContractOuterClass.TriggerSmartContract; + +/** + * Demo client that connects to {@link PQWitnessNode} and continuously broadcasts FN-DSA-512 signed + * transfer and TRC20 transactions at 10 TPS. + *

+ * The keypair is derived from the same fixed seed used by PQWitnessNode, so no out-of-band key + * exchange is needed. + *

+ * Run from the repository root: + * ./gradlew :framework:buildFullNodeJar :framework:compileTestJava + * java -Dpqc.host=127.0.0.1 -Dpqc.port=50051 -Dpqc.transfer.tps=10 -Dpqc.trc20.tps=10 \ + * -cp "framework/build/classes/java/test:framework/build/resources/test:\ + * framework/build/libs/FullNode.jar" \ + * org.tron.common.crypto.pqc.program.PQTxSender + * + * Optional JVM args: + * -Dpqc.host=localhost + * -Dpqc.port=50051 + * -Dpqc.transfer.tps=10 + * -Dpqc.trc20.tps=10 + */ +public class PQTxSender { + + private static final String HOST = + System.getProperty("pqc.host", "localhost"); + private static final int PORT = + Integer.parseInt(System.getProperty("pqc.port", "50051")); + + /** + * Recipient of the demo transfer. + */ + private static final byte[] TO_ADDR = + Commons.decodeFromBase58Check("T9zNBvTFD97XzGsjGqvg2QHizTG8sibsHt"); + + /** + * TRC20 contract address (USDT on TRON). + */ + private static final byte[] TRC20_CONTRACT_ADDR = + Commons.decodeFromBase58Check("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"); + + /** + * Demo TRC20 amount in base units (6 decimals = 1 token). + */ + private static final long TRC20_AMOUNT = 1L; + + /** + * Upper bound for TRC20 execution fee. + */ + private static final long TRC20_FEE_LIMIT = 1000_000_000L; + + /** + * Default send rate for transfer transactions. + */ + private static final double DEFAULT_TRANSFER_TPS = 10.0d; + /** + * Default send rate for TRC20 transactions. + */ + private static final double DEFAULT_TRC20_TPS = 10.0d; + + public static void main(String[] args) throws Exception { + // Force INFO level: logback-test.xml (on the test classpath) sets root=DEBUG + // which is far too noisy for a demo run. + ((ch.qos.logback.classic.Logger) org.slf4j.LoggerFactory + .getLogger(ch.qos.logback.classic.Logger.ROOT_LOGGER_NAME)) + .setLevel(ch.qos.logback.classic.Level.INFO); + + // ── 1. Derive user keypair from same fixed seed as PQWitnessNode ───── + byte[] userSeed = new byte[FNDSA512.SEED_LENGTH]; + Arrays.fill(userSeed, (byte) 0x02); + FNDSA512 userKp = new FNDSA512(userSeed); + + byte[] userPub = userKp.getPublicKey(); + byte[] userPriv = userKp.getPrivateKey(); + byte[] signerAddr = FNDSA512.computeAddress(userPub); + byte[] ownerAddr = Commons.decodeFromBase58Check("TJUfbazhixG4YtqJxUDmv5XisZvvy1wP91"); + double transferTps = readTps("pqc.transfer.tps", DEFAULT_TRANSFER_TPS); + double trc20Tps = readTps("pqc.trc20.tps", DEFAULT_TRC20_TPS); + + System.out.println("=== PQC Client ==="); + System.out.println("Connecting to " + HOST + ":" + PORT); + System.out.println("Owner address: " + ByteArray.toHexString(ownerAddr)); + System.out.println("Signer address: " + ByteArray.toHexString(signerAddr)); + System.out.println("Transfer TPS: " + transferTps); + System.out.println("TRC20 TPS: " + trc20Tps); + + // ── 2. Connect via gRPC ─────────────────────────────────────────────── + ManagedChannel channel = ManagedChannelBuilder + .forAddress(HOST, PORT) + .usePlaintext() + .build(); + WalletBlockingStub stub = WalletGrpc.newBlockingStub(channel); + + try { + Thread transferThread = new Thread( + () -> runTransferLoop(stub, ownerAddr, userPub, userPriv, transferTps), + "pqc-transfer-sender-grpc"); + Thread trc20Thread = new Thread( + () -> runTrc20Loop(stub, ownerAddr, userPub, userPriv, trc20Tps), + "pqc-trc20-sender-grpc"); + + transferThread.start(); + trc20Thread.start(); + transferThread.join(); + trc20Thread.join(); + } finally { + channel.shutdown(); + channel.awaitTermination(5, TimeUnit.SECONDS); + } + } + + private static byte[] sha256(byte[] data) throws Exception { + return MessageDigest.getInstance("SHA-256").digest(data); + } + + private static byte[] longToBytes(long value) { + return ByteBuffer.allocate(8).putLong(value).array(); + } + + private static void runTransferLoop(WalletBlockingStub stub, byte[] ownerAddr, + byte[] userPub, byte[] userPriv, double tps) { + if (tps <= 0) { + System.out.println("transfer sender disabled"); + return; + } + long intervalMs = tpsToIntervalMs(tps); + long counter = 1L; + while (!Thread.currentThread().isInterrupted()) { + long loopStart = System.currentTimeMillis(); + sendTransferTransaction(stub, ownerAddr, userPub, userPriv, counter++); + sleepRemaining(intervalMs, loopStart); + } + } + + private static void runTrc20Loop(WalletBlockingStub stub, byte[] ownerAddr, + byte[] userPub, byte[] userPriv, double tps) { + if (tps <= 0) { + System.out.println("trc20 sender disabled"); + return; + } + long intervalMs = tpsToIntervalMs(tps); + long counter = 1L; + while (!Thread.currentThread().isInterrupted()) { + long loopStart = System.currentTimeMillis(); + sendTrc20Transaction(stub, ownerAddr, userPub, userPriv, counter++); + sleepRemaining(intervalMs, loopStart); + } + } + + private static void sendTransferTransaction(WalletBlockingStub stub, byte[] ownerAddr, + byte[] userPub, byte[] userPriv, long seq) { + try { + WalletBlockingStub timedStub = stub.withDeadlineAfter(10, TimeUnit.SECONDS); + + // Fetch the latest block for TaPoS before every send so the demo stays valid + // even if the node advances quickly. + Block head = timedStub.getNowBlock(EmptyMessage.getDefaultInstance()); + byte[] headerRaw = head.getBlockHeader().getRawData().toByteArray(); + long refNum = head.getBlockHeader().getRawData().getNumber(); + byte[] blockHash = sha256(headerRaw); + + Transaction tx = buildTransferTransaction(ownerAddr, blockHash, refNum); + byte[] txId = sha256(tx.getRawData().toByteArray()); + byte[] sig = FNDSA512.sign(userPriv, txId); + Transaction signedTx = tx.toBuilder() + .addPqAuthSig(PQAuthSig.newBuilder() + .setPublicKey(ByteString.copyFrom(userPub)) + .setSignature(ByteString.copyFrom(sig))) + .build(); + + Return result = timedStub.broadcastTransaction(signedTx); + System.out.println("[transfer-" + seq + "] ref=#" + refNum + + " tx=" + ByteArray.toHexString(txId) + + " result=" + result.getCode() + + " msg=" + result.getMessage().toStringUtf8()); + } catch (Exception e) { + System.err.println("[transfer-" + seq + "] send failed: " + e.getMessage()); + e.printStackTrace(System.err); + } + } + + private static void sendTrc20Transaction(WalletBlockingStub stub, byte[] ownerAddr, + byte[] userPub, byte[] userPriv, long seq) { + try { + WalletBlockingStub timedStub = stub.withDeadlineAfter(10, TimeUnit.SECONDS); + + // Fetch the latest block for TaPoS before every send so the demo stays valid + // even if the node advances quickly. + Block head = timedStub.getNowBlock(EmptyMessage.getDefaultInstance()); + byte[] headerRaw = head.getBlockHeader().getRawData().toByteArray(); + long refNum = head.getBlockHeader().getRawData().getNumber(); + byte[] blockHash = sha256(headerRaw); + + Transaction tx = buildTrc20Transaction(ownerAddr, blockHash, refNum); + Transaction.raw.Builder rawBuilder = tx.getRawData().toBuilder(); + rawBuilder.setFeeLimit(TRC20_FEE_LIMIT); + tx = tx.toBuilder().setRawData(rawBuilder).build(); + + byte[] txId = sha256(tx.getRawData().toByteArray()); + byte[] sig = FNDSA512.sign(userPriv, txId); + Transaction signedTx = tx.toBuilder() + .addPqAuthSig(PQAuthSig.newBuilder() + .setPublicKey(ByteString.copyFrom(userPub)) + .setSignature(ByteString.copyFrom(sig))) + .build(); + + Return result = timedStub.broadcastTransaction(signedTx); + System.out.println("[trc20-" + seq + "] ref=#" + refNum + + " tx=" + ByteArray.toHexString(txId) + + " result=" + result.getCode() + + " msg=" + result.getMessage().toStringUtf8()); + } catch (Exception e) { + System.err.println("[trc20-" + seq + "] send failed: " + e.getMessage()); + e.printStackTrace(System.err); + } + } + + private static Transaction buildTransferTransaction(byte[] ownerAddr, byte[] blockHash, + long refNum) { + Transaction.raw rawData = Transaction.raw.newBuilder() + .addContract(Transaction.Contract.newBuilder() + .setType(ContractType.TransferContract) + .setParameter(Any.pack(TransferContract.newBuilder() + .setOwnerAddress(ByteString.copyFrom(ownerAddr)) + .setToAddress(ByteString.copyFrom(TO_ADDR)) + .setAmount(1000L) + .build())) + .setPermissionId(0)) + .setRefBlockHash(ByteString.copyFrom(Arrays.copyOfRange(blockHash, 8, 16))) + .setRefBlockBytes(ByteString.copyFrom(longToBytes(refNum), 6, 2)) + .setExpiration(System.currentTimeMillis() + 60_000L) + .build(); + return Transaction.newBuilder().setRawData(rawData).build(); + } + + private static Transaction buildTrc20Transaction(byte[] ownerAddr, byte[] blockHash, + long refNum) { + String callData = AbiUtil.parseMethod("transfer(address,uint256)", + Arrays.asList(StringUtil.encode58Check(TO_ADDR), Long.toString(TRC20_AMOUNT))); + TriggerSmartContract trigger = TriggerSmartContract.newBuilder() + .setOwnerAddress(ByteString.copyFrom(ownerAddr)) + .setContractAddress(ByteString.copyFrom(TRC20_CONTRACT_ADDR)) + .setData(ByteString.copyFrom(ByteArray.fromHexString(callData))) + .setCallValue(0L) + .build(); + TransactionCapsule trxCap = new TransactionCapsule(trigger, ContractType.TriggerSmartContract); + Transaction tx = trxCap.getInstance(); + Transaction.raw.Builder rawBuilder = tx.getRawData().toBuilder(); + rawBuilder.setRefBlockHash(ByteString.copyFrom(Arrays.copyOfRange(blockHash, 8, 16))); + rawBuilder.setRefBlockBytes(ByteString.copyFrom(longToBytes(refNum), 6, 2)); + rawBuilder.setExpiration(System.currentTimeMillis() + 60_000L); + return tx.toBuilder().setRawData(rawBuilder).build(); + } + + private static double readTps(String key, double defaultValue) { + return Double.parseDouble(System.getProperty(key, Double.toString(defaultValue))); + } + + private static long tpsToIntervalMs(double tps) { + return StrictMathWrapper.max(1L, StrictMathWrapper.round(1000.0d / tps)); + } + + private static void sleepRemaining(long intervalMs, long loopStartMs) { + long sleepMs = intervalMs - (System.currentTimeMillis() - loopStartMs); + if (sleepMs > 0) { + try { + Thread.sleep(sleepMs); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + } +} diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/program/PQWitnessNode.java b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQWitnessNode.java index f677a44cf01..6d4e688445e 100644 --- a/framework/src/test/java/org/tron/common/crypto/pqc/program/PQWitnessNode.java +++ b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQWitnessNode.java @@ -12,7 +12,7 @@ import org.tron.common.application.Application; import org.tron.common.application.ApplicationFactory; import org.tron.common.application.TronApplicationContext; -import org.tron.common.crypto.pqc.FNDSA; +import org.tron.common.crypto.pqc.FNDSA512; import org.tron.common.crypto.pqc.PQSchemeRegistry; import org.tron.common.utils.ByteArray; import org.tron.core.ChainBaseManager; @@ -73,13 +73,13 @@ public static void main(String[] args) throws Exception { .setLevel(ch.qos.logback.classic.Level.INFO); // ── 1. Derive deterministic keypairs ────────────────────────────────── - FNDSA witnessKp = new FNDSA(WITNESS_SEED); - FNDSA userKp = new FNDSA(USER_SEED); + FNDSA512 witnessKp = new FNDSA512(WITNESS_SEED); + FNDSA512 userKp = new FNDSA512(USER_SEED); byte[] witnessPub = witnessKp.getPublicKey(); - byte[] witnessAddr = FNDSA.computeAddress(witnessPub); + byte[] witnessAddr = FNDSA512.computeAddress(witnessPub); byte[] userPub = userKp.getPublicKey(); - byte[] signerAddr = FNDSA.computeAddress(userPub); + byte[] signerAddr = FNDSA512.computeAddress(userPub); System.out.println("=== PQC Witness Node ==="); System.out.println("Witness address (FN-DSA-512): " + ByteArray.toHexString(witnessAddr)); @@ -185,12 +185,12 @@ static void installPQGenesisState(Manager db, ChainBaseManager chain, } private static byte[] filledSeed(int value) { - byte[] seed = new byte[FNDSA.SEED_LENGTH]; + byte[] seed = new byte[FNDSA512.SEED_LENGTH]; Arrays.fill(seed, (byte) value); return seed; } - private static Path writeWitnessConfig(FNDSA witnessKp) throws java.io.IOException { + private static Path writeWitnessConfig(FNDSA512 witnessKp) throws java.io.IOException { Path conf = Files.createTempFile("pqc-witness-", ".conf"); conf.toFile().deleteOnExit(); String body = "include classpath(\"config-test.conf\")\n" diff --git a/framework/src/test/java/org/tron/common/runtime/vm/BatchValidateSignPQTest.java b/framework/src/test/java/org/tron/common/runtime/vm/BatchValidateFnDsa512Test.java similarity index 89% rename from framework/src/test/java/org/tron/common/runtime/vm/BatchValidateSignPQTest.java rename to framework/src/test/java/org/tron/common/runtime/vm/BatchValidateFnDsa512Test.java index aae387ad2b7..a08ddf41fd1 100644 --- a/framework/src/test/java/org/tron/common/runtime/vm/BatchValidateSignPQTest.java +++ b/framework/src/test/java/org/tron/common/runtime/vm/BatchValidateFnDsa512Test.java @@ -10,11 +10,11 @@ import org.junit.Assert; import org.junit.Before; import org.junit.Test; -import org.tron.common.crypto.pqc.FNDSA; +import org.tron.common.crypto.pqc.FNDSA512; import org.tron.common.crypto.pqc.PQSchemeRegistry; import org.tron.common.utils.client.utils.AbiUtil; import org.tron.core.vm.PrecompiledContracts; -import org.tron.core.vm.PrecompiledContracts.BatchValidateSignPQ; +import org.tron.core.vm.PrecompiledContracts.BatchValidateFnDsa512; import org.tron.core.vm.PrecompiledContracts.PrecompiledContract; import org.tron.core.vm.config.VMConfig; import org.tron.protos.Protocol.PQScheme; @@ -22,17 +22,17 @@ /** * Unit tests for the 0x18 batch independent Falcon-512 verify precompile. * Returns a 256-bit bitmap where bit i is set iff - * {@code derive(pk_i) == expectedAddr_i && FNDSA.verify(pk_i, hash, sig_i)}. + * {@code derive(pk_i) == expectedAddr_i && FNDSA512.verify(pk_i, hash, sig_i)}. * Stateless — no chain DB. */ @Slf4j -public class BatchValidateSignPQTest { +public class BatchValidateFnDsa512Test { private static final DataWord ADDR_0X18 = new DataWord( "0000000000000000000000000000000000000000000000000000000000000018"); private static final String METHOD_SIGN = - "batchvalidatesignpq(bytes32,bytes[],bytes[],bytes32[])"; + "batchvalidatefndsa512(bytes32,bytes[],bytes[],bytes32[])"; private static final byte[] HASH; @@ -43,7 +43,7 @@ public class BatchValidateSignPQTest { } } - private final BatchValidateSignPQ contract = new BatchValidateSignPQ(); + private final BatchValidateFnDsa512 contract = new BatchValidateFnDsa512(); @Before public void enableProposal() { @@ -65,7 +65,7 @@ public void switchOff_returnsNull() { public void switchOn_returnsContract() { PrecompiledContract pc = PrecompiledContracts.getContractForAddress(ADDR_0X18); Assert.assertNotNull(pc); - Assert.assertTrue(pc instanceof BatchValidateSignPQ); + Assert.assertTrue(pc instanceof BatchValidateFnDsa512); } @Test @@ -76,7 +76,7 @@ public void constantCall_allValid_setsAllBits() { List pks = new ArrayList<>(n); List addrs = new ArrayList<>(n); for (int i = 0; i < n; i++) { - FNDSA k = new FNDSA(); + FNDSA512 k = new FNDSA512(); sigs.add(Hex.toHexString(k.sign(HASH))); pks.add(Hex.toHexString(k.getPublicKey())); addrs.add(addrAsBytes32Hex(k.getPublicKey())); @@ -93,8 +93,8 @@ public void constantCall_allValid_setsAllBits() { @Test public void constantCall_mismatchedAddress_clearsBit() { contract.setConstantCall(true); - FNDSA k1 = new FNDSA(); - FNDSA k2 = new FNDSA(); + FNDSA512 k1 = new FNDSA512(); + FNDSA512 k2 = new FNDSA512(); List sigs = Arrays.asList( Hex.toHexString(k1.sign(HASH)), Hex.toHexString(k2.sign(HASH))); @@ -114,7 +114,7 @@ public void constantCall_mismatchedAddress_clearsBit() { @Test public void constantCall_tamperedSignature_clearsBit() { contract.setConstantCall(true); - FNDSA k = new FNDSA(); + FNDSA512 k = new FNDSA512(); byte[] sig = k.sign(HASH); sig[0] ^= 0x01; List sigs = Collections1(Hex.toHexString(sig)); @@ -128,7 +128,7 @@ public void constantCall_tamperedSignature_clearsBit() { @Test public void constantCall_wrongPkLength_clearsBit() { contract.setConstantCall(true); - FNDSA k = new FNDSA(); + FNDSA512 k = new FNDSA512(); byte[] truncatedPk = Arrays.copyOf(k.getPublicKey(), k.getPublicKey().length - 1); List sigs = Collections1(Hex.toHexString(k.sign(HASH))); List pks = Collections1(Hex.toHexString(truncatedPk)); @@ -147,7 +147,7 @@ public void asyncPath_allValid_setsAllBits() { List pks = new ArrayList<>(n); List addrs = new ArrayList<>(n); for (int i = 0; i < n; i++) { - FNDSA k = new FNDSA(); + FNDSA512 k = new FNDSA512(); sigs.add(Hex.toHexString(k.sign(HASH))); pks.add(Hex.toHexString(k.getPublicKey())); addrs.add(addrAsBytes32Hex(k.getPublicKey())); @@ -161,7 +161,7 @@ public void asyncPath_allValid_setsAllBits() { @Test public void mismatchedArrayLengths_returnsZero() { contract.setConstantCall(true); - FNDSA k = new FNDSA(); + FNDSA512 k = new FNDSA512(); List sigs = Collections1(Hex.toHexString(k.sign(HASH))); List pks = Arrays.asList( Hex.toHexString(k.getPublicKey()), Hex.toHexString(k.getPublicKey())); @@ -180,7 +180,7 @@ public void overMaxSize_returnsZero() { List pks = new ArrayList<>(n); List addrs = new ArrayList<>(n); for (int i = 0; i < n; i++) { - FNDSA k = new FNDSA(); + FNDSA512 k = new FNDSA512(); sigs.add(Hex.toHexString(k.sign(HASH))); pks.add(Hex.toHexString(k.getPublicKey())); addrs.add(addrAsBytes32Hex(k.getPublicKey())); @@ -197,13 +197,13 @@ public void energyScalesWithCount() { List pks = new ArrayList<>(n); List addrs = new ArrayList<>(n); for (int i = 0; i < n; i++) { - FNDSA k = new FNDSA(); + FNDSA512 k = new FNDSA512(); sigs.add(Hex.toHexString(k.sign(HASH))); pks.add(Hex.toHexString(k.getPublicKey())); addrs.add(addrAsBytes32Hex(k.getPublicKey())); } byte[] input = encode(HASH, sigs, pks, addrs); - Assert.assertEquals(3L * 15000L, contract.getEnergyForData(input)); + Assert.assertEquals(3L * 2000L, contract.getEnergyForData(input)); } @Test @@ -221,7 +221,7 @@ public void differentHash_clearsAllBits() { List pks = new ArrayList<>(n); List addrs = new ArrayList<>(n); for (int i = 0; i < n; i++) { - FNDSA k = new FNDSA(); + FNDSA512 k = new FNDSA512(); // Sign HASH... sigs.add(Hex.toHexString(k.sign(HASH))); pks.add(Hex.toHexString(k.getPublicKey())); @@ -243,7 +243,7 @@ public void atMaxSize16_setsAllBits() { List pks = new ArrayList<>(n); List addrs = new ArrayList<>(n); for (int i = 0; i < n; i++) { - FNDSA k = new FNDSA(); + FNDSA512 k = new FNDSA512(); sigs.add(Hex.toHexString(k.sign(HASH))); pks.add(Hex.toHexString(k.getPublicKey())); addrs.add(addrAsBytes32Hex(k.getPublicKey())); @@ -267,7 +267,7 @@ public void asyncPath_mixedValidInvalid() { List pks = new ArrayList<>(n); List addrs = new ArrayList<>(n); for (int i = 0; i < n; i++) { - FNDSA k = new FNDSA(); + FNDSA512 k = new FNDSA512(); byte[] sig = k.sign(HASH); // Tamper entries 1 and 3. if (i == 1 || i == 3) { @@ -287,7 +287,7 @@ public void asyncPath_mixedValidInvalid() { @Test public void sigTooLong_clearsBit() { contract.setConstantCall(true); - FNDSA k = new FNDSA(); + FNDSA512 k = new FNDSA512(); byte[] oversized = new byte[800]; Arrays.fill(oversized, (byte) 0x99); List sigs = Collections1(Hex.toHexString(oversized)); @@ -303,7 +303,11 @@ public void sigTooLong_clearsBit() { private Pair run(byte[] hash, List sigs, List pks, List addrs) { byte[] input = encode(hash, sigs, pks, addrs); - contract.setVmShouldEndInUs(System.nanoTime() / 1000 + 5_000_000L); + // Preserve any longer budget callers set (e.g. atMaxSize16_setsAllBits and + // asyncPath_* need 10-30s for 16 parallel Falcon-512 verifies on slow CI). + if (contract.getVmShouldEndInUs() == 0) { + contract.setVmShouldEndInUs(System.nanoTime() / 1000 + 5_000_000L); + } Pair ret = contract.execute(input); logger.info("0x18 bitmap: {}", Hex.toHexString(ret.getRight())); return ret; diff --git a/framework/src/test/java/org/tron/common/runtime/vm/FnDsaPrecompileTest.java b/framework/src/test/java/org/tron/common/runtime/vm/FnDsaPrecompileTest.java index 4e21409e2ce..e8fa8bc27d0 100644 --- a/framework/src/test/java/org/tron/common/runtime/vm/FnDsaPrecompileTest.java +++ b/framework/src/test/java/org/tron/common/runtime/vm/FnDsaPrecompileTest.java @@ -5,7 +5,7 @@ import org.junit.Assert; import org.junit.Before; import org.junit.Test; -import org.tron.common.crypto.pqc.FNDSA; +import org.tron.common.crypto.pqc.FNDSA512; import org.tron.core.vm.PrecompiledContracts; import org.tron.core.vm.PrecompiledContracts.PrecompiledContract; import org.tron.core.vm.config.VMConfig; @@ -50,7 +50,7 @@ public void switchOn_returnsContract() { @Test public void validSignature_returnsOne() { - FNDSA key = new FNDSA(); + FNDSA512 key = new FNDSA512(); byte[] sig = key.sign(MESSAGE_HASH); byte[] input = buildInput(MESSAGE_HASH, sig, key.getPublicKey()); @@ -59,12 +59,12 @@ public void validSignature_returnsOne() { Assert.assertTrue(result.getLeft()); Assert.assertArrayEquals(DataWord.ONE().getData(), result.getRight()); - Assert.assertEquals(2500, pc.getEnergyForData(input)); + Assert.assertEquals(4000, pc.getEnergyForData(input)); } @Test public void tamperedMessage_returnsZero() { - FNDSA key = new FNDSA(); + FNDSA512 key = new FNDSA512(); byte[] sig = key.sign(MESSAGE_HASH); byte[] tampered = MESSAGE_HASH.clone(); tampered[0] ^= 0x01; @@ -79,7 +79,7 @@ public void tamperedMessage_returnsZero() { @Test public void tamperedSignature_returnsZero() { - FNDSA key = new FNDSA(); + FNDSA512 key = new FNDSA512(); byte[] sig = key.sign(MESSAGE_HASH); sig[0] ^= 0x01; byte[] input = buildInput(MESSAGE_HASH, sig, key.getPublicKey()); @@ -93,8 +93,8 @@ public void tamperedSignature_returnsZero() { @Test public void wrongPublicKey_returnsZero() { - FNDSA signer = new FNDSA(); - FNDSA other = new FNDSA(); + FNDSA512 signer = new FNDSA512(); + FNDSA512 other = new FNDSA512(); byte[] sig = signer.sign(MESSAGE_HASH); byte[] input = buildInput(MESSAGE_HASH, sig, other.getPublicKey()); @@ -125,7 +125,7 @@ public void shortInput_returnsZero() { @Test public void zeroSigLen_returnsZero() { - FNDSA key = new FNDSA(); + FNDSA512 key = new FNDSA512(); byte[] pk = key.getPublicKey(); // sig_len = 0 is invalid (must be >= 1) // input must be >= MIN_INPUT_LEN (931 = 32 + 2 + 1 + 896) to reach the sigLen check @@ -143,8 +143,8 @@ public void zeroSigLen_returnsZero() { @Test public void oversizedSigLen_returnsZero() { - // sig_len = 753, which exceeds FNDSA.SIGNATURE_LENGTH (752) - byte[] input = new byte[32 + 2 + 753 + FNDSA.PUBLIC_KEY_LENGTH]; + // sig_len = 753, which exceeds FNDSA512.SIGNATURE_LENGTH (752) + byte[] input = new byte[32 + 2 + 753 + FNDSA512.PUBLIC_KEY_LENGTH]; input[32] = 0x02; // high byte input[33] = (byte) 0xF1; // low byte → 0x02F1 = 753 Pair result = @@ -156,7 +156,7 @@ public void oversizedSigLen_returnsZero() { @Test public void sigLenLargerThanActualData_returnsZero() { - FNDSA key = new FNDSA(); + FNDSA512 key = new FNDSA512(); byte[] sig = key.sign(MESSAGE_HASH); // claim sig is 100 bytes longer than it is byte[] input = buildInput(MESSAGE_HASH, sig, key.getPublicKey()); @@ -172,6 +172,23 @@ public void sigLenLargerThanActualData_returnsZero() { Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); } + @Test + public void trailingBytes_returnsZero() { + // Strict equality (matches 0x100 P256Verify / EIP-7951): appending even one byte + // to an otherwise-valid input must be rejected to prevent non-canonical encodings. + FNDSA512 key = new FNDSA512(); + byte[] sig = key.sign(MESSAGE_HASH); + byte[] valid = buildInput(MESSAGE_HASH, sig, key.getPublicKey()); + byte[] padded = new byte[valid.length + 1]; + System.arraycopy(valid, 0, padded, 0, valid.length); + + Pair result = + PrecompiledContracts.getContractForAddress(FNDSA_ADDR).execute(padded); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + /** Encodes input as [msg 32B | sig_len 2B | sig | pk]. */ private static byte[] buildInput(byte[] msg, byte[] sig, byte[] pk) { int sigLen = sig.length; diff --git a/framework/src/test/java/org/tron/common/runtime/vm/ValidateMultiSignPQTest.java b/framework/src/test/java/org/tron/common/runtime/vm/ValidateMultiFnDsa512Test.java similarity index 95% rename from framework/src/test/java/org/tron/common/runtime/vm/ValidateMultiSignPQTest.java rename to framework/src/test/java/org/tron/common/runtime/vm/ValidateMultiFnDsa512Test.java index 37a7cd7aa02..3d820644b7c 100644 --- a/framework/src/test/java/org/tron/common/runtime/vm/ValidateMultiSignPQTest.java +++ b/framework/src/test/java/org/tron/common/runtime/vm/ValidateMultiFnDsa512Test.java @@ -14,7 +14,7 @@ import org.tron.common.BaseTest; import org.tron.common.TestConstants; import org.tron.common.crypto.ECKey; -import org.tron.common.crypto.pqc.FNDSA; +import org.tron.common.crypto.pqc.FNDSA512; import org.tron.common.crypto.pqc.PQSchemeRegistry; import org.tron.common.parameter.CommonParameter; import org.tron.common.utils.ByteArray; @@ -27,7 +27,7 @@ import org.tron.core.store.StoreFactory; import org.tron.core.vm.PrecompiledContracts; import org.tron.core.vm.PrecompiledContracts.PrecompiledContract; -import org.tron.core.vm.PrecompiledContracts.ValidateMultiSignPQ; +import org.tron.core.vm.PrecompiledContracts.ValidateMultiFnDsa512; import org.tron.core.vm.config.VMConfig; import org.tron.core.vm.repository.Repository; import org.tron.core.vm.repository.RepositoryImpl; @@ -40,7 +40,7 @@ * Falcon-512 entries alongside ECDSA against the same Permission.keys[]. */ @Slf4j -public class ValidateMultiSignPQTest extends BaseTest { +public class ValidateMultiFnDsa512Test extends BaseTest { private static final DataWord ADDR_0X17 = new DataWord( "0000000000000000000000000000000000000000000000000000000000000017"); @@ -56,7 +56,7 @@ public class ValidateMultiSignPQTest extends BaseTest { Arrays.fill(longData, (byte) 7); } - private final ValidateMultiSignPQ contract = new ValidateMultiSignPQ(); + private final ValidateMultiFnDsa512 contract = new ValidateMultiFnDsa512(); @Before public void before() { @@ -75,7 +75,7 @@ public void switchOff_returnsNull() { public void switchOn_returnsContract() { PrecompiledContract pc = PrecompiledContracts.getContractForAddress(ADDR_0X17); Assert.assertNotNull(pc); - Assert.assertTrue(pc instanceof ValidateMultiSignPQ); + Assert.assertTrue(pc instanceof ValidateMultiFnDsa512); } @Test @@ -111,8 +111,8 @@ public void pureEcdsaThresholdReached_returnsOne() { @Test public void purePqThresholdReached_returnsOne() { - FNDSA pq1 = new FNDSA(); - FNDSA pq2 = new FNDSA(); + FNDSA512 pq1 = new FNDSA512(); + FNDSA512 pq2 = new FNDSA512(); ECKey owner = new ECKey(); byte[] addr1 = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pq1.getPublicKey()); byte[] addr2 = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pq2.getPublicKey()); @@ -137,7 +137,7 @@ public void purePqThresholdReached_returnsOne() { @Test public void mixedEcdsaAndPq_returnsOne() { ECKey k1 = new ECKey(); - FNDSA pq1 = new FNDSA(); + FNDSA512 pq1 = new FNDSA512(); ECKey owner = new ECKey(); byte[] pqAddr = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pq1.getPublicKey()); setupPermission(owner, Collections.singletonList(k1.getAddress()), Collections.singletonList(1), @@ -157,7 +157,7 @@ public void mixedEcdsaAndPq_returnsOne() { @Test public void pqSignatureForgery_returnsZero() { - FNDSA pq1 = new FNDSA(); + FNDSA512 pq1 = new FNDSA512(); ECKey owner = new ECKey(); byte[] pqAddr = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pq1.getPublicKey()); setupPermission(owner, Collections.emptyList(), Collections.emptyList(), @@ -178,7 +178,7 @@ public void pqSignatureForgery_returnsZero() { @Test public void wrongPqPublicKeyLength_returnsZero() { - FNDSA pq1 = new FNDSA(); + FNDSA512 pq1 = new FNDSA512(); ECKey owner = new ECKey(); byte[] pqAddr = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pq1.getPublicKey()); setupPermission(owner, Collections.emptyList(), Collections.emptyList(), @@ -198,8 +198,8 @@ public void wrongPqPublicKeyLength_returnsZero() { @Test public void mismatchedPqArrayLengths_returnsZero() { - FNDSA pq1 = new FNDSA(); - FNDSA pq2 = new FNDSA(); + FNDSA512 pq1 = new FNDSA512(); + FNDSA512 pq2 = new FNDSA512(); ECKey owner = new ECKey(); byte[] addr1 = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pq1.getPublicKey()); setupPermission(owner, Collections.emptyList(), Collections.emptyList(), @@ -247,7 +247,7 @@ public void totalCountOverMaxSize_returnsZero() { @Test public void duplicatePqSig_doesNotDoubleCount() { - FNDSA pq1 = new FNDSA(); + FNDSA512 pq1 = new FNDSA512(); ECKey owner = new ECKey(); byte[] pqAddr = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pq1.getPublicKey()); setupPermission(owner, Collections.emptyList(), Collections.emptyList(), @@ -268,7 +268,7 @@ public void duplicatePqSig_doesNotDoubleCount() { @Test public void energyChargesEcdsaAndPqSeparately() { - FNDSA pq1 = new FNDSA(); + FNDSA512 pq1 = new FNDSA512(); ECKey k1 = new ECKey(); ECKey owner = new ECKey(); byte[] pqAddr = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pq1.getPublicKey()); @@ -283,8 +283,8 @@ public void energyChargesEcdsaAndPqSeparately() { List pqPks = Collections.singletonList(Hex.toHexString(pq1.getPublicKey())); byte[] input = encodeInput(owner.getAddress(), 2, data, ecdsaSigs, pqSigs, pqPks); - // 1 ECDSA × 1500 + 1 PQ × 15000 = 16500 - Assert.assertEquals(16500L, contract.getEnergyForData(input)); + // 1 ECDSA × 1500 + 1 PQ × 2000 = 3500 + Assert.assertEquals(3500L, contract.getEnergyForData(input)); } @Test @@ -308,8 +308,8 @@ public void thresholdNotReached_returnsZero() { @Test public void pqKeyNotInPermission_returnsZero() { - FNDSA inPerm = new FNDSA(); - FNDSA outsider = new FNDSA(); + FNDSA512 inPerm = new FNDSA512(); + FNDSA512 outsider = new FNDSA512(); ECKey owner = new ECKey(); byte[] inAddr = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, inPerm.getPublicKey()); setupPermission(owner, Collections.emptyList(), Collections.emptyList(), @@ -330,7 +330,7 @@ public void pqKeyNotInPermission_returnsZero() { @Test public void pqSigTooLong_returnsZero() { - FNDSA pq1 = new FNDSA(); + FNDSA512 pq1 = new FNDSA512(); ECKey owner = new ECKey(); byte[] pqAddr = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pq1.getPublicKey()); setupPermission(owner, Collections.emptyList(), Collections.emptyList(), @@ -372,7 +372,7 @@ public void mixedFailingPqAborts_returnsZero() { // threshold. Verifies 0x17 does not silently skip a forged PQ signature. ECKey k1 = new ECKey(); ECKey k2 = new ECKey(); - FNDSA pq1 = new FNDSA(); + FNDSA512 pq1 = new FNDSA512(); ECKey owner = new ECKey(); byte[] pqAddr = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pq1.getPublicKey()); setupPermission(owner, diff --git a/framework/src/test/java/org/tron/common/utils/LocalWitnessesTest.java b/framework/src/test/java/org/tron/common/utils/LocalWitnessesTest.java index 22e99ef4571..b48995b89aa 100644 --- a/framework/src/test/java/org/tron/common/utils/LocalWitnessesTest.java +++ b/framework/src/test/java/org/tron/common/utils/LocalWitnessesTest.java @@ -9,7 +9,7 @@ import org.bouncycastle.util.encoders.Hex; import org.junit.BeforeClass; import org.junit.Test; -import org.tron.common.crypto.pqc.FNDSA; +import org.tron.common.crypto.pqc.FNDSA512; import org.tron.core.exception.TronError; import org.tron.protos.Protocol.PQScheme; @@ -25,8 +25,8 @@ public class LocalWitnessesTest { @BeforeClass public static void generateKeypairs() { - FNDSA k1 = new FNDSA(); - FNDSA k2 = new FNDSA(); + FNDSA512 k1 = new FNDSA512(); + FNDSA512 k2 = new FNDSA512(); priv = Hex.toHexString(k1.getPrivateKey()); pub = Hex.toHexString(k1.getPublicKey()); priv2 = Hex.toHexString(k2.getPrivateKey()); diff --git a/framework/src/test/java/org/tron/core/BandwidthProcessorTest.java b/framework/src/test/java/org/tron/core/BandwidthProcessorTest.java index f6fede77523..5467777e538 100755 --- a/framework/src/test/java/org/tron/core/BandwidthProcessorTest.java +++ b/framework/src/test/java/org/tron/core/BandwidthProcessorTest.java @@ -12,6 +12,7 @@ import org.junit.Test; import org.tron.common.BaseTest; import org.tron.common.TestConstants; +import org.tron.common.crypto.pqc.FNDSA512; import org.tron.common.runtime.RuntimeImpl; import org.tron.common.utils.ByteArray; import org.tron.core.capsule.AccountCapsule; @@ -905,8 +906,8 @@ public void pqPQAuthWitnessBytesSubtractedInCreateAccountCap() throws Exception .setAmount(100L) .build(); - byte[] fakeSig = new byte[752]; - byte[] fakePub = new byte[897]; + byte[] fakeSig = new byte[FNDSA512.SIGNATURE_LENGTH]; + byte[] fakePub = new byte[FNDSA512.PUBLIC_KEY_LENGTH]; Protocol.PQAuthSig pqAuthSig = Protocol.PQAuthSig.newBuilder() .setScheme(Protocol.PQScheme.FN_DSA_512) .setPublicKey(ByteString.copyFrom(fakePub)) @@ -967,8 +968,8 @@ public void pqPQAuthWitnessCountedInBandwidthUsage() throws Exception { .setAmount(100L) .build(); - byte[] fakeSig = new byte[752]; - byte[] fakePub = new byte[897]; + byte[] fakeSig = new byte[FNDSA512.SIGNATURE_LENGTH]; + byte[] fakePub = new byte[FNDSA512.PUBLIC_KEY_LENGTH]; Protocol.PQAuthSig pqAuthSig = Protocol.PQAuthSig.newBuilder() .setScheme(Protocol.PQScheme.FN_DSA_512) .setPublicKey(ByteString.copyFrom(fakePub)) diff --git a/framework/src/test/java/org/tron/core/actuator/utils/ProposalUtilTest.java b/framework/src/test/java/org/tron/core/actuator/utils/ProposalUtilTest.java index 1e7be605104..d26d3c51d18 100644 --- a/framework/src/test/java/org/tron/core/actuator/utils/ProposalUtilTest.java +++ b/framework/src/test/java/org/tron/core/actuator/utils/ProposalUtilTest.java @@ -808,6 +808,7 @@ public void validateAllowFnDsa512() { ThrowingRunnable proposeTwo = () -> ProposalUtil.validator(dynamicPropertiesStore, forkUtils, code, 2); + forkUtils.init(dbManager.getChainBaseManager()); byte[] stats = new byte[27]; forkUtils.getManager().getDynamicPropertiesStore() .statsByVersion(ForkBlockVersionEnum.VERSION_4_8_1.getValue(), stats); diff --git a/framework/src/test/java/org/tron/core/capsule/BlockCapsulePQTest.java b/framework/src/test/java/org/tron/core/capsule/BlockCapsulePQTest.java index f224652046e..6c8b482cd51 100644 --- a/framework/src/test/java/org/tron/core/capsule/BlockCapsulePQTest.java +++ b/framework/src/test/java/org/tron/core/capsule/BlockCapsulePQTest.java @@ -8,7 +8,7 @@ import org.tron.common.BaseTest; import org.tron.common.TestConstants; import org.tron.common.crypto.ECKey; -import org.tron.common.crypto.pqc.FNDSA; +import org.tron.common.crypto.pqc.FNDSA512; import org.tron.common.crypto.pqc.PQSchemeRegistry; import org.tron.common.utils.Sha256Hash; import org.tron.core.config.args.Args; @@ -25,7 +25,7 @@ public class BlockCapsulePQTest extends BaseTest { private ECKey witnessKey; private byte[] witnessAddress; - private FNDSA pqKeypair; + private FNDSA512 pqKeypair; private byte[] pqAddress; @BeforeClass @@ -37,7 +37,7 @@ public static void init() { public void setUp() { witnessKey = new ECKey(); witnessAddress = witnessKey.getAddress(); - pqKeypair = new FNDSA(); + pqKeypair = new FNDSA512(); pqAddress = PQSchemeRegistry.computeAddress( PQScheme.FN_DSA_512, pqKeypair.getPublicKey()); } @@ -89,7 +89,7 @@ private BlockCapsule buildUnsignedBlock(byte[] parentHash) { } private byte[] signPQ(byte[] message) { - return FNDSA.sign(pqKeypair.getPrivateKey(), message); + return FNDSA512.sign(pqKeypair.getPrivateKey(), message); } private PQAuthSig buildPQAuthSig(byte[] signature) { @@ -158,6 +158,28 @@ public void pqOnlyAccepted() throws Exception { dbManager.getDynamicPropertiesStore(), dbManager.getAccountStore())); } + @Test + public void pqAuthSigWithDefaultSchemeAcceptedAsFnDsa512() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowMultiSign(1L); + dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(1L); + AccountCapsule witness = buildWitnessAccount(pqAddress); + dbManager.getAccountStore().put(witnessAddress, witness); + + byte[] parentHash = new byte[32]; + BlockCapsule block = buildUnsignedBlock(parentHash); + byte[] digest = block.getRawHashBytes(); + // Omit setScheme(...) so the field stays at the proto3 default + // UNKNOWN_PQ_SCHEME; PQSchemeRegistry#resolve normalizes it to FN_DSA_512. + PQAuthSig defaultScheme = PQAuthSig.newBuilder() + .setPublicKey(ByteString.copyFrom(pqKeypair.getPublicKey())) + .setSignature(ByteString.copyFrom(signPQ(digest))) + .build(); + Assert.assertEquals(PQScheme.UNKNOWN_PQ_SCHEME, defaultScheme.getScheme()); + block.setPqAuthSig(defaultScheme); + Assert.assertTrue(block.validateSignature( + dbManager.getDynamicPropertiesStore(), dbManager.getAccountStore())); + } + @Test public void tamperedPQAuthSigFails() throws Exception { dbManager.getDynamicPropertiesStore().saveAllowMultiSign(1L); diff --git a/framework/src/test/java/org/tron/core/capsule/TransactionCapsuleTest.java b/framework/src/test/java/org/tron/core/capsule/TransactionCapsuleTest.java index 078e6153f39..7c1c7356383 100644 --- a/framework/src/test/java/org/tron/core/capsule/TransactionCapsuleTest.java +++ b/framework/src/test/java/org/tron/core/capsule/TransactionCapsuleTest.java @@ -22,7 +22,7 @@ import org.tron.common.BaseTest; import org.tron.common.TestConstants; import org.tron.common.crypto.ECKey; -import org.tron.common.crypto.pqc.FNDSA; +import org.tron.common.crypto.pqc.FNDSA512; import org.tron.common.crypto.pqc.PQSchemeRegistry; import org.tron.common.utils.ByteArray; import org.tron.common.utils.Sha256Hash; @@ -180,8 +180,8 @@ public void pqAuthSigBeforeActivationRejected() { Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0).toBuilder() .addPqAuthSig(PQAuthSig.newBuilder() .setScheme(PQScheme.FN_DSA_512) - .setPublicKey(ByteString.copyFrom(new byte[FNDSA.PUBLIC_KEY_LENGTH])) - .setSignature(ByteString.copyFrom(new byte[FNDSA.SIGNATURE_LENGTH])) + .setPublicKey(ByteString.copyFrom(new byte[FNDSA512.PUBLIC_KEY_LENGTH])) + .setSignature(ByteString.copyFrom(new byte[FNDSA512.SIGNATURE_LENGTH])) .build()) .build(); TransactionCapsule cap = new TransactionCapsule(tx); @@ -216,16 +216,21 @@ public void fastVerify() { } } + private static byte[] txId(Transaction tx) { + return Sha256Hash.of(Args.getInstance().isECKeyCryptoEngine(), + tx.getRawData().toByteArray()).getBytes(); + } + @Test public void validPQAuthSigAccepted() throws Exception { dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(1L); - FNDSA kp = new FNDSA(); + FNDSA512 kp = new FNDSA512(); putAccountWithPQPermission(PQ_OWNER_HEX, kp.getPublicKey(), PQScheme.FN_DSA_512); Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0); - byte[] txid = Sha256Hash.of(true, tx.getRawData().toByteArray()).getBytes(); - - byte[] sig = FNDSA.sign(kp.getPrivateKey(), txid); + byte[] txid = txId(tx); + + byte[] sig = FNDSA512.sign(kp.getPrivateKey(), txid); Transaction signed = tx.toBuilder() .addPqAuthSig(PQAuthSig.newBuilder() @@ -242,13 +247,13 @@ public void validPQAuthSigAccepted() throws Exception { @Test public void duplicateSignerRejected() throws Exception { dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(1L); - FNDSA kp = new FNDSA(); + FNDSA512 kp = new FNDSA512(); putAccountWithPQPermission(PQ_OWNER_HEX, kp.getPublicKey(), PQScheme.FN_DSA_512); Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0); - byte[] txid = Sha256Hash.of(true, tx.getRawData().toByteArray()).getBytes(); + byte[] txid = txId(tx); - byte[] sig = FNDSA.sign(kp.getPrivateKey(), txid); + byte[] sig = FNDSA512.sign(kp.getPrivateKey(), txid); PQAuthSig w = PQAuthSig.newBuilder() .setScheme(PQScheme.FN_DSA_512) .setPublicKey(ByteString.copyFrom(kp.getPublicKey())) @@ -269,13 +274,13 @@ public void duplicateSignerRejected() throws Exception { @Test public void tamperedPQAuthSigRejected() throws Exception { dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(1L); - FNDSA kp = new FNDSA(); + FNDSA512 kp = new FNDSA512(); putAccountWithPQPermission(PQ_OWNER_HEX, kp.getPublicKey(), PQScheme.FN_DSA_512); Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0); - byte[] txid = Sha256Hash.of(true, tx.getRawData().toByteArray()).getBytes(); + byte[] txid = txId(tx); - byte[] sig = FNDSA.sign(kp.getPrivateKey(), txid); + byte[] sig = FNDSA512.sign(kp.getPrivateKey(), txid); sig[0] ^= 0x01; Transaction signed = tx.toBuilder() @@ -298,15 +303,15 @@ public void tamperedPQAuthSigRejected() throws Exception { @Test public void signerNotInPermissionRejected() throws Exception { dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(1L); - FNDSA known = new FNDSA(); + FNDSA512 known = new FNDSA512(); putAccountWithPQPermission(PQ_OWNER_HEX, known.getPublicKey(), PQScheme.FN_DSA_512); // Sign with a *different* keypair → derived address is not in the permission. - FNDSA stranger = new FNDSA(); + FNDSA512 stranger = new FNDSA512(); Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0); - byte[] txid = Sha256Hash.of(true, tx.getRawData().toByteArray()).getBytes(); + byte[] txid = txId(tx); - byte[] sig = FNDSA.sign(stranger.getPrivateKey(), txid); + byte[] sig = FNDSA512.sign(stranger.getPrivateKey(), txid); Transaction signed = tx.toBuilder() .addPqAuthSig(PQAuthSig.newBuilder() @@ -373,9 +378,9 @@ private long[][] measureSizes(Transaction baseTx) { long ecPack = ecCap.computeTrxSizeForBlockMessage(); // FN-DSA-512: variable-length signature (<= 752 bytes) + 897-byte public key - FNDSA kpFn = new FNDSA(); - byte[] txidFn = Sha256Hash.of(true, baseTx.getRawData().toByteArray()).getBytes(); - byte[] sigFn = FNDSA.sign(kpFn.getPrivateKey(), txidFn); + FNDSA512 kpFn = new FNDSA512(); + byte[] txidFn = txId(baseTx); + byte[] sigFn = FNDSA512.sign(kpFn.getPrivateKey(), txidFn); Transaction txFn = baseTx.toBuilder() .addPqAuthSig(PQAuthSig.newBuilder() .setScheme(PQScheme.FN_DSA_512) @@ -420,15 +425,15 @@ public void transactionSizeComparisonByScheme() { @Test public void pqAuthSigWrongPublicKeyLengthRejected() throws Exception { dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(1L); - FNDSA kp = new FNDSA(); + FNDSA512 kp = new FNDSA512(); putAccountWithPQPermission(PQ_OWNER_HEX, kp.getPublicKey(), PQScheme.FN_DSA_512); Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0); - byte[] txid = Sha256Hash.of(true, tx.getRawData().toByteArray()).getBytes(); - byte[] sig = FNDSA.sign(kp.getPrivateKey(), txid); + byte[] txid = txId(tx); + byte[] sig = FNDSA512.sign(kp.getPrivateKey(), txid); // Truncate public key by one byte to force the length-mismatch branch. - byte[] shortPub = new byte[FNDSA.PUBLIC_KEY_LENGTH - 1]; + byte[] shortPub = new byte[FNDSA512.PUBLIC_KEY_LENGTH - 1]; System.arraycopy(kp.getPublicKey(), 0, shortPub, 0, shortPub.length); Transaction signed = tx.toBuilder() @@ -451,7 +456,7 @@ public void pqAuthSigWrongPublicKeyLengthRejected() throws Exception { @Test public void pqAuthSigWrongSignatureLengthRejected() throws Exception { dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(1L); - FNDSA kp = new FNDSA(); + FNDSA512 kp = new FNDSA512(); putAccountWithPQPermission(PQ_OWNER_HEX, kp.getPublicKey(), PQScheme.FN_DSA_512); Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0); @@ -477,12 +482,12 @@ public void pqAuthSigWrongSignatureLengthRejected() throws Exception { @Test public void pqAuthSigUnsupportedSchemeRejected() throws Exception { dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(1L); - FNDSA kp = new FNDSA(); + FNDSA512 kp = new FNDSA512(); putAccountWithPQPermission(PQ_OWNER_HEX, kp.getPublicKey(), PQScheme.FN_DSA_512); Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0); - byte[] txid = Sha256Hash.of(true, tx.getRawData().toByteArray()).getBytes(); - byte[] sig = FNDSA.sign(kp.getPrivateKey(), txid); + byte[] txid = txId(tx); + byte[] sig = FNDSA512.sign(kp.getPrivateKey(), txid); // setSchemeValue(99) sets an unknown numeric tag; reading back yields // PQScheme.UNRECOGNIZED, which PQSchemeRegistry.contains() rejects. @@ -519,8 +524,8 @@ public void validatePubSignatureRejectsMissingSig() { @Test public void validatePubSignatureRejectsMissingContract() throws Exception { dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(1L); - FNDSA kp = new FNDSA(); - byte[] sig = FNDSA.sign(kp.getPrivateKey(), new byte[32]); + FNDSA512 kp = new FNDSA512(); + byte[] sig = FNDSA512.sign(kp.getPrivateKey(), new byte[32]); // No contracts in raw_data, but a pq_auth_sig is attached so we get past // the "miss sig" guard and into the "miss contract" branch. @@ -548,14 +553,14 @@ public void validatePubSignatureRejectsTooManySignatures() throws Exception { int original = dbManager.getDynamicPropertiesStore().getTotalSignNum(); try { dbManager.getDynamicPropertiesStore().saveTotalSignNum(1); - FNDSA a = new FNDSA(); - FNDSA b = new FNDSA(); + FNDSA512 a = new FNDSA512(); + FNDSA512 b = new FNDSA512(); putAccountWithPQPermission(PQ_OWNER_HEX, a.getPublicKey(), PQScheme.FN_DSA_512); Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0); - byte[] txid = Sha256Hash.of(true, tx.getRawData().toByteArray()).getBytes(); - byte[] sigA = FNDSA.sign(a.getPrivateKey(), txid); - byte[] sigB = FNDSA.sign(b.getPrivateKey(), txid); + byte[] txid = txId(tx); + byte[] sigA = FNDSA512.sign(a.getPrivateKey(), txid); + byte[] sigB = FNDSA512.sign(b.getPrivateKey(), txid); Transaction signed = tx.toBuilder() .addPqAuthSig(PQAuthSig.newBuilder() @@ -585,13 +590,13 @@ public void validatePubSignatureRejectsTooManySignatures() throws Exception { @Test public void fnDsaPQAuthSigRejectedWhenNotActivated() throws Exception { dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(0L); - FNDSA kp = new FNDSA(); + FNDSA512 kp = new FNDSA512(); putAccountWithPQPermission(PQ_OWNER_HEX, kp.getPublicKey(), PQScheme.FN_DSA_512); Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0); - byte[] txid = Sha256Hash.of(true, tx.getRawData().toByteArray()).getBytes(); + byte[] txid = txId(tx); - byte[] sig = FNDSA.sign(kp.getPrivateKey(), txid); + byte[] sig = FNDSA512.sign(kp.getPrivateKey(), txid); Transaction signed = tx.toBuilder() .addPqAuthSig(PQAuthSig.newBuilder() diff --git a/protocol/src/main/protos/core/Tron.proto b/protocol/src/main/protos/core/Tron.proto index d9ca33f7f53..208f4337314 100644 --- a/protocol/src/main/protos/core/Tron.proto +++ b/protocol/src/main/protos/core/Tron.proto @@ -258,7 +258,7 @@ message Key { // Per-signer post-quantum authentication witness for a transaction or block. // The signing public key is carried in-band; node verifies binding via -// derived_addr = 0x41 ‖ deriveHash(scheme, public_key)[0:20] +// derived_addr = 0x41 ‖ deriveHash(scheme, public_key)[12..32] // and matches against Permission.keys[].address. message PQAuthSig { PQScheme scheme = 1; From f8a0277cb4755b08dfbf99df854044df191eda4a Mon Sep 17 00:00:00 2001 From: federico Date: Thu, 14 May 2026 09:47:50 +0700 Subject: [PATCH 04/47] feat(net): support PQ signatures in HelloMessage --- .../org/tron/core/config/args/ConfigKey.java | 4 +- .../tron/core/consensus/ConsensusService.java | 4 +- .../core/net/service/relay/RelayService.java | 142 ++++++++++++++---- framework/src/main/resources/config.conf | 28 ++-- .../crypto/pqc/program/PQWitnessNode.java | 12 +- .../tron/core/exception/TronErrorTest.java | 2 +- .../core/net/services/RelayServiceTest.java | 116 ++++++++++++++ framework/src/test/resources/config-test.conf | 8 +- protocol/src/main/protos/core/Tron.proto | 7 + 9 files changed, 264 insertions(+), 59 deletions(-) diff --git a/framework/src/main/java/org/tron/core/config/args/ConfigKey.java b/framework/src/main/java/org/tron/core/config/args/ConfigKey.java index 71fb1c907a6..687ba2c8b14 100644 --- a/framework/src/main/java/org/tron/core/config/args/ConfigKey.java +++ b/framework/src/main/java/org/tron/core/config/args/ConfigKey.java @@ -4,9 +4,9 @@ public final class ConfigKey { public static final String COMMITTEE_ALLOW_FN_DSA_512 = "committee.allowFnDsa512"; - public static final String LOCAL_WITNESS_PQ_KEYS = "localwitness_pq_keys"; + public static final String LOCAL_WITNESS_PQ_KEYS = "localwitness_pq.keys"; - public static final String LOCAL_WITNESS_PQ_SCHEME = "localwitness_pq_scheme"; + public static final String LOCAL_WITNESS_PQ_SCHEME = "localwitness_pq.scheme"; private ConfigKey() { } diff --git a/framework/src/main/java/org/tron/core/consensus/ConsensusService.java b/framework/src/main/java/org/tron/core/consensus/ConsensusService.java index 23cbe28e3e3..08ad9cd8a75 100644 --- a/framework/src/main/java/org/tron/core/consensus/ConsensusService.java +++ b/framework/src/main/java/org/tron/core/consensus/ConsensusService.java @@ -54,13 +54,13 @@ public void start() { List pqPublicKeys = Args.getLocalWitnesses().getPqPublicKeys(); if (pqPublicKeys.size() != pqPrivateKeys.size()) { throw new TronError( - "localwitness_pq_keys size mismatch: " + pqPrivateKeys.size() + "localwitness_pq.keys size mismatch: " + pqPrivateKeys.size() + " private vs " + pqPublicKeys.size() + " public", TronError.ErrCode.WITNESS_INIT); } if (!privateKeys.isEmpty() && !pqPrivateKeys.isEmpty()) { throw new TronError( - "legacy localwitness keys and localwitness_pq_keys are mutually exclusive", + "legacy localwitness keys and localwitness_pq.keys are mutually exclusive", TronError.ErrCode.WITNESS_INIT); } if (privateKeys.size() > 1) { diff --git a/framework/src/main/java/org/tron/core/net/service/relay/RelayService.java b/framework/src/main/java/org/tron/core/net/service/relay/RelayService.java index 61ae6326e9f..55ac1063f5f 100644 --- a/framework/src/main/java/org/tron/core/net/service/relay/RelayService.java +++ b/framework/src/main/java/org/tron/core/net/service/relay/RelayService.java @@ -17,10 +17,12 @@ import org.tron.common.backup.BackupManager.BackupStatusEnum; import org.tron.common.crypto.SignInterface; import org.tron.common.crypto.SignUtils; +import org.tron.common.crypto.pqc.PQSchemeRegistry; import org.tron.common.es.ExecutorServiceManager; import org.tron.common.log.layout.DesensitizedConverter; import org.tron.common.parameter.CommonParameter; import org.tron.common.utils.ByteArray; +import org.tron.common.utils.LocalWitnesses; import org.tron.common.utils.Sha256Hash; import org.tron.core.ChainBaseManager; import org.tron.core.capsule.TransactionCapsule; @@ -35,6 +37,8 @@ import org.tron.core.store.WitnessScheduleStore; import org.tron.p2p.connection.Channel; import org.tron.protos.Protocol; +import org.tron.protos.Protocol.PQAuthSig; +import org.tron.protos.Protocol.PQScheme; import org.tron.protos.Protocol.ReasonCode; @Slf4j(topic = "net") @@ -68,6 +72,8 @@ public class RelayService { private final int keySize = Args.getLocalWitnesses().getPrivateKeys().size(); + private final int pqKeySize = Args.getLocalWitnesses().getPqPrivateKeys().size(); + private final ByteString witnessAddress = Args.getLocalWitnesses().getWitnessAccountAddress() != null ? ByteString .copyFrom(Args.getLocalWitnesses().getWitnessAccountAddress()) : null; @@ -79,10 +85,12 @@ public void init() { witnessScheduleStore = ctx.getBean(WitnessScheduleStore.class); backupManager = ctx.getBean(BackupManager.class); - logger.info("Fast forward config, isWitness: {}, keySize: {}, fastForwardNodes: {}", - parameter.isWitness(), keySize, fastForwardNodes.size()); + logger.info( + "Fast forward config, isWitness: {}, keySize: {}, pqKeySize: {}, fastForwardNodes: {}", + parameter.isWitness(), keySize, pqKeySize, fastForwardNodes.size()); - if (!parameter.isWitness() || keySize == 0 || fastForwardNodes.isEmpty()) { + if (!parameter.isWitness() || (keySize == 0 && pqKeySize == 0) + || fastForwardNodes.isEmpty()) { return; } @@ -105,22 +113,39 @@ public void close() { } public void fillHelloMessage(HelloMessage message, Channel channel) { - if (isActiveWitness()) { - fastForwardNodes.forEach(address -> { - if (address.getAddress().equals(channel.getInetAddress())) { - SignInterface cryptoEngine = SignUtils - .fromPrivate(ByteArray.fromHexString(Args.getLocalWitnesses().getPrivateKey()), - Args.getInstance().isECKeyCryptoEngine()); - - ByteString sig = ByteString.copyFrom(cryptoEngine.Base64toBytes(cryptoEngine - .signHash(Sha256Hash.of(CommonParameter.getInstance() - .isECKeyCryptoEngine(), ByteArray.fromLong(message - .getTimestamp())).getBytes()))); - message.setHelloMessage(message.getHelloMessage().toBuilder() - .setAddress(witnessAddress).setSignature(sig).build()); - } - }); + if (!isActiveWitness()) { + return; } + fastForwardNodes.forEach(address -> { + if (!address.getAddress().equals(channel.getInetAddress())) { + return; + } + byte[] digest = Sha256Hash.of(CommonParameter.getInstance() + .isECKeyCryptoEngine(), ByteArray.fromLong(message.getTimestamp())) + .getBytes(); + Protocol.HelloMessage.Builder builder = message.getHelloMessage().toBuilder() + .setAddress(witnessAddress); + if (keySize > 0) { + SignInterface cryptoEngine = SignUtils.fromPrivate( + ByteArray.fromHexString(Args.getLocalWitnesses().getPrivateKey()), + Args.getInstance().isECKeyCryptoEngine()); + ByteString sig = ByteString.copyFrom( + cryptoEngine.Base64toBytes(cryptoEngine.signHash(digest))); + builder.setSignature(sig).clearPqAuthSig(); + } else { + LocalWitnesses lw = Args.getLocalWitnesses(); + PQScheme scheme = lw.getPqScheme(); + byte[] privKey = ByteArray.fromHexString(lw.getPqPrivateKeys().get(0)); + byte[] pubKey = ByteArray.fromHexString(lw.getPqPublicKeys().get(0)); + byte[] sig = PQSchemeRegistry.sign(scheme, privKey, digest); + builder.setPqAuthSig(PQAuthSig.newBuilder() + .setScheme(scheme) + .setPublicKey(ByteString.copyFrom(pubKey)) + .setSignature(ByteString.copyFrom(sig))) + .clearSignature(); + } + message.setHelloMessage(builder.build()); + }); } public boolean checkHelloMessage(HelloMessage message, Channel channel) { @@ -150,20 +175,22 @@ public boolean checkHelloMessage(HelloMessage message, Channel channel) { return false; } + boolean hasLegacy = !msg.getSignature().isEmpty(); + boolean hasPq = msg.hasPqAuthSig(); + if (hasLegacy == hasPq) { + logger.warn("HelloMessage from {}, signature/pq_auth_sig must be set exclusively.", + channel.getInetAddress()); + return false; + } + boolean flag; try { - Sha256Hash hash = Sha256Hash.of(CommonParameter - .getInstance().isECKeyCryptoEngine(), ByteArray.fromLong(msg.getTimestamp())); - String sig = - TransactionCapsule.getBase64FromByteString(msg.getSignature()); - byte[] sigAddress = SignUtils.signatureToAddress(hash.getBytes(), sig, - Args.getInstance().isECKeyCryptoEngine()); - if (manager.getDynamicPropertiesStore().getAllowMultiSign() != 1) { - flag = Arrays.equals(sigAddress, msg.getAddress().toByteArray()); + byte[] digest = Sha256Hash.of(CommonParameter.getInstance().isECKeyCryptoEngine(), + ByteArray.fromLong(msg.getTimestamp())).getBytes(); + if (hasPq) { + flag = verifyPqAuthSig(digest, msg.getPqAuthSig(), msg.getAddress(), channel); } else { - byte[] witnessPermissionAddress = manager.getAccountStore() - .get(msg.getAddress().toByteArray()).getWitnessPermissionAddress(); - flag = Arrays.equals(sigAddress, witnessPermissionAddress); + flag = verifyLegacySignature(digest, msg.getSignature(), msg.getAddress()); } if (flag) { TronNetService.getP2pConfig().getTrustNodes().add(channel.getInetAddress()); @@ -177,6 +204,61 @@ public boolean checkHelloMessage(HelloMessage message, Channel channel) { } } + private boolean verifyLegacySignature(byte[] digest, ByteString signature, + ByteString witnessAddr) throws java.security.SignatureException { + String sig = TransactionCapsule.getBase64FromByteString(signature); + byte[] sigAddress = SignUtils.signatureToAddress(digest, sig, + Args.getInstance().isECKeyCryptoEngine()); + if (manager.getDynamicPropertiesStore().getAllowMultiSign() != 1) { + return Arrays.equals(sigAddress, witnessAddr.toByteArray()); + } + byte[] witnessPermissionAddress = manager.getAccountStore() + .get(witnessAddr.toByteArray()).getWitnessPermissionAddress(); + return Arrays.equals(sigAddress, witnessPermissionAddress); + } + + private boolean verifyPqAuthSig(byte[] digest, PQAuthSig pqAuthSig, + ByteString witnessAddr, Channel channel) { + PQScheme scheme = pqAuthSig.getScheme(); + if (!PQSchemeRegistry.contains(scheme)) { + logger.warn("HelloMessage from {}, pq_auth_sig scheme {} is not registered.", + channel.getInetAddress(), scheme); + return false; + } + if (!manager.getDynamicPropertiesStore().isPqSchemeAllowed(scheme)) { + logger.warn("HelloMessage from {}, pq_auth_sig scheme {} is not activated on chain.", + channel.getInetAddress(), scheme); + return false; + } + byte[] publicKey = pqAuthSig.getPublicKey().toByteArray(); + if (publicKey.length != PQSchemeRegistry.getPublicKeyLength(scheme)) { + logger.warn("HelloMessage from {}, pq_auth_sig public key length mismatch for {}.", + channel.getInetAddress(), scheme); + return false; + } + byte[] signature = pqAuthSig.getSignature().toByteArray(); + if (!PQSchemeRegistry.isValidSignatureLength(scheme, signature.length)) { + logger.warn("HelloMessage from {}, pq_auth_sig signature length mismatch for {}.", + channel.getInetAddress(), scheme); + return false; + } + + byte[] derivedAddr = PQSchemeRegistry.computeAddress(scheme, publicKey); + byte[] expected; + if (manager.getDynamicPropertiesStore().getAllowMultiSign() != 1) { + expected = witnessAddr.toByteArray(); + } else { + expected = manager.getAccountStore().get(witnessAddr.toByteArray()) + .getWitnessPermissionAddress(); + } + if (!Arrays.equals(derivedAddr, expected)) { + logger.warn("HelloMessage from {}, pq_auth_sig public key does not bind witness {}.", + channel.getInetAddress(), ByteArray.toHexString(witnessAddr.toByteArray())); + return false; + } + return PQSchemeRegistry.verify(scheme, publicKey, digest, signature); + } + private long getPeerCountByAddress(ByteString address) { return tronNetDelegate.getActivePeer().stream() .filter(peer -> peer.getAddress() != null && peer.getAddress().equals(address)) @@ -185,7 +267,7 @@ private long getPeerCountByAddress(ByteString address) { private boolean isActiveWitness() { return parameter.isWitness() - && keySize > 0 + && (keySize > 0 || pqKeySize > 0) && fastForwardNodes.size() > 0 && witnessScheduleStore.getActiveWitnesses().contains(witnessAddress) && backupManager.getStatus().equals(BackupStatusEnum.MASTER); diff --git a/framework/src/main/resources/config.conf b/framework/src/main/resources/config.conf index a0ed0877b74..8e0c77f8309 100644 --- a/framework/src/main/resources/config.conf +++ b/framework/src/main/resources/config.conf @@ -702,22 +702,18 @@ localwitness = [ # "localwitnesskeystore.json" # ] -# Scheme used by localwitness_pq_keys. Defaults to FN_DSA_512. -# V2 first launch only allows FN_DSA_512 (Falcon-512, FIPS 206 draft). -# localwitness_pq_scheme = "FN_DSA_512" - -# Post-quantum witness signing keypairs, hex-encoded. Each entry is the -# extended private key f‖g‖F‖h (priv ‖ pub) as one hex string. For FN_DSA_512 -# the total is 2176 bytes (4352 hex chars): 1280 B Falcon-512 private key -# (f‖g‖F) followed by the 896 B public key h. Operators MUST generate the -# keypair off-line on a single platform and distribute the extended key; -# on-node keygen is intentionally bypassed because BouncyCastle's Falcon -# FFT/FPR code paths are not declared strictfp and could in theory diverge -# across JVMs/architectures. Used only after the ALLOW_FN_DSA_512 proposal -# is active and the witness Permission has been upgraded to FN_DSA_512. -# localwitness_pq_keys = [ -# "<4352 hex chars>" -# ] +# Post-quantum witness signing. `scheme` selects the PQ algorithm; only +# FN_DSA_512 is currently supported. Each `keys` entry is a hex-encoded +# extended private key (priv‖pub), 4352 hex chars for FN_DSA_512. Keypairs +# must be generated off-line — on-node keygen is intentionally bypassed. +# Effective only after the ALLOW_FN_DSA_512 proposal is active and the +# witness Permission has been upgraded to FN_DSA_512. +# localwitness_pq = { +# scheme = "FN_DSA_512" +# keys = [ +# "<4352 hex chars>" +# ] +# } block = { needSyncCheck = true diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/program/PQWitnessNode.java b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQWitnessNode.java index 6d4e688445e..ae538ce95e7 100644 --- a/framework/src/test/java/org/tron/common/crypto/pqc/program/PQWitnessNode.java +++ b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQWitnessNode.java @@ -94,7 +94,7 @@ public static void main(String[] args) throws Exception { dbDir.deleteOnExit(); // Inject the witness keypair via a temp HOCON config that includes - // config-test.conf and overrides localwitness_pq_keys with the extended + // config-test.conf and overrides localwitness_pq.keys with the extended // priv‖pub hex derived from WITNESS_SEED (matches what PQClient derives). Path conf = writeWitnessConfig(witnessKp); @@ -194,10 +194,12 @@ private static Path writeWitnessConfig(FNDSA512 witnessKp) throws java.io.IOExce Path conf = Files.createTempFile("pqc-witness-", ".conf"); conf.toFile().deleteOnExit(); String body = "include classpath(\"config-test.conf\")\n" - + "localwitness_pq_scheme = \"FN_DSA_512\"\n" - + "localwitness_pq_keys = [\n" - + " \"" + Hex.toHexString(witnessKp.getPrivateKeyWithPublicKey()) + "\"\n" - + "]\n"; + + "localwitness_pq = {\n" + + " scheme = \"FN_DSA_512\"\n" + + " keys = [\n" + + " \"" + Hex.toHexString(witnessKp.getPrivateKeyWithPublicKey()) + "\"\n" + + " ]\n" + + "}\n"; Files.write(conf, body.getBytes(StandardCharsets.UTF_8)); return conf; } diff --git a/framework/src/test/java/org/tron/core/exception/TronErrorTest.java b/framework/src/test/java/org/tron/core/exception/TronErrorTest.java index 11538bd967e..14e4cdb4d7a 100644 --- a/framework/src/test/java/org/tron/core/exception/TronErrorTest.java +++ b/framework/src/test/java/org/tron/core/exception/TronErrorTest.java @@ -123,7 +123,7 @@ public void witnessInitTest() throws IOException { Path conf = temporaryFolder.newFile("no-witness.conf").toPath(); String content = "include classpath(\"" + TestConstants.TEST_CONF + "\")\n" + "localwitness = []\n" - + "localwitness_pq_keys = []\n"; + + "localwitness_pq.keys = []\n"; Files.write(conf, content.getBytes()); TronError thrown = assertThrows(TronError.class, () -> { Args.setParam(new String[]{"--witness"}, conf.toString()); diff --git a/framework/src/test/java/org/tron/core/net/services/RelayServiceTest.java b/framework/src/test/java/org/tron/core/net/services/RelayServiceTest.java index 8585244b941..35ff252a887 100644 --- a/framework/src/test/java/org/tron/core/net/services/RelayServiceTest.java +++ b/framework/src/test/java/org/tron/core/net/services/RelayServiceTest.java @@ -26,6 +26,8 @@ import org.tron.common.TestConstants; import org.tron.common.crypto.SignInterface; import org.tron.common.crypto.SignUtils; +import org.tron.common.crypto.pqc.FNDSA512; +import org.tron.common.crypto.pqc.PQSchemeRegistry; import org.tron.common.parameter.CommonParameter; import org.tron.common.utils.ByteArray; import org.tron.common.utils.ReflectUtils; @@ -48,6 +50,8 @@ import org.tron.p2p.discover.Node; import org.tron.p2p.utils.NetUtil; import org.tron.protos.Protocol; +import org.tron.protos.Protocol.PQAuthSig; +import org.tron.protos.Protocol.PQScheme; @Slf4j(topic = "net") public class RelayServiceTest extends BaseTest { @@ -226,6 +230,118 @@ private void testCheckHelloMessage() { } } + @Test + public void testPqHelloMessage() throws Exception { + FNDSA512 pqKeypair = new FNDSA512(); + byte[] pqAddress = PQSchemeRegistry.computeAddress( + PQScheme.FN_DSA_512, pqKeypair.getPublicKey()); + ByteString pqAddressBs = ByteString.copyFrom(pqAddress); + + // Snapshot prior active-witness list (if any) so other tests are not perturbed. + List previousActive; + try { + previousActive = new ArrayList<>( + chainBaseManager.getWitnessScheduleStore().getActiveWitnesses()); + } catch (Exception ignored) { + previousActive = null; + } + List active = previousActive == null + ? new ArrayList<>() : new ArrayList<>(previousActive); + if (!active.contains(pqAddressBs)) { + active.add(pqAddressBs); + } + chainBaseManager.getWitnessScheduleStore().saveActiveWitnesses(active); + + // Activate FN-DSA-512 on chain so verifyPqAuthSig accepts the scheme. + long previousAllowFnDsa = chainBaseManager.getDynamicPropertiesStore().getAllowFnDsa512(); + chainBaseManager.getDynamicPropertiesStore().saveAllowFnDsa512(1L); + + Args.getInstance().fastForward = true; + + InetSocketAddress addr = new InetSocketAddress("127.0.0.1", 10001); + Node node = new Node(NetUtil.getNodeId(), addr.getAddress().getHostAddress(), + null, addr.getPort()); + HelloMessage helloMessage = new HelloMessage(node, System.currentTimeMillis(), + ChainBaseManager.getChainBaseManager()); + byte[] digest = Sha256Hash.of(CommonParameter.getInstance().isECKeyCryptoEngine(), + ByteArray.fromLong(helloMessage.getTimestamp())).getBytes(); + byte[] pqSig = FNDSA512.sign(pqKeypair.getPrivateKey(), digest); + PQAuthSig pqAuthSig = PQAuthSig.newBuilder() + .setScheme(PQScheme.FN_DSA_512) + .setPublicKey(ByteString.copyFrom(pqKeypair.getPublicKey())) + .setSignature(ByteString.copyFrom(pqSig)) + .build(); + + Protocol.HelloMessage base = helloMessage.getHelloMessage().toBuilder() + .setAddress(pqAddressBs) + .clearSignature() + .setPqAuthSig(pqAuthSig) + .build(); + helloMessage.setHelloMessage(base); + + Channel channel = mock(Channel.class); + Mockito.when(channel.getInetSocketAddress()).thenReturn(addr); + Mockito.when(channel.getInetAddress()).thenReturn(addr.getAddress()); + PeerManager.add((ApplicationContext) ReflectUtils.getFieldObject(p2pEventHandler, "ctx"), + channel).setAddress(pqAddressBs); + + ReflectUtils.setFieldValue(tronNetService, "p2pConfig", new P2pConfig()); + Field scheduleField = service.getClass().getDeclaredField("witnessScheduleStore"); + scheduleField.setAccessible(true); + scheduleField.set(service, chainBaseManager.getWitnessScheduleStore()); + Field managerField = service.getClass().getDeclaredField("manager"); + managerField.setAccessible(true); + managerField.set(service, dbManager); + + try { + // Happy path: valid PQ-only signature. + Assert.assertTrue(service.checkHelloMessage(helloMessage, channel)); + + // Both legacy signature and pq_auth_sig set → mutex rejects. + helloMessage.setHelloMessage(base.toBuilder() + .setSignature(ByteString.copyFrom(new byte[]{0x01})) + .build()); + Assert.assertFalse(service.checkHelloMessage(helloMessage, channel)); + + // Neither legacy signature nor pq_auth_sig set → mutex rejects. + helloMessage.setHelloMessage(base.toBuilder() + .clearSignature() + .clearPqAuthSig() + .build()); + Assert.assertFalse(service.checkHelloMessage(helloMessage, channel)); + + // PQ public key length mismatch → reject. + helloMessage.setHelloMessage(base.toBuilder() + .setPqAuthSig(pqAuthSig.toBuilder() + .setPublicKey(ByteString.copyFrom(new byte[]{0x00}))) + .build()); + Assert.assertFalse(service.checkHelloMessage(helloMessage, channel)); + + // Derived PQ address does not match the claimed witness address → reject. + FNDSA512 strayKeypair = new FNDSA512(); + byte[] strayDigest = Sha256Hash.of(CommonParameter.getInstance().isECKeyCryptoEngine(), + ByteArray.fromLong(helloMessage.getTimestamp())).getBytes(); + byte[] straySig = FNDSA512.sign(strayKeypair.getPrivateKey(), strayDigest); + helloMessage.setHelloMessage(base.toBuilder() + .setPqAuthSig(PQAuthSig.newBuilder() + .setScheme(PQScheme.FN_DSA_512) + .setPublicKey(ByteString.copyFrom(strayKeypair.getPublicKey())) + .setSignature(ByteString.copyFrom(straySig))) + .build()); + Assert.assertFalse(service.checkHelloMessage(helloMessage, channel)); + + // Scheme not activated on chain → reject. + helloMessage.setHelloMessage(base); + chainBaseManager.getDynamicPropertiesStore().saveAllowFnDsa512(0L); + Assert.assertFalse(service.checkHelloMessage(helloMessage, channel)); + } finally { + chainBaseManager.getDynamicPropertiesStore().saveAllowFnDsa512(previousAllowFnDsa); + if (previousActive != null) { + chainBaseManager.getWitnessScheduleStore().saveActiveWitnesses(previousActive); + } + } + } + @Test public void testNullWitnessAddress() { try { diff --git a/framework/src/test/resources/config-test.conf b/framework/src/test/resources/config-test.conf index 99afce48263..ce3652af4a3 100644 --- a/framework/src/test/resources/config-test.conf +++ b/framework/src/test/resources/config-test.conf @@ -354,9 +354,11 @@ genesis.block = { localwitness = [ ] -localwitness_pq_scheme = "FN_DSA_512" -localwitness_pq_keys = [ -] +localwitness_pq = { + scheme = "FN_DSA_512" + keys = [ + ] +} block = { needSyncCheck = true # first node : false, other : true diff --git a/protocol/src/main/protos/core/Tron.proto b/protocol/src/main/protos/core/Tron.proto index 208f4337314..1745a73a928 100644 --- a/protocol/src/main/protos/core/Tron.proto +++ b/protocol/src/main/protos/core/Tron.proto @@ -657,10 +657,17 @@ message HelloMessage { BlockId solidBlockId = 5; BlockId headBlockId = 6; bytes address = 7; + // Legacy ECDSA signature over Sha256Hash(timestamp). Mutually exclusive + // with pq_auth_sig — exactly one of the two must be set by an active + // witness when fast-forward is enabled. bytes signature = 8; int32 nodeType = 9; int64 lowestBlockNum = 10; bytes codeVersion = 11; + // Post-quantum auth signature over Sha256Hash(timestamp). Set instead of + // `signature` when the local witness is PQ-only. Verifier only accepts + // this field after ALLOW_FN_DSA_512 is activated on chain. + PQAuthSig pq_auth_sig = 12; } message InternalTransaction { From 7c9715f9d7b9d39ea5facc350cb7bf47e93fde26 Mon Sep 17 00:00:00 2001 From: federico Date: Thu, 14 May 2026 10:04:08 +0700 Subject: [PATCH 05/47] feat(consensus): support PQ signatures in PBFT messages --- .../pbft/message/PbftBaseMessage.java | 37 ++- .../consensus/pbft/message/PbftMessage.java | 34 ++- .../net/messagehandler/PbftMsgHandler.java | 15 ++ .../org/tron/core/pbft/PbftPQMessageTest.java | 233 ++++++++++++++++++ protocol/src/main/protos/core/Tron.proto | 6 + 5 files changed, 316 insertions(+), 9 deletions(-) create mode 100644 framework/src/test/java/org/tron/core/pbft/PbftPQMessageTest.java diff --git a/consensus/src/main/java/org/tron/consensus/pbft/message/PbftBaseMessage.java b/consensus/src/main/java/org/tron/consensus/pbft/message/PbftBaseMessage.java index 4eb61f3e22e..82768f0ef33 100644 --- a/consensus/src/main/java/org/tron/consensus/pbft/message/PbftBaseMessage.java +++ b/consensus/src/main/java/org/tron/consensus/pbft/message/PbftBaseMessage.java @@ -6,6 +6,7 @@ import java.util.stream.Collectors; import org.bouncycastle.util.encoders.Hex; import org.tron.common.crypto.ECKey; +import org.tron.common.crypto.pqc.PQSchemeRegistry; import org.tron.common.overlay.message.Message; import org.tron.common.utils.ByteUtil; import org.tron.common.utils.Sha256Hash; @@ -14,6 +15,8 @@ import org.tron.core.exception.P2pException; import org.tron.protos.Protocol.PBFTMessage; import org.tron.protos.Protocol.PBFTMessage.DataType; +import org.tron.protos.Protocol.PQAuthSig; +import org.tron.protos.Protocol.PQScheme; import org.tron.protos.Protocol.SRL; public abstract class PbftBaseMessage extends Message { @@ -96,8 +99,38 @@ public DataType getDataType() { public void analyzeSignature() throws SignatureException { byte[] hash = Sha256Hash.hash(true, getPbftMessage().getRawData().toByteArray()); - publicKey = ECKey.signatureToAddress(hash, TransactionCapsule - .getBase64FromByteString(getPbftMessage().getSignature())); + boolean hasLegacy = !getPbftMessage().getSignature().isEmpty(); + boolean hasPq = getPbftMessage().hasPqAuthSig(); + if (hasLegacy == hasPq) { + throw new SignatureException( + "pbft message must set exactly one of signature / pq_auth_sig"); + } + if (hasPq) { + publicKey = verifyPqAuthSig(hash, getPbftMessage().getPqAuthSig()); + } else { + publicKey = ECKey.signatureToAddress(hash, TransactionCapsule + .getBase64FromByteString(getPbftMessage().getSignature())); + } + } + + private static byte[] verifyPqAuthSig(byte[] hash, PQAuthSig pqAuthSig) + throws SignatureException { + PQScheme scheme = pqAuthSig.getScheme(); + if (!PQSchemeRegistry.contains(scheme)) { + throw new SignatureException("pbft pq_auth_sig scheme not registered: " + scheme); + } + byte[] publicKey = pqAuthSig.getPublicKey().toByteArray(); + if (publicKey.length != PQSchemeRegistry.getPublicKeyLength(scheme)) { + throw new SignatureException("pbft pq_auth_sig public key length mismatch for " + scheme); + } + byte[] signature = pqAuthSig.getSignature().toByteArray(); + if (!PQSchemeRegistry.isValidSignatureLength(scheme, signature.length)) { + throw new SignatureException("pbft pq_auth_sig signature length mismatch for " + scheme); + } + if (!PQSchemeRegistry.verify(scheme, publicKey, hash, signature)) { + throw new SignatureException("pbft pq_auth_sig verification failed for " + scheme); + } + return PQSchemeRegistry.computeAddress(scheme, publicKey); } @Override diff --git a/consensus/src/main/java/org/tron/consensus/pbft/message/PbftMessage.java b/consensus/src/main/java/org/tron/consensus/pbft/message/PbftMessage.java index b6de49ee878..170c14b80eb 100644 --- a/consensus/src/main/java/org/tron/consensus/pbft/message/PbftMessage.java +++ b/consensus/src/main/java/org/tron/consensus/pbft/message/PbftMessage.java @@ -4,15 +4,19 @@ import java.util.List; import org.tron.common.crypto.ECKey; import org.tron.common.crypto.ECKey.ECDSASignature; +import org.tron.common.crypto.pqc.PQSchemeRegistry; import org.tron.common.utils.Sha256Hash; import org.tron.consensus.base.Param; import org.tron.consensus.base.Param.Miner; +import org.tron.consensus.base.Param.MinerType; import org.tron.core.capsule.BlockCapsule; import org.tron.core.net.message.MessageTypes; import org.tron.protos.Protocol.PBFTMessage; import org.tron.protos.Protocol.PBFTMessage.DataType; import org.tron.protos.Protocol.PBFTMessage.MsgType; import org.tron.protos.Protocol.PBFTMessage.Raw; +import org.tron.protos.Protocol.PQAuthSig; +import org.tron.protos.Protocol.PQScheme; import org.tron.protos.Protocol.SRL; public class PbftMessage extends PbftBaseMessage { @@ -56,15 +60,13 @@ public static PbftMessage fullNodePrePrepareSRLMsg(BlockCapsule block, private static PbftMessage buildCommon(DataType dataType, ByteString data, BlockCapsule block, long epoch, long viewN, Miner miner) { PbftMessage pbftMessage = new PbftMessage(); - ECKey ecKey = ECKey.fromPrivate(miner.getPrivateKey()); Raw.Builder rawBuilder = Raw.newBuilder(); PBFTMessage.Builder builder = PBFTMessage.newBuilder(); rawBuilder.setViewN(viewN).setEpoch(epoch).setDataType(dataType) .setMsgType(MsgType.PREPREPARE).setData(data); Raw raw = rawBuilder.build(); byte[] hash = Sha256Hash.hash(true, raw.toByteArray()); - ECDSASignature signature = ecKey.sign(hash); - builder.setRawData(raw).setSignature(ByteString.copyFrom(signature.toByteArray())); + signRaw(builder, raw, hash, miner); PBFTMessage message = builder.build(); pbftMessage.setType(MessageTypes.PBFT_MSG.asByte()) .setPbftMessage(message).setData(message.toByteArray()).setSwitch(block.isSwitch()); @@ -96,7 +98,6 @@ public PbftMessage buildCommitMessage(Miner miner) { private PbftMessage buildMessageCapsule(MsgType type, Miner miner) { PbftMessage pbftMessage = new PbftMessage(); - ECKey ecKey = ECKey.fromPrivate(miner.getPrivateKey()); PBFTMessage.Builder builder = PBFTMessage.newBuilder(); Raw.Builder rawBuilder = Raw.newBuilder(); rawBuilder.setViewN(getPbftMessage().getRawData().getViewN()) @@ -105,11 +106,30 @@ private PbftMessage buildMessageCapsule(MsgType type, Miner miner) { .setData(getPbftMessage().getRawData().getData()); Raw raw = rawBuilder.build(); byte[] hash = Sha256Hash.hash(true, raw.toByteArray()); - ECDSASignature signature = ecKey.sign(hash); - builder.setRawData(raw).setSignature(ByteString.copyFrom(signature.toByteArray())); + signRaw(builder, raw, hash, miner); PBFTMessage message = builder.build(); pbftMessage.setType(getType().asByte()) .setPbftMessage(message).setData(message.toByteArray()); return pbftMessage; } -} \ No newline at end of file + + private static void signRaw(PBFTMessage.Builder builder, Raw raw, byte[] hash, Miner miner) { + builder.setRawData(raw); + if (miner.getType() == MinerType.PQ) { + PQScheme scheme = miner.getPqScheme(); + byte[] sk = miner.getPQPrivateKey(); + byte[] pk = miner.getPQPublicKey(); + byte[] sig = PQSchemeRegistry.sign(scheme, sk, hash); + builder.setPqAuthSig(PQAuthSig.newBuilder() + .setScheme(scheme) + .setPublicKey(ByteString.copyFrom(pk)) + .setSignature(ByteString.copyFrom(sig))) + .clearSignature(); + } else { + ECKey ecKey = ECKey.fromPrivate(miner.getPrivateKey()); + ECDSASignature signature = ecKey.sign(hash); + builder.setSignature(ByteString.copyFrom(signature.toByteArray())) + .clearPqAuthSig(); + } + } +} diff --git a/framework/src/main/java/org/tron/core/net/messagehandler/PbftMsgHandler.java b/framework/src/main/java/org/tron/core/net/messagehandler/PbftMsgHandler.java index d086cc28b6c..ec0648c4d2e 100644 --- a/framework/src/main/java/org/tron/core/net/messagehandler/PbftMsgHandler.java +++ b/framework/src/main/java/org/tron/core/net/messagehandler/PbftMsgHandler.java @@ -5,19 +5,23 @@ import com.google.common.util.concurrent.Striped; import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.Lock; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.tron.consensus.base.Param; import org.tron.consensus.pbft.PbftManager; import org.tron.consensus.pbft.message.PbftBaseMessage; import org.tron.consensus.pbft.message.PbftMessage; +import org.tron.core.ChainBaseManager; import org.tron.core.config.args.Args; import org.tron.core.exception.P2pException; import org.tron.core.net.TronNetDelegate; import org.tron.core.net.TronNetService; import org.tron.core.net.peer.PeerConnection; import org.tron.protos.Protocol.PBFTMessage.DataType; +import org.tron.protos.Protocol.PQScheme; +@Slf4j(topic = "pbft") @Component public class PbftMsgHandler { @@ -32,6 +36,9 @@ public class PbftMsgHandler { @Autowired private TronNetDelegate tronNetDelegate; + @Autowired + private ChainBaseManager chainBaseManager; + public void processMessage(PeerConnection peer, PbftMessage msg) throws Exception { if (!tronNetDelegate.allowPBFT()) { return; @@ -50,6 +57,14 @@ public void processMessage(PeerConnection peer, PbftMessage msg) throws Exceptio && currentEpoch - msg.getEpoch() > expireEpoch) { return; } + if (msg.getPbftMessage().hasPqAuthSig()) { + PQScheme scheme = msg.getPbftMessage().getPqAuthSig().getScheme(); + if (!chainBaseManager.getDynamicPropertiesStore().isPqSchemeAllowed(scheme)) { + logger.warn("Pbft message from {}, pq_auth_sig scheme {} is not activated on chain.", + peer.getInetAddress(), scheme); + return; + } + } msg.analyzeSignature(); String key = buildKey(msg); Lock lock = striped.get(key); diff --git a/framework/src/test/java/org/tron/core/pbft/PbftPQMessageTest.java b/framework/src/test/java/org/tron/core/pbft/PbftPQMessageTest.java new file mode 100644 index 00000000000..a3247b448cd --- /dev/null +++ b/framework/src/test/java/org/tron/core/pbft/PbftPQMessageTest.java @@ -0,0 +1,233 @@ +package org.tron.core.pbft; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import com.google.protobuf.ByteString; +import java.security.SignatureException; +import org.bouncycastle.util.encoders.Hex; +import org.junit.Test; +import org.tron.common.crypto.ECKey; +import org.tron.common.crypto.pqc.FNDSA512; +import org.tron.common.crypto.pqc.PQSchemeRegistry; +import org.tron.common.utils.ByteArray; +import org.tron.common.utils.Sha256Hash; +import org.tron.consensus.base.Param; +import org.tron.consensus.base.Param.Miner; +import org.tron.consensus.base.Param.MinerType; +import org.tron.consensus.pbft.message.PbftMessage; +import org.tron.core.capsule.BlockCapsule; +import org.tron.protos.Protocol.Block; +import org.tron.protos.Protocol.PBFTMessage; +import org.tron.protos.Protocol.PQAuthSig; +import org.tron.protos.Protocol.PQScheme; + +public class PbftPQMessageTest { + + private static Miner pqMiner(FNDSA512 kp) { + byte[] address = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, kp.getPublicKey()); + ByteString addressBs = ByteString.copyFrom(address); + Miner miner = Param.getInstance().new Miner(null, addressBs, addressBs); + miner.setPQPrivateKey(kp.getPrivateKey()); + miner.setPQPublicKey(kp.getPublicKey()); + miner.setPqScheme(PQScheme.FN_DSA_512); + miner.setType(MinerType.PQ); + return miner; + } + + private static Miner ecdsaMiner() { + ECKey key = new ECKey(); + ByteString addressBs = ByteString.copyFrom(key.getAddress()); + return Param.getInstance().new Miner( + key.getPrivKeyBytes(), addressBs, addressBs); + } + + private static BlockCapsule emptyBlock() { + return new BlockCapsule(Block.getDefaultInstance()); + } + + /** ECDSA path is unchanged: analyzeSignature recovers the signer address. */ + @Test + public void testEcdsaHappyPath() throws Exception { + Miner miner = ecdsaMiner(); + PbftMessage msg = PbftMessage.prePrepareBlockMsg(emptyBlock(), 1, miner); + assertFalse(msg.getPbftMessage().getSignature().isEmpty()); + assertFalse(msg.getPbftMessage().hasPqAuthSig()); + msg.analyzeSignature(); + assertArrayEquals(miner.getWitnessAddress().toByteArray(), msg.getPublicKey()); + } + + /** PQ miner produces a pbft message with pq_auth_sig populated and signature cleared. */ + @Test + public void testPqHappyPath() throws Exception { + FNDSA512 kp = new FNDSA512(); + Miner miner = pqMiner(kp); + + PbftMessage msg = PbftMessage.prePrepareBlockMsg(emptyBlock(), 1, miner); + assertTrue(msg.getPbftMessage().getSignature().isEmpty()); + assertTrue(msg.getPbftMessage().hasPqAuthSig()); + PQAuthSig pqAuthSig = msg.getPbftMessage().getPqAuthSig(); + assertEquals(PQScheme.FN_DSA_512, pqAuthSig.getScheme()); + assertArrayEquals(kp.getPublicKey(), pqAuthSig.getPublicKey().toByteArray()); + + msg.analyzeSignature(); + assertArrayEquals(miner.getWitnessAddress().toByteArray(), msg.getPublicKey()); + } + + /** PREPARE / COMMIT round-trip also signs with the PQ key. */ + @Test + public void testPqPrepareAndCommit() throws Exception { + FNDSA512 kp = new FNDSA512(); + Miner miner = pqMiner(kp); + PbftMessage pre = PbftMessage.prePrepareBlockMsg(emptyBlock(), 1, miner); + + PbftMessage prepare = pre.buildPrePareMessage(miner); + assertTrue(prepare.getPbftMessage().hasPqAuthSig()); + prepare.analyzeSignature(); + assertArrayEquals(miner.getWitnessAddress().toByteArray(), prepare.getPublicKey()); + + PbftMessage commit = pre.buildCommitMessage(miner); + assertTrue(commit.getPbftMessage().hasPqAuthSig()); + commit.analyzeSignature(); + assertArrayEquals(miner.getWitnessAddress().toByteArray(), commit.getPublicKey()); + } + + /** Both signature and pq_auth_sig present → reject. */ + @Test + public void testMutexBothSet() throws Exception { + FNDSA512 kp = new FNDSA512(); + Miner miner = pqMiner(kp); + PbftMessage msg = PbftMessage.prePrepareBlockMsg(emptyBlock(), 1, miner); + + PBFTMessage tampered = msg.getPbftMessage().toBuilder() + .setSignature(ByteString.copyFrom(new byte[65])) + .build(); + PbftMessage rebuilt = rebuild(msg, tampered); + SignatureException ex = assertThrows(SignatureException.class, rebuilt::analyzeSignature); + assertTrue(ex.getMessage().contains("exactly one")); + } + + /** Neither signature nor pq_auth_sig present → reject. */ + @Test + public void testMutexNeitherSet() throws Exception { + Miner miner = ecdsaMiner(); + PbftMessage msg = PbftMessage.prePrepareBlockMsg(emptyBlock(), 1, miner); + + PBFTMessage tampered = msg.getPbftMessage().toBuilder() + .clearSignature() + .clearPqAuthSig() + .build(); + PbftMessage rebuilt = rebuild(msg, tampered); + SignatureException ex = assertThrows(SignatureException.class, rebuilt::analyzeSignature); + assertTrue(ex.getMessage().contains("exactly one")); + } + + /** Scheme not registered → reject. */ + @Test + public void testPqSchemeNotRegistered() throws Exception { + FNDSA512 kp = new FNDSA512(); + Miner miner = pqMiner(kp); + PbftMessage msg = PbftMessage.prePrepareBlockMsg(emptyBlock(), 1, miner); + + PBFTMessage tampered = msg.getPbftMessage().toBuilder() + .setPqAuthSig(msg.getPbftMessage().getPqAuthSig().toBuilder() + .setScheme(PQScheme.UNKNOWN_PQ_SCHEME)) + .build(); + PbftMessage rebuilt = rebuild(msg, tampered); + SignatureException ex = assertThrows(SignatureException.class, rebuilt::analyzeSignature); + assertTrue(ex.getMessage().contains("scheme not registered")); + } + + /** Public-key length mismatch → reject. */ + @Test + public void testPqBadPublicKeyLength() throws Exception { + FNDSA512 kp = new FNDSA512(); + Miner miner = pqMiner(kp); + PbftMessage msg = PbftMessage.prePrepareBlockMsg(emptyBlock(), 1, miner); + + byte[] shortPk = new byte[FNDSA512.PUBLIC_KEY_LENGTH - 1]; + PBFTMessage tampered = msg.getPbftMessage().toBuilder() + .setPqAuthSig(msg.getPbftMessage().getPqAuthSig().toBuilder() + .setPublicKey(ByteString.copyFrom(shortPk))) + .build(); + PbftMessage rebuilt = rebuild(msg, tampered); + SignatureException ex = assertThrows(SignatureException.class, rebuilt::analyzeSignature); + assertTrue(ex.getMessage().contains("public key length mismatch")); + } + + /** Signature length above protocol cap → reject. */ + @Test + public void testPqBadSignatureLength() throws Exception { + FNDSA512 kp = new FNDSA512(); + Miner miner = pqMiner(kp); + PbftMessage msg = PbftMessage.prePrepareBlockMsg(emptyBlock(), 1, miner); + + byte[] oversized = new byte[FNDSA512.SIGNATURE_LENGTH + 1]; + PBFTMessage tampered = msg.getPbftMessage().toBuilder() + .setPqAuthSig(msg.getPbftMessage().getPqAuthSig().toBuilder() + .setSignature(ByteString.copyFrom(oversized))) + .build(); + PbftMessage rebuilt = rebuild(msg, tampered); + SignatureException ex = assertThrows(SignatureException.class, rebuilt::analyzeSignature); + assertTrue(ex.getMessage().contains("signature length mismatch")); + } + + /** Public key replaced with a stray keypair → verify fails. */ + @Test + public void testPqSignatureFromWrongKey() throws Exception { + FNDSA512 kp = new FNDSA512(); + Miner miner = pqMiner(kp); + PbftMessage msg = PbftMessage.prePrepareBlockMsg(emptyBlock(), 1, miner); + + FNDSA512 stranger = new FNDSA512(); + PBFTMessage tampered = msg.getPbftMessage().toBuilder() + .setPqAuthSig(msg.getPbftMessage().getPqAuthSig().toBuilder() + .setPublicKey(ByteString.copyFrom(stranger.getPublicKey()))) + .build(); + PbftMessage rebuilt = rebuild(msg, tampered); + SignatureException ex = assertThrows(SignatureException.class, rebuilt::analyzeSignature); + assertTrue(ex.getMessage().contains("verification failed")); + } + + /** Hand-built PQ signature recovers the derived witness address. */ + @Test + public void testManualPqAuthSig() throws Exception { + FNDSA512 kp = new FNDSA512(); + byte[] expected = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, kp.getPublicKey()); + + PBFTMessage.Raw raw = PBFTMessage.Raw.newBuilder() + .setViewN(1) + .setEpoch(1) + .setDataType(PBFTMessage.DataType.BLOCK) + .setMsgType(PBFTMessage.MsgType.PREPREPARE) + .setData(ByteString.copyFrom(ByteArray.fromHexString("abcd"))) + .build(); + byte[] hash = Sha256Hash.hash(true, raw.toByteArray()); + byte[] sig = PQSchemeRegistry.sign(PQScheme.FN_DSA_512, kp.getPrivateKey(), hash); + + PBFTMessage message = PBFTMessage.newBuilder() + .setRawData(raw) + .setPqAuthSig(PQAuthSig.newBuilder() + .setScheme(PQScheme.FN_DSA_512) + .setPublicKey(ByteString.copyFrom(kp.getPublicKey())) + .setSignature(ByteString.copyFrom(sig))) + .build(); + PbftMessage pbft = new PbftMessage(); + pbft.setPbftMessage(message); + pbft.setData(message.toByteArray()); + pbft.analyzeSignature(); + assertEquals(Hex.toHexString(expected), Hex.toHexString(pbft.getPublicKey())); + } + + private static PbftMessage rebuild(PbftMessage original, PBFTMessage replacement) { + PbftMessage rebuilt = new PbftMessage(); + rebuilt.setType(original.getType().asByte()); + rebuilt.setPbftMessage(replacement); + rebuilt.setData(replacement.toByteArray()); + rebuilt.setSwitch(original.isSwitch()); + return rebuilt; + } +} diff --git a/protocol/src/main/protos/core/Tron.proto b/protocol/src/main/protos/core/Tron.proto index 1745a73a928..512b2898aea 100644 --- a/protocol/src/main/protos/core/Tron.proto +++ b/protocol/src/main/protos/core/Tron.proto @@ -920,7 +920,13 @@ message PBFTMessage { bytes data = 5; } Raw raw_data = 1; + // Legacy ECDSA signature over Sha256Hash(raw_data). Mutually exclusive with + // pq_auth_sig — exactly one of the two must be set per pbft message. bytes signature = 2; + // Post-quantum auth signature over Sha256Hash(raw_data). Set instead of + // `signature` when the local witness is PQ-only. Verifier only accepts + // this field after the corresponding PQ scheme is activated on chain. + PQAuthSig pq_auth_sig = 3; } message PBFTCommitResult { From fe78125b1e938405b57880e8a7e3c2167cd0bf05 Mon Sep 17 00:00:00 2001 From: federico Date: Thu, 14 May 2026 12:14:22 +0800 Subject: [PATCH 06/47] feat(consensus): aggregate PQ commit signatures and verify on sync --- .../tron/core/capsule/PbftSignCapsule.java | 16 +- .../consensus/pbft/PbftMessageAction.java | 9 +- .../consensus/pbft/PbftMessageHandle.java | 23 +- .../messagehandler/PbftDataSyncHandler.java | 120 ++++++--- .../PbftDataSyncHandlerPQTest.java | 230 ++++++++++++++++++ protocol/src/main/protos/core/Tron.proto | 8 + 6 files changed, 370 insertions(+), 36 deletions(-) create mode 100644 framework/src/test/java/org/tron/core/net/messagehandler/PbftDataSyncHandlerPQTest.java diff --git a/chainbase/src/main/java/org/tron/core/capsule/PbftSignCapsule.java b/chainbase/src/main/java/org/tron/core/capsule/PbftSignCapsule.java index 14835cb01b5..7594e8add41 100644 --- a/chainbase/src/main/java/org/tron/core/capsule/PbftSignCapsule.java +++ b/chainbase/src/main/java/org/tron/core/capsule/PbftSignCapsule.java @@ -2,11 +2,13 @@ import com.google.protobuf.ByteString; import com.google.protobuf.InvalidProtocolBufferException; +import java.util.Collections; import java.util.Deque; import java.util.List; import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.tron.protos.Protocol.PBFTCommitResult; +import org.tron.protos.Protocol.PQAuthSig; @Slf4j(topic = "pbft") public class PbftSignCapsule implements ProtoCapsule { @@ -23,8 +25,18 @@ public PbftSignCapsule(byte[] data) { } public PbftSignCapsule(ByteString data, List signList) { - PBFTCommitResult.Builder builder = PBFTCommitResult.newBuilder(); - builder.setData(data).addAllSignature(signList); + this(data, signList, Collections.emptyList()); + } + + public PbftSignCapsule(ByteString data, List signList, + List pqSignList) { + PBFTCommitResult.Builder builder = PBFTCommitResult.newBuilder().setData(data); + if (signList != null && !signList.isEmpty()) { + builder.addAllSignature(signList); + } + if (pqSignList != null && !pqSignList.isEmpty()) { + builder.addAllPqSignature(pqSignList); + } pbftCommitResult = builder.build(); } diff --git a/consensus/src/main/java/org/tron/consensus/pbft/PbftMessageAction.java b/consensus/src/main/java/org/tron/consensus/pbft/PbftMessageAction.java index c4ee235ff2d..ad0c108a98c 100644 --- a/consensus/src/main/java/org/tron/consensus/pbft/PbftMessageAction.java +++ b/consensus/src/main/java/org/tron/consensus/pbft/PbftMessageAction.java @@ -10,6 +10,7 @@ import org.tron.core.ChainBaseManager; import org.tron.core.capsule.PbftSignCapsule; import org.tron.protos.Protocol.PBFTMessage.Raw; +import org.tron.protos.Protocol.PQAuthSig; @Slf4j(topic = "pbft") @Component @@ -18,14 +19,16 @@ public class PbftMessageAction { @Autowired private ChainBaseManager chainBaseManager; - public void action(PbftMessage message, List dataSignList) { + public void action(PbftMessage message, List dataSignList, + List pqSignList) { switch (message.getDataType()) { case BLOCK: { long blockNum = message.getNumber(); chainBaseManager.getCommonDataBase().saveLatestPbftBlockNum(blockNum); Raw raw = message.getPbftMessage().getRawData(); chainBaseManager.getPbftSignDataStore() - .putBlockSignData(blockNum, new PbftSignCapsule(raw.toByteString(), dataSignList)); + .putBlockSignData(blockNum, + new PbftSignCapsule(raw.toByteString(), dataSignList, pqSignList)); logger.info("commit msg block num is:{}", blockNum); } break; @@ -33,7 +36,7 @@ public void action(PbftMessage message, List dataSignList) { try { Raw raw = message.getPbftMessage().getRawData(); chainBaseManager.getPbftSignDataStore().putSrSignData(message.getEpoch(), - new PbftSignCapsule(raw.toByteString(), dataSignList)); + new PbftSignCapsule(raw.toByteString(), dataSignList, pqSignList)); logger.info("sr commit msg :{}, epoch:{}", message.getNumber(), message.getEpoch()); } catch (Exception e) { logger.error("process the sr list error!", e); diff --git a/consensus/src/main/java/org/tron/consensus/pbft/PbftMessageHandle.java b/consensus/src/main/java/org/tron/consensus/pbft/PbftMessageHandle.java index 523ffac4d61..18462cff3cb 100644 --- a/consensus/src/main/java/org/tron/consensus/pbft/PbftMessageHandle.java +++ b/consensus/src/main/java/org/tron/consensus/pbft/PbftMessageHandle.java @@ -32,6 +32,7 @@ import org.tron.consensus.pbft.message.PbftMessage; import org.tron.core.ChainBaseManager; import org.tron.protos.Protocol.PBFTMessage.DataType; +import org.tron.protos.Protocol.PQAuthSig; @Slf4j(topic = "pbft") @Component @@ -64,6 +65,15 @@ public List load(String s) throws Exception { } }); + private LoadingCache> pqSignCache = CacheBuilder.newBuilder() + .initialCapacity(100).maximumSize(1000).expireAfterWrite(2, TimeUnit.MINUTES).build( + new CacheLoader>() { + @Override + public List load(String s) throws Exception { + return new ArrayList<>(); + } + }); + private PbftMessage srPbftMessage; private Timer timer = new Timer("pbft-timer"); @@ -205,14 +215,21 @@ public synchronized void onCommit(PbftMessage message) { commitVoteMap.put(key, message); //The number of votes plus 1 long agCou = agreeCommit.incrementAndGet(message.getDataKey()); - dataSignCache.getUnchecked(message.getDataKey()) - .add(message.getPbftMessage().getSignature()); + if (message.getPbftMessage().hasPqAuthSig()) { + pqSignCache.getUnchecked(message.getDataKey()) + .add(message.getPbftMessage().getPqAuthSig()); + } else { + dataSignCache.getUnchecked(message.getDataKey()) + .add(message.getPbftMessage().getSignature()); + } if (agCou >= Param.getInstance().getAgreeNodeCount()) { srPbftMessage = null; remove(message.getNo()); //commit, if (!isSyncing()) { - pbftMessageAction.action(message, dataSignCache.getUnchecked(message.getDataKey())); + pbftMessageAction.action(message, + dataSignCache.getUnchecked(message.getDataKey()), + pqSignCache.getUnchecked(message.getDataKey())); } } } diff --git a/framework/src/main/java/org/tron/core/net/messagehandler/PbftDataSyncHandler.java b/framework/src/main/java/org/tron/core/net/messagehandler/PbftDataSyncHandler.java index d66fa6d41f7..0d9171442b2 100644 --- a/framework/src/main/java/org/tron/core/net/messagehandler/PbftDataSyncHandler.java +++ b/framework/src/main/java/org/tron/core/net/messagehandler/PbftDataSyncHandler.java @@ -19,6 +19,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.tron.common.crypto.ECKey; +import org.tron.common.crypto.pqc.PQSchemeRegistry; import org.tron.common.es.ExecutorServiceManager; import org.tron.common.utils.ByteArray; import org.tron.common.utils.Sha256Hash; @@ -33,6 +34,8 @@ import org.tron.core.net.peer.PeerConnection; import org.tron.protos.Protocol.PBFTMessage.DataType; import org.tron.protos.Protocol.PBFTMessage.Raw; +import org.tron.protos.Protocol.PQAuthSig; +import org.tron.protos.Protocol.PQScheme; @Slf4j(topic = "pbft-data-sync") @Service @@ -102,6 +105,7 @@ private void processPBFTCommitMessage(PbftCommitMessage pbftCommitMessage) { PbftSignDataStore pbftSignDataStore = chainBaseManager.getPbftSignDataStore(); Raw raw = Raw.parseFrom(pbftCommitMessage.getPBFTCommitResult().getData()); if (!validPbftSign(raw, pbftCommitMessage.getPBFTCommitResult().getSignatureList(), + pbftCommitMessage.getPBFTCommitResult().getPqSignatureList(), chainBaseManager.getWitnesses())) { return; } @@ -120,37 +124,44 @@ private void processPBFTCommitMessage(PbftCommitMessage pbftCommitMessage) { } private boolean validPbftSign(Raw raw, List srSignList, - List currentSrList) { - //valid sr list - if (srSignList.size() != 0) { - Set srSignSet = new ConcurrentSet(); - srSignSet.addAll(srSignList); - if (srSignSet.size() < Param.getInstance().getAgreeNodeCount()) { - logger.error("sr sign count {} < sr count * 2/3 + 1 == {}", srSignSet.size(), - Param.getInstance().getAgreeNodeCount()); - return false; - } - byte[] dataHash = Sha256Hash.hash(true, raw.toByteArray()); - Set srSet = Sets.newHashSet(currentSrList); - List> futureList = new ArrayList<>(); - for (ByteString sign : srSignList) { - futureList.add(executorService.submit( - new ValidPbftSignTask(raw.getViewN(), srSignSet, dataHash, srSet, sign))); - } - for (Future future : futureList) { - try { - if (!future.get()) { - return false; - } - } catch (Exception e) { - logger.error("", e); + List pqSignList, List currentSrList) { + int totalSigs = srSignList.size() + pqSignList.size(); + if (totalSigs == 0) { + return true; + } + Set srSignSet = new ConcurrentSet(); + srSignSet.addAll(srSignList); + Set pqSignSet = new ConcurrentSet(); + for (PQAuthSig pqSign : pqSignList) { + pqSignSet.add(pqSign.toByteString()); + } + int uniqueSigs = srSignSet.size() + pqSignSet.size(); + if (uniqueSigs < Param.getInstance().getAgreeNodeCount()) { + logger.error("sr sign count {} < sr count * 2/3 + 1 == {}", uniqueSigs, + Param.getInstance().getAgreeNodeCount()); + return false; + } + byte[] dataHash = Sha256Hash.hash(true, raw.toByteArray()); + Set srSet = Sets.newHashSet(currentSrList); + List> futureList = new ArrayList<>(); + for (ByteString sign : srSignList) { + futureList.add(executorService.submit( + new ValidPbftSignTask(raw.getViewN(), srSignSet, dataHash, srSet, sign))); + } + for (PQAuthSig pqSign : pqSignList) { + futureList.add(executorService.submit( + new ValidPqPbftSignTask(raw.getViewN(), pqSignSet, dataHash, srSet, pqSign))); + } + for (Future future : futureList) { + try { + if (!future.get()) { + return false; } - } - if (srSignSet.size() != 0) { - return false; + } catch (Exception e) { + logger.error("", e); } } - return true; + return srSignSet.isEmpty() && pqSignSet.isEmpty(); } private class ValidPbftSignTask implements Callable { @@ -189,4 +200,57 @@ public Boolean call() throws Exception { } } + private class ValidPqPbftSignTask implements Callable { + + private final long viewN; + private final Set pqSignSet; + private final byte[] dataHash; + private final Set srSet; + private final PQAuthSig pqAuthSig; + + ValidPqPbftSignTask(long viewN, Set pqSignSet, + byte[] dataHash, Set srSet, PQAuthSig pqAuthSig) { + this.viewN = viewN; + this.pqSignSet = pqSignSet; + this.dataHash = dataHash; + this.srSet = srSet; + this.pqAuthSig = pqAuthSig; + } + + @Override + public Boolean call() { + PQScheme scheme = pqAuthSig.getScheme(); + if (!chainBaseManager.getDynamicPropertiesStore().isPqSchemeAllowed(scheme)) { + logger.error("viewN {} pq scheme {} not activated on chain", viewN, scheme); + return false; + } + if (!PQSchemeRegistry.contains(scheme)) { + logger.error("viewN {} pq scheme {} not registered locally", viewN, scheme); + return false; + } + byte[] publicKey = pqAuthSig.getPublicKey().toByteArray(); + if (publicKey.length != PQSchemeRegistry.getPublicKeyLength(scheme)) { + logger.error("viewN {} pq public key length mismatch for {}", viewN, scheme); + return false; + } + byte[] signature = pqAuthSig.getSignature().toByteArray(); + if (!PQSchemeRegistry.isValidSignatureLength(scheme, signature.length)) { + logger.error("viewN {} pq signature length mismatch for {}", viewN, scheme); + return false; + } + if (!PQSchemeRegistry.verify(scheme, publicKey, dataHash, signature)) { + logger.error("viewN {} pq signature verification failed for {}", viewN, scheme); + return false; + } + byte[] srAddress = PQSchemeRegistry.computeAddress(scheme, publicKey); + if (!srSet.contains(ByteString.copyFrom(srAddress))) { + logger.error("valid sr pq signature fail, error sr address:{}", + ByteArray.toHexString(srAddress)); + return false; + } + pqSignSet.remove(pqAuthSig.toByteString()); + return true; + } + } + } diff --git a/framework/src/test/java/org/tron/core/net/messagehandler/PbftDataSyncHandlerPQTest.java b/framework/src/test/java/org/tron/core/net/messagehandler/PbftDataSyncHandlerPQTest.java new file mode 100644 index 00000000000..890b0097b43 --- /dev/null +++ b/framework/src/test/java/org/tron/core/net/messagehandler/PbftDataSyncHandlerPQTest.java @@ -0,0 +1,230 @@ +package org.tron.core.net.messagehandler; + +import com.google.protobuf.ByteString; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; +import org.tron.common.crypto.ECKey; +import org.tron.common.crypto.pqc.FNDSA512; +import org.tron.common.crypto.pqc.PQSchemeRegistry; +import org.tron.common.utils.Sha256Hash; +import org.tron.consensus.base.Param; +import org.tron.core.ChainBaseManager; +import org.tron.core.store.DynamicPropertiesStore; +import org.tron.protos.Protocol.PBFTMessage.DataType; +import org.tron.protos.Protocol.PBFTMessage.MsgType; +import org.tron.protos.Protocol.PBFTMessage.Raw; +import org.tron.protos.Protocol.PQAuthSig; +import org.tron.protos.Protocol.PQScheme; + +/** + * Focused tests for {@link PbftDataSyncHandler#validPbftSign} covering PQ and + * mixed ECDSA/PQ quorums on the commit-sync path. + */ +public class PbftDataSyncHandlerPQTest { + + private PbftDataSyncHandler handler; + private ChainBaseManager chainBaseManager; + private DynamicPropertiesStore dynamicPropertiesStore; + private int previousAgreeNodeCount; + private boolean previousEnable; + + @Before + public void setUp() throws Exception { + handler = new PbftDataSyncHandler(); + chainBaseManager = Mockito.mock(ChainBaseManager.class); + dynamicPropertiesStore = Mockito.mock(DynamicPropertiesStore.class); + Mockito.when(chainBaseManager.getDynamicPropertiesStore()).thenReturn(dynamicPropertiesStore); + Mockito.when(dynamicPropertiesStore.isPqSchemeAllowed(PQScheme.FN_DSA_512)).thenReturn(true); + Mockito.when(dynamicPropertiesStore.isPqSchemeAllowed(PQScheme.UNKNOWN_PQ_SCHEME)) + .thenReturn(false); + + java.lang.reflect.Field field = PbftDataSyncHandler.class.getDeclaredField("chainBaseManager"); + field.setAccessible(true); + field.set(handler, chainBaseManager); + + previousAgreeNodeCount = Param.getInstance().getAgreeNodeCount(); + previousEnable = Param.getInstance().isEnable(); + } + + @After + public void tearDown() { + Param.getInstance().setAgreeNodeCount(previousAgreeNodeCount); + Param.getInstance().setEnable(previousEnable); + handler.close(); + } + + @Test + public void emptySignatureListsValidate() throws Exception { + Param.getInstance().setAgreeNodeCount(1); + Raw raw = buildRaw(1); + Assert.assertTrue(invokeValid(raw, Collections.emptyList(), Collections.emptyList(), + Collections.emptyList())); + } + + @Test + public void pqQuorumValidates() throws Exception { + Param.getInstance().setAgreeNodeCount(2); + Raw raw = buildRaw(1); + byte[] hash = Sha256Hash.hash(true, raw.toByteArray()); + + FNDSA512 kp1 = new FNDSA512(); + FNDSA512 kp2 = new FNDSA512(); + PQAuthSig sig1 = pqSign(kp1, hash); + PQAuthSig sig2 = pqSign(kp2, hash); + + List witnesses = Arrays.asList( + ByteString.copyFrom(PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, kp1.getPublicKey())), + ByteString.copyFrom(PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, kp2.getPublicKey()))); + + Assert.assertTrue(invokeValid(raw, Collections.emptyList(), + Arrays.asList(sig1, sig2), witnesses)); + } + + @Test + public void mixedEcdsaAndPqQuorumValidates() throws Exception { + Param.getInstance().setAgreeNodeCount(2); + Raw raw = buildRaw(2); + byte[] hash = Sha256Hash.hash(true, raw.toByteArray()); + + ECKey ec = new ECKey(); + byte[] ecSig = ec.sign(hash).toByteArray(); + + FNDSA512 kp = new FNDSA512(); + PQAuthSig pq = pqSign(kp, hash); + + List witnesses = Arrays.asList( + ByteString.copyFrom(ec.getAddress()), + ByteString.copyFrom(PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, kp.getPublicKey()))); + + Assert.assertTrue(invokeValid(raw, + Collections.singletonList(ByteString.copyFrom(ecSig)), + Collections.singletonList(pq), + witnesses)); + } + + @Test + public void underQuorumFails() throws Exception { + Param.getInstance().setAgreeNodeCount(3); + Raw raw = buildRaw(3); + byte[] hash = Sha256Hash.hash(true, raw.toByteArray()); + + FNDSA512 kp = new FNDSA512(); + PQAuthSig pq = pqSign(kp, hash); + List witnesses = Collections.singletonList( + ByteString.copyFrom(PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, kp.getPublicKey()))); + + Assert.assertFalse(invokeValid(raw, Collections.emptyList(), + Collections.singletonList(pq), witnesses)); + } + + @Test + public void pqSchemeNotActivatedFails() throws Exception { + Param.getInstance().setAgreeNodeCount(1); + Raw raw = buildRaw(4); + byte[] hash = Sha256Hash.hash(true, raw.toByteArray()); + + FNDSA512 kp = new FNDSA512(); + PQAuthSig pq = pqSign(kp, hash); + List witnesses = Collections.singletonList( + ByteString.copyFrom(PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, kp.getPublicKey()))); + + Mockito.when(dynamicPropertiesStore.isPqSchemeAllowed(PQScheme.FN_DSA_512)).thenReturn(false); + Assert.assertFalse(invokeValid(raw, Collections.emptyList(), + Collections.singletonList(pq), witnesses)); + } + + @Test + public void pqPublicKeyLengthMismatchFails() throws Exception { + Param.getInstance().setAgreeNodeCount(1); + Raw raw = buildRaw(5); + byte[] hash = Sha256Hash.hash(true, raw.toByteArray()); + + FNDSA512 kp = new FNDSA512(); + byte[] sig = PQSchemeRegistry.sign(PQScheme.FN_DSA_512, kp.getPrivateKey(), hash); + PQAuthSig pq = PQAuthSig.newBuilder() + .setScheme(PQScheme.FN_DSA_512) + .setPublicKey(ByteString.copyFrom(new byte[FNDSA512.PUBLIC_KEY_LENGTH - 1])) + .setSignature(ByteString.copyFrom(sig)) + .build(); + List witnesses = Collections.singletonList( + ByteString.copyFrom(PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, kp.getPublicKey()))); + + Assert.assertFalse(invokeValid(raw, Collections.emptyList(), + Collections.singletonList(pq), witnesses)); + } + + @Test + public void pqSignerNotInWitnessSetFails() throws Exception { + Param.getInstance().setAgreeNodeCount(1); + Raw raw = buildRaw(6); + byte[] hash = Sha256Hash.hash(true, raw.toByteArray()); + + FNDSA512 kp = new FNDSA512(); + PQAuthSig pq = pqSign(kp, hash); + FNDSA512 stranger = new FNDSA512(); + List witnesses = Collections.singletonList( + ByteString.copyFrom( + PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, stranger.getPublicKey()))); + + Assert.assertFalse(invokeValid(raw, Collections.emptyList(), + Collections.singletonList(pq), witnesses)); + } + + @Test + public void pqBadSignatureFails() throws Exception { + Param.getInstance().setAgreeNodeCount(1); + Raw raw = buildRaw(7); + byte[] hash = Sha256Hash.hash(true, raw.toByteArray()); + + FNDSA512 kp = new FNDSA512(); + byte[] goodSig = PQSchemeRegistry.sign(PQScheme.FN_DSA_512, kp.getPrivateKey(), hash); + byte[] tampered = Arrays.copyOf(goodSig, goodSig.length); + tampered[tampered.length - 1] ^= 0x01; + PQAuthSig pq = PQAuthSig.newBuilder() + .setScheme(PQScheme.FN_DSA_512) + .setPublicKey(ByteString.copyFrom(kp.getPublicKey())) + .setSignature(ByteString.copyFrom(tampered)) + .build(); + List witnesses = Collections.singletonList( + ByteString.copyFrom(PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, kp.getPublicKey()))); + + Assert.assertFalse(invokeValid(raw, Collections.emptyList(), + Collections.singletonList(pq), witnesses)); + } + + private static Raw buildRaw(long viewN) { + return Raw.newBuilder() + .setViewN(viewN) + .setEpoch(0) + .setDataType(DataType.BLOCK) + .setMsgType(MsgType.COMMIT) + .setData(ByteString.copyFromUtf8("payload-" + viewN)) + .build(); + } + + private static PQAuthSig pqSign(FNDSA512 kp, byte[] hash) { + byte[] sig = PQSchemeRegistry.sign(PQScheme.FN_DSA_512, kp.getPrivateKey(), hash); + return PQAuthSig.newBuilder() + .setScheme(PQScheme.FN_DSA_512) + .setPublicKey(ByteString.copyFrom(kp.getPublicKey())) + .setSignature(ByteString.copyFrom(sig)) + .build(); + } + + private boolean invokeValid(Raw raw, List srSignList, + List pqSignList, List witnesses) throws Exception { + Method m = PbftDataSyncHandler.class.getDeclaredMethod("validPbftSign", + Raw.class, List.class, List.class, List.class); + m.setAccessible(true); + return (Boolean) m.invoke(handler, raw, new ArrayList<>(srSignList), + new ArrayList<>(pqSignList), witnesses); + } +} diff --git a/protocol/src/main/protos/core/Tron.proto b/protocol/src/main/protos/core/Tron.proto index 512b2898aea..2b3b1733e5d 100644 --- a/protocol/src/main/protos/core/Tron.proto +++ b/protocol/src/main/protos/core/Tron.proto @@ -931,7 +931,15 @@ message PBFTMessage { message PBFTCommitResult { bytes data = 1; + // Legacy ECDSA commit signatures recoverable to a SR address via the + // signed `data` hash. May coexist with `pq_signature` when a quorum + // includes both ECDSA and PQ-only witnesses; the total of the two + // lists must meet the agreement threshold. repeated bytes signature = 2; + // Post-quantum commit signatures contributed by PQ-only witnesses. + // Verifiers must reject any pq_signature entry whose scheme is not + // activated on chain. + repeated PQAuthSig pq_signature = 3; } message SRL { From 2c4db1f39d8a36b7682b69049054add7070f6110 Mon Sep 17 00:00:00 2001 From: federico Date: Thu, 14 May 2026 15:16:35 +0800 Subject: [PATCH 07/47] fix(consensus): count pbft quorum by unique signer address --- .../org/tron/core/capsule/BlockCapsule.java | 7 +- .../messagehandler/PbftDataSyncHandler.java | 50 +++-- .../common/crypto/pqc/program/PQTxSender.java | 182 +++++++++++++++--- .../tron/core/capsule/BlockCapsulePQTest.java | 29 ++- .../PbftDataSyncHandlerPQTest.java | 42 +++- .../org/tron/core/pbft/PbftPQMessageTest.java | 5 +- 6 files changed, 246 insertions(+), 69 deletions(-) diff --git a/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java b/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java index 6c781d419aa..9324f47a08e 100755 --- a/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java +++ b/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java @@ -421,11 +421,8 @@ public long getTimeStamp() { public boolean hasWitnessSignature() { BlockHeader header = getInstance().getBlockHeader(); - if (!header.getWitnessSignature().isEmpty()) { - return true; - } - PQAuthSig auth = header.getPqAuthSig(); - return auth != null && !auth.getSignature().isEmpty(); + return !header.getWitnessSignature().isEmpty() + || !header.getPqAuthSig().getSignature().isEmpty(); } @Override diff --git a/framework/src/main/java/org/tron/core/net/messagehandler/PbftDataSyncHandler.java b/framework/src/main/java/org/tron/core/net/messagehandler/PbftDataSyncHandler.java index 0d9171442b2..78674dda47f 100644 --- a/framework/src/main/java/org/tron/core/net/messagehandler/PbftDataSyncHandler.java +++ b/framework/src/main/java/org/tron/core/net/messagehandler/PbftDataSyncHandler.java @@ -5,13 +5,13 @@ import com.google.common.collect.Sets; import com.google.protobuf.ByteString; import com.google.protobuf.InvalidProtocolBufferException; -import io.netty.util.internal.ConcurrentSet; import java.io.Closeable; import java.security.SignatureException; import java.util.ArrayList; import java.util.List; import java.util.Set; import java.util.concurrent.Callable; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; @@ -129,28 +129,17 @@ private boolean validPbftSign(Raw raw, List srSignList, if (totalSigs == 0) { return true; } - Set srSignSet = new ConcurrentSet(); - srSignSet.addAll(srSignList); - Set pqSignSet = new ConcurrentSet(); - for (PQAuthSig pqSign : pqSignList) { - pqSignSet.add(pqSign.toByteString()); - } - int uniqueSigs = srSignSet.size() + pqSignSet.size(); - if (uniqueSigs < Param.getInstance().getAgreeNodeCount()) { - logger.error("sr sign count {} < sr count * 2/3 + 1 == {}", uniqueSigs, - Param.getInstance().getAgreeNodeCount()); - return false; - } byte[] dataHash = Sha256Hash.hash(true, raw.toByteArray()); Set srSet = Sets.newHashSet(currentSrList); + Set verifiedSigners = ConcurrentHashMap.newKeySet(); List> futureList = new ArrayList<>(); for (ByteString sign : srSignList) { futureList.add(executorService.submit( - new ValidPbftSignTask(raw.getViewN(), srSignSet, dataHash, srSet, sign))); + new ValidPbftSignTask(raw.getViewN(), verifiedSigners, dataHash, srSet, sign))); } for (PQAuthSig pqSign : pqSignList) { futureList.add(executorService.submit( - new ValidPqPbftSignTask(raw.getViewN(), pqSignSet, dataHash, srSet, pqSign))); + new ValidPqPbftSignTask(raw.getViewN(), verifiedSigners, dataHash, srSet, pqSign))); } for (Future future : futureList) { try { @@ -159,23 +148,30 @@ private boolean validPbftSign(Raw raw, List srSignList, } } catch (Exception e) { logger.error("", e); + return false; } } - return srSignSet.isEmpty() && pqSignSet.isEmpty(); + int unique = verifiedSigners.size(); + if (unique < Param.getInstance().getAgreeNodeCount()) { + logger.error("sr sign count {} < sr count * 2/3 + 1 == {}", unique, + Param.getInstance().getAgreeNodeCount()); + return false; + } + return true; } private class ValidPbftSignTask implements Callable { private long viewN; - private Set srSignSet; + private Set verifiedSigners; private byte[] dataHash; private Set srSet; private ByteString sign; - ValidPbftSignTask(long viewN, Set srSignSet, + ValidPbftSignTask(long viewN, Set verifiedSigners, byte[] dataHash, Set srSet, ByteString sign) { this.viewN = viewN; - this.srSignSet = srSignSet; + this.verifiedSigners = verifiedSigners; this.dataHash = dataHash; this.srSet = srSet; this.sign = sign; @@ -186,12 +182,13 @@ public Boolean call() throws Exception { try { byte[] srAddress = ECKey.signatureToAddress(dataHash, TransactionCapsule.getBase64FromByteString(sign)); - if (!srSet.contains(ByteString.copyFrom(srAddress))) { + ByteString addressKey = ByteString.copyFrom(srAddress); + if (!srSet.contains(addressKey)) { logger.error("valid sr signature fail,error sr address:{}", ByteArray.toHexString(srAddress)); return false; } - srSignSet.remove(sign); + verifiedSigners.add(addressKey); } catch (SignatureException e) { logger.error("viewN {} valid sr list sign fail!", viewN, e); return false; @@ -203,15 +200,15 @@ public Boolean call() throws Exception { private class ValidPqPbftSignTask implements Callable { private final long viewN; - private final Set pqSignSet; + private final Set verifiedSigners; private final byte[] dataHash; private final Set srSet; private final PQAuthSig pqAuthSig; - ValidPqPbftSignTask(long viewN, Set pqSignSet, + ValidPqPbftSignTask(long viewN, Set verifiedSigners, byte[] dataHash, Set srSet, PQAuthSig pqAuthSig) { this.viewN = viewN; - this.pqSignSet = pqSignSet; + this.verifiedSigners = verifiedSigners; this.dataHash = dataHash; this.srSet = srSet; this.pqAuthSig = pqAuthSig; @@ -243,12 +240,13 @@ public Boolean call() { return false; } byte[] srAddress = PQSchemeRegistry.computeAddress(scheme, publicKey); - if (!srSet.contains(ByteString.copyFrom(srAddress))) { + ByteString addressKey = ByteString.copyFrom(srAddress); + if (!srSet.contains(addressKey)) { logger.error("valid sr pq signature fail, error sr address:{}", ByteArray.toHexString(srAddress)); return false; } - pqSignSet.remove(pqAuthSig.toByteString()); + verifiedSigners.add(addressKey); return true; } } diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/program/PQTxSender.java b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQTxSender.java index cdcaf3e72a5..7ffaa3ca02e 100644 --- a/framework/src/test/java/org/tron/common/crypto/pqc/program/PQTxSender.java +++ b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQTxSender.java @@ -12,10 +12,14 @@ import org.tron.api.GrpcAPI.Return; import org.tron.api.WalletGrpc; import org.tron.api.WalletGrpc.WalletBlockingStub; +import org.tron.common.crypto.ECKey; +import org.tron.common.crypto.ECKey.ECDSASignature; import org.tron.common.crypto.pqc.FNDSA512; import org.tron.common.math.StrictMathWrapper; +import org.tron.common.parameter.CommonParameter; import org.tron.common.utils.ByteArray; import org.tron.common.utils.Commons; +import org.tron.common.utils.Sha256Hash; import org.tron.common.utils.StringUtil; import org.tron.common.utils.client.utils.AbiUtil; import org.tron.core.capsule.TransactionCapsule; @@ -27,17 +31,19 @@ import org.tron.protos.contract.SmartContractOuterClass.TriggerSmartContract; /** - * Demo client that connects to {@link PQWitnessNode} and continuously broadcasts FN-DSA-512 signed - * transfer and TRC20 transactions at 10 TPS. + * Demo client that connects to {@link PQWitnessNode} and continuously broadcasts transfer and + * TRC20 transactions signed by FN-DSA-512 and ECDSA. *

- * The keypair is derived from the same fixed seed used by PQWitnessNode, so no out-of-band key - * exchange is needed. + * The FN-DSA-512 keypair is derived from the same fixed seed used by PQWitnessNode, so no + * out-of-band key exchange is needed. ECDSA transactions use -Decdsa.private.key. *

* Run from the repository root: * ./gradlew :framework:buildFullNodeJar :framework:compileTestJava + * CP="framework/build/classes/java/test:framework/build/resources/test" + * CP="$CP:framework/build/libs/FullNode.jar" * java -Dpqc.host=127.0.0.1 -Dpqc.port=50051 -Dpqc.transfer.tps=10 -Dpqc.trc20.tps=10 \ - * -cp "framework/build/classes/java/test:framework/build/resources/test:\ - * framework/build/libs/FullNode.jar" \ + * -Decdsa.private.key=HEX_PRIVATE_KEY -Decdsa.transfer.tps=10 -Decdsa.trc20.tps=10 \ + * -cp "$CP" \ * org.tron.common.crypto.pqc.program.PQTxSender * * Optional JVM args: @@ -45,6 +51,9 @@ * -Dpqc.port=50051 * -Dpqc.transfer.tps=10 * -Dpqc.trc20.tps=10 + * -Decdsa.private.key=1234567890123456789012345678901234567890123456789012345678901234 + * -Decdsa.transfer.tps=10 + * -Decdsa.trc20.tps=10 */ public class PQTxSender { @@ -57,7 +66,7 @@ public class PQTxSender { * Recipient of the demo transfer. */ private static final byte[] TO_ADDR = - Commons.decodeFromBase58Check("T9zNBvTFD97XzGsjGqvg2QHizTG8sibsHt"); + Commons.decodeFromBase58Check("TKmyxLsRR2FWMVEHaQA2pZh1xB7oXPXzG1"); /** * TRC20 contract address (USDT on TRON). @@ -76,13 +85,27 @@ public class PQTxSender { private static final long TRC20_FEE_LIMIT = 1000_000_000L; /** - * Default send rate for transfer transactions. + * Default demo ECDSA private key. Override it with -Decdsa.private.key for a funded account. + */ + private static final String DEFAULT_ECDSA_PRIVATE_KEY = + "1234567890123456789012345678901234567890123456789012345678901234"; + + /** + * Default send rate for FN-DSA-512 transfer transactions. */ private static final double DEFAULT_TRANSFER_TPS = 10.0d; /** - * Default send rate for TRC20 transactions. + * Default send rate for FN-DSA-512 TRC20 transactions. */ private static final double DEFAULT_TRC20_TPS = 10.0d; + /** + * Default send rate for ECDSA transfer transactions. + */ + private static final double DEFAULT_ECDSA_TRANSFER_TPS = 10.0d; + /** + * Default send rate for ECDSA TRC20 transactions. + */ + private static final double DEFAULT_ECDSA_TRC20_TPS = 10.0d; public static void main(String[] args) throws Exception { // Force INFO level: logback-test.xml (on the test classpath) sets root=DEBUG @@ -100,15 +123,24 @@ public static void main(String[] args) throws Exception { byte[] userPriv = userKp.getPrivateKey(); byte[] signerAddr = FNDSA512.computeAddress(userPub); byte[] ownerAddr = Commons.decodeFromBase58Check("TJUfbazhixG4YtqJxUDmv5XisZvvy1wP91"); + ECKey ecdsaKey = ECKey.fromPrivate( + ByteArray.fromHexString(System.getProperty("ecdsa.private.key", + DEFAULT_ECDSA_PRIVATE_KEY))); + byte[] ecdsaOwnerAddr = ecdsaKey.getAddress(); double transferTps = readTps("pqc.transfer.tps", DEFAULT_TRANSFER_TPS); double trc20Tps = readTps("pqc.trc20.tps", DEFAULT_TRC20_TPS); + double ecdsaTransferTps = readTps("ecdsa.transfer.tps", DEFAULT_ECDSA_TRANSFER_TPS); + double ecdsaTrc20Tps = readTps("ecdsa.trc20.tps", DEFAULT_ECDSA_TRC20_TPS); - System.out.println("=== PQC Client ==="); + System.out.println("=== PQC/ECDSA Tx Sender ==="); System.out.println("Connecting to " + HOST + ":" + PORT); - System.out.println("Owner address: " + ByteArray.toHexString(ownerAddr)); - System.out.println("Signer address: " + ByteArray.toHexString(signerAddr)); - System.out.println("Transfer TPS: " + transferTps); - System.out.println("TRC20 TPS: " + trc20Tps); + System.out.println("PQC owner address: " + ByteArray.toHexString(ownerAddr)); + System.out.println("PQC signer address: " + ByteArray.toHexString(signerAddr)); + System.out.println("PQC transfer TPS: " + transferTps); + System.out.println("PQC TRC20 TPS: " + trc20Tps); + System.out.println("ECDSA owner address: " + ByteArray.toHexString(ecdsaOwnerAddr)); + System.out.println("ECDSA transfer TPS: " + ecdsaTransferTps); + System.out.println("ECDSA TRC20 TPS: " + ecdsaTrc20Tps); // ── 2. Connect via gRPC ─────────────────────────────────────────────── ManagedChannel channel = ManagedChannelBuilder @@ -124,11 +156,21 @@ public static void main(String[] args) throws Exception { Thread trc20Thread = new Thread( () -> runTrc20Loop(stub, ownerAddr, userPub, userPriv, trc20Tps), "pqc-trc20-sender-grpc"); + Thread ecdsaTransferThread = new Thread( + () -> runEcdsaTransferLoop(stub, ecdsaOwnerAddr, ecdsaKey, ecdsaTransferTps), + "ecdsa-transfer-sender-grpc"); + Thread ecdsaTrc20Thread = new Thread( + () -> runEcdsaTrc20Loop(stub, ecdsaOwnerAddr, ecdsaKey, ecdsaTrc20Tps), + "ecdsa-trc20-sender-grpc"); transferThread.start(); trc20Thread.start(); + ecdsaTransferThread.start(); + ecdsaTrc20Thread.start(); transferThread.join(); trc20Thread.join(); + ecdsaTransferThread.join(); + ecdsaTrc20Thread.join(); } finally { channel.shutdown(); channel.awaitTermination(5, TimeUnit.SECONDS); @@ -139,6 +181,11 @@ private static byte[] sha256(byte[] data) throws Exception { return MessageDigest.getInstance("SHA-256").digest(data); } + private static byte[] ecdsaTxId(Transaction tx) { + return Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), + tx.getRawData().toByteArray()); + } + private static byte[] longToBytes(long value) { return ByteBuffer.allocate(8).putLong(value).array(); } @@ -146,7 +193,7 @@ private static byte[] longToBytes(long value) { private static void runTransferLoop(WalletBlockingStub stub, byte[] ownerAddr, byte[] userPub, byte[] userPriv, double tps) { if (tps <= 0) { - System.out.println("transfer sender disabled"); + System.out.println("pqc transfer sender disabled"); return; } long intervalMs = tpsToIntervalMs(tps); @@ -161,7 +208,7 @@ private static void runTransferLoop(WalletBlockingStub stub, byte[] ownerAddr, private static void runTrc20Loop(WalletBlockingStub stub, byte[] ownerAddr, byte[] userPub, byte[] userPriv, double tps) { if (tps <= 0) { - System.out.println("trc20 sender disabled"); + System.out.println("pqc trc20 sender disabled"); return; } long intervalMs = tpsToIntervalMs(tps); @@ -173,6 +220,36 @@ private static void runTrc20Loop(WalletBlockingStub stub, byte[] ownerAddr, } } + private static void runEcdsaTransferLoop(WalletBlockingStub stub, byte[] ownerAddr, + ECKey ecdsaKey, double tps) { + if (tps <= 0) { + System.out.println("ecdsa transfer sender disabled"); + return; + } + long intervalMs = tpsToIntervalMs(tps); + long counter = 1L; + while (!Thread.currentThread().isInterrupted()) { + long loopStart = System.currentTimeMillis(); + sendEcdsaTransferTransaction(stub, ownerAddr, ecdsaKey, counter++); + sleepRemaining(intervalMs, loopStart); + } + } + + private static void runEcdsaTrc20Loop(WalletBlockingStub stub, byte[] ownerAddr, + ECKey ecdsaKey, double tps) { + if (tps <= 0) { + System.out.println("ecdsa trc20 sender disabled"); + return; + } + long intervalMs = tpsToIntervalMs(tps); + long counter = 1L; + while (!Thread.currentThread().isInterrupted()) { + long loopStart = System.currentTimeMillis(); + sendEcdsaTrc20Transaction(stub, ownerAddr, ecdsaKey, counter++); + sleepRemaining(intervalMs, loopStart); + } + } + private static void sendTransferTransaction(WalletBlockingStub stub, byte[] ownerAddr, byte[] userPub, byte[] userPriv, long seq) { try { @@ -195,12 +272,11 @@ private static void sendTransferTransaction(WalletBlockingStub stub, byte[] owne .build(); Return result = timedStub.broadcastTransaction(signedTx); - System.out.println("[transfer-" + seq + "] ref=#" + refNum + System.out.println("[pqc-transfer-" + seq + "] ref=#" + refNum + " tx=" + ByteArray.toHexString(txId) - + " result=" + result.getCode() - + " msg=" + result.getMessage().toStringUtf8()); + + " result=" + result.getCode()); } catch (Exception e) { - System.err.println("[transfer-" + seq + "] send failed: " + e.getMessage()); + System.err.println("[pqc-transfer-" + seq + "] send failed: " + e.getMessage()); e.printStackTrace(System.err); } } @@ -231,16 +307,74 @@ private static void sendTrc20Transaction(WalletBlockingStub stub, byte[] ownerAd .build(); Return result = timedStub.broadcastTransaction(signedTx); - System.out.println("[trc20-" + seq + "] ref=#" + refNum + System.out.println("[pqc-trc20-" + seq + "] ref=#" + refNum + + " tx=" + ByteArray.toHexString(txId) + + " result=" + result.getCode()); + } catch (Exception e) { + System.err.println("[pqc-trc20-" + seq + "] send failed: " + e.getMessage()); + e.printStackTrace(System.err); + } + } + + private static void sendEcdsaTransferTransaction(WalletBlockingStub stub, byte[] ownerAddr, + ECKey ecdsaKey, long seq) { + try { + WalletBlockingStub timedStub = stub.withDeadlineAfter(10, TimeUnit.SECONDS); + + Block head = timedStub.getNowBlock(EmptyMessage.getDefaultInstance()); + byte[] headerRaw = head.getBlockHeader().getRawData().toByteArray(); + long refNum = head.getBlockHeader().getRawData().getNumber(); + byte[] blockHash = sha256(headerRaw); + + Transaction tx = buildTransferTransaction(ownerAddr, blockHash, refNum); + byte[] txId = ecdsaTxId(tx); + Transaction signedTx = signWithEcdsa(tx, ecdsaKey, txId); + + Return result = timedStub.broadcastTransaction(signedTx); + System.out.println("[ecdsa-transfer-" + seq + "] ref=#" + refNum + + " tx=" + ByteArray.toHexString(txId) + + " result=" + result.getCode()); + } catch (Exception e) { + System.err.println("[ecdsa-transfer-" + seq + "] send failed: " + e.getMessage()); + e.printStackTrace(System.err); + } + } + + private static void sendEcdsaTrc20Transaction(WalletBlockingStub stub, byte[] ownerAddr, + ECKey ecdsaKey, long seq) { + try { + WalletBlockingStub timedStub = stub.withDeadlineAfter(10, TimeUnit.SECONDS); + + Block head = timedStub.getNowBlock(EmptyMessage.getDefaultInstance()); + byte[] headerRaw = head.getBlockHeader().getRawData().toByteArray(); + long refNum = head.getBlockHeader().getRawData().getNumber(); + byte[] blockHash = sha256(headerRaw); + + Transaction tx = buildTrc20Transaction(ownerAddr, blockHash, refNum); + Transaction.raw.Builder rawBuilder = tx.getRawData().toBuilder(); + rawBuilder.setFeeLimit(TRC20_FEE_LIMIT); + tx = tx.toBuilder().setRawData(rawBuilder).build(); + + byte[] txId = ecdsaTxId(tx); + Transaction signedTx = signWithEcdsa(tx, ecdsaKey, txId); + + Return result = timedStub.broadcastTransaction(signedTx); + System.out.println("[ecdsa-trc20-" + seq + "] ref=#" + refNum + " tx=" + ByteArray.toHexString(txId) - + " result=" + result.getCode() - + " msg=" + result.getMessage().toStringUtf8()); + + " result=" + result.getCode()); } catch (Exception e) { - System.err.println("[trc20-" + seq + "] send failed: " + e.getMessage()); + System.err.println("[ecdsa-trc20-" + seq + "] send failed: " + e.getMessage()); e.printStackTrace(System.err); } } + private static Transaction signWithEcdsa(Transaction tx, ECKey ecdsaKey, byte[] txId) { + ECDSASignature signature = ecdsaKey.sign(txId); + return tx.toBuilder() + .addSignature(ByteString.copyFrom(signature.toByteArray())) + .build(); + } + private static Transaction buildTransferTransaction(byte[] ownerAddr, byte[] blockHash, long refNum) { Transaction.raw rawData = Transaction.raw.newBuilder() diff --git a/framework/src/test/java/org/tron/core/capsule/BlockCapsulePQTest.java b/framework/src/test/java/org/tron/core/capsule/BlockCapsulePQTest.java index 6c8b482cd51..c20df122995 100644 --- a/framework/src/test/java/org/tron/core/capsule/BlockCapsulePQTest.java +++ b/framework/src/test/java/org/tron/core/capsule/BlockCapsulePQTest.java @@ -15,6 +15,8 @@ import org.tron.core.exception.ValidateSignatureException; import org.tron.protos.Protocol.Account; import org.tron.protos.Protocol.AccountType; +import org.tron.protos.Protocol.Block; +import org.tron.protos.Protocol.BlockHeader; import org.tron.protos.Protocol.Key; import org.tron.protos.Protocol.PQAuthSig; import org.tron.protos.Protocol.PQScheme; @@ -100,6 +102,20 @@ private PQAuthSig buildPQAuthSig(byte[] signature) { .build(); } + /** + * {@link BlockCapsule#hasWitnessSignature()} is the apply-vs-pack discriminator + * in {@code Manager#processTransaction}; a PQ-only block must read as signed so + * it follows the same apply/trace-check path as ECDSA blocks. + */ + @Test + public void hasWitnessSignatureTrueForPqOnlyBlock() { + byte[] parentHash = new byte[32]; + BlockCapsule block = buildUnsignedBlock(parentHash); + Assert.assertFalse(block.hasWitnessSignature()); + block.setPqAuthSig(buildPQAuthSig(signPQ(block.getRawHashBytes()))); + Assert.assertTrue(block.hasWitnessSignature()); + } + @Test public void legacyValidateWithoutPQAuthSigAcceptedBeforeActivation() throws Exception { dbManager.getDynamicPropertiesStore().saveAllowMultiSign(1L); @@ -136,9 +152,16 @@ public void bothLegacyAndPQAuthSigRejected() throws Exception { dbManager.getAccountStore().put(witnessAddress, witness); byte[] parentHash = new byte[32]; - BlockCapsule block = buildSignedBlock(parentHash); - byte[] digest = block.getRawHashBytes(); - block.setPqAuthSig(buildPQAuthSig(signPQ(digest))); + BlockCapsule signed = buildSignedBlock(parentHash); + byte[] digest = signed.getRawHashBytes(); + // Bypass BlockCapsule#setPqAuthSig (which clears witness_signature) so the + // resulting block carries BOTH legacy ECDSA + PQ signatures — the wire shape + // that the mutual-exclusion check in validateSignature must reject. + BlockHeader dualHeader = signed.getInstance().getBlockHeader().toBuilder() + .setPqAuthSig(buildPQAuthSig(signPQ(digest))) + .build(); + Block dual = signed.getInstance().toBuilder().setBlockHeader(dualHeader).build(); + BlockCapsule block = new BlockCapsule(dual); block.validateSignature( dbManager.getDynamicPropertiesStore(), dbManager.getAccountStore()); } diff --git a/framework/src/test/java/org/tron/core/net/messagehandler/PbftDataSyncHandlerPQTest.java b/framework/src/test/java/org/tron/core/net/messagehandler/PbftDataSyncHandlerPQTest.java index 890b0097b43..365abbd891b 100644 --- a/framework/src/test/java/org/tron/core/net/messagehandler/PbftDataSyncHandlerPQTest.java +++ b/framework/src/test/java/org/tron/core/net/messagehandler/PbftDataSyncHandlerPQTest.java @@ -81,8 +81,8 @@ public void pqQuorumValidates() throws Exception { PQAuthSig sig2 = pqSign(kp2, hash); List witnesses = Arrays.asList( - ByteString.copyFrom(PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, kp1.getPublicKey())), - ByteString.copyFrom(PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, kp2.getPublicKey()))); + pqAddress(kp1), + pqAddress(kp2)); Assert.assertTrue(invokeValid(raw, Collections.emptyList(), Arrays.asList(sig1, sig2), witnesses)); @@ -102,7 +102,7 @@ public void mixedEcdsaAndPqQuorumValidates() throws Exception { List witnesses = Arrays.asList( ByteString.copyFrom(ec.getAddress()), - ByteString.copyFrom(PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, kp.getPublicKey()))); + pqAddress(kp)); Assert.assertTrue(invokeValid(raw, Collections.singletonList(ByteString.copyFrom(ecSig)), @@ -119,7 +119,7 @@ public void underQuorumFails() throws Exception { FNDSA512 kp = new FNDSA512(); PQAuthSig pq = pqSign(kp, hash); List witnesses = Collections.singletonList( - ByteString.copyFrom(PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, kp.getPublicKey()))); + pqAddress(kp)); Assert.assertFalse(invokeValid(raw, Collections.emptyList(), Collections.singletonList(pq), witnesses)); @@ -134,7 +134,7 @@ public void pqSchemeNotActivatedFails() throws Exception { FNDSA512 kp = new FNDSA512(); PQAuthSig pq = pqSign(kp, hash); List witnesses = Collections.singletonList( - ByteString.copyFrom(PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, kp.getPublicKey()))); + pqAddress(kp)); Mockito.when(dynamicPropertiesStore.isPqSchemeAllowed(PQScheme.FN_DSA_512)).thenReturn(false); Assert.assertFalse(invokeValid(raw, Collections.emptyList(), @@ -155,7 +155,7 @@ public void pqPublicKeyLengthMismatchFails() throws Exception { .setSignature(ByteString.copyFrom(sig)) .build(); List witnesses = Collections.singletonList( - ByteString.copyFrom(PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, kp.getPublicKey()))); + pqAddress(kp)); Assert.assertFalse(invokeValid(raw, Collections.emptyList(), Collections.singletonList(pq), witnesses)); @@ -170,14 +170,31 @@ public void pqSignerNotInWitnessSetFails() throws Exception { FNDSA512 kp = new FNDSA512(); PQAuthSig pq = pqSign(kp, hash); FNDSA512 stranger = new FNDSA512(); - List witnesses = Collections.singletonList( - ByteString.copyFrom( - PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, stranger.getPublicKey()))); + List witnesses = Collections.singletonList(pqAddress(stranger)); Assert.assertFalse(invokeValid(raw, Collections.emptyList(), Collections.singletonList(pq), witnesses)); } + @Test + public void duplicatePqSignerDoesNotInflateQuorum() throws Exception { + Param.getInstance().setAgreeNodeCount(2); + Raw raw = buildRaw(8); + byte[] hash = Sha256Hash.hash(true, raw.toByteArray()); + + FNDSA512 kp = new FNDSA512(); + PQAuthSig sig1 = pqSign(kp, hash); + PQAuthSig sig2 = pqSign(kp, hash); + Assert.assertNotEquals("Falcon should produce randomized signatures", + sig1.getSignature(), sig2.getSignature()); + + List witnesses = Collections.singletonList( + pqAddress(kp)); + + Assert.assertFalse(invokeValid(raw, Collections.emptyList(), + Arrays.asList(sig1, sig2), witnesses)); + } + @Test public void pqBadSignatureFails() throws Exception { Param.getInstance().setAgreeNodeCount(1); @@ -194,7 +211,7 @@ public void pqBadSignatureFails() throws Exception { .setSignature(ByteString.copyFrom(tampered)) .build(); List witnesses = Collections.singletonList( - ByteString.copyFrom(PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, kp.getPublicKey()))); + pqAddress(kp)); Assert.assertFalse(invokeValid(raw, Collections.emptyList(), Collections.singletonList(pq), witnesses)); @@ -210,6 +227,11 @@ private static Raw buildRaw(long viewN) { .build(); } + private static ByteString pqAddress(FNDSA512 kp) { + return ByteString.copyFrom( + PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, kp.getPublicKey())); + } + private static PQAuthSig pqSign(FNDSA512 kp, byte[] hash) { byte[] sig = PQSchemeRegistry.sign(PQScheme.FN_DSA_512, kp.getPrivateKey(), hash); return PQAuthSig.newBuilder() diff --git a/framework/src/test/java/org/tron/core/pbft/PbftPQMessageTest.java b/framework/src/test/java/org/tron/core/pbft/PbftPQMessageTest.java index a3247b448cd..16cab0b9920 100644 --- a/framework/src/test/java/org/tron/core/pbft/PbftPQMessageTest.java +++ b/framework/src/test/java/org/tron/core/pbft/PbftPQMessageTest.java @@ -132,9 +132,12 @@ public void testPqSchemeNotRegistered() throws Exception { Miner miner = pqMiner(kp); PbftMessage msg = PbftMessage.prePrepareBlockMsg(emptyBlock(), 1, miner); + // UNKNOWN_PQ_SCHEME (0) is normalized to FN_DSA_512 by PQSchemeRegistry#resolve + // for proto3 default-zero compatibility, so use setSchemeValue() to inject a + // truly unrecognized scheme value that bypasses the enum and the normalizer. PBFTMessage tampered = msg.getPbftMessage().toBuilder() .setPqAuthSig(msg.getPbftMessage().getPqAuthSig().toBuilder() - .setScheme(PQScheme.UNKNOWN_PQ_SCHEME)) + .setSchemeValue(999)) .build(); PbftMessage rebuilt = rebuild(msg, tampered); SignatureException ex = assertThrows(SignatureException.class, rebuilt::analyzeSignature); From 26e37e396acd96f9e782d47677c64e99912a32a5 Mon Sep 17 00:00:00 2001 From: federico Date: Thu, 14 May 2026 20:49:17 +0800 Subject: [PATCH 08/47] fix: address PQ PR review feedback --- .../main/java/org/tron/core/vm/PrecompiledContracts.java | 2 -- .../main/java/org/tron/common/utils/LocalWitnesses.java | 7 +++---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java b/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java index f5e0f63dc59..5fe58cb9e14 100644 --- a/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java +++ b/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java @@ -2497,7 +2497,6 @@ public Pair execute(byte[] data) { boolean ok = FNDSA512.verify(pk, msg, sig); return Pair.of(true, ok ? DataWord.ONE().getData() : DataWord.ZERO().getData()); } catch (Throwable t) { - logger.info("VerifyFnDsa512 error:{}", t.getMessage()); return Pair.of(true, DataWord.ZERO().getData()); } } @@ -2655,7 +2654,6 @@ public Pair execute(byte[] rawData) { if (t instanceof OutOfTimeException) { throw t; } - logger.info("ValidateMultiSign(0x17) error:{}", t.getMessage()); } return Pair.of(true, DATA_FALSE); } diff --git a/chainbase/src/main/java/org/tron/common/utils/LocalWitnesses.java b/chainbase/src/main/java/org/tron/common/utils/LocalWitnesses.java index dc8f4f0c4be..bdd0fad4269 100644 --- a/chainbase/src/main/java/org/tron/common/utils/LocalWitnesses.java +++ b/chainbase/src/main/java/org/tron/common/utils/LocalWitnesses.java @@ -142,12 +142,11 @@ public void addPrivateKeys(String privateKey) { */ public void setPqKeypairs(final List pqPrivateKeys, final List pqPublicKeys) { - if (CollectionUtils.isEmpty(pqPrivateKeys) - && CollectionUtils.isEmpty(pqPublicKeys)) { + int privCount = CollectionUtils.isEmpty(pqPrivateKeys) ? 0 : pqPrivateKeys.size(); + int pubCount = CollectionUtils.isEmpty(pqPublicKeys) ? 0 : pqPublicKeys.size(); + if (privCount == 0 && pubCount == 0) { return; } - int privCount = pqPrivateKeys == null ? 0 : pqPrivateKeys.size(); - int pubCount = pqPublicKeys == null ? 0 : pqPublicKeys.size(); if (privCount != pubCount) { throw new TronError(String.format( "PQ keypair list size mismatch: priv=%d, pub=%d", privCount, pubCount), From 6b7c1391e8d1af83e110175e75923fdfd4327a54 Mon Sep 17 00:00:00 2001 From: federico Date: Fri, 15 May 2026 17:46:23 +0800 Subject: [PATCH 09/47] feat(actuator): include pq signatures in getTransactionSignWeight --- .../org/tron/core/utils/TransactionUtil.java | 22 +++++++++++++++---- .../tron/core/capsule/TransactionCapsule.java | 10 ++++++--- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/actuator/src/main/java/org/tron/core/utils/TransactionUtil.java b/actuator/src/main/java/org/tron/core/utils/TransactionUtil.java index 53d6caf5691..e487373951e 100644 --- a/actuator/src/main/java/org/tron/core/utils/TransactionUtil.java +++ b/actuator/src/main/java/org/tron/core/utils/TransactionUtil.java @@ -37,6 +37,7 @@ import org.tron.api.GrpcAPI.TransactionExtention; import org.tron.api.GrpcAPI.TransactionSignWeight; import org.tron.api.GrpcAPI.TransactionSignWeight.Result; +import org.tron.common.math.StrictMathWrapper; import org.tron.common.parameter.CommonParameter; import org.tron.common.utils.Sha256Hash; import org.tron.core.ChainBaseManager; @@ -221,11 +222,24 @@ public TransactionSignWeight getTransactionSignWeight(Transaction trx) { } } tswBuilder.setPermission(permission); - if (trx.getSignatureCount() > 0) { + if (trx.getSignatureCount() > 0 || trx.getPqAuthSigCount() > 0) { List approveList = new ArrayList<>(); - long currentWeight = TransactionCapsule.checkWeight(permission, trx.getSignatureList(), - Sha256Hash.hash(CommonParameter.getInstance() - .isECKeyCryptoEngine(), trx.getRawData().toByteArray()), approveList); + long currentWeight = 0L; + if (trx.getSignatureCount() > 0) { + currentWeight = TransactionCapsule.checkWeight(permission, trx.getSignatureList(), + Sha256Hash.hash(CommonParameter.getInstance() + .isECKeyCryptoEngine(), trx.getRawData().toByteArray()), approveList); + } + if (trx.getPqAuthSigCount() > 0) { + java.util.Set signedAddresses = new java.util.HashSet<>(approveList); + try { + currentWeight = StrictMathWrapper.addExact(currentWeight, + TransactionCapsule.validatePQSignature(trx, permission, signedAddresses, + chainBaseManager.getDynamicPropertiesStore(), approveList)); + } catch (ArithmeticException e) { + throw new PermissionException("weight overflow"); + } + } tswBuilder.addAllApprovedList(approveList); tswBuilder.setCurrentWeight(currentWeight); } diff --git a/chainbase/src/main/java/org/tron/core/capsule/TransactionCapsule.java b/chainbase/src/main/java/org/tron/core/capsule/TransactionCapsule.java index 21f0f1adc59..0dd908a1853 100755 --- a/chainbase/src/main/java/org/tron/core/capsule/TransactionCapsule.java +++ b/chainbase/src/main/java/org/tron/core/capsule/TransactionCapsule.java @@ -504,7 +504,7 @@ public static boolean validateSignature(Transaction transaction, try { weight = StrictMathWrapper.addExact(weight, validatePQSignature(transaction, permission, signedAddresses, - dynamicPropertiesStore)); + dynamicPropertiesStore, approveList)); } catch (ArithmeticException e) { throw new PermissionException("weight overflow"); } @@ -723,9 +723,10 @@ void logSlowSigVerify(long startNs) { * part of {@code raw_data}. * */ - static long validatePQSignature(Transaction transaction, Permission permission, + public static long validatePQSignature(Transaction transaction, Permission permission, java.util.Set signedAddresses, - DynamicPropertiesStore dynamicPropertiesStore) + DynamicPropertiesStore dynamicPropertiesStore, + List approveList) throws PermissionException { byte[] digest = computeRawHash(transaction).getBytes(); @@ -770,6 +771,9 @@ static long validatePQSignature(Transaction transaction, Permission permission, } catch (ArithmeticException e) { throw new PermissionException("weight overflow"); } + if (approveList != null) { + approveList.add(addrBs); + } } return weight; } From e32012badcd185b90af504becdb965bdd92f5136 Mon Sep 17 00:00:00 2001 From: GrapeS Date: Mon, 18 May 2026 12:25:15 +0800 Subject: [PATCH 10/47] style(chainbase): use Set/HashSet imports instead of FQCN The inline fully-qualified java.util.Set / HashSet in validatePQSignature was a leftover from avoiding new imports. Standard JLS / Checkstyle style expects imports for non-conflicting JDK types. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../org/tron/core/capsule/TransactionCapsule.java | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/chainbase/src/main/java/org/tron/core/capsule/TransactionCapsule.java b/chainbase/src/main/java/org/tron/core/capsule/TransactionCapsule.java index 0dd908a1853..cf83ceeb8e8 100755 --- a/chainbase/src/main/java/org/tron/core/capsule/TransactionCapsule.java +++ b/chainbase/src/main/java/org/tron/core/capsule/TransactionCapsule.java @@ -32,7 +32,9 @@ import java.security.SignatureException; import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; import java.util.List; +import java.util.Set; import java.util.concurrent.ExecutorService; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; @@ -495,16 +497,13 @@ public static boolean validateSignature(Transaction transaction, // Hybrid weight: ECDSA signatures and PQ witnesses share one threshold // check. The two domains derive distinct addresses (Keccak vs SHA-256 // tagged with 0x41), so a key entry contributes to at most one path. - java.util.Set signedAddresses = new java.util.HashSet<>(); List approveList = new ArrayList<>(); long weight = checkWeight(permission, transaction.getSignatureList(), hash, approveList); - signedAddresses.addAll(approveList); - if (transaction.getPqAuthSigCount() > 0) { + if (dynamicPropertiesStore.isAnyPqSchemeAllowed() && transaction.getPqAuthSigCount() > 0) { try { weight = StrictMathWrapper.addExact(weight, - validatePQSignature(transaction, permission, signedAddresses, - dynamicPropertiesStore, approveList)); + validatePQSignatureGetWeight(transaction, permission, dynamicPropertiesStore, approveList)); } catch (ArithmeticException e) { throw new PermissionException("weight overflow"); } @@ -723,13 +722,15 @@ void logSlowSigVerify(long startNs) { * part of {@code raw_data}. * */ - public static long validatePQSignature(Transaction transaction, Permission permission, - java.util.Set signedAddresses, + public static long validatePQSignatureGetWeight(Transaction transaction, Permission permission, DynamicPropertiesStore dynamicPropertiesStore, List approveList) throws PermissionException { + byte[] digest = computeRawHash(transaction).getBytes(); + Set signedAddresses = new HashSet<>(approveList); + long weight = 0L; for (PQAuthSig witness : transaction.getPqAuthSigList()) { PQScheme scheme = witness.getScheme(); From 2e8a236d30a3587af1da2baeb9455ed5c4c55e20 Mon Sep 17 00:00:00 2001 From: GrapeS Date: Mon, 18 May 2026 12:25:24 +0800 Subject: [PATCH 11/47] refactor(witness): bundle PQ priv/pub key lists into PqKeypair Replace the two parallel List fields on LocalWitnesses (pqPrivateKeys / pqPublicKeys) with a single List so the index-alignment invariant is enforced by construction rather than by runtime size-mismatch checks. - chainbase: new PqKeypair (Lombok @Value) + LocalWitnesses uses it - framework: Args parses extended-hex entries directly into PqKeypair; WitnessInitializer / ConsensusService / RelayService consume pairs - test: rewritten to construct PqKeypair instances; the mismatched-list-size test is removed (impossible by construction) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../org/tron/common/utils/LocalWitnesses.java | 49 ++++++------------- .../java/org/tron/common/utils/PqKeypair.java | 14 ++++++ .../java/org/tron/core/config/args/Args.java | 17 ++++--- .../core/config/args/WitnessInitializer.java | 24 +++------ .../tron/core/consensus/ConsensusService.java | 31 +++++------- .../core/net/service/relay/RelayService.java | 8 +-- .../tron/common/utils/LocalWitnessesTest.java | 46 ++++++----------- 7 files changed, 78 insertions(+), 111 deletions(-) create mode 100644 chainbase/src/main/java/org/tron/common/utils/PqKeypair.java diff --git a/chainbase/src/main/java/org/tron/common/utils/LocalWitnesses.java b/chainbase/src/main/java/org/tron/common/utils/LocalWitnesses.java index bdd0fad4269..0f10ef09208 100644 --- a/chainbase/src/main/java/org/tron/common/utils/LocalWitnesses.java +++ b/chainbase/src/main/java/org/tron/common/utils/LocalWitnesses.java @@ -37,9 +37,10 @@ public class LocalWitnesses { private List privateKeys = Lists.newArrayList(); /** - * Pre-derived PQ private keys in hex format, one per witness. The expected - * byte length depends on {@link #pqScheme}: 1280 bytes (2560 hex chars) for - * FN-DSA-512. Index-aligned with {@link #pqPublicKeys}. + * Pre-derived PQ keypairs (private + public, hex), one per witness. The + * expected byte lengths depend on {@link #pqScheme}: for FN-DSA-512 each + * private key is 1280 bytes (2560 hex chars) and each public key is 896 + * bytes (1792 hex chars). * *

Configured directly (rather than derived from a seed on the node) so * the runtime path is not exposed to potential cross-platform floating-point @@ -47,17 +48,9 @@ public class LocalWitnesses { * off-line and ship both halves to the node. */ @Getter - private List pqPrivateKeys = Lists.newArrayList(); + private List pqKeypairs = Lists.newArrayList(); - /** - * PQ public keys in hex format, one per witness. The expected byte length - * depends on {@link #pqScheme}: 896 bytes (1792 hex chars) for FN-DSA-512. - * Index-aligned with {@link #pqPrivateKeys}. - */ - @Getter - private List pqPublicKeys = Lists.newArrayList(); - - /** PQ signature scheme used by the configured {@link #pqPrivateKeys}. */ + /** PQ signature scheme used by the configured {@link #pqKeypairs}. */ @Getter private PQScheme pqScheme = PQScheme.FN_DSA_512; @@ -134,32 +127,22 @@ public void addPrivateKeys(String privateKey) { /** * Pre-derived PQ keypairs (priv + pub) used as signing keys under - * {@link #pqScheme}. The two lists must be the same length and index-aligned; - * each entry must be a hex string whose byte length matches the scheme's - * required private/public key size. Callers must therefore set the scheme - * via {@link #setPqScheme(PQScheme)} before calling this method when - * targeting a non-default scheme. + * {@link #pqScheme}. Each entry's private/public hex byte length must match + * the scheme's required size. Callers must therefore set the scheme via + * {@link #setPqScheme(PQScheme)} before calling this method when targeting a + * non-default scheme. */ - public void setPqKeypairs(final List pqPrivateKeys, - final List pqPublicKeys) { - int privCount = CollectionUtils.isEmpty(pqPrivateKeys) ? 0 : pqPrivateKeys.size(); - int pubCount = CollectionUtils.isEmpty(pqPublicKeys) ? 0 : pqPublicKeys.size(); - if (privCount == 0 && pubCount == 0) { + public void setPqKeypairs(final List pqKeypairs) { + if (CollectionUtils.isEmpty(pqKeypairs)) { return; } - if (privCount != pubCount) { - throw new TronError(String.format( - "PQ keypair list size mismatch: priv=%d, pub=%d", privCount, pubCount), - TronError.ErrCode.WITNESS_INIT); - } int expectedPrivLen = PQSchemeRegistry.getPrivateKeyLength(pqScheme); int expectedPubLen = PQSchemeRegistry.getPublicKeyLength(pqScheme); - for (int i = 0; i < privCount; i++) { - validatePqKey(pqPrivateKeys.get(i), expectedPrivLen, "PQ private key"); - validatePqKey(pqPublicKeys.get(i), expectedPubLen, "PQ public key"); + for (PqKeypair kp : pqKeypairs) { + validatePqKey(kp.getPrivateKey(), expectedPrivLen, "PQ private key"); + validatePqKey(kp.getPublicKey(), expectedPubLen, "PQ public key"); } - this.pqPrivateKeys = pqPrivateKeys; - this.pqPublicKeys = pqPublicKeys; + this.pqKeypairs = pqKeypairs; } private static void validatePqKey(String key, int expectedLen, String label) { diff --git a/chainbase/src/main/java/org/tron/common/utils/PqKeypair.java b/chainbase/src/main/java/org/tron/common/utils/PqKeypair.java new file mode 100644 index 00000000000..22762b2690a --- /dev/null +++ b/chainbase/src/main/java/org/tron/common/utils/PqKeypair.java @@ -0,0 +1,14 @@ +package org.tron.common.utils; + +import lombok.Value; + +/** + * Immutable hex-encoded post-quantum keypair (private + public key). Bundles + * the two halves so the public/private lists can no longer drift out of + * index-alignment by construction. + */ +@Value +public class PqKeypair { + String privateKey; + String publicKey; +} diff --git a/framework/src/main/java/org/tron/core/config/args/Args.java b/framework/src/main/java/org/tron/core/config/args/Args.java index a4b6e9a4998..7306888fada 100644 --- a/framework/src/main/java/org/tron/core/config/args/Args.java +++ b/framework/src/main/java/org/tron/core/config/args/Args.java @@ -55,6 +55,7 @@ import org.tron.common.setting.RocksDbSettings; import org.tron.common.utils.Commons; import org.tron.common.utils.LocalWitnesses; +import org.tron.common.utils.PqKeypair; import org.tron.core.Constant; import org.tron.core.Wallet; import org.tron.core.config.Configuration; @@ -990,13 +991,12 @@ private static void initLocalWitnesses(Config config, CLIParameter cmd) { } // Each entry is the extended private key f‖g‖F‖h (priv ‖ pub) hex, // sized (privLen + pubLen) bytes for the active scheme. We split here - // so downstream consumers (ConsensusService, LocalWitnesses) keep the - // same priv/pub split they already use — derivePublicKey(priv) replaces - // the previous explicit `pub` config field. + // into PqKeypair entries so downstream consumers (ConsensusService, + // LocalWitnesses) get the priv/pub halves bundled — derivePublicKey + // (priv) replaces the previous explicit `pub` config field. int privHexLen = PQSchemeRegistry.getPrivateKeyLength(scheme) * 2; int extHexLen = privHexLen + PQSchemeRegistry.getPublicKeyLength(scheme) * 2; - List pqPrivateKeys = new ArrayList<>(pqEntries.size()); - List pqPublicKeys = new ArrayList<>(pqEntries.size()); + List pqKeypairs = new ArrayList<>(pqEntries.size()); for (int i = 0; i < pqEntries.size(); i++) { String hex = pqEntries.get(i); String stripped = hex; @@ -1010,11 +1010,12 @@ private static void initLocalWitnesses(Config config, CLIParameter cmd) { stripped == null ? 0 : stripped.length()), TronError.ErrCode.WITNESS_INIT); } - pqPrivateKeys.add(stripped.substring(0, privHexLen)); - pqPublicKeys.add(stripped.substring(privHexLen)); + pqKeypairs.add(new PqKeypair( + stripped.substring(0, privHexLen), + stripped.substring(privHexLen))); } localWitnesses = WitnessInitializer.initFromPQOnly( - scheme, pqPrivateKeys, pqPublicKeys, lwConfig.getAccountAddress()); + scheme, pqKeypairs, lwConfig.getAccountAddress()); return; } } diff --git a/framework/src/main/java/org/tron/core/config/args/WitnessInitializer.java b/framework/src/main/java/org/tron/core/config/args/WitnessInitializer.java index 52819df0843..935a8b597ad 100644 --- a/framework/src/main/java/org/tron/core/config/args/WitnessInitializer.java +++ b/framework/src/main/java/org/tron/core/config/args/WitnessInitializer.java @@ -11,6 +11,7 @@ import org.tron.common.utils.ByteArray; import org.tron.common.utils.Commons; import org.tron.common.utils.LocalWitnesses; +import org.tron.common.utils.PqKeypair; import org.tron.core.exception.CipherException; import org.tron.core.exception.TronError; import org.tron.keystore.Credentials; @@ -121,34 +122,23 @@ public static LocalWitnesses initFromKeystore( * byte[])}. */ public static LocalWitnesses initFromPQOnly(PQScheme scheme, - List pqPrivateKeys, List pqPublicKeys, - String witnessAccountAddress) { - if (pqPublicKeys == null || pqPublicKeys.isEmpty()) { - throw new TronError( - "PQ public keys must be set for PQ-only witness nodes", - TronError.ErrCode.WITNESS_INIT); - } - if (pqPrivateKeys == null || pqPrivateKeys.isEmpty()) { - throw new TronError( - "PQ private keys must be set for PQ-only witness nodes", - TronError.ErrCode.WITNESS_INIT); - } - if (pqPrivateKeys.size() != pqPublicKeys.size()) { + List pqKeypairs, String witnessAccountAddress) { + if (pqKeypairs == null || pqKeypairs.isEmpty()) { throw new TronError( - "PQ private/public key count mismatch", + "PQ keypairs must be set for PQ-only witness nodes", TronError.ErrCode.WITNESS_INIT); } LocalWitnesses witnesses = new LocalWitnesses(); witnesses.setPqScheme(scheme); - witnesses.setPqKeypairs(pqPrivateKeys, pqPublicKeys); + witnesses.setPqKeypairs(pqKeypairs); byte[] address; if (StringUtils.isBlank(witnessAccountAddress)) { - byte[] firstPubKey = ByteArray.fromHexString(pqPublicKeys.get(0)); + byte[] firstPubKey = ByteArray.fromHexString(pqKeypairs.get(0).getPublicKey()); address = PQSchemeRegistry.computeAddress(scheme, firstPubKey); logger.debug("Derived PQ-only witness address from public key"); } else { - if (pqPublicKeys.size() != 1) { + if (pqKeypairs.size() != 1) { throw new TronError( "LocalWitnessAccountAddress can only be set when there is only one PQ keypair", TronError.ErrCode.WITNESS_INIT); diff --git a/framework/src/main/java/org/tron/core/consensus/ConsensusService.java b/framework/src/main/java/org/tron/core/consensus/ConsensusService.java index 08ad9cd8a75..82343778335 100644 --- a/framework/src/main/java/org/tron/core/consensus/ConsensusService.java +++ b/framework/src/main/java/org/tron/core/consensus/ConsensusService.java @@ -13,6 +13,7 @@ import org.tron.common.crypto.pqc.PQSchemeRegistry; import org.tron.common.crypto.pqc.PQSignature; import org.tron.common.parameter.CommonParameter; +import org.tron.common.utils.PqKeypair; import org.tron.consensus.Consensus; import org.tron.consensus.base.Param; import org.tron.consensus.base.Param.Miner; @@ -50,15 +51,8 @@ public void start() { param.setAgreeNodeCount(parameter.getAgreeNodeCount()); List miners = new ArrayList<>(); List privateKeys = Args.getLocalWitnesses().getPrivateKeys(); - List pqPrivateKeys = Args.getLocalWitnesses().getPqPrivateKeys(); - List pqPublicKeys = Args.getLocalWitnesses().getPqPublicKeys(); - if (pqPublicKeys.size() != pqPrivateKeys.size()) { - throw new TronError( - "localwitness_pq.keys size mismatch: " + pqPrivateKeys.size() - + " private vs " + pqPublicKeys.size() + " public", - TronError.ErrCode.WITNESS_INIT); - } - if (!privateKeys.isEmpty() && !pqPrivateKeys.isEmpty()) { + List pqKeypairs = Args.getLocalWitnesses().getPqKeypairs(); + if (!privateKeys.isEmpty() && !pqKeypairs.isEmpty()) { throw new TronError( "legacy localwitness keys and localwitness_pq.keys are mutually exclusive", TronError.ErrCode.WITNESS_INIT); @@ -93,12 +87,12 @@ public void start() { Miner miner = param.new Miner(privateKey, ByteString.copyFrom(privateKeyAddress), ByteString.copyFrom(witnessAddress)); miners.add(miner); - } else if (pqPrivateKeys.size() > 1) { + } else if (pqKeypairs.size() > 1) { PQScheme scheme = Args.getLocalWitnesses().getPqScheme(); requireSupportedPqScheme(scheme); - for (int i = 0; i < pqPrivateKeys.size(); i++) { - byte[] privBytes = fromHexString(pqPrivateKeys.get(i)); - byte[] pubBytes = fromHexString(pqPublicKeys.get(i)); + for (PqKeypair kp : pqKeypairs) { + byte[] privBytes = fromHexString(kp.getPrivateKey()); + byte[] pubBytes = fromHexString(kp.getPublicKey()); PQSignature keypair = PQSchemeRegistry.fromKeypair(scheme, privBytes, pubBytes); byte[] sk = keypair.getPrivateKey(); byte[] pk = keypair.getPublicKey(); @@ -117,8 +111,8 @@ public void start() { logger.info("Add {} witness (from configured keypair): {}, size: {}", scheme, Hex.toHexString(pqAddress), miners.size()); } - } else if (pqPrivateKeys.size() == 1) { - miners.add(buildPQOnlyMinerFromKeypair(param, pqPrivateKeys.get(0), pqPublicKeys.get(0))); + } else if (pqKeypairs.size() == 1) { + miners.add(buildPQOnlyMinerFromKeypair(param, pqKeypairs.get(0))); } param.setMiners(miners); @@ -128,12 +122,11 @@ public void start() { logger.info("consensus service start success"); } - private Miner buildPQOnlyMinerFromKeypair(Param param, String pqPrivateKey, - String pqPublicKey) { + private Miner buildPQOnlyMinerFromKeypair(Param param, PqKeypair pqKeypair) { PQScheme scheme = Args.getLocalWitnesses().getPqScheme(); requireSupportedPqScheme(scheme); - byte[] privBytes = fromHexString(pqPrivateKey); - byte[] pubBytes = fromHexString(pqPublicKey); + byte[] privBytes = fromHexString(pqKeypair.getPrivateKey()); + byte[] pubBytes = fromHexString(pqKeypair.getPublicKey()); PQSignature keypair = PQSchemeRegistry.fromKeypair(scheme, privBytes, pubBytes); byte[] sk = keypair.getPrivateKey(); byte[] pk = keypair.getPublicKey(); diff --git a/framework/src/main/java/org/tron/core/net/service/relay/RelayService.java b/framework/src/main/java/org/tron/core/net/service/relay/RelayService.java index 55ac1063f5f..560e65e94e6 100644 --- a/framework/src/main/java/org/tron/core/net/service/relay/RelayService.java +++ b/framework/src/main/java/org/tron/core/net/service/relay/RelayService.java @@ -23,6 +23,7 @@ import org.tron.common.parameter.CommonParameter; import org.tron.common.utils.ByteArray; import org.tron.common.utils.LocalWitnesses; +import org.tron.common.utils.PqKeypair; import org.tron.common.utils.Sha256Hash; import org.tron.core.ChainBaseManager; import org.tron.core.capsule.TransactionCapsule; @@ -72,7 +73,7 @@ public class RelayService { private final int keySize = Args.getLocalWitnesses().getPrivateKeys().size(); - private final int pqKeySize = Args.getLocalWitnesses().getPqPrivateKeys().size(); + private final int pqKeySize = Args.getLocalWitnesses().getPqKeypairs().size(); private final ByteString witnessAddress = Args.getLocalWitnesses().getWitnessAccountAddress() != null ? ByteString @@ -135,8 +136,9 @@ public void fillHelloMessage(HelloMessage message, Channel channel) { } else { LocalWitnesses lw = Args.getLocalWitnesses(); PQScheme scheme = lw.getPqScheme(); - byte[] privKey = ByteArray.fromHexString(lw.getPqPrivateKeys().get(0)); - byte[] pubKey = ByteArray.fromHexString(lw.getPqPublicKeys().get(0)); + PqKeypair kp = lw.getPqKeypairs().get(0); + byte[] privKey = ByteArray.fromHexString(kp.getPrivateKey()); + byte[] pubKey = ByteArray.fromHexString(kp.getPublicKey()); byte[] sig = PQSchemeRegistry.sign(scheme, privKey, digest); builder.setPqAuthSig(PQAuthSig.newBuilder() .setScheme(scheme) diff --git a/framework/src/test/java/org/tron/common/utils/LocalWitnessesTest.java b/framework/src/test/java/org/tron/common/utils/LocalWitnessesTest.java index b48995b89aa..766acc4c4b5 100644 --- a/framework/src/test/java/org/tron/common/utils/LocalWitnessesTest.java +++ b/framework/src/test/java/org/tron/common/utils/LocalWitnessesTest.java @@ -36,27 +36,18 @@ public static void generateKeypairs() { @Test public void fnDsa512AcceptsValidKeypair() { LocalWitnesses lw = new LocalWitnesses(); - lw.setPqKeypairs(Collections.singletonList(priv), Collections.singletonList(pub)); + lw.setPqKeypairs(Collections.singletonList(new PqKeypair(priv, pub))); assertEquals(PQScheme.FN_DSA_512, lw.getPqScheme()); - assertEquals(1, lw.getPqPrivateKeys().size()); - assertEquals(1, lw.getPqPublicKeys().size()); + assertEquals(1, lw.getPqKeypairs().size()); } @Test public void fnDsa512AcceptsMultipleKeypairs() { LocalWitnesses lw = new LocalWitnesses(); - lw.setPqKeypairs(Arrays.asList(priv, priv2), Arrays.asList(pub, pub2)); - assertEquals(2, lw.getPqPrivateKeys().size()); - assertEquals(2, lw.getPqPublicKeys().size()); - } - - @Test - public void mismatchedListSizesRejected() { - LocalWitnesses lw = new LocalWitnesses(); - TronError err = assertThrows(TronError.class, - () -> lw.setPqKeypairs(Arrays.asList(priv, priv2), Collections.singletonList(pub))); - assertEquals(TronError.ErrCode.WITNESS_INIT, err.getErrCode()); - assertTrue(err.getMessage().contains("size mismatch")); + lw.setPqKeypairs(Arrays.asList( + new PqKeypair(priv, pub), + new PqKeypair(priv2, pub2))); + assertEquals(2, lw.getPqKeypairs().size()); } @Test @@ -64,8 +55,7 @@ public void wrongLengthPrivateKeyRejected() { LocalWitnesses lw = new LocalWitnesses(); String shortPriv = priv.substring(2); TronError err = assertThrows(TronError.class, - () -> lw.setPqKeypairs(Collections.singletonList(shortPriv), - Collections.singletonList(pub))); + () -> lw.setPqKeypairs(Collections.singletonList(new PqKeypair(shortPriv, pub)))); assertEquals(TronError.ErrCode.WITNESS_INIT, err.getErrCode()); assertTrue(err.getMessage().contains("PQ private key")); // FN-DSA-512 private key is 1280 bytes = 2560 hex chars. @@ -77,8 +67,7 @@ public void wrongLengthPublicKeyRejected() { LocalWitnesses lw = new LocalWitnesses(); String shortPub = pub.substring(2); TronError err = assertThrows(TronError.class, - () -> lw.setPqKeypairs(Collections.singletonList(priv), - Collections.singletonList(shortPub))); + () -> lw.setPqKeypairs(Collections.singletonList(new PqKeypair(priv, shortPub)))); assertEquals(TronError.ErrCode.WITNESS_INIT, err.getErrCode()); assertTrue(err.getMessage().contains("PQ public key")); // FN-DSA-512 public key is 896 bytes = 1792 hex chars. @@ -90,8 +79,7 @@ public void nonHexPrivateKeyRejected() { LocalWitnesses lw = new LocalWitnesses(); String badPriv = "zz" + priv.substring(2); TronError err = assertThrows(TronError.class, - () -> lw.setPqKeypairs(Collections.singletonList(badPriv), - Collections.singletonList(pub))); + () -> lw.setPqKeypairs(Collections.singletonList(new PqKeypair(badPriv, pub)))); assertEquals(TronError.ErrCode.WITNESS_INIT, err.getErrCode()); assertTrue(err.getMessage().contains("hex")); } @@ -123,10 +111,9 @@ public void supportedSchemeAccepted() { @Test public void emptyKeypairsAreNoop() { LocalWitnesses lw = new LocalWitnesses(); - lw.setPqKeypairs(Collections.emptyList(), Collections.emptyList()); - lw.setPqKeypairs(null, null); - assertEquals(0, lw.getPqPrivateKeys().size()); - assertEquals(0, lw.getPqPublicKeys().size()); + lw.setPqKeypairs(Collections.emptyList()); + lw.setPqKeypairs(null); + assertEquals(0, lw.getPqKeypairs().size()); } @Test @@ -134,18 +121,15 @@ public void zeroXPrefixedHexAccepted() { // validatePqKey strips a leading "0x" before measuring the length, so // hex strings with the prefix must be accepted. LocalWitnesses lw = new LocalWitnesses(); - lw.setPqKeypairs( - Collections.singletonList("0x" + priv), - Collections.singletonList("0x" + pub)); - assertEquals(1, lw.getPqPrivateKeys().size()); + lw.setPqKeypairs(Collections.singletonList(new PqKeypair("0x" + priv, "0x" + pub))); + assertEquals(1, lw.getPqKeypairs().size()); } @Test public void blankKeyRejected() { LocalWitnesses lw = new LocalWitnesses(); TronError err = assertThrows(TronError.class, - () -> lw.setPqKeypairs(Collections.singletonList(""), - Collections.singletonList(pub))); + () -> lw.setPqKeypairs(Collections.singletonList(new PqKeypair("", pub)))); assertEquals(TronError.ErrCode.WITNESS_INIT, err.getErrCode()); assertTrue(err.getMessage().contains("PQ private key")); } From 38af8ea339bfbc35ec6e4910cb79653ec2ab015a Mon Sep 17 00:00:00 2001 From: GrapeS Date: Mon, 18 May 2026 12:29:14 +0800 Subject: [PATCH 12/47] refactor(actuator): gate PQ verify on isAnyPqSchemeAllowed in getTransactionSignWeight Three changes in TransactionUtil.getTransactionSignWeight: - PQ signature validation now only runs when isAnyPqSchemeAllowed() is true, mirroring the rest of the PQ activation gating - Switches to the renamed validatePQSignatureGetWeight API, which owns its own signed-address dedup set internally (no more passing one in) - Always emits approveList / currentWeight on the builder, even when the transaction carries zero signatures of either kind Co-Authored-By: Claude Opus 4.7 (1M context) --- .../org/tron/core/utils/TransactionUtil.java | 39 ++++++++++--------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/actuator/src/main/java/org/tron/core/utils/TransactionUtil.java b/actuator/src/main/java/org/tron/core/utils/TransactionUtil.java index e487373951e..3d89dd45c74 100644 --- a/actuator/src/main/java/org/tron/core/utils/TransactionUtil.java +++ b/actuator/src/main/java/org/tron/core/utils/TransactionUtil.java @@ -222,27 +222,28 @@ public TransactionSignWeight getTransactionSignWeight(Transaction trx) { } } tswBuilder.setPermission(permission); - if (trx.getSignatureCount() > 0 || trx.getPqAuthSigCount() > 0) { - List approveList = new ArrayList<>(); - long currentWeight = 0L; - if (trx.getSignatureCount() > 0) { - currentWeight = TransactionCapsule.checkWeight(permission, trx.getSignatureList(), - Sha256Hash.hash(CommonParameter.getInstance() - .isECKeyCryptoEngine(), trx.getRawData().toByteArray()), approveList); - } - if (trx.getPqAuthSigCount() > 0) { - java.util.Set signedAddresses = new java.util.HashSet<>(approveList); - try { - currentWeight = StrictMathWrapper.addExact(currentWeight, - TransactionCapsule.validatePQSignature(trx, permission, signedAddresses, - chainBaseManager.getDynamicPropertiesStore(), approveList)); - } catch (ArithmeticException e) { - throw new PermissionException("weight overflow"); - } + long currentWeight = 0L; + List approveList = new ArrayList<>(); + if (trx.getSignatureCount() > 0 ) { + currentWeight = TransactionCapsule.checkWeight(permission, trx.getSignatureList(), + Sha256Hash.hash(CommonParameter.getInstance() + .isECKeyCryptoEngine(), trx.getRawData().toByteArray()), approveList); + } + if (chainBaseManager.getDynamicPropertiesStore().isAnyPqSchemeAllowed() + && trx.getPqAuthSigCount() > 0) { + try { + long pqWeight = TransactionCapsule.validatePQSignatureGetWeight(trx, permission, + chainBaseManager.getDynamicPropertiesStore(), approveList); + // sum all signature weight + currentWeight = StrictMathWrapper.addExact(currentWeight,pqWeight); + } catch (ArithmeticException e) { + throw new PermissionException("weight overflow"); } - tswBuilder.addAllApprovedList(approveList); - tswBuilder.setCurrentWeight(currentWeight); } + + tswBuilder.addAllApprovedList(approveList); + tswBuilder.setCurrentWeight(currentWeight); + if (tswBuilder.getCurrentWeight() >= permission.getThreshold()) { resultBuilder.setCode(Result.response_code.ENOUGH_PERMISSION); } else { From bd79efc80a0a08f80281f0bd8308877589bdd6b4 Mon Sep 17 00:00:00 2001 From: GrapeS Date: Mon, 18 May 2026 14:26:17 +0800 Subject: [PATCH 13/47] refactor(block): gate PQ signature recognition behind isAnyPqSchemeAllowed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thread DynamicPropertiesStore through BlockCapsule#hasWitnessSignature so a PQ-only block only reads as signed once at least one PQ scheme has been governance-activated; before activation, only legacy ECDSA counts. In BlockCapsule#validateSignature, simplify the PQ path: - drop the early "mutually exclusive" / "missing witness signature" checks; if PQ is allowed and pq_auth_sig is set, validate PQ — else fall through to legacy - tighten the PQ match: derive(pk) must equal AccountCapsule#getWitnessPermissionAddress() directly, no more iterating Permission.keys[] Update VMActuator and Manager call sites to pass the store, plus the two test classes that hit the renamed method. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../org/tron/core/actuator/VMActuator.java | 12 +-- .../org/tron/core/capsule/BlockCapsule.java | 86 +++++++------------ .../main/java/org/tron/core/db/Manager.java | 9 +- .../tron/core/capsule/BlockCapsulePQTest.java | 6 +- .../tron/core/capsule/BlockCapsuleTest.java | 7 +- 5 files changed, 52 insertions(+), 68 deletions(-) diff --git a/actuator/src/main/java/org/tron/core/actuator/VMActuator.java b/actuator/src/main/java/org/tron/core/actuator/VMActuator.java index 1b0e8a6637f..0a9045a1586 100644 --- a/actuator/src/main/java/org/tron/core/actuator/VMActuator.java +++ b/actuator/src/main/java/org/tron/core/actuator/VMActuator.java @@ -37,6 +37,7 @@ import org.tron.core.db.TransactionContext; import org.tron.core.exception.ContractExeException; import org.tron.core.exception.ContractValidateException; +import org.tron.core.store.DynamicPropertiesStore; import org.tron.core.utils.TransactionUtil; import org.tron.core.vm.EnergyCost; import org.tron.core.vm.LogInfoTriggerParser; @@ -177,7 +178,8 @@ public void execute(Object object) throws ContractExeException { ProgramResult result = context.getProgramResult(); try { if (program != null) { - if (null != blockCap && blockCap.generatedByMyself && blockCap.hasWitnessSignature() + if (null != blockCap && blockCap.generatedByMyself && blockCap.hasWitnessSignature(context.getStoreFactory().getChainBaseManager() + .getDynamicPropertiesStore()) && null != TransactionUtil.getContractRet(trx) && contractResult.OUT_OF_TIME == TransactionUtil.getContractRet(trx)) { result = program.getResult(); @@ -400,7 +402,7 @@ private void create() long thisTxCPULimitInUs = calculateCpuLimitInUs(isConstantCall, rootRepository.getDynamicPropertiesStore().getMaxCpuTimeOfOneTx(), - getCpuLimitInUsRatio(), CommonParameter.getInstance().getConstantCallTimeoutMs()); + getCpuLimitInUsRatio(rootRepository.getDynamicPropertiesStore()), CommonParameter.getInstance().getConstantCallTimeoutMs()); long vmStartInUs = System.nanoTime() / VMConstant.ONE_THOUSAND; long vmShouldEndInUs = vmStartInUs + thisTxCPULimitInUs; ProgramInvoke programInvoke = ProgramInvokeFactory @@ -514,7 +516,7 @@ private void call() long thisTxCPULimitInUs = calculateCpuLimitInUs(isConstantCall, rootRepository.getDynamicPropertiesStore().getMaxCpuTimeOfOneTx(), - getCpuLimitInUsRatio(), CommonParameter.getInstance().getConstantCallTimeoutMs()); + getCpuLimitInUsRatio(rootRepository.getDynamicPropertiesStore()), CommonParameter.getInstance().getConstantCallTimeoutMs()); long vmStartInUs = System.nanoTime() / VMConstant.ONE_THOUSAND; long vmShouldEndInUs = vmStartInUs + thisTxCPULimitInUs; ProgramInvoke programInvoke = ProgramInvokeFactory @@ -666,14 +668,14 @@ public void checkTokenValueAndId(long tokenValue, long tokenId) throws ContractV } - private double getCpuLimitInUsRatio() { + private double getCpuLimitInUsRatio(DynamicPropertiesStore dynamicPropertiesStore) { double cpuLimitRatio; if (ExecutorType.ET_NORMAL_TYPE == executorType) { // self witness generates block if (blockCap != null && blockCap.generatedByMyself - && !blockCap.hasWitnessSignature()) { + && !blockCap.hasWitnessSignature(dynamicPropertiesStore)) { cpuLimitRatio = 1.0; } else { // self witness or other witness or fullnode verifies block diff --git a/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java b/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java index 9324f47a08e..274dbed95a4 100755 --- a/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java +++ b/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java @@ -47,10 +47,8 @@ import org.tron.core.store.DynamicPropertiesStore; import org.tron.protos.Protocol.Block; import org.tron.protos.Protocol.BlockHeader; -import org.tron.protos.Protocol.Key; import org.tron.protos.Protocol.PQScheme; import org.tron.protos.Protocol.PQAuthSig; -import org.tron.protos.Protocol.Permission; import org.tron.protos.Protocol.Transaction; @Slf4j(topic = "capsule") @@ -203,41 +201,27 @@ private Sha256Hash getRawHash() { public boolean validateSignature(DynamicPropertiesStore dynamicPropertiesStore, AccountStore accountStore) throws ValidateSignatureException { BlockHeader header = block.getBlockHeader(); - boolean hasLegacy = !header.getWitnessSignature().isEmpty(); - boolean hasPq = header.hasPqAuthSig(); + byte[] witnessAccountAddress = header.getRawData().getWitnessAddress() + .toByteArray(); - if (hasLegacy && hasPq) { - throw new ValidateSignatureException( - "witness_signature and pq_auth_sig are mutually exclusive"); - } - if (!hasLegacy && !hasPq) { - throw new ValidateSignatureException("missing witness signature"); + byte[] witnessPermissionAddress; + if (dynamicPropertiesStore.getAllowMultiSign() != 1) { + witnessPermissionAddress = witnessAccountAddress; + } else { + witnessPermissionAddress = accountStore.get(witnessAccountAddress) + .getWitnessPermissionAddress(); } - byte[] witnessAccountAddress = header.getRawData().getWitnessAddress().toByteArray(); - if (hasPq) { - return validatePQSignature(dynamicPropertiesStore, accountStore, - witnessAccountAddress, header.getPqAuthSig()); + if (dynamicPropertiesStore.isAnyPqSchemeAllowed() && header.hasPqAuthSig()) { + return validatePQSignature(dynamicPropertiesStore, accountStore, witnessPermissionAddress, + header.getPqAuthSig()); } - return validateLegacySignature(dynamicPropertiesStore, accountStore, witnessAccountAddress); - } - private boolean validateLegacySignature(DynamicPropertiesStore dynamicPropertiesStore, - AccountStore accountStore, byte[] witnessAccountAddress) - throws ValidateSignatureException { try { byte[] sigAddress = SignUtils.signatureToAddress(getRawHash().getBytes(), - TransactionCapsule.getBase64FromByteString( - block.getBlockHeader().getWitnessSignature()), + TransactionCapsule.getBase64FromByteString(header.getWitnessSignature()), CommonParameter.getInstance().isECKeyCryptoEngine()); - if (dynamicPropertiesStore.getAllowMultiSign() != 1) { - return Arrays.equals(sigAddress, witnessAccountAddress); - } - AccountCapsule witnessAccount = accountStore.get(witnessAccountAddress); - if (witnessAccount == null) { - throw new ValidateSignatureException("witness account does not exist"); - } - byte[] witnessPermissionAddress = witnessAccount.getWitnessPermissionAddress(); + return Arrays.equals(sigAddress, witnessPermissionAddress); } catch (SignatureException e) { throw new ValidateSignatureException(e.getMessage()); @@ -250,8 +234,11 @@ private boolean validateLegacySignature(DynamicPropertiesStore dynamicProperties * the witness account's Witness Permission keys[]. */ private boolean validatePQSignature(DynamicPropertiesStore dynamicPropertiesStore, - AccountStore accountStore, byte[] witnessAccountAddress, PQAuthSig pqAuthSig) + AccountStore accountStore, byte[] witnessPermissionAddress, PQAuthSig pqAuthSig) throws ValidateSignatureException { + /* + Verify the PQ scheme is supported and proposal opened + */ PQScheme scheme = pqAuthSig.getScheme(); if (!PQSchemeRegistry.contains(scheme)) { throw new ValidateSignatureException( @@ -262,38 +249,22 @@ private boolean validatePQSignature(DynamicPropertiesStore dynamicPropertiesStor "pq_auth_sig scheme " + scheme + " is not activated"); } - AccountCapsule accountCapsule = accountStore.get(witnessAccountAddress); - Permission witnessPermission = null; - if (accountCapsule != null && accountCapsule.getInstance().hasWitnessPermission()) { - witnessPermission = accountCapsule.getInstance().getWitnessPermission(); - } - if (witnessPermission == null || witnessPermission.getKeysCount() == 0) { - throw new ValidateSignatureException( - "pq_auth_sig present but witness permission is not configured"); - } - byte[] publicKey = pqAuthSig.getPublicKey().toByteArray(); if (publicKey.length != PQSchemeRegistry.getPublicKeyLength(scheme)) { throw new ValidateSignatureException( "pq_auth_sig public key length mismatch for scheme " + scheme); } - byte[] signature = pqAuthSig.getSignature().toByteArray(); - if (!PQSchemeRegistry.isValidSignatureLength(scheme, signature.length)) { - throw new ValidateSignatureException( - "pq_auth_sig signature length mismatch for scheme " + scheme); - } byte[] derivedAddr = PQSchemeRegistry.computeAddress(scheme, publicKey); - Key matched = null; - for (Key k : witnessPermission.getKeysList()) { - if (Arrays.equals(k.getAddress().toByteArray(), derivedAddr)) { - matched = k; - break; - } + if (!Arrays.equals(derivedAddr, witnessPermissionAddress)) { + throw new ValidateSignatureException( + "pq_auth_sig public key does not match witness permission address"); } - if (matched == null) { + + byte[] signature = pqAuthSig.getSignature().toByteArray(); + if (!PQSchemeRegistry.isValidSignatureLength(scheme, signature.length)) { throw new ValidateSignatureException( - "pq_auth_sig public key does not match any witness permission key"); + "pq_auth_sig signature length mismatch for scheme " + scheme); } byte[] digest = getRawHash().getBytes(); @@ -419,10 +390,13 @@ public long getTimeStamp() { return this.block.getBlockHeader().getRawData().getTimestamp(); } - public boolean hasWitnessSignature() { + public boolean hasWitnessSignature(DynamicPropertiesStore dynamicPropertiesStore) { BlockHeader header = getInstance().getBlockHeader(); - return !header.getWitnessSignature().isEmpty() - || !header.getPqAuthSig().getSignature().isEmpty(); + boolean hasLegacySignature = !header.getWitnessSignature().isEmpty(); + if (!dynamicPropertiesStore.isAnyPqSchemeAllowed()) { + return hasLegacySignature; + } + return hasLegacySignature || !header.getPqAuthSig().getSignature().isEmpty(); } @Override diff --git a/framework/src/main/java/org/tron/core/db/Manager.java b/framework/src/main/java/org/tron/core/db/Manager.java index 7dd5a9b7a21..e51215163b4 100644 --- a/framework/src/main/java/org/tron/core/db/Manager.java +++ b/framework/src/main/java/org/tron/core/db/Manager.java @@ -1552,9 +1552,10 @@ public TransactionInfo processTransaction(final TransactionCapsule trxCap, Block trace.exec(); trace.setResult(); logger.info("Retry result when push: {}, for tx id: {}, tx resultCode in receipt: {}.", - blockCap.hasWitnessSignature(), txId, trace.getReceipt().getResult()); + blockCap.hasWitnessSignature(chainBaseManager.getDynamicPropertiesStore()), txId, + trace.getReceipt().getResult()); } - if (blockCap.hasWitnessSignature()) { + if (blockCap.hasWitnessSignature(chainBaseManager.getDynamicPropertiesStore())) { trace.check(); } } @@ -1600,7 +1601,9 @@ public TransactionInfo processTransaction(final TransactionCapsule trxCap, Block if (cost > 100) { String type = "broadcast"; if (Objects.nonNull(blockCap)) { - type = blockCap.hasWitnessSignature() ? "apply" : "pack"; + type = + blockCap.hasWitnessSignature(chainBaseManager.getDynamicPropertiesStore()) ? "apply" : + "pack"; } logger.info("Process transaction {} cost {} ms during {}, {}", Hex.toHexString(transactionInfo.getId()), cost, type, contract.getType().name()); diff --git a/framework/src/test/java/org/tron/core/capsule/BlockCapsulePQTest.java b/framework/src/test/java/org/tron/core/capsule/BlockCapsulePQTest.java index c20df122995..e2ae68efab0 100644 --- a/framework/src/test/java/org/tron/core/capsule/BlockCapsulePQTest.java +++ b/framework/src/test/java/org/tron/core/capsule/BlockCapsulePQTest.java @@ -111,9 +111,11 @@ private PQAuthSig buildPQAuthSig(byte[] signature) { public void hasWitnessSignatureTrueForPqOnlyBlock() { byte[] parentHash = new byte[32]; BlockCapsule block = buildUnsignedBlock(parentHash); - Assert.assertFalse(block.hasWitnessSignature()); + Assert.assertFalse(block.hasWitnessSignature(dbManager.getDynamicPropertiesStore())); + + dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(1L); block.setPqAuthSig(buildPQAuthSig(signPQ(block.getRawHashBytes()))); - Assert.assertTrue(block.hasWitnessSignature()); + Assert.assertTrue(block.hasWitnessSignature(dbManager.getDynamicPropertiesStore())); } @Test diff --git a/framework/src/test/java/org/tron/core/capsule/BlockCapsuleTest.java b/framework/src/test/java/org/tron/core/capsule/BlockCapsuleTest.java index ca0844c2c16..b89d6003369 100644 --- a/framework/src/test/java/org/tron/core/capsule/BlockCapsuleTest.java +++ b/framework/src/test/java/org/tron/core/capsule/BlockCapsuleTest.java @@ -15,12 +15,14 @@ import org.tron.common.TestConstants; import org.tron.common.utils.ByteArray; import org.tron.common.utils.LocalWitnesses; +import org.mockito.Mockito; import org.tron.common.utils.PublicMethod; import org.tron.common.utils.Sha256Hash; import org.tron.core.Wallet; import org.tron.core.config.args.Args; import org.tron.core.exception.BadBlockException; import org.tron.core.exception.BadItemException; +import org.tron.core.store.DynamicPropertiesStore; import org.tron.protos.Protocol.Transaction.Contract.ContractType; import org.tron.protos.contract.BalanceContract.TransferContract; @@ -169,10 +171,11 @@ public void testHasWitnessSignature() { localWitnesses.initWitnessAccountAddress(null, true); Args.setLocalWitnesses(localWitnesses); - Assert.assertFalse(blockCapsule0.hasWitnessSignature()); + DynamicPropertiesStore dps = Mockito.mock(DynamicPropertiesStore.class); + Assert.assertFalse(blockCapsule0.hasWitnessSignature(dps)); blockCapsule0 .sign(ByteArray.fromHexString(Args.getLocalWitnesses().getPrivateKey())); - Assert.assertTrue(blockCapsule0.hasWitnessSignature()); + Assert.assertTrue(blockCapsule0.hasWitnessSignature(dps)); } @Test From 5b946e9d5f19045ff2746ceec53f6a110e8b26cf Mon Sep 17 00:00:00 2001 From: GrapeS Date: Wed, 20 May 2026 15:32:54 +0800 Subject: [PATCH 14/47] fix move keypair --- .../main/java/org/tron/common/utils/LocalWitnesses.java | 1 + .../main/java/org/tron/common/crypto/pqc}/PqKeypair.java | 7 ++++++- .../src/main/java/org/tron/core/config/args/Args.java | 2 +- .../java/org/tron/core/config/args/WitnessInitializer.java | 2 +- .../java/org/tron/core/consensus/ConsensusService.java | 2 +- .../java/org/tron/core/net/service/relay/RelayService.java | 2 +- 6 files changed, 11 insertions(+), 5 deletions(-) rename {chainbase/src/main/java/org/tron/common/utils => crypto/src/main/java/org/tron/common/crypto/pqc}/PqKeypair.java (58%) diff --git a/chainbase/src/main/java/org/tron/common/utils/LocalWitnesses.java b/chainbase/src/main/java/org/tron/common/utils/LocalWitnesses.java index 0f10ef09208..3abc43cb71d 100644 --- a/chainbase/src/main/java/org/tron/common/utils/LocalWitnesses.java +++ b/chainbase/src/main/java/org/tron/common/utils/LocalWitnesses.java @@ -26,6 +26,7 @@ import org.tron.common.crypto.SignInterface; import org.tron.common.crypto.SignUtils; import org.tron.common.crypto.pqc.PQSchemeRegistry; +import org.tron.common.crypto.pqc.PqKeypair; import org.tron.core.config.Parameter.ChainConstant; import org.tron.core.exception.TronError; import org.tron.protos.Protocol.PQScheme; diff --git a/chainbase/src/main/java/org/tron/common/utils/PqKeypair.java b/crypto/src/main/java/org/tron/common/crypto/pqc/PqKeypair.java similarity index 58% rename from chainbase/src/main/java/org/tron/common/utils/PqKeypair.java rename to crypto/src/main/java/org/tron/common/crypto/pqc/PqKeypair.java index 22762b2690a..d10d6948396 100644 --- a/chainbase/src/main/java/org/tron/common/utils/PqKeypair.java +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/PqKeypair.java @@ -1,14 +1,19 @@ -package org.tron.common.utils; +package org.tron.common.crypto.pqc; +import lombok.ToString; import lombok.Value; /** * Immutable hex-encoded post-quantum keypair (private + public key). Bundles * the two halves so the public/private lists can no longer drift out of * index-alignment by construction. + * + *

{@code privateKey} is excluded from {@link #toString()} to prevent + * accidental leakage of secret-key material into logs. */ @Value public class PqKeypair { + @ToString.Exclude String privateKey; String publicKey; } diff --git a/framework/src/main/java/org/tron/core/config/args/Args.java b/framework/src/main/java/org/tron/core/config/args/Args.java index 7306888fada..c974be608e9 100644 --- a/framework/src/main/java/org/tron/core/config/args/Args.java +++ b/framework/src/main/java/org/tron/core/config/args/Args.java @@ -55,7 +55,7 @@ import org.tron.common.setting.RocksDbSettings; import org.tron.common.utils.Commons; import org.tron.common.utils.LocalWitnesses; -import org.tron.common.utils.PqKeypair; +import org.tron.common.crypto.pqc.PqKeypair; import org.tron.core.Constant; import org.tron.core.Wallet; import org.tron.core.config.Configuration; diff --git a/framework/src/main/java/org/tron/core/config/args/WitnessInitializer.java b/framework/src/main/java/org/tron/core/config/args/WitnessInitializer.java index 935a8b597ad..ab90e145373 100644 --- a/framework/src/main/java/org/tron/core/config/args/WitnessInitializer.java +++ b/framework/src/main/java/org/tron/core/config/args/WitnessInitializer.java @@ -11,7 +11,7 @@ import org.tron.common.utils.ByteArray; import org.tron.common.utils.Commons; import org.tron.common.utils.LocalWitnesses; -import org.tron.common.utils.PqKeypair; +import org.tron.common.crypto.pqc.PqKeypair; import org.tron.core.exception.CipherException; import org.tron.core.exception.TronError; import org.tron.keystore.Credentials; diff --git a/framework/src/main/java/org/tron/core/consensus/ConsensusService.java b/framework/src/main/java/org/tron/core/consensus/ConsensusService.java index 82343778335..ea759bbe48f 100644 --- a/framework/src/main/java/org/tron/core/consensus/ConsensusService.java +++ b/framework/src/main/java/org/tron/core/consensus/ConsensusService.java @@ -13,7 +13,7 @@ import org.tron.common.crypto.pqc.PQSchemeRegistry; import org.tron.common.crypto.pqc.PQSignature; import org.tron.common.parameter.CommonParameter; -import org.tron.common.utils.PqKeypair; +import org.tron.common.crypto.pqc.PqKeypair; import org.tron.consensus.Consensus; import org.tron.consensus.base.Param; import org.tron.consensus.base.Param.Miner; diff --git a/framework/src/main/java/org/tron/core/net/service/relay/RelayService.java b/framework/src/main/java/org/tron/core/net/service/relay/RelayService.java index 560e65e94e6..b393d37bb52 100644 --- a/framework/src/main/java/org/tron/core/net/service/relay/RelayService.java +++ b/framework/src/main/java/org/tron/core/net/service/relay/RelayService.java @@ -23,7 +23,7 @@ import org.tron.common.parameter.CommonParameter; import org.tron.common.utils.ByteArray; import org.tron.common.utils.LocalWitnesses; -import org.tron.common.utils.PqKeypair; +import org.tron.common.crypto.pqc.PqKeypair; import org.tron.common.utils.Sha256Hash; import org.tron.core.ChainBaseManager; import org.tron.core.capsule.TransactionCapsule; From 6f5054e1ab6fe3faacc98fa897a9c2ee21e84dce Mon Sep 17 00:00:00 2001 From: GrapeS Date: Wed, 20 May 2026 15:57:48 +0800 Subject: [PATCH 15/47] fix hasLegacy hasPq --- .../main/java/org/tron/core/capsule/BlockCapsule.java | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java b/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java index 274dbed95a4..ad0e90662cc 100755 --- a/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java +++ b/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java @@ -212,7 +212,16 @@ public boolean validateSignature(DynamicPropertiesStore dynamicPropertiesStore, .getWitnessPermissionAddress(); } - if (dynamicPropertiesStore.isAnyPqSchemeAllowed() && header.hasPqAuthSig()) { + if (dynamicPropertiesStore.isAnyPqSchemeAllowed()) { + boolean hasLegacy = !header.getWitnessSignature().isEmpty(); + boolean hasPq = header.hasPqAuthSig(); + if (hasLegacy && hasLegacy) { + throw new ValidateSignatureException( + "witness_signature and pq_auth_sig are mutually exclusive"); + } + if (!hasLegacy && !hasPq) { + throw new ValidateSignatureException("missing witness signature"); + } return validatePQSignature(dynamicPropertiesStore, accountStore, witnessPermissionAddress, header.getPqAuthSig()); } From edad7b311e8ba9d87682552243718e2d7013914e Mon Sep 17 00:00:00 2001 From: GrapeS Date: Wed, 20 May 2026 16:05:47 +0800 Subject: [PATCH 16/47] fix build --- framework/src/main/java/org/tron/core/config/args/Args.java | 2 +- .../main/java/org/tron/core/config/args/WitnessInitializer.java | 2 +- .../src/main/java/org/tron/core/consensus/ConsensusService.java | 2 +- .../main/java/org/tron/core/net/service/relay/RelayService.java | 2 +- .../src/test/java/org/tron/common/utils/LocalWitnessesTest.java | 1 + 5 files changed, 5 insertions(+), 4 deletions(-) diff --git a/framework/src/main/java/org/tron/core/config/args/Args.java b/framework/src/main/java/org/tron/core/config/args/Args.java index c974be608e9..c10186a35f4 100644 --- a/framework/src/main/java/org/tron/core/config/args/Args.java +++ b/framework/src/main/java/org/tron/core/config/args/Args.java @@ -45,6 +45,7 @@ import org.tron.common.args.Witness; import org.tron.common.cron.CronExpression; import org.tron.common.crypto.pqc.PQSchemeRegistry; +import org.tron.common.crypto.pqc.PqKeypair; import org.tron.common.logsfilter.EventPluginConfig; import org.tron.common.logsfilter.FilterQuery; import org.tron.common.logsfilter.TriggerConfig; @@ -55,7 +56,6 @@ import org.tron.common.setting.RocksDbSettings; import org.tron.common.utils.Commons; import org.tron.common.utils.LocalWitnesses; -import org.tron.common.crypto.pqc.PqKeypair; import org.tron.core.Constant; import org.tron.core.Wallet; import org.tron.core.config.Configuration; diff --git a/framework/src/main/java/org/tron/core/config/args/WitnessInitializer.java b/framework/src/main/java/org/tron/core/config/args/WitnessInitializer.java index ab90e145373..fd6a10f3853 100644 --- a/framework/src/main/java/org/tron/core/config/args/WitnessInitializer.java +++ b/framework/src/main/java/org/tron/core/config/args/WitnessInitializer.java @@ -8,10 +8,10 @@ import org.apache.commons.lang3.StringUtils; import org.tron.common.crypto.SignInterface; import org.tron.common.crypto.pqc.PQSchemeRegistry; +import org.tron.common.crypto.pqc.PqKeypair; import org.tron.common.utils.ByteArray; import org.tron.common.utils.Commons; import org.tron.common.utils.LocalWitnesses; -import org.tron.common.crypto.pqc.PqKeypair; import org.tron.core.exception.CipherException; import org.tron.core.exception.TronError; import org.tron.keystore.Credentials; diff --git a/framework/src/main/java/org/tron/core/consensus/ConsensusService.java b/framework/src/main/java/org/tron/core/consensus/ConsensusService.java index ea759bbe48f..600262c6241 100644 --- a/framework/src/main/java/org/tron/core/consensus/ConsensusService.java +++ b/framework/src/main/java/org/tron/core/consensus/ConsensusService.java @@ -12,8 +12,8 @@ import org.tron.common.crypto.SignUtils; import org.tron.common.crypto.pqc.PQSchemeRegistry; import org.tron.common.crypto.pqc.PQSignature; -import org.tron.common.parameter.CommonParameter; import org.tron.common.crypto.pqc.PqKeypair; +import org.tron.common.parameter.CommonParameter; import org.tron.consensus.Consensus; import org.tron.consensus.base.Param; import org.tron.consensus.base.Param.Miner; diff --git a/framework/src/main/java/org/tron/core/net/service/relay/RelayService.java b/framework/src/main/java/org/tron/core/net/service/relay/RelayService.java index b393d37bb52..e78e90341a5 100644 --- a/framework/src/main/java/org/tron/core/net/service/relay/RelayService.java +++ b/framework/src/main/java/org/tron/core/net/service/relay/RelayService.java @@ -18,12 +18,12 @@ import org.tron.common.crypto.SignInterface; import org.tron.common.crypto.SignUtils; import org.tron.common.crypto.pqc.PQSchemeRegistry; +import org.tron.common.crypto.pqc.PqKeypair; import org.tron.common.es.ExecutorServiceManager; import org.tron.common.log.layout.DesensitizedConverter; import org.tron.common.parameter.CommonParameter; import org.tron.common.utils.ByteArray; import org.tron.common.utils.LocalWitnesses; -import org.tron.common.crypto.pqc.PqKeypair; import org.tron.common.utils.Sha256Hash; import org.tron.core.ChainBaseManager; import org.tron.core.capsule.TransactionCapsule; diff --git a/framework/src/test/java/org/tron/common/utils/LocalWitnessesTest.java b/framework/src/test/java/org/tron/common/utils/LocalWitnessesTest.java index 766acc4c4b5..6564ab79be2 100644 --- a/framework/src/test/java/org/tron/common/utils/LocalWitnessesTest.java +++ b/framework/src/test/java/org/tron/common/utils/LocalWitnessesTest.java @@ -10,6 +10,7 @@ import org.junit.BeforeClass; import org.junit.Test; import org.tron.common.crypto.pqc.FNDSA512; +import org.tron.common.crypto.pqc.PqKeypair; import org.tron.core.exception.TronError; import org.tron.protos.Protocol.PQScheme; From 27f3ddc00e09786ba021b56cada02dffbbacd068 Mon Sep 17 00:00:00 2001 From: federico Date: Wed, 20 May 2026 16:52:39 +0800 Subject: [PATCH 17/47] revert(pbft): drop pq signature additions --- .../tron/core/capsule/PbftSignCapsule.java | 16 +- .../consensus/pbft/PbftMessageAction.java | 9 +- .../consensus/pbft/PbftMessageHandle.java | 23 +- .../pbft/message/PbftBaseMessage.java | 37 +-- .../consensus/pbft/message/PbftMessage.java | 34 +-- .../messagehandler/PbftDataSyncHandler.java | 126 +++------ .../net/messagehandler/PbftMsgHandler.java | 15 -- .../tron/core/capsule/BlockCapsuleTest.java | 2 +- .../PbftDataSyncHandlerPQTest.java | 252 ------------------ .../org/tron/core/pbft/PbftPQMessageTest.java | 236 ---------------- protocol/src/main/protos/core/Tron.proto | 14 - 11 files changed, 50 insertions(+), 714 deletions(-) delete mode 100644 framework/src/test/java/org/tron/core/net/messagehandler/PbftDataSyncHandlerPQTest.java delete mode 100644 framework/src/test/java/org/tron/core/pbft/PbftPQMessageTest.java diff --git a/chainbase/src/main/java/org/tron/core/capsule/PbftSignCapsule.java b/chainbase/src/main/java/org/tron/core/capsule/PbftSignCapsule.java index 7594e8add41..14835cb01b5 100644 --- a/chainbase/src/main/java/org/tron/core/capsule/PbftSignCapsule.java +++ b/chainbase/src/main/java/org/tron/core/capsule/PbftSignCapsule.java @@ -2,13 +2,11 @@ import com.google.protobuf.ByteString; import com.google.protobuf.InvalidProtocolBufferException; -import java.util.Collections; import java.util.Deque; import java.util.List; import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.tron.protos.Protocol.PBFTCommitResult; -import org.tron.protos.Protocol.PQAuthSig; @Slf4j(topic = "pbft") public class PbftSignCapsule implements ProtoCapsule { @@ -25,18 +23,8 @@ public PbftSignCapsule(byte[] data) { } public PbftSignCapsule(ByteString data, List signList) { - this(data, signList, Collections.emptyList()); - } - - public PbftSignCapsule(ByteString data, List signList, - List pqSignList) { - PBFTCommitResult.Builder builder = PBFTCommitResult.newBuilder().setData(data); - if (signList != null && !signList.isEmpty()) { - builder.addAllSignature(signList); - } - if (pqSignList != null && !pqSignList.isEmpty()) { - builder.addAllPqSignature(pqSignList); - } + PBFTCommitResult.Builder builder = PBFTCommitResult.newBuilder(); + builder.setData(data).addAllSignature(signList); pbftCommitResult = builder.build(); } diff --git a/consensus/src/main/java/org/tron/consensus/pbft/PbftMessageAction.java b/consensus/src/main/java/org/tron/consensus/pbft/PbftMessageAction.java index ad0c108a98c..c4ee235ff2d 100644 --- a/consensus/src/main/java/org/tron/consensus/pbft/PbftMessageAction.java +++ b/consensus/src/main/java/org/tron/consensus/pbft/PbftMessageAction.java @@ -10,7 +10,6 @@ import org.tron.core.ChainBaseManager; import org.tron.core.capsule.PbftSignCapsule; import org.tron.protos.Protocol.PBFTMessage.Raw; -import org.tron.protos.Protocol.PQAuthSig; @Slf4j(topic = "pbft") @Component @@ -19,16 +18,14 @@ public class PbftMessageAction { @Autowired private ChainBaseManager chainBaseManager; - public void action(PbftMessage message, List dataSignList, - List pqSignList) { + public void action(PbftMessage message, List dataSignList) { switch (message.getDataType()) { case BLOCK: { long blockNum = message.getNumber(); chainBaseManager.getCommonDataBase().saveLatestPbftBlockNum(blockNum); Raw raw = message.getPbftMessage().getRawData(); chainBaseManager.getPbftSignDataStore() - .putBlockSignData(blockNum, - new PbftSignCapsule(raw.toByteString(), dataSignList, pqSignList)); + .putBlockSignData(blockNum, new PbftSignCapsule(raw.toByteString(), dataSignList)); logger.info("commit msg block num is:{}", blockNum); } break; @@ -36,7 +33,7 @@ public void action(PbftMessage message, List dataSignList, try { Raw raw = message.getPbftMessage().getRawData(); chainBaseManager.getPbftSignDataStore().putSrSignData(message.getEpoch(), - new PbftSignCapsule(raw.toByteString(), dataSignList, pqSignList)); + new PbftSignCapsule(raw.toByteString(), dataSignList)); logger.info("sr commit msg :{}, epoch:{}", message.getNumber(), message.getEpoch()); } catch (Exception e) { logger.error("process the sr list error!", e); diff --git a/consensus/src/main/java/org/tron/consensus/pbft/PbftMessageHandle.java b/consensus/src/main/java/org/tron/consensus/pbft/PbftMessageHandle.java index 18462cff3cb..523ffac4d61 100644 --- a/consensus/src/main/java/org/tron/consensus/pbft/PbftMessageHandle.java +++ b/consensus/src/main/java/org/tron/consensus/pbft/PbftMessageHandle.java @@ -32,7 +32,6 @@ import org.tron.consensus.pbft.message.PbftMessage; import org.tron.core.ChainBaseManager; import org.tron.protos.Protocol.PBFTMessage.DataType; -import org.tron.protos.Protocol.PQAuthSig; @Slf4j(topic = "pbft") @Component @@ -65,15 +64,6 @@ public List load(String s) throws Exception { } }); - private LoadingCache> pqSignCache = CacheBuilder.newBuilder() - .initialCapacity(100).maximumSize(1000).expireAfterWrite(2, TimeUnit.MINUTES).build( - new CacheLoader>() { - @Override - public List load(String s) throws Exception { - return new ArrayList<>(); - } - }); - private PbftMessage srPbftMessage; private Timer timer = new Timer("pbft-timer"); @@ -215,21 +205,14 @@ public synchronized void onCommit(PbftMessage message) { commitVoteMap.put(key, message); //The number of votes plus 1 long agCou = agreeCommit.incrementAndGet(message.getDataKey()); - if (message.getPbftMessage().hasPqAuthSig()) { - pqSignCache.getUnchecked(message.getDataKey()) - .add(message.getPbftMessage().getPqAuthSig()); - } else { - dataSignCache.getUnchecked(message.getDataKey()) - .add(message.getPbftMessage().getSignature()); - } + dataSignCache.getUnchecked(message.getDataKey()) + .add(message.getPbftMessage().getSignature()); if (agCou >= Param.getInstance().getAgreeNodeCount()) { srPbftMessage = null; remove(message.getNo()); //commit, if (!isSyncing()) { - pbftMessageAction.action(message, - dataSignCache.getUnchecked(message.getDataKey()), - pqSignCache.getUnchecked(message.getDataKey())); + pbftMessageAction.action(message, dataSignCache.getUnchecked(message.getDataKey())); } } } diff --git a/consensus/src/main/java/org/tron/consensus/pbft/message/PbftBaseMessage.java b/consensus/src/main/java/org/tron/consensus/pbft/message/PbftBaseMessage.java index 82768f0ef33..4eb61f3e22e 100644 --- a/consensus/src/main/java/org/tron/consensus/pbft/message/PbftBaseMessage.java +++ b/consensus/src/main/java/org/tron/consensus/pbft/message/PbftBaseMessage.java @@ -6,7 +6,6 @@ import java.util.stream.Collectors; import org.bouncycastle.util.encoders.Hex; import org.tron.common.crypto.ECKey; -import org.tron.common.crypto.pqc.PQSchemeRegistry; import org.tron.common.overlay.message.Message; import org.tron.common.utils.ByteUtil; import org.tron.common.utils.Sha256Hash; @@ -15,8 +14,6 @@ import org.tron.core.exception.P2pException; import org.tron.protos.Protocol.PBFTMessage; import org.tron.protos.Protocol.PBFTMessage.DataType; -import org.tron.protos.Protocol.PQAuthSig; -import org.tron.protos.Protocol.PQScheme; import org.tron.protos.Protocol.SRL; public abstract class PbftBaseMessage extends Message { @@ -99,38 +96,8 @@ public DataType getDataType() { public void analyzeSignature() throws SignatureException { byte[] hash = Sha256Hash.hash(true, getPbftMessage().getRawData().toByteArray()); - boolean hasLegacy = !getPbftMessage().getSignature().isEmpty(); - boolean hasPq = getPbftMessage().hasPqAuthSig(); - if (hasLegacy == hasPq) { - throw new SignatureException( - "pbft message must set exactly one of signature / pq_auth_sig"); - } - if (hasPq) { - publicKey = verifyPqAuthSig(hash, getPbftMessage().getPqAuthSig()); - } else { - publicKey = ECKey.signatureToAddress(hash, TransactionCapsule - .getBase64FromByteString(getPbftMessage().getSignature())); - } - } - - private static byte[] verifyPqAuthSig(byte[] hash, PQAuthSig pqAuthSig) - throws SignatureException { - PQScheme scheme = pqAuthSig.getScheme(); - if (!PQSchemeRegistry.contains(scheme)) { - throw new SignatureException("pbft pq_auth_sig scheme not registered: " + scheme); - } - byte[] publicKey = pqAuthSig.getPublicKey().toByteArray(); - if (publicKey.length != PQSchemeRegistry.getPublicKeyLength(scheme)) { - throw new SignatureException("pbft pq_auth_sig public key length mismatch for " + scheme); - } - byte[] signature = pqAuthSig.getSignature().toByteArray(); - if (!PQSchemeRegistry.isValidSignatureLength(scheme, signature.length)) { - throw new SignatureException("pbft pq_auth_sig signature length mismatch for " + scheme); - } - if (!PQSchemeRegistry.verify(scheme, publicKey, hash, signature)) { - throw new SignatureException("pbft pq_auth_sig verification failed for " + scheme); - } - return PQSchemeRegistry.computeAddress(scheme, publicKey); + publicKey = ECKey.signatureToAddress(hash, TransactionCapsule + .getBase64FromByteString(getPbftMessage().getSignature())); } @Override diff --git a/consensus/src/main/java/org/tron/consensus/pbft/message/PbftMessage.java b/consensus/src/main/java/org/tron/consensus/pbft/message/PbftMessage.java index 170c14b80eb..b6de49ee878 100644 --- a/consensus/src/main/java/org/tron/consensus/pbft/message/PbftMessage.java +++ b/consensus/src/main/java/org/tron/consensus/pbft/message/PbftMessage.java @@ -4,19 +4,15 @@ import java.util.List; import org.tron.common.crypto.ECKey; import org.tron.common.crypto.ECKey.ECDSASignature; -import org.tron.common.crypto.pqc.PQSchemeRegistry; import org.tron.common.utils.Sha256Hash; import org.tron.consensus.base.Param; import org.tron.consensus.base.Param.Miner; -import org.tron.consensus.base.Param.MinerType; import org.tron.core.capsule.BlockCapsule; import org.tron.core.net.message.MessageTypes; import org.tron.protos.Protocol.PBFTMessage; import org.tron.protos.Protocol.PBFTMessage.DataType; import org.tron.protos.Protocol.PBFTMessage.MsgType; import org.tron.protos.Protocol.PBFTMessage.Raw; -import org.tron.protos.Protocol.PQAuthSig; -import org.tron.protos.Protocol.PQScheme; import org.tron.protos.Protocol.SRL; public class PbftMessage extends PbftBaseMessage { @@ -60,13 +56,15 @@ public static PbftMessage fullNodePrePrepareSRLMsg(BlockCapsule block, private static PbftMessage buildCommon(DataType dataType, ByteString data, BlockCapsule block, long epoch, long viewN, Miner miner) { PbftMessage pbftMessage = new PbftMessage(); + ECKey ecKey = ECKey.fromPrivate(miner.getPrivateKey()); Raw.Builder rawBuilder = Raw.newBuilder(); PBFTMessage.Builder builder = PBFTMessage.newBuilder(); rawBuilder.setViewN(viewN).setEpoch(epoch).setDataType(dataType) .setMsgType(MsgType.PREPREPARE).setData(data); Raw raw = rawBuilder.build(); byte[] hash = Sha256Hash.hash(true, raw.toByteArray()); - signRaw(builder, raw, hash, miner); + ECDSASignature signature = ecKey.sign(hash); + builder.setRawData(raw).setSignature(ByteString.copyFrom(signature.toByteArray())); PBFTMessage message = builder.build(); pbftMessage.setType(MessageTypes.PBFT_MSG.asByte()) .setPbftMessage(message).setData(message.toByteArray()).setSwitch(block.isSwitch()); @@ -98,6 +96,7 @@ public PbftMessage buildCommitMessage(Miner miner) { private PbftMessage buildMessageCapsule(MsgType type, Miner miner) { PbftMessage pbftMessage = new PbftMessage(); + ECKey ecKey = ECKey.fromPrivate(miner.getPrivateKey()); PBFTMessage.Builder builder = PBFTMessage.newBuilder(); Raw.Builder rawBuilder = Raw.newBuilder(); rawBuilder.setViewN(getPbftMessage().getRawData().getViewN()) @@ -106,30 +105,11 @@ private PbftMessage buildMessageCapsule(MsgType type, Miner miner) { .setData(getPbftMessage().getRawData().getData()); Raw raw = rawBuilder.build(); byte[] hash = Sha256Hash.hash(true, raw.toByteArray()); - signRaw(builder, raw, hash, miner); + ECDSASignature signature = ecKey.sign(hash); + builder.setRawData(raw).setSignature(ByteString.copyFrom(signature.toByteArray())); PBFTMessage message = builder.build(); pbftMessage.setType(getType().asByte()) .setPbftMessage(message).setData(message.toByteArray()); return pbftMessage; } - - private static void signRaw(PBFTMessage.Builder builder, Raw raw, byte[] hash, Miner miner) { - builder.setRawData(raw); - if (miner.getType() == MinerType.PQ) { - PQScheme scheme = miner.getPqScheme(); - byte[] sk = miner.getPQPrivateKey(); - byte[] pk = miner.getPQPublicKey(); - byte[] sig = PQSchemeRegistry.sign(scheme, sk, hash); - builder.setPqAuthSig(PQAuthSig.newBuilder() - .setScheme(scheme) - .setPublicKey(ByteString.copyFrom(pk)) - .setSignature(ByteString.copyFrom(sig))) - .clearSignature(); - } else { - ECKey ecKey = ECKey.fromPrivate(miner.getPrivateKey()); - ECDSASignature signature = ecKey.sign(hash); - builder.setSignature(ByteString.copyFrom(signature.toByteArray())) - .clearPqAuthSig(); - } - } -} +} \ No newline at end of file diff --git a/framework/src/main/java/org/tron/core/net/messagehandler/PbftDataSyncHandler.java b/framework/src/main/java/org/tron/core/net/messagehandler/PbftDataSyncHandler.java index 78674dda47f..d66fa6d41f7 100644 --- a/framework/src/main/java/org/tron/core/net/messagehandler/PbftDataSyncHandler.java +++ b/framework/src/main/java/org/tron/core/net/messagehandler/PbftDataSyncHandler.java @@ -5,13 +5,13 @@ import com.google.common.collect.Sets; import com.google.protobuf.ByteString; import com.google.protobuf.InvalidProtocolBufferException; +import io.netty.util.internal.ConcurrentSet; import java.io.Closeable; import java.security.SignatureException; import java.util.ArrayList; import java.util.List; import java.util.Set; import java.util.concurrent.Callable; -import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; @@ -19,7 +19,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.tron.common.crypto.ECKey; -import org.tron.common.crypto.pqc.PQSchemeRegistry; import org.tron.common.es.ExecutorServiceManager; import org.tron.common.utils.ByteArray; import org.tron.common.utils.Sha256Hash; @@ -34,8 +33,6 @@ import org.tron.core.net.peer.PeerConnection; import org.tron.protos.Protocol.PBFTMessage.DataType; import org.tron.protos.Protocol.PBFTMessage.Raw; -import org.tron.protos.Protocol.PQAuthSig; -import org.tron.protos.Protocol.PQScheme; @Slf4j(topic = "pbft-data-sync") @Service @@ -105,7 +102,6 @@ private void processPBFTCommitMessage(PbftCommitMessage pbftCommitMessage) { PbftSignDataStore pbftSignDataStore = chainBaseManager.getPbftSignDataStore(); Raw raw = Raw.parseFrom(pbftCommitMessage.getPBFTCommitResult().getData()); if (!validPbftSign(raw, pbftCommitMessage.getPBFTCommitResult().getSignatureList(), - pbftCommitMessage.getPBFTCommitResult().getPqSignatureList(), chainBaseManager.getWitnesses())) { return; } @@ -124,54 +120,51 @@ private void processPBFTCommitMessage(PbftCommitMessage pbftCommitMessage) { } private boolean validPbftSign(Raw raw, List srSignList, - List pqSignList, List currentSrList) { - int totalSigs = srSignList.size() + pqSignList.size(); - if (totalSigs == 0) { - return true; - } - byte[] dataHash = Sha256Hash.hash(true, raw.toByteArray()); - Set srSet = Sets.newHashSet(currentSrList); - Set verifiedSigners = ConcurrentHashMap.newKeySet(); - List> futureList = new ArrayList<>(); - for (ByteString sign : srSignList) { - futureList.add(executorService.submit( - new ValidPbftSignTask(raw.getViewN(), verifiedSigners, dataHash, srSet, sign))); - } - for (PQAuthSig pqSign : pqSignList) { - futureList.add(executorService.submit( - new ValidPqPbftSignTask(raw.getViewN(), verifiedSigners, dataHash, srSet, pqSign))); - } - for (Future future : futureList) { - try { - if (!future.get()) { - return false; + List currentSrList) { + //valid sr list + if (srSignList.size() != 0) { + Set srSignSet = new ConcurrentSet(); + srSignSet.addAll(srSignList); + if (srSignSet.size() < Param.getInstance().getAgreeNodeCount()) { + logger.error("sr sign count {} < sr count * 2/3 + 1 == {}", srSignSet.size(), + Param.getInstance().getAgreeNodeCount()); + return false; + } + byte[] dataHash = Sha256Hash.hash(true, raw.toByteArray()); + Set srSet = Sets.newHashSet(currentSrList); + List> futureList = new ArrayList<>(); + for (ByteString sign : srSignList) { + futureList.add(executorService.submit( + new ValidPbftSignTask(raw.getViewN(), srSignSet, dataHash, srSet, sign))); + } + for (Future future : futureList) { + try { + if (!future.get()) { + return false; + } + } catch (Exception e) { + logger.error("", e); } - } catch (Exception e) { - logger.error("", e); + } + if (srSignSet.size() != 0) { return false; } } - int unique = verifiedSigners.size(); - if (unique < Param.getInstance().getAgreeNodeCount()) { - logger.error("sr sign count {} < sr count * 2/3 + 1 == {}", unique, - Param.getInstance().getAgreeNodeCount()); - return false; - } return true; } private class ValidPbftSignTask implements Callable { private long viewN; - private Set verifiedSigners; + private Set srSignSet; private byte[] dataHash; private Set srSet; private ByteString sign; - ValidPbftSignTask(long viewN, Set verifiedSigners, + ValidPbftSignTask(long viewN, Set srSignSet, byte[] dataHash, Set srSet, ByteString sign) { this.viewN = viewN; - this.verifiedSigners = verifiedSigners; + this.srSignSet = srSignSet; this.dataHash = dataHash; this.srSet = srSet; this.sign = sign; @@ -182,13 +175,12 @@ public Boolean call() throws Exception { try { byte[] srAddress = ECKey.signatureToAddress(dataHash, TransactionCapsule.getBase64FromByteString(sign)); - ByteString addressKey = ByteString.copyFrom(srAddress); - if (!srSet.contains(addressKey)) { + if (!srSet.contains(ByteString.copyFrom(srAddress))) { logger.error("valid sr signature fail,error sr address:{}", ByteArray.toHexString(srAddress)); return false; } - verifiedSigners.add(addressKey); + srSignSet.remove(sign); } catch (SignatureException e) { logger.error("viewN {} valid sr list sign fail!", viewN, e); return false; @@ -197,58 +189,4 @@ public Boolean call() throws Exception { } } - private class ValidPqPbftSignTask implements Callable { - - private final long viewN; - private final Set verifiedSigners; - private final byte[] dataHash; - private final Set srSet; - private final PQAuthSig pqAuthSig; - - ValidPqPbftSignTask(long viewN, Set verifiedSigners, - byte[] dataHash, Set srSet, PQAuthSig pqAuthSig) { - this.viewN = viewN; - this.verifiedSigners = verifiedSigners; - this.dataHash = dataHash; - this.srSet = srSet; - this.pqAuthSig = pqAuthSig; - } - - @Override - public Boolean call() { - PQScheme scheme = pqAuthSig.getScheme(); - if (!chainBaseManager.getDynamicPropertiesStore().isPqSchemeAllowed(scheme)) { - logger.error("viewN {} pq scheme {} not activated on chain", viewN, scheme); - return false; - } - if (!PQSchemeRegistry.contains(scheme)) { - logger.error("viewN {} pq scheme {} not registered locally", viewN, scheme); - return false; - } - byte[] publicKey = pqAuthSig.getPublicKey().toByteArray(); - if (publicKey.length != PQSchemeRegistry.getPublicKeyLength(scheme)) { - logger.error("viewN {} pq public key length mismatch for {}", viewN, scheme); - return false; - } - byte[] signature = pqAuthSig.getSignature().toByteArray(); - if (!PQSchemeRegistry.isValidSignatureLength(scheme, signature.length)) { - logger.error("viewN {} pq signature length mismatch for {}", viewN, scheme); - return false; - } - if (!PQSchemeRegistry.verify(scheme, publicKey, dataHash, signature)) { - logger.error("viewN {} pq signature verification failed for {}", viewN, scheme); - return false; - } - byte[] srAddress = PQSchemeRegistry.computeAddress(scheme, publicKey); - ByteString addressKey = ByteString.copyFrom(srAddress); - if (!srSet.contains(addressKey)) { - logger.error("valid sr pq signature fail, error sr address:{}", - ByteArray.toHexString(srAddress)); - return false; - } - verifiedSigners.add(addressKey); - return true; - } - } - } diff --git a/framework/src/main/java/org/tron/core/net/messagehandler/PbftMsgHandler.java b/framework/src/main/java/org/tron/core/net/messagehandler/PbftMsgHandler.java index ec0648c4d2e..d086cc28b6c 100644 --- a/framework/src/main/java/org/tron/core/net/messagehandler/PbftMsgHandler.java +++ b/framework/src/main/java/org/tron/core/net/messagehandler/PbftMsgHandler.java @@ -5,23 +5,19 @@ import com.google.common.util.concurrent.Striped; import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.Lock; -import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.tron.consensus.base.Param; import org.tron.consensus.pbft.PbftManager; import org.tron.consensus.pbft.message.PbftBaseMessage; import org.tron.consensus.pbft.message.PbftMessage; -import org.tron.core.ChainBaseManager; import org.tron.core.config.args.Args; import org.tron.core.exception.P2pException; import org.tron.core.net.TronNetDelegate; import org.tron.core.net.TronNetService; import org.tron.core.net.peer.PeerConnection; import org.tron.protos.Protocol.PBFTMessage.DataType; -import org.tron.protos.Protocol.PQScheme; -@Slf4j(topic = "pbft") @Component public class PbftMsgHandler { @@ -36,9 +32,6 @@ public class PbftMsgHandler { @Autowired private TronNetDelegate tronNetDelegate; - @Autowired - private ChainBaseManager chainBaseManager; - public void processMessage(PeerConnection peer, PbftMessage msg) throws Exception { if (!tronNetDelegate.allowPBFT()) { return; @@ -57,14 +50,6 @@ public void processMessage(PeerConnection peer, PbftMessage msg) throws Exceptio && currentEpoch - msg.getEpoch() > expireEpoch) { return; } - if (msg.getPbftMessage().hasPqAuthSig()) { - PQScheme scheme = msg.getPbftMessage().getPqAuthSig().getScheme(); - if (!chainBaseManager.getDynamicPropertiesStore().isPqSchemeAllowed(scheme)) { - logger.warn("Pbft message from {}, pq_auth_sig scheme {} is not activated on chain.", - peer.getInetAddress(), scheme); - return; - } - } msg.analyzeSignature(); String key = buildKey(msg); Lock lock = striped.get(key); diff --git a/framework/src/test/java/org/tron/core/capsule/BlockCapsuleTest.java b/framework/src/test/java/org/tron/core/capsule/BlockCapsuleTest.java index b89d6003369..e3d366e84ab 100644 --- a/framework/src/test/java/org/tron/core/capsule/BlockCapsuleTest.java +++ b/framework/src/test/java/org/tron/core/capsule/BlockCapsuleTest.java @@ -12,10 +12,10 @@ import org.junit.ClassRule; import org.junit.Test; import org.junit.rules.TemporaryFolder; +import org.mockito.Mockito; import org.tron.common.TestConstants; import org.tron.common.utils.ByteArray; import org.tron.common.utils.LocalWitnesses; -import org.mockito.Mockito; import org.tron.common.utils.PublicMethod; import org.tron.common.utils.Sha256Hash; import org.tron.core.Wallet; diff --git a/framework/src/test/java/org/tron/core/net/messagehandler/PbftDataSyncHandlerPQTest.java b/framework/src/test/java/org/tron/core/net/messagehandler/PbftDataSyncHandlerPQTest.java deleted file mode 100644 index 365abbd891b..00000000000 --- a/framework/src/test/java/org/tron/core/net/messagehandler/PbftDataSyncHandlerPQTest.java +++ /dev/null @@ -1,252 +0,0 @@ -package org.tron.core.net.messagehandler; - -import com.google.protobuf.ByteString; -import java.lang.reflect.Method; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import org.junit.After; -import org.junit.Assert; -import org.junit.Before; -import org.junit.Test; -import org.mockito.Mockito; -import org.tron.common.crypto.ECKey; -import org.tron.common.crypto.pqc.FNDSA512; -import org.tron.common.crypto.pqc.PQSchemeRegistry; -import org.tron.common.utils.Sha256Hash; -import org.tron.consensus.base.Param; -import org.tron.core.ChainBaseManager; -import org.tron.core.store.DynamicPropertiesStore; -import org.tron.protos.Protocol.PBFTMessage.DataType; -import org.tron.protos.Protocol.PBFTMessage.MsgType; -import org.tron.protos.Protocol.PBFTMessage.Raw; -import org.tron.protos.Protocol.PQAuthSig; -import org.tron.protos.Protocol.PQScheme; - -/** - * Focused tests for {@link PbftDataSyncHandler#validPbftSign} covering PQ and - * mixed ECDSA/PQ quorums on the commit-sync path. - */ -public class PbftDataSyncHandlerPQTest { - - private PbftDataSyncHandler handler; - private ChainBaseManager chainBaseManager; - private DynamicPropertiesStore dynamicPropertiesStore; - private int previousAgreeNodeCount; - private boolean previousEnable; - - @Before - public void setUp() throws Exception { - handler = new PbftDataSyncHandler(); - chainBaseManager = Mockito.mock(ChainBaseManager.class); - dynamicPropertiesStore = Mockito.mock(DynamicPropertiesStore.class); - Mockito.when(chainBaseManager.getDynamicPropertiesStore()).thenReturn(dynamicPropertiesStore); - Mockito.when(dynamicPropertiesStore.isPqSchemeAllowed(PQScheme.FN_DSA_512)).thenReturn(true); - Mockito.when(dynamicPropertiesStore.isPqSchemeAllowed(PQScheme.UNKNOWN_PQ_SCHEME)) - .thenReturn(false); - - java.lang.reflect.Field field = PbftDataSyncHandler.class.getDeclaredField("chainBaseManager"); - field.setAccessible(true); - field.set(handler, chainBaseManager); - - previousAgreeNodeCount = Param.getInstance().getAgreeNodeCount(); - previousEnable = Param.getInstance().isEnable(); - } - - @After - public void tearDown() { - Param.getInstance().setAgreeNodeCount(previousAgreeNodeCount); - Param.getInstance().setEnable(previousEnable); - handler.close(); - } - - @Test - public void emptySignatureListsValidate() throws Exception { - Param.getInstance().setAgreeNodeCount(1); - Raw raw = buildRaw(1); - Assert.assertTrue(invokeValid(raw, Collections.emptyList(), Collections.emptyList(), - Collections.emptyList())); - } - - @Test - public void pqQuorumValidates() throws Exception { - Param.getInstance().setAgreeNodeCount(2); - Raw raw = buildRaw(1); - byte[] hash = Sha256Hash.hash(true, raw.toByteArray()); - - FNDSA512 kp1 = new FNDSA512(); - FNDSA512 kp2 = new FNDSA512(); - PQAuthSig sig1 = pqSign(kp1, hash); - PQAuthSig sig2 = pqSign(kp2, hash); - - List witnesses = Arrays.asList( - pqAddress(kp1), - pqAddress(kp2)); - - Assert.assertTrue(invokeValid(raw, Collections.emptyList(), - Arrays.asList(sig1, sig2), witnesses)); - } - - @Test - public void mixedEcdsaAndPqQuorumValidates() throws Exception { - Param.getInstance().setAgreeNodeCount(2); - Raw raw = buildRaw(2); - byte[] hash = Sha256Hash.hash(true, raw.toByteArray()); - - ECKey ec = new ECKey(); - byte[] ecSig = ec.sign(hash).toByteArray(); - - FNDSA512 kp = new FNDSA512(); - PQAuthSig pq = pqSign(kp, hash); - - List witnesses = Arrays.asList( - ByteString.copyFrom(ec.getAddress()), - pqAddress(kp)); - - Assert.assertTrue(invokeValid(raw, - Collections.singletonList(ByteString.copyFrom(ecSig)), - Collections.singletonList(pq), - witnesses)); - } - - @Test - public void underQuorumFails() throws Exception { - Param.getInstance().setAgreeNodeCount(3); - Raw raw = buildRaw(3); - byte[] hash = Sha256Hash.hash(true, raw.toByteArray()); - - FNDSA512 kp = new FNDSA512(); - PQAuthSig pq = pqSign(kp, hash); - List witnesses = Collections.singletonList( - pqAddress(kp)); - - Assert.assertFalse(invokeValid(raw, Collections.emptyList(), - Collections.singletonList(pq), witnesses)); - } - - @Test - public void pqSchemeNotActivatedFails() throws Exception { - Param.getInstance().setAgreeNodeCount(1); - Raw raw = buildRaw(4); - byte[] hash = Sha256Hash.hash(true, raw.toByteArray()); - - FNDSA512 kp = new FNDSA512(); - PQAuthSig pq = pqSign(kp, hash); - List witnesses = Collections.singletonList( - pqAddress(kp)); - - Mockito.when(dynamicPropertiesStore.isPqSchemeAllowed(PQScheme.FN_DSA_512)).thenReturn(false); - Assert.assertFalse(invokeValid(raw, Collections.emptyList(), - Collections.singletonList(pq), witnesses)); - } - - @Test - public void pqPublicKeyLengthMismatchFails() throws Exception { - Param.getInstance().setAgreeNodeCount(1); - Raw raw = buildRaw(5); - byte[] hash = Sha256Hash.hash(true, raw.toByteArray()); - - FNDSA512 kp = new FNDSA512(); - byte[] sig = PQSchemeRegistry.sign(PQScheme.FN_DSA_512, kp.getPrivateKey(), hash); - PQAuthSig pq = PQAuthSig.newBuilder() - .setScheme(PQScheme.FN_DSA_512) - .setPublicKey(ByteString.copyFrom(new byte[FNDSA512.PUBLIC_KEY_LENGTH - 1])) - .setSignature(ByteString.copyFrom(sig)) - .build(); - List witnesses = Collections.singletonList( - pqAddress(kp)); - - Assert.assertFalse(invokeValid(raw, Collections.emptyList(), - Collections.singletonList(pq), witnesses)); - } - - @Test - public void pqSignerNotInWitnessSetFails() throws Exception { - Param.getInstance().setAgreeNodeCount(1); - Raw raw = buildRaw(6); - byte[] hash = Sha256Hash.hash(true, raw.toByteArray()); - - FNDSA512 kp = new FNDSA512(); - PQAuthSig pq = pqSign(kp, hash); - FNDSA512 stranger = new FNDSA512(); - List witnesses = Collections.singletonList(pqAddress(stranger)); - - Assert.assertFalse(invokeValid(raw, Collections.emptyList(), - Collections.singletonList(pq), witnesses)); - } - - @Test - public void duplicatePqSignerDoesNotInflateQuorum() throws Exception { - Param.getInstance().setAgreeNodeCount(2); - Raw raw = buildRaw(8); - byte[] hash = Sha256Hash.hash(true, raw.toByteArray()); - - FNDSA512 kp = new FNDSA512(); - PQAuthSig sig1 = pqSign(kp, hash); - PQAuthSig sig2 = pqSign(kp, hash); - Assert.assertNotEquals("Falcon should produce randomized signatures", - sig1.getSignature(), sig2.getSignature()); - - List witnesses = Collections.singletonList( - pqAddress(kp)); - - Assert.assertFalse(invokeValid(raw, Collections.emptyList(), - Arrays.asList(sig1, sig2), witnesses)); - } - - @Test - public void pqBadSignatureFails() throws Exception { - Param.getInstance().setAgreeNodeCount(1); - Raw raw = buildRaw(7); - byte[] hash = Sha256Hash.hash(true, raw.toByteArray()); - - FNDSA512 kp = new FNDSA512(); - byte[] goodSig = PQSchemeRegistry.sign(PQScheme.FN_DSA_512, kp.getPrivateKey(), hash); - byte[] tampered = Arrays.copyOf(goodSig, goodSig.length); - tampered[tampered.length - 1] ^= 0x01; - PQAuthSig pq = PQAuthSig.newBuilder() - .setScheme(PQScheme.FN_DSA_512) - .setPublicKey(ByteString.copyFrom(kp.getPublicKey())) - .setSignature(ByteString.copyFrom(tampered)) - .build(); - List witnesses = Collections.singletonList( - pqAddress(kp)); - - Assert.assertFalse(invokeValid(raw, Collections.emptyList(), - Collections.singletonList(pq), witnesses)); - } - - private static Raw buildRaw(long viewN) { - return Raw.newBuilder() - .setViewN(viewN) - .setEpoch(0) - .setDataType(DataType.BLOCK) - .setMsgType(MsgType.COMMIT) - .setData(ByteString.copyFromUtf8("payload-" + viewN)) - .build(); - } - - private static ByteString pqAddress(FNDSA512 kp) { - return ByteString.copyFrom( - PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, kp.getPublicKey())); - } - - private static PQAuthSig pqSign(FNDSA512 kp, byte[] hash) { - byte[] sig = PQSchemeRegistry.sign(PQScheme.FN_DSA_512, kp.getPrivateKey(), hash); - return PQAuthSig.newBuilder() - .setScheme(PQScheme.FN_DSA_512) - .setPublicKey(ByteString.copyFrom(kp.getPublicKey())) - .setSignature(ByteString.copyFrom(sig)) - .build(); - } - - private boolean invokeValid(Raw raw, List srSignList, - List pqSignList, List witnesses) throws Exception { - Method m = PbftDataSyncHandler.class.getDeclaredMethod("validPbftSign", - Raw.class, List.class, List.class, List.class); - m.setAccessible(true); - return (Boolean) m.invoke(handler, raw, new ArrayList<>(srSignList), - new ArrayList<>(pqSignList), witnesses); - } -} diff --git a/framework/src/test/java/org/tron/core/pbft/PbftPQMessageTest.java b/framework/src/test/java/org/tron/core/pbft/PbftPQMessageTest.java deleted file mode 100644 index 16cab0b9920..00000000000 --- a/framework/src/test/java/org/tron/core/pbft/PbftPQMessageTest.java +++ /dev/null @@ -1,236 +0,0 @@ -package org.tron.core.pbft; - -import static org.junit.Assert.assertArrayEquals; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertThrows; -import static org.junit.Assert.assertTrue; - -import com.google.protobuf.ByteString; -import java.security.SignatureException; -import org.bouncycastle.util.encoders.Hex; -import org.junit.Test; -import org.tron.common.crypto.ECKey; -import org.tron.common.crypto.pqc.FNDSA512; -import org.tron.common.crypto.pqc.PQSchemeRegistry; -import org.tron.common.utils.ByteArray; -import org.tron.common.utils.Sha256Hash; -import org.tron.consensus.base.Param; -import org.tron.consensus.base.Param.Miner; -import org.tron.consensus.base.Param.MinerType; -import org.tron.consensus.pbft.message.PbftMessage; -import org.tron.core.capsule.BlockCapsule; -import org.tron.protos.Protocol.Block; -import org.tron.protos.Protocol.PBFTMessage; -import org.tron.protos.Protocol.PQAuthSig; -import org.tron.protos.Protocol.PQScheme; - -public class PbftPQMessageTest { - - private static Miner pqMiner(FNDSA512 kp) { - byte[] address = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, kp.getPublicKey()); - ByteString addressBs = ByteString.copyFrom(address); - Miner miner = Param.getInstance().new Miner(null, addressBs, addressBs); - miner.setPQPrivateKey(kp.getPrivateKey()); - miner.setPQPublicKey(kp.getPublicKey()); - miner.setPqScheme(PQScheme.FN_DSA_512); - miner.setType(MinerType.PQ); - return miner; - } - - private static Miner ecdsaMiner() { - ECKey key = new ECKey(); - ByteString addressBs = ByteString.copyFrom(key.getAddress()); - return Param.getInstance().new Miner( - key.getPrivKeyBytes(), addressBs, addressBs); - } - - private static BlockCapsule emptyBlock() { - return new BlockCapsule(Block.getDefaultInstance()); - } - - /** ECDSA path is unchanged: analyzeSignature recovers the signer address. */ - @Test - public void testEcdsaHappyPath() throws Exception { - Miner miner = ecdsaMiner(); - PbftMessage msg = PbftMessage.prePrepareBlockMsg(emptyBlock(), 1, miner); - assertFalse(msg.getPbftMessage().getSignature().isEmpty()); - assertFalse(msg.getPbftMessage().hasPqAuthSig()); - msg.analyzeSignature(); - assertArrayEquals(miner.getWitnessAddress().toByteArray(), msg.getPublicKey()); - } - - /** PQ miner produces a pbft message with pq_auth_sig populated and signature cleared. */ - @Test - public void testPqHappyPath() throws Exception { - FNDSA512 kp = new FNDSA512(); - Miner miner = pqMiner(kp); - - PbftMessage msg = PbftMessage.prePrepareBlockMsg(emptyBlock(), 1, miner); - assertTrue(msg.getPbftMessage().getSignature().isEmpty()); - assertTrue(msg.getPbftMessage().hasPqAuthSig()); - PQAuthSig pqAuthSig = msg.getPbftMessage().getPqAuthSig(); - assertEquals(PQScheme.FN_DSA_512, pqAuthSig.getScheme()); - assertArrayEquals(kp.getPublicKey(), pqAuthSig.getPublicKey().toByteArray()); - - msg.analyzeSignature(); - assertArrayEquals(miner.getWitnessAddress().toByteArray(), msg.getPublicKey()); - } - - /** PREPARE / COMMIT round-trip also signs with the PQ key. */ - @Test - public void testPqPrepareAndCommit() throws Exception { - FNDSA512 kp = new FNDSA512(); - Miner miner = pqMiner(kp); - PbftMessage pre = PbftMessage.prePrepareBlockMsg(emptyBlock(), 1, miner); - - PbftMessage prepare = pre.buildPrePareMessage(miner); - assertTrue(prepare.getPbftMessage().hasPqAuthSig()); - prepare.analyzeSignature(); - assertArrayEquals(miner.getWitnessAddress().toByteArray(), prepare.getPublicKey()); - - PbftMessage commit = pre.buildCommitMessage(miner); - assertTrue(commit.getPbftMessage().hasPqAuthSig()); - commit.analyzeSignature(); - assertArrayEquals(miner.getWitnessAddress().toByteArray(), commit.getPublicKey()); - } - - /** Both signature and pq_auth_sig present → reject. */ - @Test - public void testMutexBothSet() throws Exception { - FNDSA512 kp = new FNDSA512(); - Miner miner = pqMiner(kp); - PbftMessage msg = PbftMessage.prePrepareBlockMsg(emptyBlock(), 1, miner); - - PBFTMessage tampered = msg.getPbftMessage().toBuilder() - .setSignature(ByteString.copyFrom(new byte[65])) - .build(); - PbftMessage rebuilt = rebuild(msg, tampered); - SignatureException ex = assertThrows(SignatureException.class, rebuilt::analyzeSignature); - assertTrue(ex.getMessage().contains("exactly one")); - } - - /** Neither signature nor pq_auth_sig present → reject. */ - @Test - public void testMutexNeitherSet() throws Exception { - Miner miner = ecdsaMiner(); - PbftMessage msg = PbftMessage.prePrepareBlockMsg(emptyBlock(), 1, miner); - - PBFTMessage tampered = msg.getPbftMessage().toBuilder() - .clearSignature() - .clearPqAuthSig() - .build(); - PbftMessage rebuilt = rebuild(msg, tampered); - SignatureException ex = assertThrows(SignatureException.class, rebuilt::analyzeSignature); - assertTrue(ex.getMessage().contains("exactly one")); - } - - /** Scheme not registered → reject. */ - @Test - public void testPqSchemeNotRegistered() throws Exception { - FNDSA512 kp = new FNDSA512(); - Miner miner = pqMiner(kp); - PbftMessage msg = PbftMessage.prePrepareBlockMsg(emptyBlock(), 1, miner); - - // UNKNOWN_PQ_SCHEME (0) is normalized to FN_DSA_512 by PQSchemeRegistry#resolve - // for proto3 default-zero compatibility, so use setSchemeValue() to inject a - // truly unrecognized scheme value that bypasses the enum and the normalizer. - PBFTMessage tampered = msg.getPbftMessage().toBuilder() - .setPqAuthSig(msg.getPbftMessage().getPqAuthSig().toBuilder() - .setSchemeValue(999)) - .build(); - PbftMessage rebuilt = rebuild(msg, tampered); - SignatureException ex = assertThrows(SignatureException.class, rebuilt::analyzeSignature); - assertTrue(ex.getMessage().contains("scheme not registered")); - } - - /** Public-key length mismatch → reject. */ - @Test - public void testPqBadPublicKeyLength() throws Exception { - FNDSA512 kp = new FNDSA512(); - Miner miner = pqMiner(kp); - PbftMessage msg = PbftMessage.prePrepareBlockMsg(emptyBlock(), 1, miner); - - byte[] shortPk = new byte[FNDSA512.PUBLIC_KEY_LENGTH - 1]; - PBFTMessage tampered = msg.getPbftMessage().toBuilder() - .setPqAuthSig(msg.getPbftMessage().getPqAuthSig().toBuilder() - .setPublicKey(ByteString.copyFrom(shortPk))) - .build(); - PbftMessage rebuilt = rebuild(msg, tampered); - SignatureException ex = assertThrows(SignatureException.class, rebuilt::analyzeSignature); - assertTrue(ex.getMessage().contains("public key length mismatch")); - } - - /** Signature length above protocol cap → reject. */ - @Test - public void testPqBadSignatureLength() throws Exception { - FNDSA512 kp = new FNDSA512(); - Miner miner = pqMiner(kp); - PbftMessage msg = PbftMessage.prePrepareBlockMsg(emptyBlock(), 1, miner); - - byte[] oversized = new byte[FNDSA512.SIGNATURE_LENGTH + 1]; - PBFTMessage tampered = msg.getPbftMessage().toBuilder() - .setPqAuthSig(msg.getPbftMessage().getPqAuthSig().toBuilder() - .setSignature(ByteString.copyFrom(oversized))) - .build(); - PbftMessage rebuilt = rebuild(msg, tampered); - SignatureException ex = assertThrows(SignatureException.class, rebuilt::analyzeSignature); - assertTrue(ex.getMessage().contains("signature length mismatch")); - } - - /** Public key replaced with a stray keypair → verify fails. */ - @Test - public void testPqSignatureFromWrongKey() throws Exception { - FNDSA512 kp = new FNDSA512(); - Miner miner = pqMiner(kp); - PbftMessage msg = PbftMessage.prePrepareBlockMsg(emptyBlock(), 1, miner); - - FNDSA512 stranger = new FNDSA512(); - PBFTMessage tampered = msg.getPbftMessage().toBuilder() - .setPqAuthSig(msg.getPbftMessage().getPqAuthSig().toBuilder() - .setPublicKey(ByteString.copyFrom(stranger.getPublicKey()))) - .build(); - PbftMessage rebuilt = rebuild(msg, tampered); - SignatureException ex = assertThrows(SignatureException.class, rebuilt::analyzeSignature); - assertTrue(ex.getMessage().contains("verification failed")); - } - - /** Hand-built PQ signature recovers the derived witness address. */ - @Test - public void testManualPqAuthSig() throws Exception { - FNDSA512 kp = new FNDSA512(); - byte[] expected = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, kp.getPublicKey()); - - PBFTMessage.Raw raw = PBFTMessage.Raw.newBuilder() - .setViewN(1) - .setEpoch(1) - .setDataType(PBFTMessage.DataType.BLOCK) - .setMsgType(PBFTMessage.MsgType.PREPREPARE) - .setData(ByteString.copyFrom(ByteArray.fromHexString("abcd"))) - .build(); - byte[] hash = Sha256Hash.hash(true, raw.toByteArray()); - byte[] sig = PQSchemeRegistry.sign(PQScheme.FN_DSA_512, kp.getPrivateKey(), hash); - - PBFTMessage message = PBFTMessage.newBuilder() - .setRawData(raw) - .setPqAuthSig(PQAuthSig.newBuilder() - .setScheme(PQScheme.FN_DSA_512) - .setPublicKey(ByteString.copyFrom(kp.getPublicKey())) - .setSignature(ByteString.copyFrom(sig))) - .build(); - PbftMessage pbft = new PbftMessage(); - pbft.setPbftMessage(message); - pbft.setData(message.toByteArray()); - pbft.analyzeSignature(); - assertEquals(Hex.toHexString(expected), Hex.toHexString(pbft.getPublicKey())); - } - - private static PbftMessage rebuild(PbftMessage original, PBFTMessage replacement) { - PbftMessage rebuilt = new PbftMessage(); - rebuilt.setType(original.getType().asByte()); - rebuilt.setPbftMessage(replacement); - rebuilt.setData(replacement.toByteArray()); - rebuilt.setSwitch(original.isSwitch()); - return rebuilt; - } -} diff --git a/protocol/src/main/protos/core/Tron.proto b/protocol/src/main/protos/core/Tron.proto index 2b3b1733e5d..1745a73a928 100644 --- a/protocol/src/main/protos/core/Tron.proto +++ b/protocol/src/main/protos/core/Tron.proto @@ -920,26 +920,12 @@ message PBFTMessage { bytes data = 5; } Raw raw_data = 1; - // Legacy ECDSA signature over Sha256Hash(raw_data). Mutually exclusive with - // pq_auth_sig — exactly one of the two must be set per pbft message. bytes signature = 2; - // Post-quantum auth signature over Sha256Hash(raw_data). Set instead of - // `signature` when the local witness is PQ-only. Verifier only accepts - // this field after the corresponding PQ scheme is activated on chain. - PQAuthSig pq_auth_sig = 3; } message PBFTCommitResult { bytes data = 1; - // Legacy ECDSA commit signatures recoverable to a SR address via the - // signed `data` hash. May coexist with `pq_signature` when a quorum - // includes both ECDSA and PQ-only witnesses; the total of the two - // lists must meet the agreement threshold. repeated bytes signature = 2; - // Post-quantum commit signatures contributed by PQ-only witnesses. - // Verifiers must reject any pq_signature entry whose scheme is not - // activated on chain. - repeated PQAuthSig pq_signature = 3; } message SRL { From 8b5f3c19ce992b5b5becaa9e4d5e824c41eba8d2 Mon Sep 17 00:00:00 2001 From: GrapeS Date: Wed, 20 May 2026 17:36:46 +0800 Subject: [PATCH 18/47] fix review --- .../tron/core/capsule/TransactionCapsule.java | 18 +++--- .../org/tron/core/db/BandwidthProcessor.java | 8 +-- .../core/store/DynamicPropertiesStore.java | 16 ++++- .../common/crypto/pqc/PQSchemeRegistry.java | 12 ++++ .../main/java/org/tron/core/db/Manager.java | 64 ++++++------------- 5 files changed, 59 insertions(+), 59 deletions(-) diff --git a/chainbase/src/main/java/org/tron/core/capsule/TransactionCapsule.java b/chainbase/src/main/java/org/tron/core/capsule/TransactionCapsule.java index cf83ceeb8e8..191afdfed1e 100755 --- a/chainbase/src/main/java/org/tron/core/capsule/TransactionCapsule.java +++ b/chainbase/src/main/java/org/tron/core/capsule/TransactionCapsule.java @@ -657,20 +657,20 @@ public boolean validatePubSignature(AccountStore accountStore, DynamicPropertiesStore dynamicPropertiesStore) throws ValidateSignatureException { if (!isVerified) { - int legacyCount = this.transaction.getSignatureCount(); + int signatureCount = this.transaction.getSignatureCount(); int pqCount = this.transaction.getPqAuthSigCount(); - if (pqCount > 0 && !dynamicPropertiesStore.isAnyPqSchemeAllowed()) { + if (dynamicPropertiesStore.isAnyPqSchemeAllowed()) { + signatureCount += pqCount; + } else if (pqCount > 0) { throw new ValidateSignatureException( "pq_auth_sig not allowed: no post-quantum scheme is activated"); } - if (legacyCount == 0 && pqCount == 0) { - throw new ValidateSignatureException("miss sig"); - } - if (this.transaction.getRawData().getContractCount() <= 0) { - throw new ValidateSignatureException("miss contract"); + + if (signatureCount == 0 || this.transaction.getRawData().getContractCount() <= 0) { + throw new ValidateSignatureException("miss sig or contract"); } - if (legacyCount + pqCount > dynamicPropertiesStore.getTotalSignNum()) { + if (signatureCount > dynamicPropertiesStore.getTotalSignNum()) { throw new ValidateSignatureException("too many signatures"); } @@ -800,7 +800,7 @@ public boolean validateSignature(AccountStore accountStore, validatePubSignature(accountStore, dynamicPropertiesStore); } else { //transfer from shielded address if (this.transaction.getSignatureCount() > 0 - || this.transaction.getPqAuthSigCount() > 0) { + || (dynamicPropertiesStore.isAnyPqSchemeAllowed() && this.transaction.getPqAuthSigCount() > 0)) { throw new ValidateSignatureException("there should be no signatures signed by " + "transparent address when transfer from shielded address"); } diff --git a/chainbase/src/main/java/org/tron/core/db/BandwidthProcessor.java b/chainbase/src/main/java/org/tron/core/db/BandwidthProcessor.java index 1d2987d4d44..859b1bbc199 100644 --- a/chainbase/src/main/java/org/tron/core/db/BandwidthProcessor.java +++ b/chainbase/src/main/java/org/tron/core/db/BandwidthProcessor.java @@ -23,6 +23,7 @@ import org.tron.core.exception.ContractValidateException; import org.tron.core.exception.TooBigTransactionException; import org.tron.core.exception.TooBigTransactionResultException; +import org.tron.protos.Protocol.PQAuthSig; import org.tron.protos.Protocol.Transaction.Contract; import org.tron.protos.contract.AssetIssueContractOuterClass.TransferAssetContract; import org.tron.protos.contract.BalanceContract.TransferContract; @@ -141,11 +142,10 @@ public void consume(TransactionCapsule trx, TransactionTrace trace) long maxCreateAccountTxSize = dynamicPropertiesStore.getMaxCreateAccountTxSize(); int signatureCount = trx.getInstance().getSignatureCount(); long sigOverhead = signatureCount * PER_SIGN_LENGTH; - if (trx.getInstance().getPqAuthSigCount() > 0) { + if (dynamicPropertiesStore.isAnyPqSchemeAllowed() && trx.getInstance().getPqAuthSigCount() > 0) { long pqAuthSigBytes = 0L; - for (org.tron.protos.Protocol.PQAuthSig aw - : trx.getInstance().getPqAuthSigList()) { - pqAuthSigBytes += aw.getSerializedSize(); + for (PQAuthSig pqAuthSig : trx.getInstance().getPqAuthSigList()) { + pqAuthSigBytes += pqAuthSig.getSerializedSize(); } sigOverhead += pqAuthSigBytes; } diff --git a/chainbase/src/main/java/org/tron/core/store/DynamicPropertiesStore.java b/chainbase/src/main/java/org/tron/core/store/DynamicPropertiesStore.java index ea88128c54a..aa7103fb958 100644 --- a/chainbase/src/main/java/org/tron/core/store/DynamicPropertiesStore.java +++ b/chainbase/src/main/java/org/tron/core/store/DynamicPropertiesStore.java @@ -16,6 +16,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +import org.tron.common.crypto.pqc.PQSchemeRegistry; import org.tron.common.parameter.CommonParameter; import org.tron.common.utils.ByteArray; import org.tron.common.utils.Sha256Hash; @@ -3101,9 +3102,20 @@ public boolean allowFnDsa512() { return getAllowFnDsa512() == 1L; } - /** Returns true iff at least one post-quantum signature scheme is currently activated. */ + /** + * Returns true iff at least one post-quantum signature scheme is currently + * activated. Driven by {@link PQSchemeRegistry#registeredSchemes()} so that + * adding a new scheme to the registry (and its corresponding case in + * {@link #isPqSchemeAllowed}) automatically propagates here — no manual edit + * needed. + */ public boolean isAnyPqSchemeAllowed() { - return allowFnDsa512(); + for (PQScheme scheme : PQSchemeRegistry.registeredSchemes()) { + if (isPqSchemeAllowed(scheme)) { + return true; + } + } + return false; } /** diff --git a/crypto/src/main/java/org/tron/common/crypto/pqc/PQSchemeRegistry.java b/crypto/src/main/java/org/tron/common/crypto/pqc/PQSchemeRegistry.java index c57d7e702f2..757eb9d8319 100644 --- a/crypto/src/main/java/org/tron/common/crypto/pqc/PQSchemeRegistry.java +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/PQSchemeRegistry.java @@ -3,6 +3,7 @@ import java.util.Collections; import java.util.EnumMap; import java.util.Map; +import java.util.Set; import org.tron.common.crypto.Hash; import org.tron.protos.Protocol.PQScheme; @@ -124,6 +125,17 @@ public static boolean contains(PQScheme scheme) { return resolved != null && SCHEMES.containsKey(resolved); } + /** + * Returns the set of post-quantum schemes that are registered (i.e. have an + * active {@link SignatureOps} entry). Lets governance / config layers + * enumerate "all PQ schemes" without hard-coding the list — adding a new + * scheme to the registry then auto-propagates to any caller iterating over + * this set. + */ + public static Set registeredSchemes() { + return SCHEMES.keySet(); + } + public static int getPrivateKeyLength(PQScheme scheme) { return require(scheme).privateKeyLength; } diff --git a/framework/src/main/java/org/tron/core/db/Manager.java b/framework/src/main/java/org/tron/core/db/Manager.java index e51215163b4..878ff84780d 100644 --- a/framework/src/main/java/org/tron/core/db/Manager.java +++ b/framework/src/main/java/org/tron/core/db/Manager.java @@ -86,6 +86,7 @@ import org.tron.common.utils.StringUtil; import org.tron.common.zksnark.MerkleContainer; import org.tron.consensus.Consensus; +import org.tron.consensus.base.Param; import org.tron.consensus.base.Param.Miner; import org.tron.core.ChainBaseManager; import org.tron.core.Constant; @@ -1746,7 +1747,12 @@ public BlockCapsule generateBlock(Miner miner, long blockTime, long timeout) { session.reset(); blockCapsule.setMerkleRoot(); - signBlockCapsule(blockCapsule, miner); + if (getDynamicPropertiesStore().isAnyPqSchemeAllowed() && + miner.getType() != Param.MinerType.ECDSA) { + signBlockCapsuleWithPQ(blockCapsule, miner); + } else { + blockCapsule.sign(miner.getPrivateKey()); + } BlockCapsule capsule = new BlockCapsule(blockCapsule.getInstance()); capsule.generatedByMyself = true; @@ -1762,57 +1768,27 @@ public BlockCapsule generateBlock(Miner miner, long blockTime, long timeout) { return capsule; } - private void signBlockCapsule(BlockCapsule blockCapsule, Miner miner) { - switch (miner.getType()) { - case PQ: - PQScheme scheme = resolveWitnessScheme(miner); - if (scheme == null) { - // PQ-only miner whose configured scheme is not currently usable - // (proposal not activated, scheme allow flag flipped, witness - // permission missing, etc.). Surface a clear cause; DposTask's - // Throwable handler will log and the witness will miss this slot, - // but the producer thread keeps running. - throw new IllegalStateException( - "PQ-only miner " + Hex.toHexString(miner.getWitnessAddress().toByteArray()) - + " has scheme " + miner.getPqScheme() - + " configured but it is not currently usable " - + "(scheme not allowed by dynamic properties, " - + "or witness permission is missing/empty)"); - } - signWitnessAuth(blockCapsule, miner, scheme); - break; - case ECDSA: - blockCapsule.sign(miner.getPrivateKey()); - break; - default: - throw new IllegalStateException("unknown miner type: " + miner.getType()); + private void signBlockCapsuleWithPQ(BlockCapsule blockCapsule, Miner miner) { + if (miner.getType() != Param.MinerType.PQ) { + throw new IllegalStateException("unknown PQ miner type: " + miner.getType()); } - } - private PQScheme resolveWitnessScheme(Miner miner) { - if (!chainBaseManager.getDynamicPropertiesStore().isAnyPqSchemeAllowed()) { - return null; - } PQScheme scheme = miner.getPqScheme(); if (scheme == null || !PQSchemeRegistry.contains(scheme)) { - return null; + throw new IllegalStateException( + "PQ-only miner " + Hex.toHexString(miner.getWitnessAddress().toByteArray()) + + " has scheme " + miner.getPqScheme() + + " configured but it is not currently usable " + + "or witness permission is missing/empty)"); } if (!chainBaseManager.getDynamicPropertiesStore().isPqSchemeAllowed(scheme)) { - return null; - } - byte[] witnessAddress = miner.getWitnessAddress().toByteArray(); - AccountCapsule accountCapsule = chainBaseManager.getAccountStore().get(witnessAddress); - if (accountCapsule == null || !accountCapsule.getInstance().hasWitnessPermission()) { - return null; - } - Permission witnessPermission = accountCapsule.getInstance().getWitnessPermission(); - if (witnessPermission.getKeysCount() == 0) { - return null; + throw new IllegalStateException( + "PQ-only miner " + Hex.toHexString(miner.getWitnessAddress().toByteArray()) + + " has scheme " + miner.getPqScheme() + + " but it is not allowed by dynamic properties"); } - return scheme; - } - private void signWitnessAuth(BlockCapsule blockCapsule, Miner miner, PQScheme scheme) { + byte[] pqPrivateKey = miner.getPQPrivateKey(); byte[] pqPublicKey = miner.getPQPublicKey(); if (pqPrivateKey == null || pqPublicKey == null) { From cc484282514223d695a0aeb814110a39b22cdf34 Mon Sep 17 00:00:00 2001 From: GrapeS Date: Thu, 21 May 2026 15:25:37 +0800 Subject: [PATCH 19/47] fix --- .../org/tron/core/capsule/TransactionCapsule.java | 2 +- .../java/org/tron/core/db/BandwidthProcessor.java | 4 +++- .../main/java/org/tron/consensus/base/Param.java | 13 +++++-------- .../org/tron/core/consensus/ConsensusService.java | 2 -- .../src/main/java/org/tron/core/db/Manager.java | 6 +----- 5 files changed, 10 insertions(+), 17 deletions(-) diff --git a/chainbase/src/main/java/org/tron/core/capsule/TransactionCapsule.java b/chainbase/src/main/java/org/tron/core/capsule/TransactionCapsule.java index 191afdfed1e..23bfc6ba649 100755 --- a/chainbase/src/main/java/org/tron/core/capsule/TransactionCapsule.java +++ b/chainbase/src/main/java/org/tron/core/capsule/TransactionCapsule.java @@ -800,7 +800,7 @@ public boolean validateSignature(AccountStore accountStore, validatePubSignature(accountStore, dynamicPropertiesStore); } else { //transfer from shielded address if (this.transaction.getSignatureCount() > 0 - || (dynamicPropertiesStore.isAnyPqSchemeAllowed() && this.transaction.getPqAuthSigCount() > 0)) { + || (this.transaction.getPqAuthSigCount() > 0)) { throw new ValidateSignatureException("there should be no signatures signed by " + "transparent address when transfer from shielded address"); } diff --git a/chainbase/src/main/java/org/tron/core/db/BandwidthProcessor.java b/chainbase/src/main/java/org/tron/core/db/BandwidthProcessor.java index 859b1bbc199..6b7e9795bf5 100644 --- a/chainbase/src/main/java/org/tron/core/db/BandwidthProcessor.java +++ b/chainbase/src/main/java/org/tron/core/db/BandwidthProcessor.java @@ -142,7 +142,9 @@ public void consume(TransactionCapsule trx, TransactionTrace trace) long maxCreateAccountTxSize = dynamicPropertiesStore.getMaxCreateAccountTxSize(); int signatureCount = trx.getInstance().getSignatureCount(); long sigOverhead = signatureCount * PER_SIGN_LENGTH; - if (dynamicPropertiesStore.isAnyPqSchemeAllowed() && trx.getInstance().getPqAuthSigCount() > 0) { + + // PQAuthSig bytes are subtracted as signature overhead regardless of open or not + if (trx.getInstance().getPqAuthSigCount() > 0) { long pqAuthSigBytes = 0L; for (PQAuthSig pqAuthSig : trx.getInstance().getPqAuthSigList()) { pqAuthSigBytes += pqAuthSig.getSerializedSize(); diff --git a/consensus/src/main/java/org/tron/consensus/base/Param.java b/consensus/src/main/java/org/tron/consensus/base/Param.java index 71f23ce23ae..fd9b130a3c3 100644 --- a/consensus/src/main/java/org/tron/consensus/base/Param.java +++ b/consensus/src/main/java/org/tron/consensus/base/Param.java @@ -80,18 +80,15 @@ public class Miner { private byte[] pqPublicKey; - @Getter - @Setter - private PQScheme pqScheme; - /** - * Explicit signing-family marker so the block producer doesn't have to infer - * key type from {@code privateKey == null}. Defaults to {@link MinerType#ECDSA}; - * PQ-only miners must call {@link #setType(MinerType)} with {@link MinerType#PQ}. + * Post-quantum signature scheme for this miner. When unset (null), the + * miner signs blocks with the legacy ECDSA path using {@link #privateKey}; + * when set (e.g. {@code FN_DSA_512}), the PQ path is used with + * {@link #pqPrivateKey} / {@link #pqPublicKey}. */ @Getter @Setter - private MinerType type = MinerType.ECDSA; + private PQScheme pqScheme; public Miner(byte[] privateKey, ByteString privateKeyAddress, ByteString witnessAddress) { this.privateKey = privateKey; diff --git a/framework/src/main/java/org/tron/core/consensus/ConsensusService.java b/framework/src/main/java/org/tron/core/consensus/ConsensusService.java index 600262c6241..a41e16b40f3 100644 --- a/framework/src/main/java/org/tron/core/consensus/ConsensusService.java +++ b/framework/src/main/java/org/tron/core/consensus/ConsensusService.java @@ -106,7 +106,6 @@ public void start() { miner.setPQPrivateKey(sk); miner.setPQPublicKey(pk); miner.setPqScheme(scheme); - miner.setType(Param.MinerType.PQ); miners.add(miner); logger.info("Add {} witness (from configured keypair): {}, size: {}", scheme, Hex.toHexString(pqAddress), miners.size()); @@ -145,7 +144,6 @@ private Miner buildPQOnlyMinerFromKeypair(Param param, PqKeypair pqKeypair) { miner.setPQPrivateKey(sk); miner.setPQPublicKey(pk); miner.setPqScheme(scheme); - miner.setType(Param.MinerType.PQ); logger.info("Add {} witness (from configured keypair): {}", scheme, Hex.toHexString(witnessAddress)); return miner; diff --git a/framework/src/main/java/org/tron/core/db/Manager.java b/framework/src/main/java/org/tron/core/db/Manager.java index 878ff84780d..59747b99453 100644 --- a/framework/src/main/java/org/tron/core/db/Manager.java +++ b/framework/src/main/java/org/tron/core/db/Manager.java @@ -1748,7 +1748,7 @@ public BlockCapsule generateBlock(Miner miner, long blockTime, long timeout) { blockCapsule.setMerkleRoot(); if (getDynamicPropertiesStore().isAnyPqSchemeAllowed() && - miner.getType() != Param.MinerType.ECDSA) { + miner.getPqScheme() != null) { signBlockCapsuleWithPQ(blockCapsule, miner); } else { blockCapsule.sign(miner.getPrivateKey()); @@ -1769,10 +1769,6 @@ public BlockCapsule generateBlock(Miner miner, long blockTime, long timeout) { } private void signBlockCapsuleWithPQ(BlockCapsule blockCapsule, Miner miner) { - if (miner.getType() != Param.MinerType.PQ) { - throw new IllegalStateException("unknown PQ miner type: " + miner.getType()); - } - PQScheme scheme = miner.getPqScheme(); if (scheme == null || !PQSchemeRegistry.contains(scheme)) { throw new IllegalStateException( From 33f8e303e16fded78e54be1287e0b1a0f7ec2063 Mon Sep 17 00:00:00 2001 From: federico Date: Fri, 22 May 2026 15:37:50 +0800 Subject: [PATCH 20/47] refactor(crypto): require explicit pq scheme tag --- .../core/store/DynamicPropertiesStore.java | 1 - .../common/crypto/pqc/PQSchemeRegistry.java | 41 ++++++++++--------- .../main/java/org/tron/core/db/Manager.java | 7 +--- .../tron/common/crypto/pqc/FNDSA512Test.java | 31 +++++--------- .../crypto/pqc/PQSchemeRegistryTest.java | 23 ++++++++--- .../common/crypto/pqc/program/PQClient.java | 6 ++- .../tron/core/capsule/BlockCapsulePQTest.java | 11 ++--- 7 files changed, 61 insertions(+), 59 deletions(-) diff --git a/chainbase/src/main/java/org/tron/core/store/DynamicPropertiesStore.java b/chainbase/src/main/java/org/tron/core/store/DynamicPropertiesStore.java index aa7103fb958..9b05d65dfa5 100644 --- a/chainbase/src/main/java/org/tron/core/store/DynamicPropertiesStore.java +++ b/chainbase/src/main/java/org/tron/core/store/DynamicPropertiesStore.java @@ -3127,7 +3127,6 @@ public boolean isPqSchemeAllowed(PQScheme scheme) { return false; } switch (scheme) { - case UNKNOWN_PQ_SCHEME: // proto3 default → Falcon-512 (see PQSchemeRegistry#resolve) case FN_DSA_512: return allowFnDsa512(); default: diff --git a/crypto/src/main/java/org/tron/common/crypto/pqc/PQSchemeRegistry.java b/crypto/src/main/java/org/tron/common/crypto/pqc/PQSchemeRegistry.java index 757eb9d8319..f40cc07d2e2 100644 --- a/crypto/src/main/java/org/tron/common/crypto/pqc/PQSchemeRegistry.java +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/PQSchemeRegistry.java @@ -20,11 +20,12 @@ * addresses share the same derivation shape. The hash function is scheme- * specific (see {@link #deriveHash}); {@code FN_DSA_512} uses Keccak-256. * - *

Wire-format default. {@code UNKNOWN_PQ_SCHEME = 0} is the proto3 - * default (reserved for the {@code UNKNOWN_} API-evolution slot); on the wire - * it is interpreted as {@code FN_DSA_512} so V2-launch witnesses pay zero - * bytes for the scheme tag. All public methods normalize via - * {@link #resolve(PQScheme)} before dispatch. + *

Wire format. The proto3 default {@code UNKNOWN_PQ_SCHEME = 0} is + * reserved for the {@code UNKNOWN_} API-evolution slot and is NOT interpreted + * as any registered scheme — producers must set the scheme tag explicitly so + * future schemes can be added without ambiguity between "client did not set + * scheme" and "client meant FN_DSA_512". {@link #contains}/{@link #require} + * reject {@code UNKNOWN_PQ_SCHEME} on the same path as {@code UNRECOGNIZED}. */ public final class PQSchemeRegistry { @@ -107,22 +108,20 @@ private PQSchemeRegistry() { } /** - * Map a wire-format {@link PQScheme} to its registered scheme. The proto3 - * default {@code UNKNOWN_PQ_SCHEME} is normalized to {@code FN_DSA_512} so - * V2-launch witnesses that omit the scheme tag are decoded as Falcon-512. - * {@code null} and {@code UNRECOGNIZED} pass through unchanged so the - * caller-side {@code contains}/{@code require} checks reject them. + * Pass-through for API stability. {@code UNKNOWN_PQ_SCHEME} is no longer + * normalized to {@code FN_DSA_512}; producers must set the scheme tag + * explicitly. {@code null} and {@code UNRECOGNIZED} pass through unchanged + * so the caller-side {@code contains}/{@code require} checks reject them. */ public static PQScheme resolve(PQScheme scheme) { - if (scheme == PQScheme.UNKNOWN_PQ_SCHEME) { - return PQScheme.FN_DSA_512; - } return scheme; } public static boolean contains(PQScheme scheme) { - PQScheme resolved = resolve(scheme); - return resolved != null && SCHEMES.containsKey(resolved); + if (scheme == null || scheme == PQScheme.UNKNOWN_PQ_SCHEME) { + return false; + } + return SCHEMES.containsKey(scheme); } /** @@ -159,9 +158,8 @@ public static int getSeedLength(PQScheme scheme) { * any {@code 1..max}. */ public static boolean isValidSignatureLength(PQScheme scheme, int length) { - PQScheme resolved = resolve(scheme); - SchemeInfo info = require(resolved); - if (resolved == PQScheme.FN_DSA_512) { + SchemeInfo info = require(scheme); + if (scheme == PQScheme.FN_DSA_512) { return length > 0 && length <= info.signatureLength; } return length == info.signatureLength; @@ -224,8 +222,11 @@ private static SchemeInfo require(PQScheme scheme) { if (scheme == null) { throw new IllegalArgumentException("scheme must not be null"); } - PQScheme resolved = resolve(scheme); - SchemeInfo info = SCHEMES.get(resolved); + if (scheme == PQScheme.UNKNOWN_PQ_SCHEME) { + throw new IllegalArgumentException( + "no PQSignature registered for scheme: " + scheme); + } + SchemeInfo info = SCHEMES.get(scheme); if (info == null) { throw new IllegalArgumentException( "no PQSignature registered for scheme: " + scheme); diff --git a/framework/src/main/java/org/tron/core/db/Manager.java b/framework/src/main/java/org/tron/core/db/Manager.java index 59747b99453..bc2ebd432ea 100644 --- a/framework/src/main/java/org/tron/core/db/Manager.java +++ b/framework/src/main/java/org/tron/core/db/Manager.java @@ -1796,14 +1796,9 @@ private void signBlockCapsuleWithPQ(BlockCapsule blockCapsule, Miner miner) { byte[] digest = blockCapsule.getRawHashBytes(); byte[] signature = PQSchemeRegistry.sign(scheme, pqPrivateKey, digest); PQAuthSig.Builder builder = PQAuthSig.newBuilder() + .setScheme(scheme) .setPublicKey(ByteString.copyFrom(pqPublicKey)) .setSignature(ByteString.copyFrom(signature)); - // FN_DSA_512 is the launch scheme: leave scheme at the proto3 default - // (UNKNOWN_PQ_SCHEME) and rely on PQSchemeRegistry.resolve() on the read - // path so the tag costs zero wire bytes per block. - if (scheme != PQScheme.FN_DSA_512) { - builder.setScheme(scheme); - } blockCapsule.setPqAuthSig(builder.build()); } diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/FNDSA512Test.java b/framework/src/test/java/org/tron/common/crypto/pqc/FNDSA512Test.java index fce3565f2ca..8f62577f0c0 100644 --- a/framework/src/test/java/org/tron/common/crypto/pqc/FNDSA512Test.java +++ b/framework/src/test/java/org/tron/common/crypto/pqc/FNDSA512Test.java @@ -431,25 +431,16 @@ public void staticVerifyRejectsNullPublicKey() { } @Test - public void unknownPqSchemeResolvesToFnDsa512() { - assertEquals(PQScheme.FN_DSA_512, - PQSchemeRegistry.resolve(PQScheme.UNKNOWN_PQ_SCHEME)); - assertTrue(PQSchemeRegistry.contains(PQScheme.UNKNOWN_PQ_SCHEME)); - assertEquals(FNDSA512.PUBLIC_KEY_LENGTH, - PQSchemeRegistry.getPublicKeyLength(PQScheme.UNKNOWN_PQ_SCHEME)); - assertEquals(FNDSA512.SIGNATURE_LENGTH, - PQSchemeRegistry.getSignatureLength(PQScheme.UNKNOWN_PQ_SCHEME)); - assertTrue(PQSchemeRegistry.isValidSignatureLength( - PQScheme.UNKNOWN_PQ_SCHEME, FNDSA512.SIGNATURE_LENGTH)); - assertArrayEquals( - FNDSA512.computeAddress(pk.getH()), - PQSchemeRegistry.computeAddress(PQScheme.UNKNOWN_PQ_SCHEME, pk.getH())); - - byte[] msg = "unknown-resolves-to-falcon".getBytes(); - byte[] sig = PQSchemeRegistry.sign( - PQScheme.UNKNOWN_PQ_SCHEME, sk.getEncoded(), msg); - assertTrue(PQSchemeRegistry.verify( - PQScheme.UNKNOWN_PQ_SCHEME, pk.getH(), msg, sig)); - assertTrue(FNDSA512.verify(pk.getH(), msg, sig)); + public void unknownPqSchemeIsRejectedAtRegistry() { + // The proto3 default UNKNOWN_PQ_SCHEME is reserved and must not be + // interpreted as any registered scheme; producers must set the tag + // explicitly. + assertFalse(PQSchemeRegistry.contains(PQScheme.UNKNOWN_PQ_SCHEME)); + try { + PQSchemeRegistry.getPublicKeyLength(PQScheme.UNKNOWN_PQ_SCHEME); + fail("UNKNOWN_PQ_SCHEME must be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("PQSignature registered")); + } } } diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/PQSchemeRegistryTest.java b/framework/src/test/java/org/tron/common/crypto/pqc/PQSchemeRegistryTest.java index 817d1dc1f07..431f4eeab25 100644 --- a/framework/src/test/java/org/tron/common/crypto/pqc/PQSchemeRegistryTest.java +++ b/framework/src/test/java/org/tron/common/crypto/pqc/PQSchemeRegistryTest.java @@ -28,6 +28,11 @@ public void containsRejectsUnrecognized() { assertFalse(PQSchemeRegistry.contains(PQScheme.UNRECOGNIZED)); } + @Test + public void containsRejectsUnknownPqScheme() { + assertFalse(PQSchemeRegistry.contains(PQScheme.UNKNOWN_PQ_SCHEME)); + } + @Test public void containsAcceptsRegisteredScheme() { assertTrue(PQSchemeRegistry.contains(PQScheme.FN_DSA_512)); @@ -37,9 +42,6 @@ public void containsAcceptsRegisteredScheme() { public void getSeedLengthReturnsRegisteredValue() { assertEquals(FNDSA512.SEED_LENGTH, PQSchemeRegistry.getSeedLength(PQScheme.FN_DSA_512)); - // UNKNOWN_PQ_SCHEME normalizes to FN_DSA_512. - assertEquals(FNDSA512.SEED_LENGTH, - PQSchemeRegistry.getSeedLength(PQScheme.UNKNOWN_PQ_SCHEME)); } @Test @@ -116,10 +118,21 @@ public void requireRejectsUnrecognizedScheme() { } @Test - public void resolvePassesThroughNonDefaultSchemes() { + public void requireRejectsUnknownPqScheme() { + try { + PQSchemeRegistry.getPublicKeyLength(PQScheme.UNKNOWN_PQ_SCHEME); + fail("UNKNOWN_PQ_SCHEME must be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("PQSignature registered")); + } + } + + @Test + public void resolveIsPassThrough() { assertEquals(PQScheme.FN_DSA_512, PQSchemeRegistry.resolve(PQScheme.FN_DSA_512)); - // null should pass through so contains/require can decide. + assertEquals(PQScheme.UNKNOWN_PQ_SCHEME, + PQSchemeRegistry.resolve(PQScheme.UNKNOWN_PQ_SCHEME)); assertTrue(PQSchemeRegistry.resolve(null) == null); } diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/program/PQClient.java b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQClient.java index 192e4abc23b..890b922a962 100644 --- a/framework/src/test/java/org/tron/common/crypto/pqc/program/PQClient.java +++ b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQClient.java @@ -17,6 +17,7 @@ import org.tron.common.utils.Sha256Hash; import org.tron.protos.Protocol.Block; import org.tron.protos.Protocol.PQAuthSig; +import org.tron.protos.Protocol.PQScheme; import org.tron.protos.Protocol.Transaction; import org.tron.protos.Protocol.Transaction.Contract.ContractType; import org.tron.protos.contract.BalanceContract.TransferContract; @@ -114,10 +115,11 @@ public static void main(String[] args) throws Exception { rawData.toByteArray()).getBytes(); byte[] sig = FNDSA512.sign(userPriv, txId); - // FN_DSA_512 is the launch scheme → leave scheme at proto3 default and - // let PQSchemeRegistry.resolve() normalize it on the verifier side. + // Producers must set the scheme tag explicitly; scheme=0 + // (UNKNOWN_PQ_SCHEME) is rejected by the verifier as unregistered. Transaction signedTx = tx.toBuilder() .addPqAuthSig(PQAuthSig.newBuilder() + .setScheme(PQScheme.FN_DSA_512) .setPublicKey(ByteString.copyFrom(userPub)) .setSignature(ByteString.copyFrom(sig))) .build(); diff --git a/framework/src/test/java/org/tron/core/capsule/BlockCapsulePQTest.java b/framework/src/test/java/org/tron/core/capsule/BlockCapsulePQTest.java index e2ae68efab0..16feba0f4b3 100644 --- a/framework/src/test/java/org/tron/core/capsule/BlockCapsulePQTest.java +++ b/framework/src/test/java/org/tron/core/capsule/BlockCapsulePQTest.java @@ -183,8 +183,8 @@ public void pqOnlyAccepted() throws Exception { dbManager.getDynamicPropertiesStore(), dbManager.getAccountStore())); } - @Test - public void pqAuthSigWithDefaultSchemeAcceptedAsFnDsa512() throws Exception { + @Test(expected = ValidateSignatureException.class) + public void pqAuthSigWithDefaultSchemeRejected() throws Exception { dbManager.getDynamicPropertiesStore().saveAllowMultiSign(1L); dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(1L); AccountCapsule witness = buildWitnessAccount(pqAddress); @@ -194,15 +194,16 @@ public void pqAuthSigWithDefaultSchemeAcceptedAsFnDsa512() throws Exception { BlockCapsule block = buildUnsignedBlock(parentHash); byte[] digest = block.getRawHashBytes(); // Omit setScheme(...) so the field stays at the proto3 default - // UNKNOWN_PQ_SCHEME; PQSchemeRegistry#resolve normalizes it to FN_DSA_512. + // UNKNOWN_PQ_SCHEME. Producers must set the scheme tag explicitly; the + // verifier rejects scheme=0 as unregistered. PQAuthSig defaultScheme = PQAuthSig.newBuilder() .setPublicKey(ByteString.copyFrom(pqKeypair.getPublicKey())) .setSignature(ByteString.copyFrom(signPQ(digest))) .build(); Assert.assertEquals(PQScheme.UNKNOWN_PQ_SCHEME, defaultScheme.getScheme()); block.setPqAuthSig(defaultScheme); - Assert.assertTrue(block.validateSignature( - dbManager.getDynamicPropertiesStore(), dbManager.getAccountStore())); + block.validateSignature( + dbManager.getDynamicPropertiesStore(), dbManager.getAccountStore()); } @Test From 4ab20e27cbcead107779264eeeb9820a9e06d322 Mon Sep 17 00:00:00 2001 From: federico Date: Fri, 22 May 2026 16:35:02 +0800 Subject: [PATCH 21/47] feat(crypto): add ML-DSA-44 signature scheme --- .../org/tron/core/utils/ProposalUtil.java | 19 +- .../core/store/DynamicPropertiesStore.java | 23 +- .../common/parameter/CommonParameter.java | 4 + .../org/tron/common/crypto/pqc/MLDSA44.java | 239 ++++++++++ .../common/crypto/pqc/PQSchemeRegistry.java | 28 +- .../java/org/tron/core/config/args/Args.java | 6 +- .../org/tron/core/config/args/ConfigKey.java | 2 + .../tron/core/consensus/ProposalService.java | 4 + framework/src/main/resources/config.conf | 1 + .../tron/common/crypto/pqc/MLDSA44Test.java | 420 ++++++++++++++++++ .../crypto/pqc/PQSchemeRegistryTest.java | 23 + .../core/actuator/utils/ProposalUtilTest.java | 57 +++ .../tron/core/capsule/BlockCapsulePQTest.java | 53 +++ protocol/src/main/protos/core/Tron.proto | 6 +- 14 files changed, 878 insertions(+), 7 deletions(-) create mode 100644 crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA44.java create mode 100644 framework/src/test/java/org/tron/common/crypto/pqc/MLDSA44Test.java diff --git a/actuator/src/main/java/org/tron/core/utils/ProposalUtil.java b/actuator/src/main/java/org/tron/core/utils/ProposalUtil.java index b7954165e4c..3975f8a631b 100644 --- a/actuator/src/main/java/org/tron/core/utils/ProposalUtil.java +++ b/actuator/src/main/java/org/tron/core/utils/ProposalUtil.java @@ -957,6 +957,22 @@ public static void validator(DynamicPropertiesStore dynamicPropertiesStore, } break; } + case ALLOW_ML_DSA_44: { + if (!forkController.pass(ForkBlockVersionEnum.VERSION_4_8_2)) { + throw new ContractValidateException( + "Bad chain parameter id [ALLOW_ML_DSA_44]"); + } + if (value != 0 && value != 1) { + throw new ContractValidateException( + "This value[ALLOW_ML_DSA_44] is only allowed to be 0 or 1"); + } + if (dynamicPropertiesStore.getAllowMlDsa44() == value) { + throw new ContractValidateException( + "[ALLOW_ML_DSA_44] has been set to " + value + + ", no need to propose again"); + } + break; + } default: break; } @@ -1046,7 +1062,8 @@ public enum ProposalType { // current value, value range ALLOW_TVM_OSAKA(96), // 0, 1 ALLOW_HARDEN_RESOURCE_CALCULATION(97), // 0, 1 ALLOW_HARDEN_EXCHANGE_CALCULATION(98), // 0, 1 - ALLOW_FN_DSA_512(99); // 0, 1 + ALLOW_FN_DSA_512(99), // 0, 1 + ALLOW_ML_DSA_44(100); // 0, 1 private long code; diff --git a/chainbase/src/main/java/org/tron/core/store/DynamicPropertiesStore.java b/chainbase/src/main/java/org/tron/core/store/DynamicPropertiesStore.java index 9b05d65dfa5..217765c8909 100644 --- a/chainbase/src/main/java/org/tron/core/store/DynamicPropertiesStore.java +++ b/chainbase/src/main/java/org/tron/core/store/DynamicPropertiesStore.java @@ -262,6 +262,8 @@ public class DynamicPropertiesStore extends TronStoreWithRevoking private static final byte[] ALLOW_FN_DSA_512 = "ALLOW_FN_DSA_512".getBytes(); + private static final byte[] ALLOW_ML_DSA_44 = "ALLOW_ML_DSA_44".getBytes(); + @Autowired private DynamicPropertiesStore(@Value("properties") String dbName) { super(dbName); @@ -3102,6 +3104,21 @@ public boolean allowFnDsa512() { return getAllowFnDsa512() == 1L; } + public long getAllowMlDsa44() { + return Optional.ofNullable(getUnchecked(ALLOW_ML_DSA_44)) + .map(BytesCapsule::getData) + .map(ByteArray::toLong) + .orElse(CommonParameter.getInstance().getAllowMlDsa44()); + } + + public void saveAllowMlDsa44(long value) { + this.put(ALLOW_ML_DSA_44, new BytesCapsule(ByteArray.fromLong(value))); + } + + public boolean allowMlDsa44() { + return getAllowMlDsa44() == 1L; + } + /** * Returns true iff at least one post-quantum signature scheme is currently * activated. Driven by {@link PQSchemeRegistry#registeredSchemes()} so that @@ -3119,8 +3136,8 @@ public boolean isAnyPqSchemeAllowed() { } /** - * Per-scheme governance check. V2 launches with FN-DSA-512 only. Future schemes will - * each get their own flag. + * Per-scheme governance check. Each registered scheme has its own flag so + * activation is independent. */ public boolean isPqSchemeAllowed(PQScheme scheme) { if (scheme == null) { @@ -3129,6 +3146,8 @@ public boolean isPqSchemeAllowed(PQScheme scheme) { switch (scheme) { case FN_DSA_512: return allowFnDsa512(); + case ML_DSA_44: + return allowMlDsa44(); default: return false; } diff --git a/common/src/main/java/org/tron/common/parameter/CommonParameter.java b/common/src/main/java/org/tron/common/parameter/CommonParameter.java index d18f09a9ea7..b2704e55e24 100644 --- a/common/src/main/java/org/tron/common/parameter/CommonParameter.java +++ b/common/src/main/java/org/tron/common/parameter/CommonParameter.java @@ -675,6 +675,10 @@ public class CommonParameter { @Setter public long allowFnDsa512; + @Getter + @Setter + public long allowMlDsa44; + private static double calcMaxTimeRatio() { return 5.0; diff --git a/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA44.java b/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA44.java new file mode 100644 index 00000000000..e4825ec0863 --- /dev/null +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA44.java @@ -0,0 +1,239 @@ +package org.tron.common.crypto.pqc; + +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import org.bouncycastle.crypto.AsymmetricCipherKeyPair; +import org.bouncycastle.crypto.params.ParametersWithRandom; +import org.bouncycastle.crypto.prng.FixedSecureRandom; +import org.bouncycastle.pqc.crypto.mldsa.MLDSAKeyGenerationParameters; +import org.bouncycastle.pqc.crypto.mldsa.MLDSAKeyPairGenerator; +import org.bouncycastle.pqc.crypto.mldsa.MLDSAParameters; +import org.bouncycastle.pqc.crypto.mldsa.MLDSAPrivateKeyParameters; +import org.bouncycastle.pqc.crypto.mldsa.MLDSAPublicKeyParameters; +import org.bouncycastle.pqc.crypto.mldsa.MLDSASigner; +import org.tron.protos.Protocol.PQScheme; + +/** + * FIPS 204 ML-DSA-44 (CRYSTALS-Dilithium-2) keypair-bound signer/verifier. + * Instance methods sign/verify with the bound keypair, static + * {@link #sign(byte[], byte[])} / {@link #verify} provide stateless entry + * points used by {@link PQSchemeRegistry}. + * + *

ML-DSA-44 signatures are fixed-length at + * {@link #SIGNATURE_LENGTH} (2420 B). Public keys are the standard encoding + * {@code rho ‖ t1} ({@link #PUBLIC_KEY_LENGTH} = 1312 B); private keys are + * BC's expanded encoding {@code rho ‖ K ‖ tr ‖ s1 ‖ s2 ‖ t0} + * ({@link #PRIVATE_KEY_LENGTH} = 2560 B). Unlike Falcon-512 there is no + * extended priv-with-pub form: BC's {@code MLDSAPrivateKeyParameters} can + * recover the public key directly from the expanded private key (the + * derived {@code t1} stays in memory after instantiation). + * + *

Pure ML-DSA only (no SHA2-512 pre-hash variant). The "pure" mode signs + * the raw message under SHAKE-256 per FIPS 204 §5.2, matching the verify + * side of the EVM precompile at address 0x19 (EIP-8051). + */ +public final class MLDSA44 implements PQSignature { + + /** + * ML-DSA-44 expanded private key from BC: {@code rho(32) ‖ K(32) ‖ tr(64) + * ‖ s1(384) ‖ s2(384) ‖ t0(1664)} = 2560 bytes. + */ + public static final int PRIVATE_KEY_LENGTH = 2560; + /** + * ML-DSA-44 public key: {@code rho(32) ‖ t1(1280)} = 1312 bytes. + */ + public static final int PUBLIC_KEY_LENGTH = 1312; + /** ML-DSA-44 signature length is fixed at 2420 bytes per FIPS 204. */ + public static final int SIGNATURE_LENGTH = 2420; + /** ML-DSA keygen seed length (xi) per FIPS 204 §5.1 is 32 bytes. */ + public static final int SEED_LENGTH = 32; + + private static final MLDSAParameters PARAMS = MLDSAParameters.ml_dsa_44; + + private final byte[] privateKey; + private final byte[] publicKey; + + public MLDSA44() { + AsymmetricCipherKeyPair kp = generateKeyPair(new SecureRandom()); + this.privateKey = ((MLDSAPrivateKeyParameters) kp.getPrivate()).getEncoded(); + this.publicKey = ((MLDSAPublicKeyParameters) kp.getPublic()).getEncoded(); + } + + public MLDSA44(byte[] seed) { + if (seed == null || seed.length != SEED_LENGTH) { + throw new IllegalArgumentException("ML-DSA seed length must be " + SEED_LENGTH); + } + AsymmetricCipherKeyPair kp = generateKeyPair(new FixedSecureRandom(seed)); + this.privateKey = ((MLDSAPrivateKeyParameters) kp.getPrivate()).getEncoded(); + this.publicKey = ((MLDSAPublicKeyParameters) kp.getPublic()).getEncoded(); + } + + public MLDSA44(byte[] privateKey, byte[] publicKey) { + if (privateKey == null || privateKey.length != PRIVATE_KEY_LENGTH) { + throw new IllegalArgumentException( + "ML-DSA private key length must be " + PRIVATE_KEY_LENGTH); + } + if (publicKey == null || publicKey.length != PUBLIC_KEY_LENGTH) { + throw new IllegalArgumentException( + "ML-DSA public key length must be " + PUBLIC_KEY_LENGTH); + } + requireConsistent(privateKey, publicKey); + this.privateKey = privateKey.clone(); + this.publicKey = publicKey.clone(); + } + + @Override + public PQScheme getScheme() { + return PQScheme.ML_DSA_44; + } + + @Override + public int getPrivateKeyLength() { + return PRIVATE_KEY_LENGTH; + } + + @Override + public int getPublicKeyLength() { + return PUBLIC_KEY_LENGTH; + } + + /** Returns the protocol-level signature length (signatures are fixed-length). */ + @Override + public int getSignatureLength() { + return SIGNATURE_LENGTH; + } + + @Override + public byte[] getPrivateKey() { + return privateKey.clone(); + } + + @Override + public byte[] getPublicKey() { + return publicKey.clone(); + } + + @Override + public byte[] getAddress() { + return PQSchemeRegistry.computeAddress(PQScheme.ML_DSA_44, publicKey); + } + + @Override + public byte[] sign(byte[] message) { + return sign(privateKey, message); + } + + @Override + public boolean verify(byte[] message, byte[] signature) { + return verify(publicKey, message, signature); + } + + /** + * Strict fixed-length verify: ML-DSA-44 signatures are exactly + * {@link #SIGNATURE_LENGTH} bytes; any other length is rejected before BC + * is invoked. + */ + @Override + public void validateSignature(byte[] signature) { + if (signature == null || signature.length != SIGNATURE_LENGTH) { + throw new IllegalArgumentException( + "invalid " + getScheme() + " signature length: " + + (signature == null ? "null" : signature.length) + + ", expected " + SIGNATURE_LENGTH); + } + } + + public static boolean verify(byte[] publicKey, byte[] message, byte[] signature) { + if (publicKey == null || publicKey.length != PUBLIC_KEY_LENGTH) { + throw new IllegalArgumentException( + "ML-DSA public key length must be " + PUBLIC_KEY_LENGTH); + } + if (signature == null || signature.length != SIGNATURE_LENGTH) { + throw new IllegalArgumentException( + "ML-DSA signature length must be " + SIGNATURE_LENGTH); + } + if (message == null) { + throw new IllegalArgumentException("message must not be null"); + } + MLDSAPublicKeyParameters pk = new MLDSAPublicKeyParameters(PARAMS, publicKey); + MLDSASigner verifier = new MLDSASigner(); + verifier.init(false, pk); + verifier.update(message, 0, message.length); + try { + return verifier.verifySignature(signature); + } catch (RuntimeException e) { + return false; + } + } + + public static byte[] sign(byte[] privateKey, byte[] message) { + if (privateKey == null || privateKey.length != PRIVATE_KEY_LENGTH) { + throw new IllegalArgumentException( + "ML-DSA private key length must be " + PRIVATE_KEY_LENGTH); + } + if (message == null) { + throw new IllegalArgumentException("message must not be null"); + } + MLDSAPrivateKeyParameters sk = new MLDSAPrivateKeyParameters(PARAMS, privateKey); + MLDSASigner signer = new MLDSASigner(); + signer.init(true, new ParametersWithRandom(sk, new SecureRandom())); + signer.update(message, 0, message.length); + try { + return signer.generateSignature(); + } catch (Exception e) { + throw new IllegalStateException("ML-DSA signing failed", e); + } + } + + /** + * Recovers the public key from the expanded private key. ML-DSA's BC + * encoding includes {@code rho} and the witness {@code t0}, from which + * {@code t1} is re-derived during {@link MLDSAPrivateKeyParameters} + * construction — so {@code pk = rho ‖ t1} is recoverable without + * persisting it alongside. + */ + public static byte[] derivePublicKey(byte[] privateKey) { + if (privateKey == null || privateKey.length != PRIVATE_KEY_LENGTH) { + throw new IllegalArgumentException( + "ML-DSA private key length must be " + PRIVATE_KEY_LENGTH); + } + MLDSAPrivateKeyParameters sk = new MLDSAPrivateKeyParameters(PARAMS, privateKey); + return sk.getPublicKey(); + } + + public static byte[] computeAddress(byte[] publicKey) { + return PQSchemeRegistry.computeAddress(PQScheme.ML_DSA_44, publicKey); + } + + private static AsymmetricCipherKeyPair generateKeyPair(SecureRandom random) { + MLDSAKeyPairGenerator generator = new MLDSAKeyPairGenerator(); + generator.init(new MLDSAKeyGenerationParameters(random, PARAMS)); + return generator.generateKeyPair(); + } + + /** + * Domain-separated probe used by {@link #requireConsistent}; not a security + * boundary (ML-DSA hashes the message internally), the constant just makes the + * keypair self-check searchable in logs/stack traces. + */ + private static final byte[] CONSISTENCY_PROBE = + "tron:ML-DSA-44:keypair-consistency-probe".getBytes(StandardCharsets.UTF_8); + + /** + * Probe that the supplied (sk, pk) actually form a keypair. Sign and verify + * a fixed probe message; runs once per witness load and costs a few ms on + * ML-DSA-44 — acceptable for a startup-time misconfiguration check, and + * avoids advertising an address that signatures will never satisfy. + */ + private static void requireConsistent(byte[] privateKey, byte[] publicKey) { + byte[] sig; + try { + sig = sign(privateKey, CONSISTENCY_PROBE); + } catch (RuntimeException e) { + throw new IllegalArgumentException("ML-DSA private/public key mismatch", e); + } + if (!verify(publicKey, CONSISTENCY_PROBE, sig)) { + throw new IllegalArgumentException("ML-DSA private/public key mismatch"); + } + } +} diff --git a/crypto/src/main/java/org/tron/common/crypto/pqc/PQSchemeRegistry.java b/crypto/src/main/java/org/tron/common/crypto/pqc/PQSchemeRegistry.java index f40cc07d2e2..439a453cf5d 100644 --- a/crypto/src/main/java/org/tron/common/crypto/pqc/PQSchemeRegistry.java +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/PQSchemeRegistry.java @@ -18,7 +18,8 @@ * {@code 0x41 ‖ deriveHash(scheme, public_key)[12..32]}, matching the ECDSA * flow's {@code 0x41 ‖ Keccak-256(public_key)[12..32]} so PQ and ECDSA * addresses share the same derivation shape. The hash function is scheme- - * specific (see {@link #deriveHash}); {@code FN_DSA_512} uses Keccak-256. + * specific (see {@link #deriveHash}); {@code FN_DSA_512} and {@code ML_DSA_44} + * both use Keccak-256. * *

Wire format. The proto3 default {@code UNKNOWN_PQ_SCHEME = 0} is * reserved for the {@code UNKNOWN_} API-evolution slot and is NOT interpreted @@ -101,6 +102,31 @@ public PQSignature fromKeypair(byte[] privateKey, byte[] publicKey) { return new FNDSA512(privateKey, publicKey); } })); + m.put(PQScheme.ML_DSA_44, new SchemeInfo( + MLDSA44.PRIVATE_KEY_LENGTH, MLDSA44.PUBLIC_KEY_LENGTH, + MLDSA44.SIGNATURE_LENGTH, MLDSA44.SEED_LENGTH, + KECCAK_256, + new SignatureOps() { + @Override + public byte[] sign(byte[] privateKey, byte[] message) { + return MLDSA44.sign(privateKey, message); + } + + @Override + public boolean verify(byte[] publicKey, byte[] message, byte[] signature) { + return MLDSA44.verify(publicKey, message, signature); + } + + @Override + public PQSignature fromSeed(byte[] seed) { + return new MLDSA44(seed); + } + + @Override + public PQSignature fromKeypair(byte[] privateKey, byte[] publicKey) { + return new MLDSA44(privateKey, publicKey); + } + })); SCHEMES = Collections.unmodifiableMap(m); } diff --git a/framework/src/main/java/org/tron/core/config/args/Args.java b/framework/src/main/java/org/tron/core/config/args/Args.java index c10186a35f4..e76bfffee80 100644 --- a/framework/src/main/java/org/tron/core/config/args/Args.java +++ b/framework/src/main/java/org/tron/core/config/args/Args.java @@ -786,6 +786,10 @@ public static void applyConfigParams( config.hasPath(ConfigKey.COMMITTEE_ALLOW_FN_DSA_512) ? config .getInt(ConfigKey.COMMITTEE_ALLOW_FN_DSA_512) : 0; + PARAMETER.allowMlDsa44 = + config.hasPath(ConfigKey.COMMITTEE_ALLOW_ML_DSA_44) ? config + .getInt(ConfigKey.COMMITTEE_ALLOW_ML_DSA_44) : 0; + logConfig(); } @@ -927,7 +931,7 @@ private static void applyCLIParams(CLIParameter cmd, JCommander jc) { } private static final EnumSet WITNESS_PQ_SCHEMES = EnumSet.of( - PQScheme.FN_DSA_512); + PQScheme.FN_DSA_512, PQScheme.ML_DSA_44); private static void initLocalWitnesses(Config config, CLIParameter cmd) { // not a witness node, skip diff --git a/framework/src/main/java/org/tron/core/config/args/ConfigKey.java b/framework/src/main/java/org/tron/core/config/args/ConfigKey.java index 687ba2c8b14..e1129717690 100644 --- a/framework/src/main/java/org/tron/core/config/args/ConfigKey.java +++ b/framework/src/main/java/org/tron/core/config/args/ConfigKey.java @@ -4,6 +4,8 @@ public final class ConfigKey { public static final String COMMITTEE_ALLOW_FN_DSA_512 = "committee.allowFnDsa512"; + public static final String COMMITTEE_ALLOW_ML_DSA_44 = "committee.allowMlDsa44"; + public static final String LOCAL_WITNESS_PQ_KEYS = "localwitness_pq.keys"; public static final String LOCAL_WITNESS_PQ_SCHEME = "localwitness_pq.scheme"; diff --git a/framework/src/main/java/org/tron/core/consensus/ProposalService.java b/framework/src/main/java/org/tron/core/consensus/ProposalService.java index 42e24a767be..74ab62194ca 100644 --- a/framework/src/main/java/org/tron/core/consensus/ProposalService.java +++ b/framework/src/main/java/org/tron/core/consensus/ProposalService.java @@ -416,6 +416,10 @@ public static boolean process(Manager manager, ProposalCapsule proposalCapsule) manager.getDynamicPropertiesStore().saveAllowFnDsa512(entry.getValue()); break; } + case ALLOW_ML_DSA_44: { + manager.getDynamicPropertiesStore().saveAllowMlDsa44(entry.getValue()); + break; + } default: find = false; break; diff --git a/framework/src/main/resources/config.conf b/framework/src/main/resources/config.conf index 8e0c77f8309..9fdbe6232fb 100644 --- a/framework/src/main/resources/config.conf +++ b/framework/src/main/resources/config.conf @@ -815,6 +815,7 @@ committee = { # allowOptimizedReturnValueOfChainId = 0 # allowTvmOsaka = 0 # allowFnDsa512 = 0 + # allowMlDsa44 = 0 } event.subscribe = { diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/MLDSA44Test.java b/framework/src/test/java/org/tron/common/crypto/pqc/MLDSA44Test.java new file mode 100644 index 00000000000..2d542cc52bf --- /dev/null +++ b/framework/src/test/java/org/tron/common/crypto/pqc/MLDSA44Test.java @@ -0,0 +1,420 @@ +package org.tron.common.crypto.pqc; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.security.SecureRandom; +import org.bouncycastle.crypto.AsymmetricCipherKeyPair; +import org.bouncycastle.crypto.params.ParametersWithRandom; +import org.bouncycastle.pqc.crypto.mldsa.MLDSAKeyGenerationParameters; +import org.bouncycastle.pqc.crypto.mldsa.MLDSAKeyPairGenerator; +import org.bouncycastle.pqc.crypto.mldsa.MLDSAParameters; +import org.bouncycastle.pqc.crypto.mldsa.MLDSAPrivateKeyParameters; +import org.bouncycastle.pqc.crypto.mldsa.MLDSAPublicKeyParameters; +import org.bouncycastle.pqc.crypto.mldsa.MLDSASigner; +import org.junit.Before; +import org.junit.Test; +import org.tron.protos.Protocol.PQScheme; + +public class MLDSA44Test { + + private static final MLDSAParameters PARAMS = MLDSAParameters.ml_dsa_44; + + private MLDSA44 keypair; + private MLDSAPublicKeyParameters pk; + private MLDSAPrivateKeyParameters sk; + + @Before + public void setUp() { + AsymmetricCipherKeyPair kp = freshKeyPair(); + pk = (MLDSAPublicKeyParameters) kp.getPublic(); + sk = (MLDSAPrivateKeyParameters) kp.getPrivate(); + keypair = new MLDSA44(sk.getEncoded(), pk.getEncoded()); + } + + private static AsymmetricCipherKeyPair freshKeyPair() { + MLDSAKeyPairGenerator gen = new MLDSAKeyPairGenerator(); + gen.init(new MLDSAKeyGenerationParameters(new SecureRandom(), PARAMS)); + return gen.generateKeyPair(); + } + + private byte[] rawSign(byte[] message) { + MLDSASigner signer = new MLDSASigner(); + signer.init(true, new ParametersWithRandom(sk, new SecureRandom())); + signer.update(message, 0, message.length); + try { + return signer.generateSignature(); + } catch (Exception e) { + throw new AssertionError("failed to sign in test setup", e); + } + } + + @Test + public void schemeAndLengthsMatchFips204() { + assertEquals(PQScheme.ML_DSA_44, keypair.getScheme()); + assertEquals(MLDSA44.PUBLIC_KEY_LENGTH, keypair.getPublicKeyLength()); + assertEquals(MLDSA44.SIGNATURE_LENGTH, keypair.getSignatureLength()); + assertEquals(MLDSA44.PRIVATE_KEY_LENGTH, keypair.getPrivateKeyLength()); + assertEquals(MLDSA44.PUBLIC_KEY_LENGTH, pk.getEncoded().length); + } + + @Test + public void publicKeyHasFixedLength() { + for (int i = 0; i < 4; i++) { + AsymmetricCipherKeyPair kp = freshKeyPair(); + byte[] pkBytes = ((MLDSAPublicKeyParameters) kp.getPublic()).getEncoded(); + assertEquals(MLDSA44.PUBLIC_KEY_LENGTH, pkBytes.length); + } + } + + @Test + public void privateKeyEncodingHasFixedLength() { + for (int i = 0; i < 4; i++) { + AsymmetricCipherKeyPair kp = freshKeyPair(); + byte[] skBytes = ((MLDSAPrivateKeyParameters) kp.getPrivate()).getEncoded(); + assertEquals(MLDSA44.PRIVATE_KEY_LENGTH, skBytes.length); + } + } + + @Test + public void signProducesVerifiableSignatureAtFixedLength() { + byte[] msg = "hello, ml-dsa".getBytes(); + byte[] sig = MLDSA44.sign(sk.getEncoded(), msg); + assertEquals( + "ML-DSA-44 signatures must be exactly SIGNATURE_LENGTH bytes", + MLDSA44.SIGNATURE_LENGTH, sig.length); + assertTrue(MLDSA44.verify(pk.getEncoded(), msg, sig)); + } + + @Test + public void signatureBoundaryAtExactLengthAcceptedByLengthCheck() { + byte[] sig = new byte[MLDSA44.SIGNATURE_LENGTH]; + keypair.validateSignature(sig); + } + + @Test + public void signatureBoundaryAboveExactRejected() { + byte[] sig = new byte[MLDSA44.SIGNATURE_LENGTH + 1]; + try { + keypair.validateSignature(sig); + fail("signature longer than fixed length should be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("signature length")); + } + } + + @Test + public void shorterThanExactLengthRejectedByLengthCheck() { + byte[] sig = new byte[MLDSA44.SIGNATURE_LENGTH - 1]; + try { + keypair.validateSignature(sig); + fail("signature shorter than fixed length should be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("signature length")); + } + } + + @Test + public void emptySignatureRejectedByLengthCheck() { + byte[] sig = new byte[0]; + try { + keypair.validateSignature(sig); + fail("empty signature should be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("signature length")); + } + } + + @Test + public void verifyRejectsSignatureOfWrongLength() { + byte[] msg = new byte[] {1, 2, 3}; + byte[] wrong = new byte[MLDSA44.SIGNATURE_LENGTH + 1]; + try { + MLDSA44.verify(pk.getEncoded(), msg, wrong); + fail("wrong-length signature should be rejected at static verify"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("signature length")); + } + } + + @Test + public void verifyRejectsEmptySignature() { + byte[] msg = new byte[] {1, 2, 3}; + byte[] empty = new byte[0]; + try { + MLDSA44.verify(pk.getEncoded(), msg, empty); + fail("empty signature should be rejected at static verify"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("signature length")); + } + } + + @Test + public void invalidPublicKeyLengthRejected() { + byte[] badPk = new byte[MLDSA44.PUBLIC_KEY_LENGTH - 1]; + byte[] msg = new byte[] {1}; + byte[] sig = new byte[MLDSA44.SIGNATURE_LENGTH]; + try { + MLDSA44.verify(badPk, msg, sig); + fail("short public key should be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("public key length")); + } + } + + @Test + public void nullMessageRejected() { + byte[] sig = new byte[MLDSA44.SIGNATURE_LENGTH]; + try { + MLDSA44.verify(pk.getEncoded(), null, sig); + fail("null message should be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("message")); + } + } + + @Test + public void signatureBoundToMessage() { + byte[] msg = "hello".getBytes(); + byte[] sig = rawSign(msg); + byte[] tamperedMsg = "hellp".getBytes(); + assertFalse(keypair.verify(tamperedMsg, sig)); + } + + @Test + public void tamperedSignatureFailsVerification() { + byte[] msg = "payload".getBytes(); + byte[] sig = rawSign(msg); + sig[0] ^= 0x01; + assertFalse(keypair.verify(msg, sig)); + } + + @Test + public void wrongPublicKeyFailsVerification() { + byte[] msg = "payload".getBytes(); + byte[] sig = rawSign(msg); + AsymmetricCipherKeyPair other = freshKeyPair(); + byte[] otherPk = ((MLDSAPublicKeyParameters) other.getPublic()).getEncoded(); + assertFalse(MLDSA44.verify(otherPk, msg, sig)); + } + + @Test + public void crossAlgoSignatureRejected() { + // ML-DSA-44 signature length is fixed at 2420; FN-DSA-512 (≤752), + // ML-DSA-65 (3309), SLH-DSA (7856) all differ and must be rejected. + byte[] msg = "cross-algo".getBytes(); + int[] foreignLengths = {752, 3309, 7856}; + for (int len : foreignLengths) { + byte[] foreign = new byte[len]; + try { + MLDSA44.verify(pk.getEncoded(), msg, foreign); + fail("foreign-scheme signature length " + len + " should be rejected for ML-DSA-44"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("signature length")); + } + } + } + + @Test + public void emptyMessageVerifiesConsistently() { + byte[] msg = new byte[0]; + byte[] sig = rawSign(msg); + assertTrue(keypair.verify(msg, sig)); + } + + @Test + public void keypairBoundInstanceSignsAndVerifies() { + MLDSA44 signer = new MLDSA44(); + byte[] msg = "keypair-bound".getBytes(); + byte[] sig = signer.sign(msg); + assertEquals(MLDSA44.SIGNATURE_LENGTH, sig.length); + assertTrue(signer.verify(msg, sig)); + } + + @Test + public void fromSeedIsDeterministic() { + byte[] seed = new byte[MLDSA44.SEED_LENGTH]; + for (int i = 0; i < seed.length; i++) { + seed[i] = (byte) i; + } + MLDSA44 a = new MLDSA44(seed); + MLDSA44 b = new MLDSA44(seed); + assertArrayEquals(a.getPublicKey(), b.getPublicKey()); + assertArrayEquals(a.getPrivateKey(), b.getPrivateKey()); + } + + @Test(expected = IllegalArgumentException.class) + public void invalidSeedLengthRejected() { + new MLDSA44(new byte[MLDSA44.SEED_LENGTH - 1]); + } + + @Test + public void derivePublicKeyFromExpandedPrivateKey() { + // Unlike Falcon, ML-DSA's expanded private key contains rho + t0 so the + // public key (rho ‖ t1) can be recovered directly via BC's + // MLDSAPrivateKeyParameters.getPublicKey(). + byte[] derived = MLDSA44.derivePublicKey(sk.getEncoded()); + assertArrayEquals(pk.getEncoded(), derived); + } + + @Test + public void computeAddressIs21Bytes() { + assertEquals(21, MLDSA44.computeAddress(pk.getEncoded()).length); + } + + @Test + public void registryDispatchMatchesDirectCalls() { + byte[] msg = "registry-dispatch".getBytes(); + byte[] sigDirect = MLDSA44.sign(sk.getEncoded(), msg); + assertTrue(PQSchemeRegistry.verify( + PQScheme.ML_DSA_44, pk.getEncoded(), msg, sigDirect)); + byte[] sigViaRegistry = PQSchemeRegistry.sign( + PQScheme.ML_DSA_44, sk.getEncoded(), msg); + assertTrue(MLDSA44.verify(pk.getEncoded(), msg, sigViaRegistry)); + assertEquals(MLDSA44.PUBLIC_KEY_LENGTH, + PQSchemeRegistry.getPublicKeyLength(PQScheme.ML_DSA_44)); + assertEquals(MLDSA44.SIGNATURE_LENGTH, + PQSchemeRegistry.getSignatureLength(PQScheme.ML_DSA_44)); + } + + @Test + public void registryIsValidSignatureLengthRequiresExactEquality() { + assertTrue(PQSchemeRegistry.isValidSignatureLength( + PQScheme.ML_DSA_44, MLDSA44.SIGNATURE_LENGTH)); + assertFalse(PQSchemeRegistry.isValidSignatureLength(PQScheme.ML_DSA_44, 0)); + assertFalse(PQSchemeRegistry.isValidSignatureLength( + PQScheme.ML_DSA_44, MLDSA44.SIGNATURE_LENGTH - 1)); + assertFalse(PQSchemeRegistry.isValidSignatureLength( + PQScheme.ML_DSA_44, MLDSA44.SIGNATURE_LENGTH + 1)); + // Variable-length tolerance only applies to FN_DSA_512 — for ML-DSA-44 + // any short length must be rejected. + assertFalse(PQSchemeRegistry.isValidSignatureLength(PQScheme.ML_DSA_44, 1)); + } + + @Test + public void registryComputeAddressMatchesDirect() { + assertArrayEquals( + MLDSA44.computeAddress(pk.getEncoded()), + PQSchemeRegistry.computeAddress(PQScheme.ML_DSA_44, pk.getEncoded())); + } + + @Test(expected = IllegalArgumentException.class) + public void seedConstructorRejectsNull() { + new MLDSA44((byte[]) null); + } + + @Test + public void keypairConstructorRejectsNullPrivateKey() { + try { + new MLDSA44(null, pk.getEncoded()); + fail("null private key must be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("private key length")); + } + } + + @Test + public void keypairConstructorRejectsWrongPrivateKeyLength() { + try { + new MLDSA44(new byte[MLDSA44.PRIVATE_KEY_LENGTH - 1], pk.getEncoded()); + fail("short private key must be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("private key length")); + } + } + + @Test + public void keypairConstructorRejectsNullPublicKey() { + try { + new MLDSA44(sk.getEncoded(), null); + fail("null public key must be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("public key length")); + } + } + + @Test + public void keypairConstructorRejectsWrongPublicKeyLength() { + try { + new MLDSA44(sk.getEncoded(), new byte[MLDSA44.PUBLIC_KEY_LENGTH + 1]); + fail("over-long public key must be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("public key length")); + } + } + + @Test + public void keypairConstructorRejectsMismatchedHalves() { + MLDSAPublicKeyParameters strangerPk = + (MLDSAPublicKeyParameters) freshKeyPair().getPublic(); + try { + new MLDSA44(sk.getEncoded(), strangerPk.getEncoded()); + fail("mismatched private/public key pair must be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("mismatch")); + } + } + + @Test + public void staticSignRejectsNullPrivateKey() { + try { + MLDSA44.sign(null, new byte[] {1}); + fail("null private key must be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("private key length")); + } + } + + @Test + public void staticSignRejectsWrongPrivateKeyLength() { + try { + MLDSA44.sign(new byte[MLDSA44.PRIVATE_KEY_LENGTH - 1], new byte[] {1}); + fail("short private key must be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("private key length")); + } + } + + @Test + public void staticSignRejectsNullMessage() { + try { + MLDSA44.sign(sk.getEncoded(), null); + fail("null message must be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("message")); + } + } + + @Test + public void staticVerifyRejectsNullPublicKey() { + try { + MLDSA44.verify(null, new byte[] {1}, new byte[MLDSA44.SIGNATURE_LENGTH]); + fail("null public key must be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("public key length")); + } + } + + @Test + public void derivePublicKeyRejectsNull() { + try { + MLDSA44.derivePublicKey(null); + fail("null private key must be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("private key length")); + } + } + + @Test + public void derivePublicKeyRejectsWrongLength() { + try { + MLDSA44.derivePublicKey(new byte[MLDSA44.PRIVATE_KEY_LENGTH - 1]); + fail("short private key must be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("private key length")); + } + } +} diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/PQSchemeRegistryTest.java b/framework/src/test/java/org/tron/common/crypto/pqc/PQSchemeRegistryTest.java index 431f4eeab25..cd6dd37af91 100644 --- a/framework/src/test/java/org/tron/common/crypto/pqc/PQSchemeRegistryTest.java +++ b/framework/src/test/java/org/tron/common/crypto/pqc/PQSchemeRegistryTest.java @@ -36,18 +36,29 @@ public void containsRejectsUnknownPqScheme() { @Test public void containsAcceptsRegisteredScheme() { assertTrue(PQSchemeRegistry.contains(PQScheme.FN_DSA_512)); + assertTrue(PQSchemeRegistry.contains(PQScheme.ML_DSA_44)); + } + + @Test + public void registeredSchemesContainsBothLaunchSchemes() { + assertTrue(PQSchemeRegistry.registeredSchemes().contains(PQScheme.FN_DSA_512)); + assertTrue(PQSchemeRegistry.registeredSchemes().contains(PQScheme.ML_DSA_44)); } @Test public void getSeedLengthReturnsRegisteredValue() { assertEquals(FNDSA512.SEED_LENGTH, PQSchemeRegistry.getSeedLength(PQScheme.FN_DSA_512)); + assertEquals(MLDSA44.SEED_LENGTH, + PQSchemeRegistry.getSeedLength(PQScheme.ML_DSA_44)); } @Test public void getPrivateKeyLengthReturnsRegisteredValue() { assertEquals(FNDSA512.PRIVATE_KEY_LENGTH, PQSchemeRegistry.getPrivateKeyLength(PQScheme.FN_DSA_512)); + assertEquals(MLDSA44.PRIVATE_KEY_LENGTH, + PQSchemeRegistry.getPrivateKeyLength(PQScheme.ML_DSA_44)); } @Test @@ -63,6 +74,18 @@ public void fromSeedDispatchesToFalcon() { assertArrayEquals(direct.getPrivateKey(), sig.getPrivateKey()); } + @Test + public void fromSeedDispatchesToMlDsa() { + byte[] seed = new byte[MLDSA44.SEED_LENGTH]; + Arrays.fill(seed, (byte) 0x07); + PQSignature sig = PQSchemeRegistry.fromSeed(PQScheme.ML_DSA_44, seed); + assertNotNull(sig); + assertEquals(PQScheme.ML_DSA_44, sig.getScheme()); + MLDSA44 direct = new MLDSA44(seed); + assertArrayEquals(direct.getPublicKey(), sig.getPublicKey()); + assertArrayEquals(direct.getPrivateKey(), sig.getPrivateKey()); + } + @Test public void fromKeypairDispatchesAndPreservesAddress() { byte[] seed = new byte[FNDSA512.SEED_LENGTH]; diff --git a/framework/src/test/java/org/tron/core/actuator/utils/ProposalUtilTest.java b/framework/src/test/java/org/tron/core/actuator/utils/ProposalUtilTest.java index d26d3c51d18..dae6f1cc750 100644 --- a/framework/src/test/java/org/tron/core/actuator/utils/ProposalUtilTest.java +++ b/framework/src/test/java/org/tron/core/actuator/utils/ProposalUtilTest.java @@ -860,4 +860,61 @@ public void validateAllowFnDsa512() { } dynamicPropertiesStore.saveAllowFnDsa512(0L); } + + @Test + public void validateAllowMlDsa44() { + long code = ProposalType.ALLOW_ML_DSA_44.getCode(); + ThrowingRunnable proposeZero = () -> ProposalUtil.validator(dynamicPropertiesStore, forkUtils, + code, 0); + ThrowingRunnable proposeOne = () -> ProposalUtil.validator(dynamicPropertiesStore, forkUtils, + code, 1); + ThrowingRunnable proposeTwo = () -> ProposalUtil.validator(dynamicPropertiesStore, forkUtils, + code, 2); + + forkUtils.init(dbManager.getChainBaseManager()); + byte[] stats = new byte[27]; + forkUtils.getManager().getDynamicPropertiesStore() + .statsByVersion(ForkBlockVersionEnum.VERSION_4_8_1.getValue(), stats); + long maintenanceTimeInterval = forkUtils.getManager().getDynamicPropertiesStore() + .getMaintenanceTimeInterval(); + long hardForkTime = + ((ForkBlockVersionEnum.VERSION_4_8_2.getHardForkTime() - 1) / maintenanceTimeInterval + 1) + * maintenanceTimeInterval; + forkUtils.getManager().getDynamicPropertiesStore() + .saveLatestBlockHeaderTimestamp(hardForkTime - 1); + + ContractValidateException thrown = assertThrows(ContractValidateException.class, proposeOne); + assertEquals("Bad chain parameter id [ALLOW_ML_DSA_44]", thrown.getMessage()); + + forkUtils.getManager().getDynamicPropertiesStore() + .saveLatestBlockHeaderTimestamp(hardForkTime + 1); + Arrays.fill(stats, (byte) 1); + forkUtils.getManager().getDynamicPropertiesStore() + .statsByVersion(ForkBlockVersionEnum.VERSION_4_8_2.getValue(), stats); + + thrown = assertThrows(ContractValidateException.class, proposeTwo); + assertEquals("This value[ALLOW_ML_DSA_44] is only allowed to be 0 or 1", thrown.getMessage()); + + thrown = assertThrows(ContractValidateException.class, proposeZero); + assertEquals("[ALLOW_ML_DSA_44] has been set to 0, no need to propose again", + thrown.getMessage()); + + try { + proposeOne.run(); + } catch (Throwable e) { + Assert.fail("Should pass when toggling 0 -> 1: " + e.getMessage()); + } + + dynamicPropertiesStore.saveAllowMlDsa44(1L); + thrown = assertThrows(ContractValidateException.class, proposeOne); + assertEquals("[ALLOW_ML_DSA_44] has been set to 1, no need to propose again", + thrown.getMessage()); + + try { + proposeZero.run(); + } catch (Throwable e) { + Assert.fail("Should pass when toggling 1 -> 0: " + e.getMessage()); + } + dynamicPropertiesStore.saveAllowMlDsa44(0L); + } } diff --git a/framework/src/test/java/org/tron/core/capsule/BlockCapsulePQTest.java b/framework/src/test/java/org/tron/core/capsule/BlockCapsulePQTest.java index 16feba0f4b3..e353706a916 100644 --- a/framework/src/test/java/org/tron/core/capsule/BlockCapsulePQTest.java +++ b/framework/src/test/java/org/tron/core/capsule/BlockCapsulePQTest.java @@ -9,6 +9,7 @@ import org.tron.common.TestConstants; import org.tron.common.crypto.ECKey; import org.tron.common.crypto.pqc.FNDSA512; +import org.tron.common.crypto.pqc.MLDSA44; import org.tron.common.crypto.pqc.PQSchemeRegistry; import org.tron.common.utils.Sha256Hash; import org.tron.core.config.args.Args; @@ -239,4 +240,56 @@ public void signerNotInWitnessPermissionRejected() throws Exception { block.validateSignature( dbManager.getDynamicPropertiesStore(), dbManager.getAccountStore()); } + + /** + * Smoke test that the registry-driven block-signing path also accepts ML-DSA-44. + * The validate path is scheme-agnostic; a happy-path + tampered-sig pair is + * enough to prove parametric correctness across both registered schemes. + */ + @Test + public void pqOnlyAcceptedForMlDsa44() throws Exception { + MLDSA44 mlKeypair = new MLDSA44(); + byte[] mlAddress = PQSchemeRegistry.computeAddress( + PQScheme.ML_DSA_44, mlKeypair.getPublicKey()); + dbManager.getDynamicPropertiesStore().saveAllowMultiSign(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa44(1L); + AccountCapsule witness = buildWitnessAccount(mlAddress); + dbManager.getAccountStore().put(witnessAddress, witness); + + byte[] parentHash = new byte[32]; + BlockCapsule block = buildUnsignedBlock(parentHash); + byte[] digest = block.getRawHashBytes(); + byte[] sig = MLDSA44.sign(mlKeypair.getPrivateKey(), digest); + block.setPqAuthSig(PQAuthSig.newBuilder() + .setScheme(PQScheme.ML_DSA_44) + .setPublicKey(ByteString.copyFrom(mlKeypair.getPublicKey())) + .setSignature(ByteString.copyFrom(sig)) + .build()); + Assert.assertTrue(block.validateSignature( + dbManager.getDynamicPropertiesStore(), dbManager.getAccountStore())); + } + + @Test + public void tamperedPQAuthSigFailsForMlDsa44() throws Exception { + MLDSA44 mlKeypair = new MLDSA44(); + byte[] mlAddress = PQSchemeRegistry.computeAddress( + PQScheme.ML_DSA_44, mlKeypair.getPublicKey()); + dbManager.getDynamicPropertiesStore().saveAllowMultiSign(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa44(1L); + AccountCapsule witness = buildWitnessAccount(mlAddress); + dbManager.getAccountStore().put(witnessAddress, witness); + + byte[] parentHash = new byte[32]; + BlockCapsule block = buildUnsignedBlock(parentHash); + byte[] digest = block.getRawHashBytes(); + byte[] sig = MLDSA44.sign(mlKeypair.getPrivateKey(), digest); + sig[sig.length - 1] ^= 0x01; + block.setPqAuthSig(PQAuthSig.newBuilder() + .setScheme(PQScheme.ML_DSA_44) + .setPublicKey(ByteString.copyFrom(mlKeypair.getPublicKey())) + .setSignature(ByteString.copyFrom(sig)) + .build()); + Assert.assertFalse(block.validateSignature( + dbManager.getDynamicPropertiesStore(), dbManager.getAccountStore())); + } } diff --git a/protocol/src/main/protos/core/Tron.proto b/protocol/src/main/protos/core/Tron.proto index 1745a73a928..a7066584e73 100644 --- a/protocol/src/main/protos/core/Tron.proto +++ b/protocol/src/main/protos/core/Tron.proto @@ -20,12 +20,14 @@ enum AccountType { // UNKNOWN_PQ_SCHEME = 0 is the proto3 default and is reserved per the // java-tron API evolution standard (issue #6515) so unset / unrecognized // values are detectable by JSON consumers; it MUST never be registered -// in PQSchemeRegistry. FN_DSA_512 = 1 is the V2 launch scheme. +// in PQSchemeRegistry. FN_DSA_512 = 1 is the V2 launch scheme; +// ML_DSA_44 = 2 (FIPS 204 / CRYSTALS-Dilithium-2) is the second scheme. // New schemes are reserved for future activation (see PQSchemeRegistry). enum PQScheme { UNKNOWN_PQ_SCHEME = 0; FN_DSA_512 = 1; - reserved 2 to 15; + ML_DSA_44 = 2; + reserved 3 to 15; } // AccountId, (name, address) use name, (null, address) use address, (name, null) use name, From 62859291a10c6a82a7c980d4a3159e6303d1aeb5 Mon Sep 17 00:00:00 2001 From: federico Date: Fri, 22 May 2026 18:07:27 +0800 Subject: [PATCH 22/47] test(crypto): cover ml-dsa-44 in pq test programs --- .../pqc/SignatureSchemeBenchmarkTest.java | 39 ++++++++++++- .../common/crypto/pqc/program/PQClient.java | 29 ++++++---- .../common/crypto/pqc/program/PQFullNode.java | 18 ++++-- .../common/crypto/pqc/program/PQTxSender.java | 43 +++++++++------ .../crypto/pqc/program/PQWitnessNode.java | 55 +++++++++++++------ .../core/services/ProposalServiceTest.java | 18 ++++++ 6 files changed, 146 insertions(+), 56 deletions(-) diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/SignatureSchemeBenchmarkTest.java b/framework/src/test/java/org/tron/common/crypto/pqc/SignatureSchemeBenchmarkTest.java index 7b99e7d7796..c271ec0afc1 100644 --- a/framework/src/test/java/org/tron/common/crypto/pqc/SignatureSchemeBenchmarkTest.java +++ b/framework/src/test/java/org/tron/common/crypto/pqc/SignatureSchemeBenchmarkTest.java @@ -9,8 +9,9 @@ /** * Micro-benchmark comparing key generation, signing and verification latency for - * secp256k1 ECDSA (ECKey) and FN-DSA / Falcon-512. Numbers are reported - * in microseconds (avg of {@link #ITERATIONS} iterations after {@link #WARMUP} warm-up rounds). + * secp256k1 ECDSA (ECKey), FN-DSA-512 (Falcon-512) and ML-DSA-44 (Dilithium-2). + * Numbers are reported in microseconds (avg of {@link #ITERATIONS} iterations after + * {@link #WARMUP} warm-up rounds). */ public class SignatureSchemeBenchmarkTest { @@ -23,6 +24,7 @@ public class SignatureSchemeBenchmarkTest { public void benchmarkAllSchemes() { Result eckey = benchEcKey(); Result fndsa = benchFnDsa(); + Result mldsa = benchMlDsa(); System.out.println(String.format(Locale.ROOT, "=== Signature scheme benchmark (avg over %d iterations, warmup %d) ===", @@ -33,6 +35,7 @@ public void benchmarkAllSchemes() { System.out.println("-------------+--------------+--------------+--------------"); printResult(eckey); printResult(fndsa); + printResult(mldsa); } private Result benchEcKey() { @@ -107,6 +110,38 @@ private Result benchFnDsa() { return new Result("FN-DSA-512", keygenNs, signNs, verifyNs); } + private Result benchMlDsa() { + for (int i = 0; i < WARMUP; i++) { + MLDSA44 k = new MLDSA44(); + byte[] sig = k.sign(MESSAGE); + k.verify(MESSAGE, sig); + } + + long keygenNs = 0; + MLDSA44[] keys = new MLDSA44[ITERATIONS]; + for (int i = 0; i < ITERATIONS; i++) { + long t0 = System.nanoTime(); + keys[i] = new MLDSA44(); + keygenNs += System.nanoTime() - t0; + } + + long signNs = 0; + byte[][] sigs = new byte[ITERATIONS][]; + for (int i = 0; i < ITERATIONS; i++) { + long t0 = System.nanoTime(); + sigs[i] = keys[i].sign(MESSAGE); + signNs += System.nanoTime() - t0; + } + + long verifyNs = 0; + for (int i = 0; i < ITERATIONS; i++) { + long t0 = System.nanoTime(); + keys[i].verify(MESSAGE, sigs[i]); + verifyNs += System.nanoTime() - t0; + } + return new Result("ML-DSA-44", keygenNs, signNs, verifyNs); + } + private static void printResult(Result r) { System.out.println(String.format(Locale.ROOT, "%-12s | %12.2f | %12.2f | %12.2f", diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/program/PQClient.java b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQClient.java index 890b922a962..2f1369dbb41 100644 --- a/framework/src/test/java/org/tron/common/crypto/pqc/program/PQClient.java +++ b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQClient.java @@ -11,7 +11,8 @@ import org.tron.api.GrpcAPI.Return; import org.tron.api.WalletGrpc; import org.tron.api.WalletGrpc.WalletBlockingStub; -import org.tron.common.crypto.pqc.FNDSA512; +import org.tron.common.crypto.pqc.PQSchemeRegistry; +import org.tron.common.crypto.pqc.PQSignature; import org.tron.common.parameter.CommonParameter; import org.tron.common.utils.ByteArray; import org.tron.common.utils.Sha256Hash; @@ -23,8 +24,9 @@ import org.tron.protos.contract.BalanceContract.TransferContract; /** - * Demo client that connects to {@link PQWitnessNode} and broadcasts an FN-DSA-512 - * signed transfer transaction. + * Demo client that connects to {@link PQWitnessNode} and broadcasts a + * PQ-signed transfer transaction. Scheme is selected via {@code -Dpqc.scheme} + * (FN_DSA_512 or ML_DSA_44, default FN_DSA_512) and must match the witness node. * * The keypair is derived from the same fixed seed used by PQWitnessNode, so no * out-of-band key exchange is needed. @@ -36,11 +38,14 @@ * ./gradlew :framework:run -PmainClass=org.tron.common.crypto.pqc.program.PQClient * * Optional JVM args: - * -Dpqc.host=localhost (default: localhost) - * -Dpqc.port=50051 (default: 50051) + * -Dpqc.scheme=FN_DSA_512 (default; or ML_DSA_44) + * -Dpqc.host=localhost (default: localhost) + * -Dpqc.port=50051 (default: 50051) */ public class PQClient { + private static final PQScheme PQ_SCHEME = PQScheme.valueOf( + System.getProperty("pqc.scheme", PQScheme.FN_DSA_512.name())); private static final String HOST = System.getProperty("pqc.host", "localhost"); private static final int PORT = @@ -58,16 +63,16 @@ public static void main(String[] args) throws Exception { .setLevel(ch.qos.logback.classic.Level.INFO); // ── 1. Derive user keypair from same fixed seed as PQWitnessNode ───── - byte[] userSeed = new byte[FNDSA512.SEED_LENGTH]; + byte[] userSeed = new byte[PQSchemeRegistry.getSeedLength(PQ_SCHEME)]; Arrays.fill(userSeed, (byte) 0x02); - FNDSA512 userKp = new FNDSA512(userSeed); + PQSignature userKp = PQSchemeRegistry.fromSeed(PQ_SCHEME, userSeed); byte[] userPub = userKp.getPublicKey(); - byte[] userPriv = userKp.getPrivateKey(); - byte[] signerAddr = FNDSA512.computeAddress(userPub); + byte[] signerAddr = userKp.getAddress(); byte[] ownerAddr = PQWitnessNode.USER_ADDR; System.out.println("=== PQC Client ==="); + System.out.println("Scheme: " + PQ_SCHEME); System.out.println("Connecting to " + HOST + ":" + PORT); System.out.println("Owner address: " + ByteArray.toHexString(ownerAddr)); System.out.println("Signer address: " + ByteArray.toHexString(signerAddr)); @@ -109,17 +114,17 @@ public static void main(String[] args) throws Exception { Transaction tx = Transaction.newBuilder().setRawData(rawData).build(); - // ── 5. Sign with FN-DSA-512 pq_auth_sig ───────────────────────────── + // ── 5. Sign with selected PQ scheme ───────────────────────────────── byte[] txId = Sha256Hash.of( CommonParameter.getInstance().isECKeyCryptoEngine(), rawData.toByteArray()).getBytes(); - byte[] sig = FNDSA512.sign(userPriv, txId); + byte[] sig = userKp.sign(txId); // Producers must set the scheme tag explicitly; scheme=0 // (UNKNOWN_PQ_SCHEME) is rejected by the verifier as unregistered. Transaction signedTx = tx.toBuilder() .addPqAuthSig(PQAuthSig.newBuilder() - .setScheme(PQScheme.FN_DSA_512) + .setScheme(PQ_SCHEME) .setPublicKey(ByteString.copyFrom(userPub)) .setSignature(ByteString.copyFrom(sig))) .build(); diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/program/PQFullNode.java b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQFullNode.java index d6bf351c772..d11d6028c83 100644 --- a/framework/src/test/java/org/tron/common/crypto/pqc/program/PQFullNode.java +++ b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQFullNode.java @@ -8,7 +8,8 @@ import org.tron.common.application.Application; import org.tron.common.application.ApplicationFactory; import org.tron.common.application.TronApplicationContext; -import org.tron.common.crypto.pqc.FNDSA512; +import org.tron.common.crypto.pqc.PQSchemeRegistry; +import org.tron.common.crypto.pqc.PQSignature; import org.tron.common.utils.ByteArray; import org.tron.core.ChainBaseManager; import org.tron.core.config.DefaultConfig; @@ -17,9 +18,11 @@ /** * Demo fullnode that dials {@link PQWitnessNode} via P2P and syncs PQ-signed blocks. + * The active scheme follows {@link PQWitnessNode#PQ_SCHEME} (selectable via + * {@code -Dpqc.scheme}), so both processes derive matching genesis state. * - * Both nodes share the same deterministic PQ genesis pre-state (witness account with an - * FN-DSA-512 witness permission + demo user account with an FN-DSA-512 owner permission), + * Both nodes share the same deterministic PQ genesis pre-state (witness account with a + * PQ witness permission + demo user account with a PQ owner permission), * installed via {@link PQWitnessNode#installPQGenesisState}. Once the witness produces * a block it is broadcast over P2P; this node validates {@code BlockHeader.pq_auth_sig} * against the same on-chain public key and applies the block. @@ -56,19 +59,22 @@ public static void main(String[] args) throws Exception { .setLevel(ch.qos.logback.classic.Level.INFO); // ── 1. Derive the same deterministic keys used by PQWitnessNode ────── - FNDSA512 witnessKp = new FNDSA512(PQWitnessNode.WITNESS_SEED); - FNDSA512 userKp = new FNDSA512(PQWitnessNode.USER_SEED); + PQSignature witnessKp = PQSchemeRegistry.fromSeed( + PQWitnessNode.PQ_SCHEME, PQWitnessNode.WITNESS_SEED); + PQSignature userKp = PQSchemeRegistry.fromSeed( + PQWitnessNode.PQ_SCHEME, PQWitnessNode.USER_SEED); byte[] witnessPub = witnessKp.getPublicKey(); byte[] userPub = userKp.getPublicKey(); System.out.println("=== PQC Full Node ==="); + System.out.println("Scheme: " + PQWitnessNode.PQ_SCHEME); System.out.println("Peer (witness): " + WITNESS_HOST + ":" + WITNESS_P2P_PORT); System.out.println("gRPC port: " + GRPC_PORT); System.out.println("HTTP port: " + HTTP_PORT); System.out.println("P2P port: " + P2P_PORT); System.out.println("Witness address (expected): " - + ByteArray.toHexString(FNDSA512.computeAddress(witnessPub))); + + ByteArray.toHexString(witnessKp.getAddress())); // ── 2. Configure node (no -w: this is a pure fullnode) ──────────────── File dbDir = Files.createTempDirectory("pqc-fullnode-").toFile(); diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/program/PQTxSender.java b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQTxSender.java index 7ffaa3ca02e..f6127e9f3c6 100644 --- a/framework/src/test/java/org/tron/common/crypto/pqc/program/PQTxSender.java +++ b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQTxSender.java @@ -14,7 +14,8 @@ import org.tron.api.WalletGrpc.WalletBlockingStub; import org.tron.common.crypto.ECKey; import org.tron.common.crypto.ECKey.ECDSASignature; -import org.tron.common.crypto.pqc.FNDSA512; +import org.tron.common.crypto.pqc.PQSchemeRegistry; +import org.tron.common.crypto.pqc.PQSignature; import org.tron.common.math.StrictMathWrapper; import org.tron.common.parameter.CommonParameter; import org.tron.common.utils.ByteArray; @@ -25,6 +26,7 @@ import org.tron.core.capsule.TransactionCapsule; import org.tron.protos.Protocol.Block; import org.tron.protos.Protocol.PQAuthSig; +import org.tron.protos.Protocol.PQScheme; import org.tron.protos.Protocol.Transaction; import org.tron.protos.Protocol.Transaction.Contract.ContractType; import org.tron.protos.contract.BalanceContract.TransferContract; @@ -32,7 +34,8 @@ /** * Demo client that connects to {@link PQWitnessNode} and continuously broadcasts transfer and - * TRC20 transactions signed by FN-DSA-512 and ECDSA. + * TRC20 transactions signed by a configurable PQ scheme ({@code -Dpqc.scheme}, default + * FN_DSA_512; must match the witness node) and ECDSA. *

* The FN-DSA-512 keypair is derived from the same fixed seed used by PQWitnessNode, so no * out-of-band key exchange is needed. ECDSA transactions use -Decdsa.private.key. @@ -57,6 +60,8 @@ */ public class PQTxSender { + private static final PQScheme PQ_SCHEME = PQScheme.valueOf( + System.getProperty("pqc.scheme", PQScheme.FN_DSA_512.name())); private static final String HOST = System.getProperty("pqc.host", "localhost"); private static final int PORT = @@ -115,13 +120,12 @@ public static void main(String[] args) throws Exception { .setLevel(ch.qos.logback.classic.Level.INFO); // ── 1. Derive user keypair from same fixed seed as PQWitnessNode ───── - byte[] userSeed = new byte[FNDSA512.SEED_LENGTH]; + byte[] userSeed = new byte[PQSchemeRegistry.getSeedLength(PQ_SCHEME)]; Arrays.fill(userSeed, (byte) 0x02); - FNDSA512 userKp = new FNDSA512(userSeed); + PQSignature userKp = PQSchemeRegistry.fromSeed(PQ_SCHEME, userSeed); byte[] userPub = userKp.getPublicKey(); - byte[] userPriv = userKp.getPrivateKey(); - byte[] signerAddr = FNDSA512.computeAddress(userPub); + byte[] signerAddr = userKp.getAddress(); byte[] ownerAddr = Commons.decodeFromBase58Check("TJUfbazhixG4YtqJxUDmv5XisZvvy1wP91"); ECKey ecdsaKey = ECKey.fromPrivate( ByteArray.fromHexString(System.getProperty("ecdsa.private.key", @@ -134,6 +138,7 @@ public static void main(String[] args) throws Exception { System.out.println("=== PQC/ECDSA Tx Sender ==="); System.out.println("Connecting to " + HOST + ":" + PORT); + System.out.println("PQC scheme: " + PQ_SCHEME); System.out.println("PQC owner address: " + ByteArray.toHexString(ownerAddr)); System.out.println("PQC signer address: " + ByteArray.toHexString(signerAddr)); System.out.println("PQC transfer TPS: " + transferTps); @@ -151,10 +156,10 @@ public static void main(String[] args) throws Exception { try { Thread transferThread = new Thread( - () -> runTransferLoop(stub, ownerAddr, userPub, userPriv, transferTps), + () -> runTransferLoop(stub, ownerAddr, userKp, transferTps), "pqc-transfer-sender-grpc"); Thread trc20Thread = new Thread( - () -> runTrc20Loop(stub, ownerAddr, userPub, userPriv, trc20Tps), + () -> runTrc20Loop(stub, ownerAddr, userKp, trc20Tps), "pqc-trc20-sender-grpc"); Thread ecdsaTransferThread = new Thread( () -> runEcdsaTransferLoop(stub, ecdsaOwnerAddr, ecdsaKey, ecdsaTransferTps), @@ -191,7 +196,7 @@ private static byte[] longToBytes(long value) { } private static void runTransferLoop(WalletBlockingStub stub, byte[] ownerAddr, - byte[] userPub, byte[] userPriv, double tps) { + PQSignature userKp, double tps) { if (tps <= 0) { System.out.println("pqc transfer sender disabled"); return; @@ -200,13 +205,13 @@ private static void runTransferLoop(WalletBlockingStub stub, byte[] ownerAddr, long counter = 1L; while (!Thread.currentThread().isInterrupted()) { long loopStart = System.currentTimeMillis(); - sendTransferTransaction(stub, ownerAddr, userPub, userPriv, counter++); + sendTransferTransaction(stub, ownerAddr, userKp, counter++); sleepRemaining(intervalMs, loopStart); } } private static void runTrc20Loop(WalletBlockingStub stub, byte[] ownerAddr, - byte[] userPub, byte[] userPriv, double tps) { + PQSignature userKp, double tps) { if (tps <= 0) { System.out.println("pqc trc20 sender disabled"); return; @@ -215,7 +220,7 @@ private static void runTrc20Loop(WalletBlockingStub stub, byte[] ownerAddr, long counter = 1L; while (!Thread.currentThread().isInterrupted()) { long loopStart = System.currentTimeMillis(); - sendTrc20Transaction(stub, ownerAddr, userPub, userPriv, counter++); + sendTrc20Transaction(stub, ownerAddr, userKp, counter++); sleepRemaining(intervalMs, loopStart); } } @@ -251,7 +256,7 @@ private static void runEcdsaTrc20Loop(WalletBlockingStub stub, byte[] ownerAddr, } private static void sendTransferTransaction(WalletBlockingStub stub, byte[] ownerAddr, - byte[] userPub, byte[] userPriv, long seq) { + PQSignature userKp, long seq) { try { WalletBlockingStub timedStub = stub.withDeadlineAfter(10, TimeUnit.SECONDS); @@ -264,10 +269,11 @@ private static void sendTransferTransaction(WalletBlockingStub stub, byte[] owne Transaction tx = buildTransferTransaction(ownerAddr, blockHash, refNum); byte[] txId = sha256(tx.getRawData().toByteArray()); - byte[] sig = FNDSA512.sign(userPriv, txId); + byte[] sig = userKp.sign(txId); Transaction signedTx = tx.toBuilder() .addPqAuthSig(PQAuthSig.newBuilder() - .setPublicKey(ByteString.copyFrom(userPub)) + .setScheme(PQ_SCHEME) + .setPublicKey(ByteString.copyFrom(userKp.getPublicKey())) .setSignature(ByteString.copyFrom(sig))) .build(); @@ -282,7 +288,7 @@ private static void sendTransferTransaction(WalletBlockingStub stub, byte[] owne } private static void sendTrc20Transaction(WalletBlockingStub stub, byte[] ownerAddr, - byte[] userPub, byte[] userPriv, long seq) { + PQSignature userKp, long seq) { try { WalletBlockingStub timedStub = stub.withDeadlineAfter(10, TimeUnit.SECONDS); @@ -299,10 +305,11 @@ private static void sendTrc20Transaction(WalletBlockingStub stub, byte[] ownerAd tx = tx.toBuilder().setRawData(rawBuilder).build(); byte[] txId = sha256(tx.getRawData().toByteArray()); - byte[] sig = FNDSA512.sign(userPriv, txId); + byte[] sig = userKp.sign(txId); Transaction signedTx = tx.toBuilder() .addPqAuthSig(PQAuthSig.newBuilder() - .setPublicKey(ByteString.copyFrom(userPub)) + .setScheme(PQ_SCHEME) + .setPublicKey(ByteString.copyFrom(userKp.getPublicKey())) .setSignature(ByteString.copyFrom(sig))) .build(); diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/program/PQWitnessNode.java b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQWitnessNode.java index ae538ce95e7..f354954330f 100644 --- a/framework/src/test/java/org/tron/common/crypto/pqc/program/PQWitnessNode.java +++ b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQWitnessNode.java @@ -12,8 +12,8 @@ import org.tron.common.application.Application; import org.tron.common.application.ApplicationFactory; import org.tron.common.application.TronApplicationContext; -import org.tron.common.crypto.pqc.FNDSA512; import org.tron.common.crypto.pqc.PQSchemeRegistry; +import org.tron.common.crypto.pqc.PQSignature; import org.tron.common.utils.ByteArray; import org.tron.core.ChainBaseManager; import org.tron.core.capsule.AccountCapsule; @@ -30,10 +30,12 @@ import org.tron.protos.Protocol.Permission.PermissionType; /** - * Demo witness node with FN-DSA-512 block production. + * Demo witness node with PQ block production. Scheme is selected via + * {@code -Dpqc.scheme} (FN_DSA_512 or ML_DSA_44, default FN_DSA_512) and must + * match what {@link PQClient} / {@link PQFullNode} use. * * Starts an in-process TRON node configured with a PQC witness keypair and - * a user account that holds an FN-DSA-512 owner permission — ready to receive + * a user account that holds a PQ owner permission — ready to receive * transactions from {@link PQClient}. * * Keypairs are derived from fixed seeds so PQClient can derive matching keys @@ -47,9 +49,13 @@ */ public class PQWitnessNode { - /** Fixed seed for the FN-DSA-512 witness keypair (shared with PQClient for derivation). */ + /** Active PQ scheme, selectable via {@code -Dpqc.scheme}. */ + static final PQScheme PQ_SCHEME = PQScheme.valueOf( + System.getProperty("pqc.scheme", PQScheme.FN_DSA_512.name())); + + /** Fixed seed for the PQ witness keypair (shared with PQClient for derivation). */ static final byte[] WITNESS_SEED = filledSeed(0x01); - /** Fixed seed for the FN-DSA-512 user keypair (shared with PQClient for derivation). */ + /** Fixed seed for the PQ user keypair (shared with PQClient for derivation). */ static final byte[] USER_SEED = filledSeed(0x02); /** gRPC port the node listens on. */ @@ -73,16 +79,17 @@ public static void main(String[] args) throws Exception { .setLevel(ch.qos.logback.classic.Level.INFO); // ── 1. Derive deterministic keypairs ────────────────────────────────── - FNDSA512 witnessKp = new FNDSA512(WITNESS_SEED); - FNDSA512 userKp = new FNDSA512(USER_SEED); + PQSignature witnessKp = PQSchemeRegistry.fromSeed(PQ_SCHEME, WITNESS_SEED); + PQSignature userKp = PQSchemeRegistry.fromSeed(PQ_SCHEME, USER_SEED); byte[] witnessPub = witnessKp.getPublicKey(); - byte[] witnessAddr = FNDSA512.computeAddress(witnessPub); + byte[] witnessAddr = witnessKp.getAddress(); byte[] userPub = userKp.getPublicKey(); - byte[] signerAddr = FNDSA512.computeAddress(userPub); + byte[] signerAddr = userKp.getAddress(); System.out.println("=== PQC Witness Node ==="); - System.out.println("Witness address (FN-DSA-512): " + ByteArray.toHexString(witnessAddr)); + System.out.println("Scheme: " + PQ_SCHEME); + System.out.println("Witness address: " + ByteArray.toHexString(witnessAddr)); System.out.println("User address: " + ByteArray.toHexString(USER_ADDR)); System.out.println("User signer address: " + ByteArray.toHexString(signerAddr)); System.out.println("gRPC port: " + GRPC_PORT); @@ -143,13 +150,17 @@ public static void main(String[] args) throws Exception { */ static void installPQGenesisState(Manager db, ChainBaseManager chain, byte[] witnessPub, byte[] userPub) { - byte[] witnessAddr = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, witnessPub); + byte[] witnessAddr = PQSchemeRegistry.computeAddress(PQ_SCHEME, witnessPub); ByteString witnessAddrBs = ByteString.copyFrom(witnessAddr); - byte[] signerAddr = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, userPub); + byte[] signerAddr = PQSchemeRegistry.computeAddress(PQ_SCHEME, userPub); ByteString signerAddrBs = ByteString.copyFrom(signerAddr); - // Activate FN-DSA on the local chain params. - db.getDynamicPropertiesStore().saveAllowFnDsa512(1L); + // Activate the active scheme on the local chain params. + if (PQ_SCHEME == PQScheme.ML_DSA_44) { + db.getDynamicPropertiesStore().saveAllowMlDsa44(1L); + } else { + db.getDynamicPropertiesStore().saveAllowFnDsa512(1L); + } db.getDynamicPropertiesStore().saveAllowMultiSign(1L); // Witness account with FN-DSA-512 witness permission. Address-as-fingerprint @@ -185,19 +196,27 @@ static void installPQGenesisState(Manager db, ChainBaseManager chain, } private static byte[] filledSeed(int value) { - byte[] seed = new byte[FNDSA512.SEED_LENGTH]; + byte[] seed = new byte[PQSchemeRegistry.getSeedLength(PQ_SCHEME)]; Arrays.fill(seed, (byte) value); return seed; } - private static Path writeWitnessConfig(FNDSA512 witnessKp) throws java.io.IOException { + private static Path writeWitnessConfig(PQSignature witnessKp) throws java.io.IOException { Path conf = Files.createTempFile("pqc-witness-", ".conf"); conf.toFile().deleteOnExit(); + // `localwitness_pq.keys` is the extended priv ‖ pub hex; Falcon exposes that + // explicitly while ML-DSA-44's expanded sk already lets BC recover the pk, + // so we just concatenate getPrivateKey() ‖ getPublicKey() for both schemes. + byte[] priv = witnessKp.getPrivateKey(); + byte[] pub = witnessKp.getPublicKey(); + byte[] extended = new byte[priv.length + pub.length]; + System.arraycopy(priv, 0, extended, 0, priv.length); + System.arraycopy(pub, 0, extended, priv.length, pub.length); String body = "include classpath(\"config-test.conf\")\n" + "localwitness_pq = {\n" - + " scheme = \"FN_DSA_512\"\n" + + " scheme = \"" + PQ_SCHEME.name() + "\"\n" + " keys = [\n" - + " \"" + Hex.toHexString(witnessKp.getPrivateKeyWithPublicKey()) + "\"\n" + + " \"" + Hex.toHexString(extended) + "\"\n" + " ]\n" + "}\n"; Files.write(conf, body.getBytes(StandardCharsets.UTF_8)); diff --git a/framework/src/test/java/org/tron/core/services/ProposalServiceTest.java b/framework/src/test/java/org/tron/core/services/ProposalServiceTest.java index 070a49bdd85..86418687a95 100644 --- a/framework/src/test/java/org/tron/core/services/ProposalServiceTest.java +++ b/framework/src/test/java/org/tron/core/services/ProposalServiceTest.java @@ -2,6 +2,7 @@ import static org.tron.core.Constant.MAX_PROPOSAL_EXPIRE_TIME; import static org.tron.core.utils.ProposalUtil.ProposalType.ALLOW_FN_DSA_512; +import static org.tron.core.utils.ProposalUtil.ProposalType.ALLOW_ML_DSA_44; import static org.tron.core.utils.ProposalUtil.ProposalType.CONSENSUS_LOGIC_OPTIMIZATION; import static org.tron.core.utils.ProposalUtil.ProposalType.ENERGY_FEE; import static org.tron.core.utils.ProposalUtil.ProposalType.PROPOSAL_EXPIRE_TIME; @@ -168,4 +169,21 @@ public void testProcessAllowFnDsa512() { dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(0L); } + + @Test + public void testProcessAllowMlDsa44() { + dbManager.getDynamicPropertiesStore().saveAllowMlDsa44(0L); + Assert.assertFalse(dbManager.getDynamicPropertiesStore().allowMlDsa44()); + + Proposal proposal = Proposal.newBuilder() + .putParameters(ALLOW_ML_DSA_44.getCode(), 1L).build(); + ProposalCapsule proposalCapsule = new ProposalCapsule(proposal); + boolean result = ProposalService.process(dbManager, proposalCapsule); + Assert.assertTrue(result); + + Assert.assertEquals(1L, dbManager.getDynamicPropertiesStore().getAllowMlDsa44()); + Assert.assertTrue(dbManager.getDynamicPropertiesStore().allowMlDsa44()); + + dbManager.getDynamicPropertiesStore().saveAllowMlDsa44(0L); + } } \ No newline at end of file From d43838c6de564fd954919ec3136bf0315c560f47 Mon Sep 17 00:00:00 2001 From: federico Date: Fri, 22 May 2026 18:07:34 +0800 Subject: [PATCH 23/47] feat(vm): add ml-dsa-44 precompiles 0x19/0x1a/0x1b --- .../tron/core/vm/PrecompiledContracts.java | 382 ++++++++++++++ .../org/tron/core/vm/config/ConfigLoader.java | 1 + .../org/tron/core/vm/config/VMConfig.java | 10 + .../runtime/vm/BatchValidateMlDsa44Test.java | 352 +++++++++++++ .../runtime/vm/MlDsa44PrecompileTest.java | 162 ++++++ .../runtime/vm/ValidateMultiMlDsa44Test.java | 465 ++++++++++++++++++ 6 files changed, 1372 insertions(+) create mode 100644 framework/src/test/java/org/tron/common/runtime/vm/BatchValidateMlDsa44Test.java create mode 100644 framework/src/test/java/org/tron/common/runtime/vm/MlDsa44PrecompileTest.java create mode 100644 framework/src/test/java/org/tron/common/runtime/vm/ValidateMultiMlDsa44Test.java diff --git a/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java b/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java index 5fe58cb9e14..f5954122777 100644 --- a/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java +++ b/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java @@ -54,6 +54,7 @@ import org.tron.common.crypto.SignUtils; import org.tron.common.crypto.SignatureInterface; import org.tron.common.crypto.pqc.FNDSA512; +import org.tron.common.crypto.pqc.MLDSA44; import org.tron.common.crypto.pqc.PQSchemeRegistry; import org.tron.common.crypto.zksnark.BN128; import org.tron.common.crypto.zksnark.BN128Fp; @@ -125,6 +126,10 @@ public class PrecompiledContracts { new ValidateMultiFnDsa512(); private static final BatchValidateFnDsa512 batchValidateFnDsa512 = new BatchValidateFnDsa512(); + private static final VerifyMlDsa44 verifyMlDsa44 = new VerifyMlDsa44(); + private static final ValidateMultiMlDsa44 validateMultiMlDsa44 = new ValidateMultiMlDsa44(); + private static final BatchValidateMlDsa44 batchValidateMlDsa44 = new BatchValidateMlDsa44(); + // FreezeV2 PrecompileContracts private static final GetChainParameter getChainParameter = new GetChainParameter(); private static final AvailableUnfreezeV2Size availableUnfreezeV2Size = new AvailableUnfreezeV2Size(); @@ -238,6 +243,22 @@ public class PrecompiledContracts { private static final DataWord batchValidateFnDsa512Addr = new DataWord( "0000000000000000000000000000000000000000000000000000000000000018"); + // 0x19: ML-DSA-44 single verify (FIPS 204 / CRYSTALS-Dilithium-2). TRON-style + // layout uses the standard 1312-byte public key encoding rho‖t1, not the + // EIP-8051 22964-byte expanded form — the standard encoding lets us call + // BC's stock MLDSASigner directly without re-implementing FIPS 204 §6.5. + private static final DataWord verifyMlDsa44Addr = new DataWord( + "0000000000000000000000000000000000000000000000000000000000000019"); + + // 0x1a: algorithm-agnostic Permission multi-sign with ML-DSA-44, mirroring + // 0x17's mixed ECDSA + PQ semantics for Dilithium signatures. + private static final DataWord validateMultiMlDsa44Addr = new DataWord( + "000000000000000000000000000000000000000000000000000000000000001a"); + + // 0x1b: batch independent ML-DSA-44 verify — bitmap output, same shape as 0x18. + private static final DataWord batchValidateMlDsa44Addr = new DataWord( + "000000000000000000000000000000000000000000000000000000000000001b"); + public static PrecompiledContract getOptimizedContractForConstant(PrecompiledContract contract) { try { Constructor constructor = contract.getClass().getDeclaredConstructor(); @@ -337,6 +358,20 @@ public static PrecompiledContract getContractForAddress(DataWord address) { } } + // ML-DSA-44 (FIPS 204 / Dilithium-2): second registered PQ scheme; gated by + // its own proposal flag so it can be activated independently of FN-DSA-512. + if (VMConfig.allowMlDsa44()) { + if (address.equals(verifyMlDsa44Addr)) { + return verifyMlDsa44; + } + if (address.equals(validateMultiMlDsa44Addr)) { + return validateMultiMlDsa44; + } + if (address.equals(batchValidateMlDsa44Addr)) { + return batchValidateMlDsa44; + } + } + if (VMConfig.allowTvmFreezeV2()) { if (address.equals(getChainParameterAddr)) { return getChainParameter; @@ -2831,4 +2866,351 @@ private static class PqVerifyResult { } } + /** + * Verifies an ML-DSA-44 signature (FIPS 204 / CRYSTALS-Dilithium-2). + * + *

Input layout (fixed-length): + *

+   *   [msg 32B | sig 2420B | pk 1312B]   // total 3764 B, strict equality
+   * 
+ * Uses the standard 1312-byte public key encoding {@code rho ‖ t1}. EIP-8051 + * defines an alternative 22964-byte "expanded" public-key layout + * ({@code A_hat ‖ tr ‖ t1_NTT}) that lets the verifier skip {@code ExpandA(rho)}; + * we deliberately diverge from that to call BC's stock {@code MLDSASigner} + * directly. Solidity callers that already produce 1312-byte standard keys + * can use this precompile unchanged; an expanded-pk variant can be added + * later without re-numbering this slot. + * + *

Returns a 32-byte word: 1 on valid signature, 0 otherwise. Malformed + * input (wrong length) returns 0 without error. + */ + public static class VerifyMlDsa44 extends PrecompiledContract { + + private static final int MSG_LEN = 32; + private static final int SIG_LEN = MLDSA44.SIGNATURE_LENGTH; + private static final int PK_LEN = MLDSA44.PUBLIC_KEY_LENGTH; + private static final int INPUT_LEN = MSG_LEN + SIG_LEN + PK_LEN; + + @Override + public long getEnergyForData(byte[] data) { + return 4500; + } + + @Override + public Pair execute(byte[] data) { + if (data == null || data.length != INPUT_LEN) { + return Pair.of(true, DataWord.ZERO().getData()); + } + try { + byte[] msg = copyOfRange(data, 0, MSG_LEN); + byte[] sig = copyOfRange(data, MSG_LEN, MSG_LEN + SIG_LEN); + byte[] pk = copyOfRange(data, MSG_LEN + SIG_LEN, INPUT_LEN); + boolean ok = MLDSA44.verify(pk, msg, sig); + return Pair.of(true, ok ? DataWord.ONE().getData() : DataWord.ZERO().getData()); + } catch (Throwable t) { + return Pair.of(true, DataWord.ZERO().getData()); + } + } + } + + /** + * 0x1a ValidateMultiMlDsa44 — algorithm-agnostic Permission multi-sign for + * ML-DSA-44, mirroring 0x17's ABI and mixed-weight semantics. Each pq + * signature is exactly 2420 B and each pq public key is exactly 1312 B. + * {@code MAX_SIZE = 5}; energy is {@code ecdsaCnt × 1500 + pqCnt × 4000} + * (Dilithium verify is ~2× a Falcon verify in our microbenchmarks). + */ + public static class ValidateMultiMlDsa44 extends PrecompiledContract { + + private static final int ECDSA_ENERGY_PER_SIGN = 1500; + private static final int PQ_ENERGY_PER_SIGN = 4000; + private static final int MAX_SIZE = 5; + private static final int PK_LEN = MLDSA44.PUBLIC_KEY_LENGTH; + private static final int SIG_LEN = MLDSA44.SIGNATURE_LENGTH; + // address, permissionId, data, ecdsaOffset, pqSigOffset, pqPkOffset. + private static final int ABI_HEAD_WORDS = 6; + + @Override + public long getEnergyForData(byte[] data) { + try { + DataWord[] words = DataWord.parseArray(data); + int ecdsaCnt = words[words[3].intValueSafe() / WORD_SIZE].intValueSafe(); + int pqCnt = words[words[4].intValueSafe() / WORD_SIZE].intValueSafe(); + return (long) ecdsaCnt * ECDSA_ENERGY_PER_SIGN + + (long) pqCnt * PQ_ENERGY_PER_SIGN; + } catch (Throwable t) { + return (long) MAX_SIZE * PQ_ENERGY_PER_SIGN; + } + } + + @Override + public Pair execute(byte[] rawData) { + if (!isValidAbiHead(rawData, ABI_HEAD_WORDS)) { + return Pair.of(false, EMPTY_BYTE_ARRAY); + } + try { + DataWord[] words = DataWord.parseArray(rawData); + if (!isValidArrayOffset(words, 3, ABI_HEAD_WORDS) + || !isValidArrayOffset(words, 4, ABI_HEAD_WORDS) + || !isValidArrayOffset(words, 5, ABI_HEAD_WORDS)) { + return Pair.of(false, EMPTY_BYTE_ARRAY); + } + byte[] address = words[0].toTronAddress(); + int permissionId = words[1].intValueSafe(); + byte[] data = words[2].getData(); + + byte[] combine = ByteUtil.merge(address, ByteArray.fromInt(permissionId), data); + byte[] hash = Sha256Hash.hash(CommonParameter + .getInstance().isECKeyCryptoEngine(), combine); + + int ecdsaArrayWord = words[3].intValueSafe() / WORD_SIZE; + int pqSigArrayWord = words[4].intValueSafe() / WORD_SIZE; + int pqPkArrayWord = words[5].intValueSafe() / WORD_SIZE; + + int ecdsaCnt = words[ecdsaArrayWord].intValueSafe(); + int pqSigCnt = words[pqSigArrayWord].intValueSafe(); + int pqPkCnt = words[pqPkArrayWord].intValueSafe(); + + if (pqSigCnt != pqPkCnt + || ecdsaCnt + pqSigCnt == 0 + || ecdsaCnt + pqSigCnt > MAX_SIZE) { + return Pair.of(true, DATA_FALSE); + } + + byte[][] ecdsaSigs = extractSigArray(words, ecdsaArrayWord, rawData); + byte[][] pqSigs = extractBytesArray(words, pqSigArrayWord, rawData); + byte[][] pqPks = extractBytesArray(words, pqPkArrayWord, rawData); + + AccountCapsule account = this.getDeposit().getAccount(address); + if (account == null) { + return Pair.of(true, DATA_FALSE); + } + Permission permission = account.getPermissionById(permissionId); + if (permission == null) { + return Pair.of(true, DATA_FALSE); + } + + long totalWeight = 0L; + List executedSignList = new ArrayList<>(); + + for (byte[] sign : ecdsaSigs) { + byte[] recoveredAddr = recoverAddrBySign(sign, hash); + byte[] dedupKey = merge(recoveredAddr, sign); + if (ByteArray.matrixContains(executedSignList, recoveredAddr)) { + if (ByteArray.matrixContains(executedSignList, dedupKey)) { + continue; + } + MUtil.checkCPUTime(); + } + long weight = TransactionCapsule.getWeight(permission, recoveredAddr); + if (weight == 0) { + return Pair.of(true, DATA_FALSE); + } + totalWeight += weight; + executedSignList.add(dedupKey); + executedSignList.add(recoveredAddr); + } + + for (int i = 0; i < pqSigs.length; i++) { + byte[] sig = pqSigs[i]; + byte[] pk = pqPks[i]; + if (pk == null || pk.length != PK_LEN + || sig == null || sig.length != SIG_LEN) { + return Pair.of(true, DATA_FALSE); + } + byte[] derivedAddr; + try { + derivedAddr = PQSchemeRegistry.computeAddress(PQScheme.ML_DSA_44, pk); + } catch (Throwable t) { + return Pair.of(true, DATA_FALSE); + } + // ML-DSA signing is randomized (rho' is hashed from the seeded RNG), + // so the same key can produce many valid signatures for one message. + // Dedup keyed on the derived address — same reasoning as 0x17. + if (ByteArray.matrixContains(executedSignList, derivedAddr)) { + continue; + } + long weight = TransactionCapsule.getWeight(permission, derivedAddr); + if (weight == 0) { + return Pair.of(true, DATA_FALSE); + } + if (!MLDSA44.verify(pk, hash, sig)) { + return Pair.of(true, DATA_FALSE); + } + totalWeight += weight; + executedSignList.add(derivedAddr); + } + + if (totalWeight >= permission.getThreshold()) { + return Pair.of(true, dataOne()); + } + } catch (Throwable t) { + if (t instanceof OutOfTimeException) { + throw t; + } + } + return Pair.of(true, DATA_FALSE); + } + } + + /** + * 0x1b BatchValidateMlDsa44 — independent per-element ML-DSA-44 verify. + * Returns a 256-bit bitmap where bit {@code i} is set iff + * {@code derive(pk_i) == expectedAddr_i} AND {@code MLDSA44.verify(pk_i, hash, sig_i)}. + * Same ABI shape as 0x18, with sigs 2420 B and pks 1312 B. + * {@code MAX_SIZE = 16}; energy is {@code cnt × 4000}. + */ + public static class BatchValidateMlDsa44 extends PrecompiledContract { + + private static final int ENERGY_PER_SIGN = 4000; + private static final int MAX_SIZE = 16; + private static final int PK_LEN = MLDSA44.PUBLIC_KEY_LENGTH; + private static final int SIG_LEN = MLDSA44.SIGNATURE_LENGTH; + // hash, sigArrayOffset, pkArrayOffset, addrArrayOffset. + private static final int ABI_HEAD_WORDS = 4; + + @Override + public long getEnergyForData(byte[] data) { + try { + DataWord[] words = DataWord.parseArray(data); + int cnt = words[words[1].intValueSafe() / WORD_SIZE].intValueSafe(); + return (long) cnt * ENERGY_PER_SIGN; + } catch (Throwable t) { + return (long) MAX_SIZE * ENERGY_PER_SIGN; + } + } + + @Override + public Pair execute(byte[] data) { + try { + return doExecute(data); + } catch (Throwable t) { + if (t instanceof OutOfTimeException) { + throw (OutOfTimeException) t; + } + if (t instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + return Pair.of(true, new byte[WORD_SIZE]); + } + } + + private Pair doExecute(byte[] data) + throws InterruptedException, ExecutionException { + if (!isValidAbiHead(data, ABI_HEAD_WORDS)) { + return Pair.of(false, EMPTY_BYTE_ARRAY); + } + DataWord[] words = DataWord.parseArray(data); + if (!isValidArrayOffset(words, 1, ABI_HEAD_WORDS) + || !isValidArrayOffset(words, 2, ABI_HEAD_WORDS) + || !isValidArrayOffset(words, 3, ABI_HEAD_WORDS)) { + return Pair.of(false, EMPTY_BYTE_ARRAY); + } + byte[] hash = words[0].getData(); + + int sigArrayWord = words[1].intValueSafe() / WORD_SIZE; + int pkArrayWord = words[2].intValueSafe() / WORD_SIZE; + int addrArrayWord = words[3].intValueSafe() / WORD_SIZE; + + int sigArraySize = words[sigArrayWord].intValueSafe(); + int pkArraySize = words[pkArrayWord].intValueSafe(); + int addrArraySize = words[addrArrayWord].intValueSafe(); + + if (sigArraySize > MAX_SIZE || pkArraySize > MAX_SIZE + || addrArraySize > MAX_SIZE + || sigArraySize != pkArraySize || sigArraySize != addrArraySize) { + return Pair.of(true, DATA_FALSE); + } + + byte[][] signatures = extractBytesArray(words, sigArrayWord, data); + byte[][] publicKeys = extractBytesArray(words, pkArrayWord, data); + byte[][] addresses = extractBytes32Array(words, addrArrayWord); + + int cnt = signatures.length; + if (cnt == 0) { + return Pair.of(true, DATA_FALSE); + } + + byte[] res = new byte[WORD_SIZE]; + if (isConstantCall()) { + for (int i = 0; i < cnt; i++) { + if (verifyOne(signatures[i], publicKeys[i], hash, addresses[i])) { + res[i] = 1; + } + } + } else { + CountDownLatch countDownLatch = new CountDownLatch(cnt); + List> futures = new ArrayList<>(cnt); + + for (int i = 0; i < cnt; i++) { + Future future = BatchValidateSign.workers.submit( + new PqVerifyTask(countDownLatch, hash, signatures[i], + publicKeys[i], addresses[i], i)); + futures.add(future); + } + + boolean withNoTimeout = countDownLatch + .await(getCPUTimeLeftInNanoSecond(), TimeUnit.NANOSECONDS); + + if (!withNoTimeout) { + logger.info("BatchValidateMlDsa44 timeout"); + throw Program.Exception.notEnoughTime("call BatchValidateMlDsa44 precompile method"); + } + + for (Future future : futures) { + PqVerifyResult r = future.get(); + if (r.success) { + res[r.nonce] = 1; + } + } + } + return Pair.of(true, res); + } + + private static boolean verifyOne(byte[] sig, byte[] pk, byte[] hash, + byte[] expectedAddr) { + if (pk == null || pk.length != PK_LEN + || sig == null || sig.length != SIG_LEN) { + return false; + } + try { + byte[] derived = PQSchemeRegistry.computeAddress(PQScheme.ML_DSA_44, pk); + if (!DataWord.equalAddressByteArray(derived, expectedAddr)) { + return false; + } + return MLDSA44.verify(pk, hash, sig); + } catch (Throwable t) { + return false; + } + } + + @AllArgsConstructor + private static class PqVerifyTask implements Callable { + + private CountDownLatch countDownLatch; + private byte[] hash; + private byte[] signature; + private byte[] publicKey; + private byte[] expectedAddr; + private int nonce; + + @Override + public PqVerifyResult call() { + try { + return new PqVerifyResult( + verifyOne(signature, publicKey, hash, expectedAddr), nonce); + } finally { + countDownLatch.countDown(); + } + } + } + + @AllArgsConstructor + private static class PqVerifyResult { + + private boolean success; + private int nonce; + } + } + } diff --git a/actuator/src/main/java/org/tron/core/vm/config/ConfigLoader.java b/actuator/src/main/java/org/tron/core/vm/config/ConfigLoader.java index 6a992ae5f0d..22d7a506c53 100644 --- a/actuator/src/main/java/org/tron/core/vm/config/ConfigLoader.java +++ b/actuator/src/main/java/org/tron/core/vm/config/ConfigLoader.java @@ -48,6 +48,7 @@ public static void load(StoreFactory storeFactory) { VMConfig.initAllowTvmOsaka(ds.getAllowTvmOsaka()); VMConfig.initAllowHardenResourceCalculation(ds.getAllowHardenResourceCalculation()); VMConfig.initAllowFnDsa512(ds.getAllowFnDsa512()); + VMConfig.initAllowMlDsa44(ds.getAllowMlDsa44()); } } } diff --git a/common/src/main/java/org/tron/core/vm/config/VMConfig.java b/common/src/main/java/org/tron/core/vm/config/VMConfig.java index 2df06e2dd22..3878fd875dc 100644 --- a/common/src/main/java/org/tron/core/vm/config/VMConfig.java +++ b/common/src/main/java/org/tron/core/vm/config/VMConfig.java @@ -67,6 +67,8 @@ public class VMConfig { private static boolean ALLOW_FN_DSA_512 = false; + private static boolean ALLOW_ML_DSA_44 = false; + private VMConfig() { } @@ -190,6 +192,10 @@ public static void initAllowFnDsa512(long allow) { ALLOW_FN_DSA_512 = allow == 1; } + public static void initAllowMlDsa44(long allow) { + ALLOW_ML_DSA_44 = allow == 1; + } + public static boolean getEnergyLimitHardFork() { return CommonParameter.ENERGY_LIMIT_HARD_FORK; } @@ -301,4 +307,8 @@ public static boolean allowHardenResourceCalculation() { public static boolean allowFnDsa512() { return ALLOW_FN_DSA_512; } + + public static boolean allowMlDsa44() { + return ALLOW_ML_DSA_44; + } } diff --git a/framework/src/test/java/org/tron/common/runtime/vm/BatchValidateMlDsa44Test.java b/framework/src/test/java/org/tron/common/runtime/vm/BatchValidateMlDsa44Test.java new file mode 100644 index 00000000000..045a675cfe2 --- /dev/null +++ b/framework/src/test/java/org/tron/common/runtime/vm/BatchValidateMlDsa44Test.java @@ -0,0 +1,352 @@ +package org.tron.common.runtime.vm; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.tuple.Pair; +import org.bouncycastle.util.encoders.Hex; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.tron.common.crypto.pqc.MLDSA44; +import org.tron.common.crypto.pqc.PQSchemeRegistry; +import org.tron.common.utils.client.utils.AbiUtil; +import org.tron.core.vm.PrecompiledContracts; +import org.tron.core.vm.PrecompiledContracts.BatchValidateMlDsa44; +import org.tron.core.vm.PrecompiledContracts.PrecompiledContract; +import org.tron.core.vm.config.VMConfig; +import org.tron.protos.Protocol.PQScheme; + +/** + * Unit tests for the 0x1b batch independent ML-DSA-44 verify precompile. + * Returns a 256-bit bitmap where bit i is set iff + * {@code derive(pk_i) == expectedAddr_i && MLDSA44.verify(pk_i, hash, sig_i)}. + * Stateless — no chain DB. + */ +@Slf4j +public class BatchValidateMlDsa44Test { + + private static final DataWord ADDR_0X1B = new DataWord( + "000000000000000000000000000000000000000000000000000000000000001b"); + + private static final String METHOD_SIGN = + "batchvalidatemldsa44(bytes32,bytes[],bytes[],bytes32[])"; + + private static final byte[] HASH; + + static { + HASH = new byte[32]; + for (int i = 0; i < 32; i++) { + HASH[i] = (byte) (i + 1); + } + } + + private final BatchValidateMlDsa44 contract = new BatchValidateMlDsa44(); + + @Before + public void enableProposal() { + VMConfig.initAllowMlDsa44(1L); + } + + @After + public void disableProposal() { + VMConfig.initAllowMlDsa44(0L); + } + + @Test + public void switchOff_returnsNull() { + VMConfig.initAllowMlDsa44(0L); + Assert.assertNull(PrecompiledContracts.getContractForAddress(ADDR_0X1B)); + } + + @Test + public void switchOn_returnsContract() { + PrecompiledContract pc = PrecompiledContracts.getContractForAddress(ADDR_0X1B); + Assert.assertNotNull(pc); + Assert.assertTrue(pc instanceof BatchValidateMlDsa44); + } + + @Test + public void constantCall_allValid_setsAllBits() { + contract.setConstantCall(true); + int n = 4; + List sigs = new ArrayList<>(n); + List pks = new ArrayList<>(n); + List addrs = new ArrayList<>(n); + for (int i = 0; i < n; i++) { + MLDSA44 k = new MLDSA44(); + sigs.add(Hex.toHexString(k.sign(HASH))); + pks.add(Hex.toHexString(k.getPublicKey())); + addrs.add(addrAsBytes32Hex(k.getPublicKey())); + } + byte[] res = run(HASH, sigs, pks, addrs).getRight(); + for (int i = 0; i < n; i++) { + Assert.assertEquals("bit " + i, 1, res[i]); + } + for (int i = n; i < 32; i++) { + Assert.assertEquals("padding bit " + i, 0, res[i]); + } + } + + @Test + public void constantCall_mismatchedAddress_clearsBit() { + contract.setConstantCall(true); + MLDSA44 k1 = new MLDSA44(); + MLDSA44 k2 = new MLDSA44(); + List sigs = Arrays.asList( + Hex.toHexString(k1.sign(HASH)), + Hex.toHexString(k2.sign(HASH))); + List pks = Arrays.asList( + Hex.toHexString(k1.getPublicKey()), + Hex.toHexString(k2.getPublicKey())); + // entry 1's address is wrong + List addrs = Arrays.asList( + addrAsBytes32Hex(k1.getPublicKey()), + addrAsBytes32Hex(k1.getPublicKey())); + + byte[] res = run(HASH, sigs, pks, addrs).getRight(); + Assert.assertEquals(1, res[0]); + Assert.assertEquals(0, res[1]); + } + + @Test + public void constantCall_tamperedSignature_clearsBit() { + contract.setConstantCall(true); + MLDSA44 k = new MLDSA44(); + byte[] sig = k.sign(HASH); + sig[0] ^= 0x01; + List sigs = Collections1(Hex.toHexString(sig)); + List pks = Collections1(Hex.toHexString(k.getPublicKey())); + List addrs = Collections1(addrAsBytes32Hex(k.getPublicKey())); + + byte[] res = run(HASH, sigs, pks, addrs).getRight(); + Assert.assertEquals(0, res[0]); + } + + @Test + public void constantCall_wrongPkLength_clearsBit() { + contract.setConstantCall(true); + MLDSA44 k = new MLDSA44(); + byte[] truncatedPk = Arrays.copyOf(k.getPublicKey(), k.getPublicKey().length - 1); + List sigs = Collections1(Hex.toHexString(k.sign(HASH))); + List pks = Collections1(Hex.toHexString(truncatedPk)); + List addrs = Collections1(addrAsBytes32Hex(k.getPublicKey())); + + byte[] res = run(HASH, sigs, pks, addrs).getRight(); + Assert.assertEquals(0, res[0]); + } + + @Test + public void asyncPath_allValid_setsAllBits() { + contract.setConstantCall(false); + contract.setVmShouldEndInUs(System.nanoTime() / 1000 + 10_000_000L); + int n = 4; + List sigs = new ArrayList<>(n); + List pks = new ArrayList<>(n); + List addrs = new ArrayList<>(n); + for (int i = 0; i < n; i++) { + MLDSA44 k = new MLDSA44(); + sigs.add(Hex.toHexString(k.sign(HASH))); + pks.add(Hex.toHexString(k.getPublicKey())); + addrs.add(addrAsBytes32Hex(k.getPublicKey())); + } + byte[] res = run(HASH, sigs, pks, addrs).getRight(); + for (int i = 0; i < n; i++) { + Assert.assertEquals("bit " + i, 1, res[i]); + } + } + + @Test + public void mismatchedArrayLengths_returnsZero() { + contract.setConstantCall(true); + MLDSA44 k = new MLDSA44(); + List sigs = Collections1(Hex.toHexString(k.sign(HASH))); + List pks = Arrays.asList( + Hex.toHexString(k.getPublicKey()), Hex.toHexString(k.getPublicKey())); + List addrs = Collections1(addrAsBytes32Hex(k.getPublicKey())); + + byte[] res = run(HASH, sigs, pks, addrs).getRight(); + Assert.assertArrayEquals(new byte[32], res); + } + + @Test + public void overMaxSize_returnsZero() { + contract.setConstantCall(true); + contract.setVmShouldEndInUs(System.nanoTime() / 1000 + 60_000_000L); + int n = 17; + List sigs = new ArrayList<>(n); + List pks = new ArrayList<>(n); + List addrs = new ArrayList<>(n); + for (int i = 0; i < n; i++) { + MLDSA44 k = new MLDSA44(); + sigs.add(Hex.toHexString(k.sign(HASH))); + pks.add(Hex.toHexString(k.getPublicKey())); + addrs.add(addrAsBytes32Hex(k.getPublicKey())); + } + byte[] res = run(HASH, sigs, pks, addrs).getRight(); + Assert.assertArrayEquals(new byte[32], res); + } + + @Test + public void energyScalesWithCount() { + contract.setConstantCall(true); + int n = 3; + List sigs = new ArrayList<>(n); + List pks = new ArrayList<>(n); + List addrs = new ArrayList<>(n); + for (int i = 0; i < n; i++) { + MLDSA44 k = new MLDSA44(); + sigs.add(Hex.toHexString(k.sign(HASH))); + pks.add(Hex.toHexString(k.getPublicKey())); + addrs.add(addrAsBytes32Hex(k.getPublicKey())); + } + byte[] input = encode(HASH, sigs, pks, addrs); + Assert.assertEquals(3L * 4000L, contract.getEnergyForData(input)); + } + + @Test + public void emptyArrays_returnsAllZero() { + contract.setConstantCall(true); + byte[] res = run(HASH, new ArrayList<>(), new ArrayList<>(), new ArrayList<>()).getRight(); + Assert.assertArrayEquals(new byte[32], res); + } + + @Test + public void differentHash_clearsAllBits() { + contract.setConstantCall(true); + int n = 3; + List sigs = new ArrayList<>(n); + List pks = new ArrayList<>(n); + List addrs = new ArrayList<>(n); + for (int i = 0; i < n; i++) { + MLDSA44 k = new MLDSA44(); + // Sign HASH... + sigs.add(Hex.toHexString(k.sign(HASH))); + pks.add(Hex.toHexString(k.getPublicKey())); + addrs.add(addrAsBytes32Hex(k.getPublicKey())); + } + // ...but verify against a different hash. + byte[] otherHash = new byte[32]; + Arrays.fill(otherHash, (byte) 0xAA); + + byte[] res = run(otherHash, sigs, pks, addrs).getRight(); + Assert.assertArrayEquals(new byte[32], res); + } + + @Test + public void atMaxSize16_setsAllBits() { + contract.setConstantCall(true); + int n = 16; + List sigs = new ArrayList<>(n); + List pks = new ArrayList<>(n); + List addrs = new ArrayList<>(n); + for (int i = 0; i < n; i++) { + MLDSA44 k = new MLDSA44(); + sigs.add(Hex.toHexString(k.sign(HASH))); + pks.add(Hex.toHexString(k.getPublicKey())); + addrs.add(addrAsBytes32Hex(k.getPublicKey())); + } + contract.setVmShouldEndInUs(System.nanoTime() / 1000 + 60_000_000L); + byte[] res = run(HASH, sigs, pks, addrs).getRight(); + for (int i = 0; i < n; i++) { + Assert.assertEquals("bit " + i, 1, res[i]); + } + for (int i = n; i < 32; i++) { + Assert.assertEquals("padding bit " + i, 0, res[i]); + } + } + + @Test + public void asyncPath_mixedValidInvalid() { + contract.setConstantCall(false); + contract.setVmShouldEndInUs(System.nanoTime() / 1000 + 20_000_000L); + int n = 4; + List sigs = new ArrayList<>(n); + List pks = new ArrayList<>(n); + List addrs = new ArrayList<>(n); + for (int i = 0; i < n; i++) { + MLDSA44 k = new MLDSA44(); + byte[] sig = k.sign(HASH); + // Tamper entries 1 and 3. + if (i == 1 || i == 3) { + sig[0] ^= 0x01; + } + sigs.add(Hex.toHexString(sig)); + pks.add(Hex.toHexString(k.getPublicKey())); + addrs.add(addrAsBytes32Hex(k.getPublicKey())); + } + byte[] res = run(HASH, sigs, pks, addrs).getRight(); + Assert.assertEquals(1, res[0]); + Assert.assertEquals(0, res[1]); + Assert.assertEquals(1, res[2]); + Assert.assertEquals(0, res[3]); + } + + @Test + public void sigWrongLength_clearsBit() { + // ML-DSA-44 signatures are fixed at 2420 B; any other length must fail. + contract.setConstantCall(true); + MLDSA44 k = new MLDSA44(); + byte[] wrongLen = new byte[MLDSA44.SIGNATURE_LENGTH - 1]; + Arrays.fill(wrongLen, (byte) 0x99); + List sigs = Collections1(Hex.toHexString(wrongLen)); + List pks = Collections1(Hex.toHexString(k.getPublicKey())); + List addrs = Collections1(addrAsBytes32Hex(k.getPublicKey())); + + byte[] res = run(HASH, sigs, pks, addrs).getRight(); + Assert.assertEquals(0, res[0]); + } + + // -------- helpers -------- + + private Pair run(byte[] hash, List sigs, + List pks, List addrs) { + byte[] input = encode(hash, sigs, pks, addrs); + // Preserve any longer budget callers set (e.g. atMaxSize16 and asyncPath_* + // need 20-60s for 16 parallel ML-DSA-44 verifies on slow CI; Dilithium-2 + // verify is ~2× slower than Falcon-512 verify). + if (contract.getVmShouldEndInUs() == 0) { + contract.setVmShouldEndInUs(System.nanoTime() / 1000 + 10_000_000L); + } + Pair ret = contract.execute(input); + logger.info("0x1b bitmap: {}", Hex.toHexString(ret.getRight())); + return ret; + } + + private byte[] encode(byte[] hash, List sigs, List pks, List addrs) { + List parameters = Arrays.asList( + "0x" + Hex.toHexString(hash), + toHexList(sigs), + toHexList(pks), + toHexList(addrs)); + return Hex.decode(AbiUtil.parseParameters(METHOD_SIGN, parameters)); + } + + private static List toHexList(List hexes) { + List out = new ArrayList<>(hexes.size()); + for (String h : hexes) { + out.add(h.startsWith("0x") ? h : ("0x" + h)); + } + return out; + } + + private static List Collections1(String s) { + List l = new ArrayList<>(1); + l.add(s); + return l; + } + + /** + * Build a bytes32 hex string whose low 21 bytes hold the derived TRON address + * (high 11 bytes left zero). Matches {@code DataWord.equalAddressByteArray}'s + * "compare last 20 bytes" semantics. + */ + private static String addrAsBytes32Hex(byte[] pk) { + byte[] addr21 = PQSchemeRegistry.computeAddress(PQScheme.ML_DSA_44, pk); + byte[] padded = new byte[32]; + System.arraycopy(addr21, 0, padded, 32 - addr21.length, addr21.length); + return "0x" + Hex.toHexString(padded); + } +} diff --git a/framework/src/test/java/org/tron/common/runtime/vm/MlDsa44PrecompileTest.java b/framework/src/test/java/org/tron/common/runtime/vm/MlDsa44PrecompileTest.java new file mode 100644 index 00000000000..4728bea42d6 --- /dev/null +++ b/framework/src/test/java/org/tron/common/runtime/vm/MlDsa44PrecompileTest.java @@ -0,0 +1,162 @@ +package org.tron.common.runtime.vm; + +import org.apache.commons.lang3.tuple.Pair; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.tron.common.crypto.pqc.MLDSA44; +import org.tron.core.vm.PrecompiledContracts; +import org.tron.core.vm.PrecompiledContracts.PrecompiledContract; +import org.tron.core.vm.config.VMConfig; + +/** + * Unit tests for the ML-DSA-44 (0x19) verify precompile (FIPS 204 / Dilithium-2). + * Input layout: [msg 32B | sig 2420B | pk 1312B]. Stateless — no chain DB. + */ +public class MlDsa44PrecompileTest { + + private static final DataWord MLDSA_ADDR = new DataWord( + "0000000000000000000000000000000000000000000000000000000000000019"); + + private static final byte[] MESSAGE_HASH = new byte[32]; + + static { + for (int i = 0; i < 32; i++) { + MESSAGE_HASH[i] = (byte) i; + } + } + + @Before + public void enableProposal() { + VMConfig.initAllowMlDsa44(1L); + } + + @After + public void disableProposal() { + VMConfig.initAllowMlDsa44(0L); + } + + @Test + public void switchOff_returnsNull() { + VMConfig.initAllowMlDsa44(0L); + Assert.assertNull(PrecompiledContracts.getContractForAddress(MLDSA_ADDR)); + } + + @Test + public void switchOn_returnsContract() { + Assert.assertNotNull(PrecompiledContracts.getContractForAddress(MLDSA_ADDR)); + } + + @Test + public void validSignature_returnsOne() { + MLDSA44 key = new MLDSA44(); + byte[] sig = key.sign(MESSAGE_HASH); + byte[] input = buildInput(MESSAGE_HASH, sig, key.getPublicKey()); + + PrecompiledContract pc = PrecompiledContracts.getContractForAddress(MLDSA_ADDR); + Pair result = pc.execute(input); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ONE().getData(), result.getRight()); + Assert.assertEquals(4500, pc.getEnergyForData(input)); + } + + @Test + public void tamperedMessage_returnsZero() { + MLDSA44 key = new MLDSA44(); + byte[] sig = key.sign(MESSAGE_HASH); + byte[] tampered = MESSAGE_HASH.clone(); + tampered[0] ^= 0x01; + byte[] input = buildInput(tampered, sig, key.getPublicKey()); + + Pair result = + PrecompiledContracts.getContractForAddress(MLDSA_ADDR).execute(input); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + @Test + public void tamperedSignature_returnsZero() { + MLDSA44 key = new MLDSA44(); + byte[] sig = key.sign(MESSAGE_HASH); + sig[0] ^= 0x01; + byte[] input = buildInput(MESSAGE_HASH, sig, key.getPublicKey()); + + Pair result = + PrecompiledContracts.getContractForAddress(MLDSA_ADDR).execute(input); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + @Test + public void wrongPublicKey_returnsZero() { + MLDSA44 signer = new MLDSA44(); + MLDSA44 other = new MLDSA44(); + byte[] sig = signer.sign(MESSAGE_HASH); + byte[] input = buildInput(MESSAGE_HASH, sig, other.getPublicKey()); + + Pair result = + PrecompiledContracts.getContractForAddress(MLDSA_ADDR).execute(input); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + @Test + public void nullInput_returnsZero() { + Pair result = + PrecompiledContracts.getContractForAddress(MLDSA_ADDR).execute(null); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + @Test + public void shortInput_returnsZero() { + Pair result = + PrecompiledContracts.getContractForAddress(MLDSA_ADDR).execute(new byte[100]); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + @Test + public void wrongLengthInput_returnsZero() { + // ML-DSA-44 input is fixed-length 3764B; any other length must be rejected. + int expected = 32 + MLDSA44.SIGNATURE_LENGTH + MLDSA44.PUBLIC_KEY_LENGTH; + byte[] oneByteShort = new byte[expected - 1]; + Pair r1 = + PrecompiledContracts.getContractForAddress(MLDSA_ADDR).execute(oneByteShort); + Assert.assertTrue(r1.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), r1.getRight()); + } + + @Test + public void trailingBytes_returnsZero() { + // Strict equality: even one extra trailing byte must be rejected. + MLDSA44 key = new MLDSA44(); + byte[] sig = key.sign(MESSAGE_HASH); + byte[] valid = buildInput(MESSAGE_HASH, sig, key.getPublicKey()); + byte[] padded = new byte[valid.length + 1]; + System.arraycopy(valid, 0, padded, 0, valid.length); + + Pair result = + PrecompiledContracts.getContractForAddress(MLDSA_ADDR).execute(padded); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + /** Encodes input as [msg 32B | sig 2420B | pk 1312B]. */ + private static byte[] buildInput(byte[] msg, byte[] sig, byte[] pk) { + int total = 32 + sig.length + pk.length; + byte[] out = new byte[total]; + System.arraycopy(msg, 0, out, 0, 32); + System.arraycopy(sig, 0, out, 32, sig.length); + System.arraycopy(pk, 0, out, 32 + sig.length, pk.length); + return out; + } +} diff --git a/framework/src/test/java/org/tron/common/runtime/vm/ValidateMultiMlDsa44Test.java b/framework/src/test/java/org/tron/common/runtime/vm/ValidateMultiMlDsa44Test.java new file mode 100644 index 00000000000..1ff31d16805 --- /dev/null +++ b/framework/src/test/java/org/tron/common/runtime/vm/ValidateMultiMlDsa44Test.java @@ -0,0 +1,465 @@ +package org.tron.common.runtime.vm; + +import com.google.protobuf.ByteString; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.tuple.Pair; +import org.bouncycastle.util.encoders.Hex; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.tron.common.BaseTest; +import org.tron.common.TestConstants; +import org.tron.common.crypto.ECKey; +import org.tron.common.crypto.pqc.MLDSA44; +import org.tron.common.crypto.pqc.PQSchemeRegistry; +import org.tron.common.parameter.CommonParameter; +import org.tron.common.utils.ByteArray; +import org.tron.common.utils.ByteUtil; +import org.tron.common.utils.Sha256Hash; +import org.tron.common.utils.StringUtil; +import org.tron.common.utils.client.utils.AbiUtil; +import org.tron.core.capsule.AccountCapsule; +import org.tron.core.config.args.Args; +import org.tron.core.store.StoreFactory; +import org.tron.core.vm.PrecompiledContracts; +import org.tron.core.vm.PrecompiledContracts.PrecompiledContract; +import org.tron.core.vm.PrecompiledContracts.ValidateMultiMlDsa44; +import org.tron.core.vm.config.VMConfig; +import org.tron.core.vm.repository.Repository; +import org.tron.core.vm.repository.RepositoryImpl; +import org.tron.protos.Protocol; +import org.tron.protos.Protocol.PQScheme; + +/** + * Unit tests for the 0x1a algorithm-agnostic Permission multi-sign precompile. + * Mirrors 0x09 hash construction and threshold semantics, while supporting + * ML-DSA-44 entries alongside ECDSA against the same Permission.keys[]. + */ +@Slf4j +public class ValidateMultiMlDsa44Test extends BaseTest { + + private static final DataWord ADDR_0X1A = new DataWord( + "000000000000000000000000000000000000000000000000000000000000001a"); + + private static final String METHOD_SIGN = + "validatemultisign(address,uint256,bytes32,bytes[],bytes[],bytes[])"; + + private static final byte[] longData; + + static { + Args.setParam(new String[]{"--output-directory", dbPath(), "--debug"}, TestConstants.TEST_CONF); + longData = new byte[1000]; + Arrays.fill(longData, (byte) 7); + } + + private final ValidateMultiMlDsa44 contract = new ValidateMultiMlDsa44(); + + @Before + public void before() { + dbManager.getDynamicPropertiesStore().saveAllowMultiSign(1); + dbManager.getDynamicPropertiesStore().saveTotalSignNum(5); + VMConfig.initAllowMlDsa44(1L); + } + + @Test + public void switchOff_returnsNull() { + VMConfig.initAllowMlDsa44(0L); + Assert.assertNull(PrecompiledContracts.getContractForAddress(ADDR_0X1A)); + } + + @Test + public void switchOn_returnsContract() { + PrecompiledContract pc = PrecompiledContracts.getContractForAddress(ADDR_0X1A); + Assert.assertNotNull(pc); + Assert.assertTrue(pc instanceof ValidateMultiMlDsa44); + } + + @Test + public void unknownAccount_returnsZero() { + ECKey owner = new ECKey(); + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + List ecdsaSigs = Collections.singletonList( + Hex.toHexString(new ECKey().sign(toSign).toByteArray())); + Assert.assertArrayEquals(DataWord.ZERO().getData(), + runContract(owner.getAddress(), 2, data, ecdsaSigs, + Collections.emptyList(), Collections.emptyList()).getRight()); + } + + @Test + public void pureEcdsaThresholdReached_returnsOne() { + ECKey k1 = new ECKey(); + ECKey k2 = new ECKey(); + ECKey owner = new ECKey(); + setupPermission(owner, Arrays.asList(k1.getAddress(), k2.getAddress()), + Arrays.asList(1, 1), 2, Collections.emptyList(), Collections.emptyList()); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + List ecdsaSigs = Arrays.asList( + Hex.toHexString(k1.sign(toSign).toByteArray()), + Hex.toHexString(k2.sign(toSign).toByteArray())); + + Assert.assertArrayEquals(DataWord.ONE().getData(), + runContract(owner.getAddress(), 2, data, ecdsaSigs, + Collections.emptyList(), Collections.emptyList()).getRight()); + } + + @Test + public void purePqThresholdReached_returnsOne() { + MLDSA44 pq1 = new MLDSA44(); + MLDSA44 pq2 = new MLDSA44(); + ECKey owner = new ECKey(); + byte[] addr1 = PQSchemeRegistry.computeAddress(PQScheme.ML_DSA_44, pq1.getPublicKey()); + byte[] addr2 = PQSchemeRegistry.computeAddress(PQScheme.ML_DSA_44, pq2.getPublicKey()); + setupPermission(owner, Collections.emptyList(), Collections.emptyList(), + 2, Arrays.asList(addr1, addr2), Arrays.asList(1, 1)); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + + List pqSigs = Arrays.asList( + Hex.toHexString(pq1.sign(toSign)), + Hex.toHexString(pq2.sign(toSign))); + List pqPks = Arrays.asList( + Hex.toHexString(pq1.getPublicKey()), + Hex.toHexString(pq2.getPublicKey())); + + Assert.assertArrayEquals(DataWord.ONE().getData(), + runContract(owner.getAddress(), 2, data, + Collections.emptyList(), pqSigs, pqPks).getRight()); + } + + @Test + public void mixedEcdsaAndPq_returnsOne() { + ECKey k1 = new ECKey(); + MLDSA44 pq1 = new MLDSA44(); + ECKey owner = new ECKey(); + byte[] pqAddr = PQSchemeRegistry.computeAddress(PQScheme.ML_DSA_44, pq1.getPublicKey()); + setupPermission(owner, Collections.singletonList(k1.getAddress()), Collections.singletonList(1), + 2, Collections.singletonList(pqAddr), Collections.singletonList(1)); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + + List ecdsaSigs = Collections.singletonList( + Hex.toHexString(k1.sign(toSign).toByteArray())); + List pqSigs = Collections.singletonList(Hex.toHexString(pq1.sign(toSign))); + List pqPks = Collections.singletonList(Hex.toHexString(pq1.getPublicKey())); + + Assert.assertArrayEquals(DataWord.ONE().getData(), + runContract(owner.getAddress(), 2, data, ecdsaSigs, pqSigs, pqPks).getRight()); + } + + @Test + public void pqSignatureForgery_returnsZero() { + MLDSA44 pq1 = new MLDSA44(); + ECKey owner = new ECKey(); + byte[] pqAddr = PQSchemeRegistry.computeAddress(PQScheme.ML_DSA_44, pq1.getPublicKey()); + setupPermission(owner, Collections.emptyList(), Collections.emptyList(), + 1, Collections.singletonList(pqAddr), Collections.singletonList(1)); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + byte[] forgedSig = pq1.sign(toSign); + forgedSig[10] ^= 0x01; + + List pqSigs = Collections.singletonList(Hex.toHexString(forgedSig)); + List pqPks = Collections.singletonList(Hex.toHexString(pq1.getPublicKey())); + + Assert.assertArrayEquals(DataWord.ZERO().getData(), + runContract(owner.getAddress(), 2, data, + Collections.emptyList(), pqSigs, pqPks).getRight()); + } + + @Test + public void wrongPqPublicKeyLength_returnsZero() { + MLDSA44 pq1 = new MLDSA44(); + ECKey owner = new ECKey(); + byte[] pqAddr = PQSchemeRegistry.computeAddress(PQScheme.ML_DSA_44, pq1.getPublicKey()); + setupPermission(owner, Collections.emptyList(), Collections.emptyList(), + 1, Collections.singletonList(pqAddr), Collections.singletonList(1)); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + byte[] truncatedPk = Arrays.copyOf(pq1.getPublicKey(), pq1.getPublicKey().length - 1); + + List pqSigs = Collections.singletonList(Hex.toHexString(pq1.sign(toSign))); + List pqPks = Collections.singletonList(Hex.toHexString(truncatedPk)); + + Assert.assertArrayEquals(DataWord.ZERO().getData(), + runContract(owner.getAddress(), 2, data, + Collections.emptyList(), pqSigs, pqPks).getRight()); + } + + @Test + public void mismatchedPqArrayLengths_returnsZero() { + MLDSA44 pq1 = new MLDSA44(); + MLDSA44 pq2 = new MLDSA44(); + ECKey owner = new ECKey(); + byte[] addr1 = PQSchemeRegistry.computeAddress(PQScheme.ML_DSA_44, pq1.getPublicKey()); + setupPermission(owner, Collections.emptyList(), Collections.emptyList(), + 1, Collections.singletonList(addr1), Collections.singletonList(1)); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + + List pqSigs = Arrays.asList( + Hex.toHexString(pq1.sign(toSign)), + Hex.toHexString(pq2.sign(toSign))); + List pqPks = Collections.singletonList(Hex.toHexString(pq1.getPublicKey())); + + Assert.assertArrayEquals(DataWord.ZERO().getData(), + runContract(owner.getAddress(), 2, data, + Collections.emptyList(), pqSigs, pqPks).getRight()); + } + + @Test + public void totalCountOverMaxSize_returnsZero() { + ECKey owner = new ECKey(); + List ecdsaAddrs = new ArrayList<>(); + List ecdsaWeights = new ArrayList<>(); + List ecdsaKeys = new ArrayList<>(); + for (int i = 0; i < 6; i++) { + ECKey k = new ECKey(); + ecdsaKeys.add(k); + ecdsaAddrs.add(k.getAddress()); + ecdsaWeights.add(1); + } + setupPermission(owner, ecdsaAddrs, ecdsaWeights, 6, + Collections.emptyList(), Collections.emptyList()); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + List ecdsaSigs = new ArrayList<>(); + for (ECKey k : ecdsaKeys) { + ecdsaSigs.add(Hex.toHexString(k.sign(toSign).toByteArray())); + } + + Assert.assertArrayEquals(DataWord.ZERO().getData(), + runContract(owner.getAddress(), 2, data, ecdsaSigs, + Collections.emptyList(), Collections.emptyList()).getRight()); + } + + @Test + public void duplicatePqSig_doesNotDoubleCount() { + MLDSA44 pq1 = new MLDSA44(); + ECKey owner = new ECKey(); + byte[] pqAddr = PQSchemeRegistry.computeAddress(PQScheme.ML_DSA_44, pq1.getPublicKey()); + setupPermission(owner, Collections.emptyList(), Collections.emptyList(), + 2, Collections.singletonList(pqAddr), Collections.singletonList(1)); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + byte[] sig = pq1.sign(toSign); + + List pqSigs = Arrays.asList(Hex.toHexString(sig), Hex.toHexString(sig)); + List pqPks = Arrays.asList( + Hex.toHexString(pq1.getPublicKey()), Hex.toHexString(pq1.getPublicKey())); + + Assert.assertArrayEquals(DataWord.ZERO().getData(), + runContract(owner.getAddress(), 2, data, + Collections.emptyList(), pqSigs, pqPks).getRight()); + } + + @Test + public void energyChargesEcdsaAndPqSeparately() { + MLDSA44 pq1 = new MLDSA44(); + ECKey k1 = new ECKey(); + ECKey owner = new ECKey(); + byte[] pqAddr = PQSchemeRegistry.computeAddress(PQScheme.ML_DSA_44, pq1.getPublicKey()); + setupPermission(owner, Collections.singletonList(k1.getAddress()), Collections.singletonList(1), + 2, Collections.singletonList(pqAddr), Collections.singletonList(1)); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + List ecdsaSigs = Collections.singletonList( + Hex.toHexString(k1.sign(toSign).toByteArray())); + List pqSigs = Collections.singletonList(Hex.toHexString(pq1.sign(toSign))); + List pqPks = Collections.singletonList(Hex.toHexString(pq1.getPublicKey())); + + byte[] input = encodeInput(owner.getAddress(), 2, data, ecdsaSigs, pqSigs, pqPks); + // 1 ECDSA × 1500 + 1 PQ × 4000 = 5500 + Assert.assertEquals(5500L, contract.getEnergyForData(input)); + } + + @Test + public void thresholdNotReached_returnsZero() { + ECKey k1 = new ECKey(); + ECKey k2 = new ECKey(); + ECKey owner = new ECKey(); + setupPermission(owner, Arrays.asList(k1.getAddress(), k2.getAddress()), + Arrays.asList(1, 1), 2, Collections.emptyList(), Collections.emptyList()); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + // Only one valid signature; threshold is 2. + List ecdsaSigs = Collections.singletonList( + Hex.toHexString(k1.sign(toSign).toByteArray())); + + Assert.assertArrayEquals(DataWord.ZERO().getData(), + runContract(owner.getAddress(), 2, data, ecdsaSigs, + Collections.emptyList(), Collections.emptyList()).getRight()); + } + + @Test + public void pqKeyNotInPermission_returnsZero() { + MLDSA44 inPerm = new MLDSA44(); + MLDSA44 outsider = new MLDSA44(); + ECKey owner = new ECKey(); + byte[] inAddr = PQSchemeRegistry.computeAddress(PQScheme.ML_DSA_44, inPerm.getPublicKey()); + setupPermission(owner, Collections.emptyList(), Collections.emptyList(), + 1, Collections.singletonList(inAddr), Collections.singletonList(1)); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + + // Outsider produces a perfectly valid ML-DSA signature, but its derived + // address is not in Permission.keys[] → weight 0 → not counted. + List pqSigs = Collections.singletonList(Hex.toHexString(outsider.sign(toSign))); + List pqPks = Collections.singletonList(Hex.toHexString(outsider.getPublicKey())); + + Assert.assertArrayEquals(DataWord.ZERO().getData(), + runContract(owner.getAddress(), 2, data, + Collections.emptyList(), pqSigs, pqPks).getRight()); + } + + @Test + public void pqSigWrongLength_returnsZero() { + // ML-DSA-44 signatures are fixed-length 2420 B; any other length must be + // rejected before reaching BC's verifier. Differs from FN-DSA-512 (variable). + MLDSA44 pq1 = new MLDSA44(); + ECKey owner = new ECKey(); + byte[] pqAddr = PQSchemeRegistry.computeAddress(PQScheme.ML_DSA_44, pq1.getPublicKey()); + setupPermission(owner, Collections.emptyList(), Collections.emptyList(), + 1, Collections.singletonList(pqAddr), Collections.singletonList(1)); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + + byte[] wrongLen = new byte[MLDSA44.SIGNATURE_LENGTH - 1]; + Arrays.fill(wrongLen, (byte) 0x42); + List pqSigs = Collections.singletonList(Hex.toHexString(wrongLen)); + List pqPks = Collections.singletonList(Hex.toHexString(pq1.getPublicKey())); + + Assert.assertArrayEquals(DataWord.ZERO().getData(), + runContract(owner.getAddress(), 2, data, + Collections.emptyList(), pqSigs, pqPks).getRight()); + } + + @Test + public void bothArraysEmpty_returnsZero() { + ECKey k1 = new ECKey(); + ECKey owner = new ECKey(); + setupPermission(owner, Collections.singletonList(k1.getAddress()), + Collections.singletonList(1), 1, + Collections.emptyList(), Collections.emptyList()); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + + Assert.assertArrayEquals(DataWord.ZERO().getData(), + runContract(owner.getAddress(), 2, data, + Collections.emptyList(), Collections.emptyList(), + Collections.emptyList()).getRight()); + } + + @Test + public void mixedFailingPqAborts_returnsZero() { + // Mirrors 0x09 semantics: a verify failure on any submitted entry aborts + // the whole call with DATA_FALSE — even if other entries would alone meet + // threshold. Verifies 0x1a does not silently skip a forged PQ signature. + ECKey k1 = new ECKey(); + ECKey k2 = new ECKey(); + MLDSA44 pq1 = new MLDSA44(); + ECKey owner = new ECKey(); + byte[] pqAddr = PQSchemeRegistry.computeAddress(PQScheme.ML_DSA_44, pq1.getPublicKey()); + setupPermission(owner, + Arrays.asList(k1.getAddress(), k2.getAddress()), Arrays.asList(1, 1), + 2, Collections.singletonList(pqAddr), Collections.singletonList(1)); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + + List ecdsaSigs = Arrays.asList( + Hex.toHexString(k1.sign(toSign).toByteArray()), + Hex.toHexString(k2.sign(toSign).toByteArray())); + byte[] forged = pq1.sign(toSign); + forged[0] ^= 0x55; + List pqSigs = Collections.singletonList(Hex.toHexString(forged)); + List pqPks = Collections.singletonList(Hex.toHexString(pq1.getPublicKey())); + + Assert.assertArrayEquals(DataWord.ZERO().getData(), + runContract(owner.getAddress(), 2, data, ecdsaSigs, pqSigs, pqPks).getRight()); + } + + // -------- helpers -------- + + private void setupPermission(ECKey owner, + List ecdsaKeyAddrs, List ecdsaWeights, + int threshold, + List pqKeyAddrs, List pqWeights) { + AccountCapsule account = new AccountCapsule(ByteString.copyFrom(owner.getAddress()), + Protocol.AccountType.Normal, System.currentTimeMillis(), true, + dbManager.getDynamicPropertiesStore()); + + Protocol.Permission.Builder perm = Protocol.Permission.newBuilder() + .setType(Protocol.Permission.PermissionType.Active) + .setId(2) + .setPermissionName("active") + .setThreshold(threshold) + .setOperations(ByteString.copyFrom(ByteArray.fromHexString( + "0000000000000000000000000000000000000000000000000000000000000000"))); + for (int i = 0; i < ecdsaKeyAddrs.size(); i++) { + perm.addKeys(Protocol.Key.newBuilder() + .setAddress(ByteString.copyFrom(ecdsaKeyAddrs.get(i))) + .setWeight(ecdsaWeights.get(i)).build()); + } + for (int i = 0; i < pqKeyAddrs.size(); i++) { + perm.addKeys(Protocol.Key.newBuilder() + .setAddress(ByteString.copyFrom(pqKeyAddrs.get(i))) + .setWeight(pqWeights.get(i)).build()); + } + account.updatePermissions(account.getPermissionById(0), null, + Collections.singletonList(perm.build())); + dbManager.getAccountStore().put(owner.getAddress(), account); + } + + private byte[] computeHash(byte[] address, int permissionId, byte[] data) { + byte[] combined = ByteUtil.merge(address, ByteArray.fromInt(permissionId), data); + return Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), combined); + } + + private byte[] encodeInput(byte[] ownerAddr, int permissionId, byte[] data, + List ecdsaSigs, List pqSigs, List pqPks) { + List parameters = Arrays.asList( + StringUtil.encode58Check(ownerAddr), + permissionId, + "0x" + Hex.toHexString(data), + toHexList(ecdsaSigs), + toHexList(pqSigs), + toHexList(pqPks)); + return Hex.decode(AbiUtil.parseParameters(METHOD_SIGN, parameters)); + } + + private Pair runContract(byte[] ownerAddr, int permissionId, byte[] data, + List ecdsaSigs, List pqSigs, + List pqPks) { + byte[] input = encodeInput(ownerAddr, permissionId, data, ecdsaSigs, pqSigs, pqPks); + Repository deposit = RepositoryImpl.createRoot(StoreFactory.getInstance()); + contract.setRepository(deposit); + Pair ret = contract.execute(input); + logger.info("0x1a result: {}", Hex.toHexString(ret.getRight())); + return ret; + } + + private static List toHexList(List hexes) { + List out = new ArrayList<>(hexes.size()); + for (String h : hexes) { + out.add(h.startsWith("0x") ? h : ("0x" + h)); + } + return out; + } +} From d30f94cb420b62a173497bb1e181f54a2fb87b1f Mon Sep 17 00:00:00 2001 From: federico Date: Fri, 22 May 2026 18:24:13 +0800 Subject: [PATCH 24/47] test(crypto): add ml-dsa-44 kat regression vectors --- .../common/crypto/pqc/MLDSA44KatTest.java | 244 ++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 framework/src/test/java/org/tron/common/crypto/pqc/MLDSA44KatTest.java diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/MLDSA44KatTest.java b/framework/src/test/java/org/tron/common/crypto/pqc/MLDSA44KatTest.java new file mode 100644 index 00000000000..8d88546d0d1 --- /dev/null +++ b/framework/src/test/java/org/tron/common/crypto/pqc/MLDSA44KatTest.java @@ -0,0 +1,244 @@ +package org.tron.common.crypto.pqc; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +import java.security.MessageDigest; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; +import org.junit.Test; +import org.tron.common.crypto.Hash; +import org.tron.protos.Protocol.PQScheme; + +/** + * Known-Answer Tests (KAT) for ML-DSA-44 / FIPS 204 / Dilithium-2. + * + *

Five seed vectors covering boundary patterns (incrementing, all-zero, + * all-ones, all-{@code 0xAA}, descending) lock in the deterministic + * seed → keypair derivation pinned by BouncyCastle 1.84's + * {@code MLDSAKeyPairGenerator}. Reference {@code pk}/{@code sk} digests and + * the V2 fingerprint address are captured from this same codebase / BC 1.84; + * the role of the test is regression detection — any change in seeding, + * encoding, or fingerprint derivation lights up. + * + *

ML-DSA signing is randomized (hedged) so signature bytes cannot be pinned. + * Sign / verify is exercised per-vector and cross-vector to confirm signatures + * only verify under their own key. + */ +public class MLDSA44KatTest { + + private static final class KatVector { + final String label; + final byte[] seed; + final String pkSha256; + final String skSha256; + + KatVector(String label, byte[] seed, String pkSha256, String skSha256) { + this.label = label; + this.seed = seed; + this.pkSha256 = pkSha256; + this.skSha256 = skSha256; + } + } + + private static byte[] seedIncrementing() { + byte[] s = new byte[MLDSA44.SEED_LENGTH]; + for (int i = 0; i < s.length; i++) { + s[i] = (byte) i; + } + return s; + } + + private static byte[] seedDescending() { + byte[] s = new byte[MLDSA44.SEED_LENGTH]; + for (int i = 0; i < s.length; i++) { + s[i] = (byte) (MLDSA44.SEED_LENGTH - 1 - i); + } + return s; + } + + private static byte[] seedFilled(int b) { + byte[] s = new byte[MLDSA44.SEED_LENGTH]; + Arrays.fill(s, (byte) b); + return s; + } + + private static final KatVector[] VECTORS = { + new KatVector("incrementing", seedIncrementing(), + "9f107644c1084526af3bc8098680b05499a2325a644e388fb4f970e058d19d46", + "04bf6b9f579166a627961dfc5c3bf9717df868db88863856356c4668c8b56b0b"), + new KatVector("all_zero", seedFilled(0x00), + "eb4e7302842153b0fa19e8620739ad258af4929c26dd89079a7ec7d4282208e1", + "0f9086044d77b6d610c7e92418d9f70a398c69febc7e99f8254aaea98dcfbe77"), + new KatVector("all_ff", seedFilled(0xff), + "62c4f1b3164db7fa896a3343e900eb3e13c9f76de122020feba37ee063d49ef0", + "6433074c5ffc9e0f2b1d68bb3fda84e439da0a2d93f508a101e9b44835f0b22c"), + new KatVector("all_aa", seedFilled(0xaa), + "ad4aff7ef5aa8895fb4f59c2c211afe55419d0d8709bfa0ee4d8f496e92600a7", + "d976fecd6cda24ca928a43e2bcd3eb53e6dfb24a759333f818f6496abc27feb5"), + new KatVector("descending", seedDescending(), + "4b002454d4516328cb1bf3667959879140dc9e6b3f405e985f707dd49918c818", + "1d144d5f05beb34beb1b909ecd469e0484f485a3c68db6e27da464418f7d69ea"), + }; + + private static byte[] sha256(byte[] in) { + try { + return MessageDigest.getInstance("SHA-256").digest(in); + } catch (Exception e) { + throw new AssertionError("SHA-256 unavailable", e); + } + } + + private static String hex(byte[] b) { + StringBuilder sb = new StringBuilder(b.length * 2); + for (byte x : b) { + sb.append(String.format("%02x", x)); + } + return sb.toString(); + } + + @Test + public void allVectorsDeriveExpectedPublicAndPrivateKey() { + for (KatVector v : VECTORS) { + MLDSA44 k = new MLDSA44(v.seed); + assertEquals(v.label + ": pk length", + MLDSA44.PUBLIC_KEY_LENGTH, k.getPublicKey().length); + assertEquals(v.label + ": sk length", + MLDSA44.PRIVATE_KEY_LENGTH, k.getPrivateKey().length); + assertEquals(v.label + ": pk SHA-256 must match KAT vector", + v.pkSha256, hex(sha256(k.getPublicKey()))); + assertEquals(v.label + ": sk SHA-256 must match KAT vector", + v.skSha256, hex(sha256(k.getPrivateKey()))); + } + } + + @Test + public void allVectorsDeriveExpectedAddress() { + for (KatVector v : VECTORS) { + MLDSA44 k = new MLDSA44(v.seed); + byte[] addr = k.getAddress(); + assertEquals(v.label + ": address length", + 21, addr.length); + + byte[] viaRegistry = + PQSchemeRegistry.computeAddress(PQScheme.ML_DSA_44, k.getPublicKey()); + assertArrayEquals(v.label + ": registry dispatch must match instance", + addr, viaRegistry); + } + } + + @Test + public void addressIsExactly0x41PlusKeccak256RightmostBytesOfPublicKey() { + for (KatVector v : VECTORS) { + MLDSA44 k = new MLDSA44(v.seed); + byte[] pk = k.getPublicKey(); + byte[] hash = Hash.sha3(pk); + byte[] expected = new byte[21]; + expected[0] = 0x41; + System.arraycopy(hash, hash.length - 20, expected, 1, 20); + assertArrayEquals(v.label + ": address must be 0x41 ‖ Keccak-256(pk)[12..32]", + expected, k.getAddress()); + } + } + + @Test + public void allVectorsAreReproducibleAcrossInstances() { + for (KatVector v : VECTORS) { + MLDSA44 a = new MLDSA44(v.seed); + MLDSA44 b = new MLDSA44(v.seed); + assertArrayEquals(v.label + ": pk reproducible", a.getPublicKey(), b.getPublicKey()); + assertArrayEquals(v.label + ": sk reproducible", a.getPrivateKey(), b.getPrivateKey()); + assertArrayEquals(v.label + ": addr reproducible", a.getAddress(), b.getAddress()); + } + } + + @Test + public void distinctSeedsProduceDistinctKeysAndAddresses() { + Set pkDigests = new HashSet<>(); + Set skDigests = new HashSet<>(); + Set addresses = new HashSet<>(); + for (KatVector v : VECTORS) { + pkDigests.add(v.pkSha256); + skDigests.add(v.skSha256); + addresses.add(hex(new MLDSA44(v.seed).getAddress())); + } + assertEquals("KAT pk digests must be pairwise distinct", + VECTORS.length, pkDigests.size()); + assertEquals("KAT sk digests must be pairwise distinct", + VECTORS.length, skDigests.size()); + assertEquals("KAT addresses must be pairwise distinct", + VECTORS.length, addresses.size()); + } + + @Test + public void signaturesFromKatKeysVerifyUnderTheirOwnPublicKey() { + byte[][] messages = { + new byte[0], + "x".getBytes(), + "tron-ml-dsa-kat-message".getBytes(), + new byte[1024], + }; + for (KatVector v : VECTORS) { + MLDSA44 k = new MLDSA44(v.seed); + for (byte[] msg : messages) { + byte[] sig = k.sign(msg); + assertEquals(v.label + ": signature must be fixed 2420 bytes", + MLDSA44.SIGNATURE_LENGTH, sig.length); + assertTrue(v.label + ": signature must verify under its own pk", + MLDSA44.verify(k.getPublicKey(), msg, sig)); + assertTrue(v.label + ": registry verify must accept own signature", + PQSchemeRegistry.verify( + PQScheme.ML_DSA_44, k.getPublicKey(), msg, sig)); + } + } + } + + @Test + public void signatureFromVectorAFailsUnderVectorBPublicKey() { + byte[] msg = "tron-ml-dsa-kat-cross".getBytes(); + MLDSA44[] keys = new MLDSA44[VECTORS.length]; + byte[][] sigs = new byte[VECTORS.length][]; + for (int i = 0; i < VECTORS.length; i++) { + keys[i] = new MLDSA44(VECTORS[i].seed); + sigs[i] = keys[i].sign(msg); + } + for (int i = 0; i < VECTORS.length; i++) { + for (int j = 0; j < VECTORS.length; j++) { + if (i == j) { + assertTrue(VECTORS[i].label + ": self-verify must succeed", + MLDSA44.verify(keys[i].getPublicKey(), msg, sigs[i])); + } else { + assertFalse("signature from " + VECTORS[i].label + + " must NOT verify under " + VECTORS[j].label, + MLDSA44.verify(keys[j].getPublicKey(), msg, sigs[i])); + } + } + } + } + + @Test + public void distinctSeedsAtRuntimeAlsoProduceDistinctRuntimePublicKeys() { + // Belt-and-braces: the sanity check above only compared hard-coded digests. + // Re-derive at runtime and confirm they're still pairwise distinct. + byte[][] pks = new byte[VECTORS.length][]; + for (int i = 0; i < VECTORS.length; i++) { + pks[i] = new MLDSA44(VECTORS[i].seed).getPublicKey(); + } + for (int i = 0; i < VECTORS.length; i++) { + for (int j = i + 1; j < VECTORS.length; j++) { + assertFalse( + VECTORS[i].label + " and " + VECTORS[j].label + + " produced identical pk bytes", + Arrays.equals(pks[i], pks[j])); + assertNotEquals( + VECTORS[i].label + " and " + VECTORS[j].label + + " produced identical pk digests", + hex(sha256(pks[i])), hex(sha256(pks[j]))); + } + } + } +} From 1f821afba758e3b7f6df0fc926f032abe4122198 Mon Sep 17 00:00:00 2001 From: federico Date: Sat, 23 May 2026 23:45:58 +0800 Subject: [PATCH 25/47] fix(block): tighten hasWitnessSignature gate --- .../org/tron/core/capsule/BlockCapsule.java | 12 ++++++++++-- .../tron/core/capsule/BlockCapsulePQTest.java | 17 +++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java b/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java index ad0e90662cc..8377ba73d9d 100755 --- a/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java +++ b/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java @@ -215,7 +215,7 @@ public boolean validateSignature(DynamicPropertiesStore dynamicPropertiesStore, if (dynamicPropertiesStore.isAnyPqSchemeAllowed()) { boolean hasLegacy = !header.getWitnessSignature().isEmpty(); boolean hasPq = header.hasPqAuthSig(); - if (hasLegacy && hasLegacy) { + if (hasLegacy && hasPq) { throw new ValidateSignatureException( "witness_signature and pq_auth_sig are mutually exclusive"); } @@ -405,7 +405,15 @@ public boolean hasWitnessSignature(DynamicPropertiesStore dynamicPropertiesStore if (!dynamicPropertiesStore.isAnyPqSchemeAllowed()) { return hasLegacySignature; } - return hasLegacySignature || !header.getPqAuthSig().getSignature().isEmpty(); + if (hasLegacySignature) { + return true; + } + PQAuthSig pqSig = header.getPqAuthSig(); + PQScheme scheme = pqSig.getScheme(); + if (!PQSchemeRegistry.contains(scheme)) { + return false; + } + return PQSchemeRegistry.isValidSignatureLength(scheme, pqSig.getSignature().size()); } @Override diff --git a/framework/src/test/java/org/tron/core/capsule/BlockCapsulePQTest.java b/framework/src/test/java/org/tron/core/capsule/BlockCapsulePQTest.java index e353706a916..32d3841be5e 100644 --- a/framework/src/test/java/org/tron/core/capsule/BlockCapsulePQTest.java +++ b/framework/src/test/java/org/tron/core/capsule/BlockCapsulePQTest.java @@ -1,6 +1,7 @@ package org.tron.core.capsule; import com.google.protobuf.ByteString; +import org.junit.After; import org.junit.Assert; import org.junit.Before; import org.junit.BeforeClass; @@ -45,6 +46,18 @@ public void setUp() { PQScheme.FN_DSA_512, pqKeypair.getPublicKey()); } + /** + * Reset every PQ-scheme activation flag. Without this, a test that flips + * {@code allowFnDsa512} or {@code allowMlDsa44} on leaks the bit into the + * next test's {@code isAnyPqSchemeAllowed()} check — which is how the + * legacy-only "before activation" cases became order-dependent. + */ + @After + public void resetPqFlags() { + dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(0L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa44(0L); + } + /** * Build a witness account whose witness permission key is bound to the * given address. For PQ scenarios, pass {@link #pqAddress}; for legacy ECDSA @@ -135,6 +148,10 @@ public void legacyValidateWithoutPQAuthSigAcceptedBeforeActivation() throws Exce @Test(expected = ValidateSignatureException.class) public void pqAuthSigBeforeActivationRejected() throws Exception { dbManager.getDynamicPropertiesStore().saveAllowMultiSign(1L); + // Keep the PQ surface on (mlDsa44=1) so validateSignature enters the PQ + // branch, but leave fnDsa512=0 — this is the per-scheme activation gate + // we expect to reject the block at. + dbManager.getDynamicPropertiesStore().saveAllowMlDsa44(1L); dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(0L); AccountCapsule witness = buildWitnessAccount(pqAddress); dbManager.getAccountStore().put(witnessAddress, witness); From 3b53c37b466d734be56692cdafad6f7092e1df03 Mon Sep 17 00:00:00 2001 From: federico Date: Sun, 24 May 2026 01:20:09 +0800 Subject: [PATCH 26/47] feat(crypto): support pq witness signatures --- .../org/tron/common/utils/LocalWitnesses.java | 39 ++-- .../org/tron/core/capsule/BlockCapsule.java | 45 ++-- .../tron/core/capsule/TransactionCapsule.java | 7 +- .../org/tron/common/crypto/pqc/FNDSA512.java | 8 +- .../org/tron/common/crypto/pqc/MLDSA44.java | 27 +-- .../common/crypto/pqc/PQSchemeRegistry.java | 20 +- .../org/tron/common/crypto/pqc/PqKeypair.java | 9 +- .../src/main/java/org/tron/core/Wallet.java | 5 + .../java/org/tron/core/config/args/Args.java | 217 ++++++++++++------ .../org/tron/core/config/args/ConfigKey.java | 2 - .../core/config/args/WitnessInitializer.java | 17 +- .../tron/core/consensus/ConsensusService.java | 22 +- .../main/java/org/tron/core/db/Manager.java | 4 +- .../core/net/service/relay/RelayService.java | 2 +- framework/src/main/resources/config.conf | 34 ++- .../tron/common/crypto/pqc/FNDSA512Test.java | 2 +- .../crypto/pqc/PQSchemeRegistryTest.java | 9 + .../crypto/pqc/program/PQWitnessNode.java | 12 +- .../tron/common/utils/LocalWitnessesTest.java | 38 +-- .../core/config/args/ArgsPqConfigTest.java | 168 ++++++++++++++ .../core/net/services/RelayServiceTest.java | 9 +- framework/src/test/resources/config-test.conf | 1 - 22 files changed, 497 insertions(+), 200 deletions(-) create mode 100644 framework/src/test/java/org/tron/core/config/args/ArgsPqConfigTest.java diff --git a/chainbase/src/main/java/org/tron/common/utils/LocalWitnesses.java b/chainbase/src/main/java/org/tron/common/utils/LocalWitnesses.java index 3abc43cb71d..bfc9ddf1c38 100644 --- a/chainbase/src/main/java/org/tron/common/utils/LocalWitnesses.java +++ b/chainbase/src/main/java/org/tron/common/utils/LocalWitnesses.java @@ -38,10 +38,11 @@ public class LocalWitnesses { private List privateKeys = Lists.newArrayList(); /** - * Pre-derived PQ keypairs (private + public, hex), one per witness. The - * expected byte lengths depend on {@link #pqScheme}: for FN-DSA-512 each - * private key is 1280 bytes (2560 hex chars) and each public key is 896 - * bytes (1792 hex chars). + * Pre-derived PQ keypairs (scheme + private + public, hex), one per witness. + * Each keypair declares its own PQ scheme so a single node can host SRs + * running different PQ algorithms (e.g. some Falcon-512, some ML-DSA-44). + * Expected byte lengths depend on the keypair's scheme: FN-DSA-512 uses a + * 1280-byte private key (2560 hex) and 896-byte public key (1792 hex). * *

Configured directly (rather than derived from a seed on the node) so * the runtime path is not exposed to potential cross-platform floating-point @@ -51,18 +52,6 @@ public class LocalWitnesses { @Getter private List pqKeypairs = Lists.newArrayList(); - /** PQ signature scheme used by the configured {@link #pqKeypairs}. */ - @Getter - private PQScheme pqScheme = PQScheme.FN_DSA_512; - - public void setPqScheme(PQScheme pqScheme) { - if (pqScheme == null || !PQSchemeRegistry.contains(pqScheme)) { - throw new TronError("unsupported PQ signature scheme: " + pqScheme, - TronError.ErrCode.WITNESS_INIT); - } - this.pqScheme = pqScheme; - } - @Setter @Getter private byte[] witnessAccountAddress; @@ -127,19 +116,23 @@ public void addPrivateKeys(String privateKey) { } /** - * Pre-derived PQ keypairs (priv + pub) used as signing keys under - * {@link #pqScheme}. Each entry's private/public hex byte length must match - * the scheme's required size. Callers must therefore set the scheme via - * {@link #setPqScheme(PQScheme)} before calling this method when targeting a - * non-default scheme. + * Pre-derived PQ keypairs (scheme + priv + pub) used as signing keys. Each + * entry's scheme must be registered and its private/public hex byte lengths + * must match that scheme's required sizes; the scheme is per-entry so + * different witnesses on the same node can use different PQ algorithms. */ public void setPqKeypairs(final List pqKeypairs) { if (CollectionUtils.isEmpty(pqKeypairs)) { return; } - int expectedPrivLen = PQSchemeRegistry.getPrivateKeyLength(pqScheme); - int expectedPubLen = PQSchemeRegistry.getPublicKeyLength(pqScheme); for (PqKeypair kp : pqKeypairs) { + PQScheme scheme = kp.getScheme(); + if (scheme == null || !PQSchemeRegistry.contains(scheme)) { + throw new TronError("unsupported PQ signature scheme: " + scheme, + TronError.ErrCode.WITNESS_INIT); + } + int expectedPrivLen = PQSchemeRegistry.getPrivateKeyLength(scheme); + int expectedPubLen = PQSchemeRegistry.getPublicKeyLength(scheme); validatePqKey(kp.getPrivateKey(), expectedPrivLen, "PQ private key"); validatePqKey(kp.getPublicKey(), expectedPubLen, "PQ public key"); } diff --git a/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java b/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java index 8377ba73d9d..4ffd08b81b9 100755 --- a/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java +++ b/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java @@ -212,20 +212,33 @@ public boolean validateSignature(DynamicPropertiesStore dynamicPropertiesStore, .getWitnessPermissionAddress(); } - if (dynamicPropertiesStore.isAnyPqSchemeAllowed()) { - boolean hasLegacy = !header.getWitnessSignature().isEmpty(); - boolean hasPq = header.hasPqAuthSig(); - if (hasLegacy && hasPq) { + boolean hasLegacy = !header.getWitnessSignature().isEmpty(); + boolean hasPq = header.hasPqAuthSig(); + + if (!dynamicPropertiesStore.isAnyPqSchemeAllowed()) { + if (hasPq) { throw new ValidateSignatureException( - "witness_signature and pq_auth_sig are mutually exclusive"); + "pq_auth_sig not allowed: no post-quantum scheme is activated"); } - if (!hasLegacy && !hasPq) { - throw new ValidateSignatureException("missing witness signature"); - } - return validatePQSignature(dynamicPropertiesStore, accountStore, witnessPermissionAddress, + return validateLegacySignature(header, witnessPermissionAddress); + } + + if (hasLegacy && hasPq) { + throw new ValidateSignatureException( + "witness_signature and pq_auth_sig are mutually exclusive"); + } + if (!hasLegacy && !hasPq) { + throw new ValidateSignatureException("missing witness signature"); + } + if (hasPq) { + return validatePQSignature(dynamicPropertiesStore, witnessPermissionAddress, header.getPqAuthSig()); } + return validateLegacySignature(header, witnessPermissionAddress); + } + private boolean validateLegacySignature(BlockHeader header, byte[] witnessPermissionAddress) + throws ValidateSignatureException { try { byte[] sigAddress = SignUtils.signatureToAddress(getRawHash().getBytes(), TransactionCapsule.getBase64FromByteString(header.getWitnessSignature()), @@ -243,7 +256,7 @@ public boolean validateSignature(DynamicPropertiesStore dynamicPropertiesStore, * the witness account's Witness Permission keys[]. */ private boolean validatePQSignature(DynamicPropertiesStore dynamicPropertiesStore, - AccountStore accountStore, byte[] witnessPermissionAddress, PQAuthSig pqAuthSig) + byte[] witnessPermissionAddress, PQAuthSig pqAuthSig) throws ValidateSignatureException { /* Verify the PQ scheme is supported and proposal opened @@ -401,19 +414,13 @@ public long getTimeStamp() { public boolean hasWitnessSignature(DynamicPropertiesStore dynamicPropertiesStore) { BlockHeader header = getInstance().getBlockHeader(); - boolean hasLegacySignature = !header.getWitnessSignature().isEmpty(); - if (!dynamicPropertiesStore.isAnyPqSchemeAllowed()) { - return hasLegacySignature; - } - if (hasLegacySignature) { + if (!header.getWitnessSignature().isEmpty()) { return true; } - PQAuthSig pqSig = header.getPqAuthSig(); - PQScheme scheme = pqSig.getScheme(); - if (!PQSchemeRegistry.contains(scheme)) { + if (!dynamicPropertiesStore.isAnyPqSchemeAllowed()) { return false; } - return PQSchemeRegistry.isValidSignatureLength(scheme, pqSig.getSignature().size()); + return !header.getPqAuthSig().getSignature().isEmpty(); } @Override diff --git a/chainbase/src/main/java/org/tron/core/capsule/TransactionCapsule.java b/chainbase/src/main/java/org/tron/core/capsule/TransactionCapsule.java index 23bfc6ba649..ecf14e10dee 100755 --- a/chainbase/src/main/java/org/tron/core/capsule/TransactionCapsule.java +++ b/chainbase/src/main/java/org/tron/core/capsule/TransactionCapsule.java @@ -667,8 +667,11 @@ public boolean validatePubSignature(AccountStore accountStore, "pq_auth_sig not allowed: no post-quantum scheme is activated"); } - if (signatureCount == 0 || this.transaction.getRawData().getContractCount() <= 0) { - throw new ValidateSignatureException("miss sig or contract"); + if (signatureCount == 0) { + throw new ValidateSignatureException("miss sig"); + } + if (this.transaction.getRawData().getContractCount() <= 0) { + throw new ValidateSignatureException("miss contract"); } if (signatureCount > dynamicPropertiesStore.getTotalSignNum()) { throw new ValidateSignatureException("too many signatures"); diff --git a/crypto/src/main/java/org/tron/common/crypto/pqc/FNDSA512.java b/crypto/src/main/java/org/tron/common/crypto/pqc/FNDSA512.java index 080eaa3a8c3..66495b35630 100644 --- a/crypto/src/main/java/org/tron/common/crypto/pqc/FNDSA512.java +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/FNDSA512.java @@ -55,6 +55,7 @@ public final class FNDSA512 implements PQSignature { public static final int SEED_LENGTH = 48; private static final FalconParameters PARAMS = FalconParameters.falcon_512; + private static final SecureRandom SIGNING_RNG = new SecureRandom(); private final byte[] privateKey; private final byte[] publicKey; @@ -220,7 +221,7 @@ public static byte[] sign(byte[] privateKey, byte[] message) { System.arraycopy(privateKey, f.length + g.length, bigF, 0, bigF.length); FalconPrivateKeyParameters sk = new FalconPrivateKeyParameters(PARAMS, f, g, bigF, new byte[0]); FalconSigner signer = new FalconSigner(); - signer.init(true, new ParametersWithRandom(sk, new SecureRandom())); + signer.init(true, new ParametersWithRandom(sk, SIGNING_RNG)); try { return signer.generateSignature(message); } catch (Exception e) { @@ -238,7 +239,10 @@ public static byte[] sign(byte[] privateKey, byte[] message) { * See bcgit/bc-java#2297. */ public static byte[] derivePublicKey(byte[] privateKey) { - if (privateKey != null && privateKey.length == PRIVATE_KEY_WITH_PUBLIC_KEY_LENGTH) { + if (privateKey == null) { + throw new IllegalArgumentException("privateKey must not be null"); + } + if (privateKey.length == PRIVATE_KEY_WITH_PUBLIC_KEY_LENGTH) { byte[] pk = new byte[PUBLIC_KEY_LENGTH]; System.arraycopy(privateKey, PRIVATE_KEY_LENGTH, pk, 0, PUBLIC_KEY_LENGTH); return pk; diff --git a/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA44.java b/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA44.java index e4825ec0863..a3eb8c1cff0 100644 --- a/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA44.java +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA44.java @@ -1,6 +1,6 @@ package org.tron.common.crypto.pqc; -import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; import java.security.SecureRandom; import org.bouncycastle.crypto.AsymmetricCipherKeyPair; import org.bouncycastle.crypto.params.ParametersWithRandom; @@ -212,27 +212,20 @@ private static AsymmetricCipherKeyPair generateKeyPair(SecureRandom random) { } /** - * Domain-separated probe used by {@link #requireConsistent}; not a security - * boundary (ML-DSA hashes the message internally), the constant just makes the - * keypair self-check searchable in logs/stack traces. - */ - private static final byte[] CONSISTENCY_PROBE = - "tron:ML-DSA-44:keypair-consistency-probe".getBytes(StandardCharsets.UTF_8); - - /** - * Probe that the supplied (sk, pk) actually form a keypair. Sign and verify - * a fixed probe message; runs once per witness load and costs a few ms on - * ML-DSA-44 — acceptable for a startup-time misconfiguration check, and - * avoids advertising an address that signatures will never satisfy. + * Probe that the supplied (sk, pk) actually form a keypair. ML-DSA's + * expanded private key already carries everything needed to reproduce the + * canonical public encoding {@code rho ‖ t1}, so we derive {@code pk} from + * {@code sk} and compare bytes — cheaper and more precise than a + * sign+verify roundtrip, and free of the RNG path used by signing. */ private static void requireConsistent(byte[] privateKey, byte[] publicKey) { - byte[] sig; + byte[] derived; try { - sig = sign(privateKey, CONSISTENCY_PROBE); + derived = derivePublicKey(privateKey); } catch (RuntimeException e) { - throw new IllegalArgumentException("ML-DSA private/public key mismatch", e); + throw new IllegalArgumentException("ML-DSA private key is malformed", e); } - if (!verify(publicKey, CONSISTENCY_PROBE, sig)) { + if (!MessageDigest.isEqual(derived, publicKey)) { throw new IllegalArgumentException("ML-DSA private/public key mismatch"); } } diff --git a/crypto/src/main/java/org/tron/common/crypto/pqc/PQSchemeRegistry.java b/crypto/src/main/java/org/tron/common/crypto/pqc/PQSchemeRegistry.java index 439a453cf5d..9eda9b2af7b 100644 --- a/crypto/src/main/java/org/tron/common/crypto/pqc/PQSchemeRegistry.java +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/PQSchemeRegistry.java @@ -59,15 +59,21 @@ private static final class SchemeInfo { final int publicKeyLength; final int signatureLength; final int seedLength; + // Whether seed -> (priv, pub) derivation is bit-for-bit reproducible + // across platforms. Falcon's reference keygen uses FFT and is not stable + // across JVMs/architectures, so operators must persist the expanded + // priv‖pub rather than a seed. + final boolean seedDeterministic; final FingerprintHash hash; final SignatureOps ops; SchemeInfo(int privateKeyLength, int publicKeyLength, int signatureLength, - int seedLength, FingerprintHash hash, SignatureOps ops) { + int seedLength, boolean seedDeterministic, FingerprintHash hash, SignatureOps ops) { this.privateKeyLength = privateKeyLength; this.publicKeyLength = publicKeyLength; this.signatureLength = signatureLength; this.seedLength = seedLength; + this.seedDeterministic = seedDeterministic; this.hash = hash; this.ops = ops; } @@ -80,6 +86,7 @@ private static final class SchemeInfo { m.put(PQScheme.FN_DSA_512, new SchemeInfo( FNDSA512.PRIVATE_KEY_LENGTH, FNDSA512.PUBLIC_KEY_LENGTH, FNDSA512.SIGNATURE_LENGTH, FNDSA512.SEED_LENGTH, + false, // Falcon keygen is FFT-based, not bit-stable across platforms. KECCAK_256, new SignatureOps() { @Override @@ -105,6 +112,7 @@ public PQSignature fromKeypair(byte[] privateKey, byte[] publicKey) { m.put(PQScheme.ML_DSA_44, new SchemeInfo( MLDSA44.PRIVATE_KEY_LENGTH, MLDSA44.PUBLIC_KEY_LENGTH, MLDSA44.SIGNATURE_LENGTH, MLDSA44.SEED_LENGTH, + true, // FIPS-204 keygen is pure integer arithmetic and reproducible. KECCAK_256, new SignatureOps() { @Override @@ -177,6 +185,16 @@ public static int getSeedLength(PQScheme scheme) { return require(scheme).seedLength; } + /** + * Whether seed -> keypair derivation is bit-for-bit reproducible across + * platforms. Operators may safely persist a seed (instead of the expanded + * priv‖pub) only when this is {@code true}; otherwise different JVMs / + * architectures may derive divergent private keys from the same seed. + */ + public static boolean isSeedDeterministic(PQScheme scheme) { + return require(scheme).seedDeterministic; + } + /** * Per-scheme signature-length predicate. Fixed-length schemes require exact * equality with {@link #getSignatureLength(PQScheme)}; variable-length diff --git a/crypto/src/main/java/org/tron/common/crypto/pqc/PqKeypair.java b/crypto/src/main/java/org/tron/common/crypto/pqc/PqKeypair.java index d10d6948396..4d6472e8c5f 100644 --- a/crypto/src/main/java/org/tron/common/crypto/pqc/PqKeypair.java +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/PqKeypair.java @@ -2,17 +2,20 @@ import lombok.ToString; import lombok.Value; +import org.tron.protos.Protocol.PQScheme; /** - * Immutable hex-encoded post-quantum keypair (private + public key). Bundles - * the two halves so the public/private lists can no longer drift out of - * index-alignment by construction. + * Immutable hex-encoded post-quantum keypair (scheme + private + public key). + * Bundles the three together so each witness key can declare its own PQ scheme, + * supporting a node that hosts SRs under different PQ algorithms (e.g. Falcon-512 + * and ML-DSA-44 side by side). * *

{@code privateKey} is excluded from {@link #toString()} to prevent * accidental leakage of secret-key material into logs. */ @Value public class PqKeypair { + PQScheme scheme; @ToString.Exclude String privateKey; String publicKey; diff --git a/framework/src/main/java/org/tron/core/Wallet.java b/framework/src/main/java/org/tron/core/Wallet.java index 3f8766f8ac4..775875acdae 100755 --- a/framework/src/main/java/org/tron/core/Wallet.java +++ b/framework/src/main/java/org/tron/core/Wallet.java @@ -1500,6 +1500,11 @@ public Protocol.ChainParameters getChainParameters() { .setValue(dbManager.getDynamicPropertiesStore().getAllowFnDsa512()) .build()); + builder.addChainParameter(Protocol.ChainParameters.ChainParameter.newBuilder() + .setKey("getAllowMlDsa44") + .setValue(dbManager.getDynamicPropertiesStore().getAllowMlDsa44()) + .build()); + return builder.build(); } diff --git a/framework/src/main/java/org/tron/core/config/args/Args.java b/framework/src/main/java/org/tron/core/config/args/Args.java index e76bfffee80..1e2681880d7 100644 --- a/framework/src/main/java/org/tron/core/config/args/Args.java +++ b/framework/src/main/java/org/tron/core/config/args/Args.java @@ -21,7 +21,6 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; -import java.util.EnumSet; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; @@ -37,6 +36,7 @@ import lombok.Setter; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; +import org.bouncycastle.util.encoders.Hex; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.tron.common.arch.Arch; @@ -45,6 +45,7 @@ import org.tron.common.args.Witness; import org.tron.common.cron.CronExpression; import org.tron.common.crypto.pqc.PQSchemeRegistry; +import org.tron.common.crypto.pqc.PQSignature; import org.tron.common.crypto.pqc.PqKeypair; import org.tron.common.logsfilter.EventPluginConfig; import org.tron.common.logsfilter.FilterQuery; @@ -930,9 +931,6 @@ private static void applyCLIParams(CLIParameter cmd, JCommander jc) { } } - private static final EnumSet WITNESS_PQ_SCHEMES = EnumSet.of( - PQScheme.FN_DSA_512, PQScheme.ML_DSA_44); - private static void initLocalWitnesses(Config config, CLIParameter cmd) { // not a witness node, skip if (!PARAMETER.isWitness()) { @@ -945,88 +943,165 @@ private static void initLocalWitnesses(Config config, CLIParameter cmd) { boolean hasCfgPriv = !lwConfig.getPrivateKeys().isEmpty(); boolean hasKeystore = !lwConfig.getKeystores().isEmpty(); boolean hasPqKeys = config.hasPath(ConfigKey.LOCAL_WITNESS_PQ_KEYS) - && !config.getStringList(ConfigKey.LOCAL_WITNESS_PQ_KEYS).isEmpty(); - if (hasPqKeys && (hasCliPriv || hasCfgPriv || hasKeystore)) { - throw new TronError( - "legacy witness keys (CLI --private-key, localwitness, localwitnesskeystore) " - + "and " + ConfigKey.LOCAL_WITNESS_PQ_KEYS + " are mutually exclusive", - TronError.ErrCode.WITNESS_INIT); - } + && !config.getConfigList(ConfigKey.LOCAL_WITNESS_PQ_KEYS).isEmpty(); - // path 1: CLI --private-key + // Load the ECDSA source. CLI > config localwitness > keystore — the three + // legacy sources stay mutually exclusive among themselves. + LocalWitnesses ecdsaWitnesses = null; if (hasCliPriv) { - localWitnesses = WitnessInitializer.initFromCLIPrivateKey( + ecdsaWitnesses = WitnessInitializer.initFromCLIPrivateKey( cmd.privateKey, cmd.witnessAddress); - return; + } else if (hasCfgPriv) { + ecdsaWitnesses = WitnessInitializer.initFromCFGPrivateKey( + lwConfig.getPrivateKeys(), lwConfig.getAccountAddress()); + } else if (hasKeystore) { + ecdsaWitnesses = WitnessInitializer.initFromKeystore( + lwConfig.getKeystores(), cmd.password, lwConfig.getAccountAddress()); } - // path 2: config localwitness (private key list) - if (hasCfgPriv) { - localWitnesses = WitnessInitializer.initFromCFGPrivateKey( - lwConfig.getPrivateKeys(), lwConfig.getAccountAddress()); - return; + // Load PQ keypairs independently so a node can host a mix of ECDSA and PQ + // SRs (e.g. during a rolling migration where some SRs have moved to PQ and + // others have not yet). + LocalWitnesses pqWitnesses = null; + if (hasPqKeys) { + // localWitnessAccountAddress overrides the on-chain witness address for + // the single-witness case. In mixed mode (ECDSA + PQ) it is ambiguous, + // so refuse it and require each entry's address to be derived from its + // own key material. + String pqAccountAddress = + ecdsaWitnesses == null ? lwConfig.getAccountAddress() : null; + if (ecdsaWitnesses != null + && StringUtils.isNotBlank(lwConfig.getAccountAddress())) { + throw new TronError( + "localWitnessAccountAddress cannot be combined with both legacy and " + + ConfigKey.LOCAL_WITNESS_PQ_KEYS + "; remove the override or " + + "configure only one key source", + TronError.ErrCode.WITNESS_INIT); + } + pqWitnesses = buildPqWitnesses(config, pqAccountAddress); } - // path 3: config localwitnesskeystore + password - if (hasKeystore) { - localWitnesses = WitnessInitializer.initFromKeystore( - lwConfig.getKeystores(), cmd.password, lwConfig.getAccountAddress()); - return; + if (ecdsaWitnesses == null && pqWitnesses == null) { + // no private key source configured + throw new TronError("This is a witness node, but localWitnesses is null", + TronError.ErrCode.WITNESS_INIT); } - // path 4: PQ pre-derived keypair configuration - if (config.hasPath(ConfigKey.LOCAL_WITNESS_PQ_KEYS)) { - List pqEntries = config.getStringList(ConfigKey.LOCAL_WITNESS_PQ_KEYS); - if (!pqEntries.isEmpty()) { - PQScheme scheme = PQScheme.FN_DSA_512; - if (config.hasPath(ConfigKey.LOCAL_WITNESS_PQ_SCHEME)) { - String schemeName = config.getString(ConfigKey.LOCAL_WITNESS_PQ_SCHEME); - try { - scheme = PQScheme.valueOf(schemeName); - } catch (IllegalArgumentException e) { - throw new TronError("invalid " + ConfigKey.LOCAL_WITNESS_PQ_SCHEME - + ": " + schemeName, TronError.ErrCode.WITNESS_INIT); - } - if (!WITNESS_PQ_SCHEMES.contains(scheme)) { - throw new TronError("invalid " + ConfigKey.LOCAL_WITNESS_PQ_SCHEME - + ": " + schemeName + "; valid values: " + WITNESS_PQ_SCHEMES, - TronError.ErrCode.WITNESS_INIT); - } - } - // Each entry is the extended private key f‖g‖F‖h (priv ‖ pub) hex, - // sized (privLen + pubLen) bytes for the active scheme. We split here - // into PqKeypair entries so downstream consumers (ConsensusService, - // LocalWitnesses) get the priv/pub halves bundled — derivePublicKey - // (priv) replaces the previous explicit `pub` config field. + if (ecdsaWitnesses != null && pqWitnesses != null) { + LocalWitnesses merged = new LocalWitnesses(); + merged.setPrivateKeys(ecdsaWitnesses.getPrivateKeys()); + merged.setPqKeypairs(pqWitnesses.getPqKeypairs()); + // No witnessAccountAddress in mixed mode: each entry derives its own. + localWitnesses = merged; + } else if (ecdsaWitnesses != null) { + localWitnesses = ecdsaWitnesses; + } else { + localWitnesses = pqWitnesses; + } + } + + private static LocalWitnesses buildPqWitnesses(Config config, String accountAddress) { + // Each entry is an object { scheme = "", key | seed = "" } + // so a single node can host SRs running different PQ algorithms (e.g. + // Falcon-512 and ML-DSA-44 side by side). `key` carries the expanded + // priv‖pub hex (any scheme); `seed` carries the keygen seed hex and is + // accepted only when PQSchemeRegistry.isSeedDeterministic(scheme) is true. + List pqEntries = + config.getConfigList(ConfigKey.LOCAL_WITNESS_PQ_KEYS); + List pqKeypairs = new ArrayList<>(pqEntries.size()); + for (int i = 0; i < pqEntries.size(); i++) { + Config entry = pqEntries.get(i); + if (!entry.hasPath("scheme")) { + throw new TronError(String.format( + "%s[%d] must define `scheme`", + ConfigKey.LOCAL_WITNESS_PQ_KEYS, i), + TronError.ErrCode.WITNESS_INIT); + } + boolean hasKey = entry.hasPath("key"); + boolean hasSeed = entry.hasPath("seed"); + if (hasKey == hasSeed) { + throw new TronError(String.format( + "%s[%d] must define exactly one of `key` or `seed`", + ConfigKey.LOCAL_WITNESS_PQ_KEYS, i), + TronError.ErrCode.WITNESS_INIT); + } + String schemeName = entry.getString("scheme"); + PQScheme scheme; + try { + scheme = PQScheme.valueOf(schemeName); + } catch (IllegalArgumentException e) { + throw new TronError(String.format("invalid %s[%d].scheme: %s", + ConfigKey.LOCAL_WITNESS_PQ_KEYS, i, schemeName), + TronError.ErrCode.WITNESS_INIT); + } + if (!PQSchemeRegistry.contains(scheme)) { + throw new TronError(String.format( + "unsupported %s[%d].scheme: %s; registered schemes: %s", + ConfigKey.LOCAL_WITNESS_PQ_KEYS, i, schemeName, + PQSchemeRegistry.registeredSchemes()), + TronError.ErrCode.WITNESS_INIT); + } + String privHex; + String pubHex; + if (hasKey) { int privHexLen = PQSchemeRegistry.getPrivateKeyLength(scheme) * 2; int extHexLen = privHexLen + PQSchemeRegistry.getPublicKeyLength(scheme) * 2; - List pqKeypairs = new ArrayList<>(pqEntries.size()); - for (int i = 0; i < pqEntries.size(); i++) { - String hex = pqEntries.get(i); - String stripped = hex; - if (hex != null && (hex.startsWith("0x") || hex.startsWith("0X"))) { - stripped = hex.substring(2); - } - if (stripped == null || stripped.length() != extHexLen) { - throw new TronError(String.format( - "%s[%d] must be %d hex chars (extended priv‖pub for %s), actual: %d", - ConfigKey.LOCAL_WITNESS_PQ_KEYS, i, extHexLen, scheme, - stripped == null ? 0 : stripped.length()), - TronError.ErrCode.WITNESS_INIT); - } - pqKeypairs.add(new PqKeypair( - stripped.substring(0, privHexLen), - stripped.substring(privHexLen))); + String stripped = stripHexPrefix(entry.getString("key")); + if (stripped == null || stripped.length() != extHexLen) { + throw new TronError(String.format( + "%s[%d].key must be %d hex chars (extended priv‖pub for %s), actual: %d", + ConfigKey.LOCAL_WITNESS_PQ_KEYS, i, extHexLen, scheme, + stripped == null ? 0 : stripped.length()), + TronError.ErrCode.WITNESS_INIT); } - localWitnesses = WitnessInitializer.initFromPQOnly( - scheme, pqKeypairs, lwConfig.getAccountAddress()); - return; + privHex = stripped.substring(0, privHexLen); + pubHex = stripped.substring(privHexLen); + } else { + if (!PQSchemeRegistry.isSeedDeterministic(scheme)) { + // Falcon's FFT-based keygen drifts across JVMs/architectures, so + // seed-only config would produce different witness keys on + // different nodes. Force operators to commit the expanded keypair. + throw new TronError(String.format( + "%s[%d].seed is not supported for %s (non-deterministic keygen); " + + "use `key` with the extended priv‖pub hex instead", + ConfigKey.LOCAL_WITNESS_PQ_KEYS, i, scheme), + TronError.ErrCode.WITNESS_INIT); + } + int seedHexLen = PQSchemeRegistry.getSeedLength(scheme) * 2; + String stripped = stripHexPrefix(entry.getString("seed")); + if (stripped == null || stripped.length() != seedHexLen) { + throw new TronError(String.format( + "%s[%d].seed must be %d hex chars for %s, actual: %d", + ConfigKey.LOCAL_WITNESS_PQ_KEYS, i, seedHexLen, scheme, + stripped == null ? 0 : stripped.length()), + TronError.ErrCode.WITNESS_INIT); + } + byte[] seedBytes; + try { + seedBytes = Hex.decode(stripped); + } catch (RuntimeException e) { + throw new TronError(String.format( + "%s[%d].seed is not valid hex for %s: %s", + ConfigKey.LOCAL_WITNESS_PQ_KEYS, i, scheme, e.getMessage()), + TronError.ErrCode.WITNESS_INIT); + } + PQSignature derived = PQSchemeRegistry.fromSeed(scheme, seedBytes); + privHex = Hex.toHexString(derived.getPrivateKey()); + pubHex = Hex.toHexString(derived.getPublicKey()); } + pqKeypairs.add(new PqKeypair(scheme, privHex, pubHex)); } + return WitnessInitializer.initFromPQOnly(pqKeypairs, accountAddress); + } - // no private key source configured - throw new TronError("This is a witness node, but localWitnesses is null", - TronError.ErrCode.WITNESS_INIT); + private static String stripHexPrefix(String hex) { + if (hex == null) { + return null; + } + if (hex.startsWith("0x") || hex.startsWith("0X")) { + return hex.substring(2); + } + return hex; } @VisibleForTesting diff --git a/framework/src/main/java/org/tron/core/config/args/ConfigKey.java b/framework/src/main/java/org/tron/core/config/args/ConfigKey.java index e1129717690..47df21e33ec 100644 --- a/framework/src/main/java/org/tron/core/config/args/ConfigKey.java +++ b/framework/src/main/java/org/tron/core/config/args/ConfigKey.java @@ -8,8 +8,6 @@ public final class ConfigKey { public static final String LOCAL_WITNESS_PQ_KEYS = "localwitness_pq.keys"; - public static final String LOCAL_WITNESS_PQ_SCHEME = "localwitness_pq.scheme"; - private ConfigKey() { } } diff --git a/framework/src/main/java/org/tron/core/config/args/WitnessInitializer.java b/framework/src/main/java/org/tron/core/config/args/WitnessInitializer.java index fd6a10f3853..7089a003eb4 100644 --- a/framework/src/main/java/org/tron/core/config/args/WitnessInitializer.java +++ b/framework/src/main/java/org/tron/core/config/args/WitnessInitializer.java @@ -116,12 +116,13 @@ public static LocalWitnesses initFromKeystore( } /** - * Init for PQ-only witness nodes (no legacy ECDSA key). When - * {@code witnessAccountAddress} is blank, the address is derived from the - * first PQ public key via {@link PQSchemeRegistry#computeAddress(PQScheme, - * byte[])}. + * Init for PQ-only witness nodes (no legacy ECDSA key). Each PqKeypair + * carries its own PQScheme. When {@code witnessAccountAddress} is blank, + * the address is derived from the first PQ public key via + * {@link PQSchemeRegistry#computeAddress(PQScheme, byte[])} using that + * entry's scheme. */ - public static LocalWitnesses initFromPQOnly(PQScheme scheme, + public static LocalWitnesses initFromPQOnly( List pqKeypairs, String witnessAccountAddress) { if (pqKeypairs == null || pqKeypairs.isEmpty()) { throw new TronError( @@ -129,13 +130,13 @@ public static LocalWitnesses initFromPQOnly(PQScheme scheme, TronError.ErrCode.WITNESS_INIT); } LocalWitnesses witnesses = new LocalWitnesses(); - witnesses.setPqScheme(scheme); witnesses.setPqKeypairs(pqKeypairs); byte[] address; if (StringUtils.isBlank(witnessAccountAddress)) { - byte[] firstPubKey = ByteArray.fromHexString(pqKeypairs.get(0).getPublicKey()); - address = PQSchemeRegistry.computeAddress(scheme, firstPubKey); + PqKeypair first = pqKeypairs.get(0); + byte[] firstPubKey = ByteArray.fromHexString(first.getPublicKey()); + address = PQSchemeRegistry.computeAddress(first.getScheme(), firstPubKey); logger.debug("Derived PQ-only witness address from public key"); } else { if (pqKeypairs.size() != 1) { diff --git a/framework/src/main/java/org/tron/core/consensus/ConsensusService.java b/framework/src/main/java/org/tron/core/consensus/ConsensusService.java index a41e16b40f3..c6752aedc16 100644 --- a/framework/src/main/java/org/tron/core/consensus/ConsensusService.java +++ b/framework/src/main/java/org/tron/core/consensus/ConsensusService.java @@ -52,11 +52,7 @@ public void start() { List miners = new ArrayList<>(); List privateKeys = Args.getLocalWitnesses().getPrivateKeys(); List pqKeypairs = Args.getLocalWitnesses().getPqKeypairs(); - if (!privateKeys.isEmpty() && !pqKeypairs.isEmpty()) { - throw new TronError( - "legacy localwitness keys and localwitness_pq.keys are mutually exclusive", - TronError.ErrCode.WITNESS_INIT); - } + if (privateKeys.size() > 1) { for (String key : privateKeys) { byte[] privateKey = fromHexString(key); @@ -78,6 +74,12 @@ public void start() { byte[] privateKeyAddress = SignUtils.fromPrivate(privateKey, Args.getInstance().isECKeyCryptoEngine()).getAddress(); byte[] witnessAddress = Args.getLocalWitnesses().getWitnessAccountAddress(); + // In mixed (ECDSA + PQ) mode Args refuses the localWitnessAccountAddress + // override and leaves witnessAccountAddress null — fall back to the + // derived address so the single-witness path stays valid. + if (witnessAddress == null || witnessAddress.length == 0) { + witnessAddress = privateKeyAddress; + } WitnessCapsule witnessCapsule = witnessStore.get(witnessAddress); if (null == witnessCapsule) { logger.warn("Witness {} is not in witnessStore.", Hex.toHexString(witnessAddress)); @@ -87,10 +89,12 @@ public void start() { Miner miner = param.new Miner(privateKey, ByteString.copyFrom(privateKeyAddress), ByteString.copyFrom(witnessAddress)); miners.add(miner); - } else if (pqKeypairs.size() > 1) { - PQScheme scheme = Args.getLocalWitnesses().getPqScheme(); - requireSupportedPqScheme(scheme); + } + + if (pqKeypairs.size() > 1) { for (PqKeypair kp : pqKeypairs) { + PQScheme scheme = kp.getScheme(); + requireSupportedPqScheme(scheme); byte[] privBytes = fromHexString(kp.getPrivateKey()); byte[] pubBytes = fromHexString(kp.getPublicKey()); PQSignature keypair = PQSchemeRegistry.fromKeypair(scheme, privBytes, pubBytes); @@ -122,7 +126,7 @@ public void start() { } private Miner buildPQOnlyMinerFromKeypair(Param param, PqKeypair pqKeypair) { - PQScheme scheme = Args.getLocalWitnesses().getPqScheme(); + PQScheme scheme = pqKeypair.getScheme(); requireSupportedPqScheme(scheme); byte[] privBytes = fromHexString(pqKeypair.getPrivateKey()); byte[] pubBytes = fromHexString(pqKeypair.getPublicKey()); diff --git a/framework/src/main/java/org/tron/core/db/Manager.java b/framework/src/main/java/org/tron/core/db/Manager.java index bc2ebd432ea..a5d716cfd08 100644 --- a/framework/src/main/java/org/tron/core/db/Manager.java +++ b/framework/src/main/java/org/tron/core/db/Manager.java @@ -1747,8 +1747,8 @@ public BlockCapsule generateBlock(Miner miner, long blockTime, long timeout) { session.reset(); blockCapsule.setMerkleRoot(); - if (getDynamicPropertiesStore().isAnyPqSchemeAllowed() && - miner.getPqScheme() != null) { + if (getDynamicPropertiesStore().isAnyPqSchemeAllowed() + && miner.getPqScheme() != null) { signBlockCapsuleWithPQ(blockCapsule, miner); } else { blockCapsule.sign(miner.getPrivateKey()); diff --git a/framework/src/main/java/org/tron/core/net/service/relay/RelayService.java b/framework/src/main/java/org/tron/core/net/service/relay/RelayService.java index e78e90341a5..3178a722e0d 100644 --- a/framework/src/main/java/org/tron/core/net/service/relay/RelayService.java +++ b/framework/src/main/java/org/tron/core/net/service/relay/RelayService.java @@ -135,8 +135,8 @@ public void fillHelloMessage(HelloMessage message, Channel channel) { builder.setSignature(sig).clearPqAuthSig(); } else { LocalWitnesses lw = Args.getLocalWitnesses(); - PQScheme scheme = lw.getPqScheme(); PqKeypair kp = lw.getPqKeypairs().get(0); + PQScheme scheme = kp.getScheme(); byte[] privKey = ByteArray.fromHexString(kp.getPrivateKey()); byte[] pubKey = ByteArray.fromHexString(kp.getPublicKey()); byte[] sig = PQSchemeRegistry.sign(scheme, privKey, digest); diff --git a/framework/src/main/resources/config.conf b/framework/src/main/resources/config.conf index 9fdbe6232fb..fc563623a79 100644 --- a/framework/src/main/resources/config.conf +++ b/framework/src/main/resources/config.conf @@ -702,16 +702,34 @@ localwitness = [ # "localwitnesskeystore.json" # ] -# Post-quantum witness signing. `scheme` selects the PQ algorithm; only -# FN_DSA_512 is currently supported. Each `keys` entry is a hex-encoded -# extended private key (priv‖pub), 4352 hex chars for FN_DSA_512. Keypairs -# must be generated off-line — on-node keygen is intentionally bypassed. -# Effective only after the ALLOW_FN_DSA_512 proposal is active and the -# witness Permission has been upgraded to FN_DSA_512. +# Post-quantum witness signing. Each `keys` entry pins its own `scheme` (the +# PQ algorithm) and exactly one of `key` or `seed`, so a single node can host +# SRs running different PQ algorithms side by side. +# +# `key` — hex-encoded extended private key (priv‖pub). Required for any +# scheme whose keygen is not reproducible across platforms. +# Length is scheme-specific: 4352 hex chars for FN_DSA_512, +# 11648 hex chars for ML_DSA_44. +# `seed` — hex-encoded keygen seed. Accepted only for schemes whose keygen +# is deterministic across JVMs/architectures (currently ML_DSA_44, +# 64 hex chars). Rejected for FN_DSA_512 because Falcon's FFT-based +# keygen drifts across platforms and would produce divergent keys. +# +# Keypairs must be generated off-line — on-node keygen is intentionally +# bypassed. Each scheme is effective only after its activation proposal +# (e.g. ALLOW_FN_DSA_512, ALLOW_ML_DSA_44) is active and the corresponding +# witness Permission has been upgraded. +# +# A single node may host both legacy ECDSA witnesses (via `localwitness` or +# `localwitnesskeystore`) and PQ witnesses (via `localwitness_pq.keys`) at +# the same time; each entry signs blocks under its own scheme. The +# `localWitnessAccountAddress` override is only available when exactly one +# witness is configured — drop it when mixing schemes or running multiple +# SRs. # localwitness_pq = { -# scheme = "FN_DSA_512" # keys = [ -# "<4352 hex chars>" +# { scheme = "FN_DSA_512", key = "<4352 hex chars>" }, +# { scheme = "ML_DSA_44", seed = "<64 hex chars>" } # ] # } diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/FNDSA512Test.java b/framework/src/test/java/org/tron/common/crypto/pqc/FNDSA512Test.java index 8f62577f0c0..b9422c65928 100644 --- a/framework/src/test/java/org/tron/common/crypto/pqc/FNDSA512Test.java +++ b/framework/src/test/java/org/tron/common/crypto/pqc/FNDSA512Test.java @@ -376,7 +376,7 @@ public void derivePublicKeyFromExtendedFormReturnsAppendedPublicKey() { assertArrayEquals(keypair.getPublicKey(), derived); } - @Test(expected = UnsupportedOperationException.class) + @Test(expected = IllegalArgumentException.class) public void derivePublicKeyRejectsNull() { FNDSA512.derivePublicKey(null); } diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/PQSchemeRegistryTest.java b/framework/src/test/java/org/tron/common/crypto/pqc/PQSchemeRegistryTest.java index cd6dd37af91..288c1a47efb 100644 --- a/framework/src/test/java/org/tron/common/crypto/pqc/PQSchemeRegistryTest.java +++ b/framework/src/test/java/org/tron/common/crypto/pqc/PQSchemeRegistryTest.java @@ -45,6 +45,15 @@ public void registeredSchemesContainsBothLaunchSchemes() { assertTrue(PQSchemeRegistry.registeredSchemes().contains(PQScheme.ML_DSA_44)); } + @Test + public void isSeedDeterministicMatchesSchemeProperties() { + // Falcon's FFT-based keygen drifts across platforms — operators must + // persist the expanded priv‖pub, not just the seed. + assertFalse(PQSchemeRegistry.isSeedDeterministic(PQScheme.FN_DSA_512)); + // FIPS-204 keygen is pure integer arithmetic and reproducible. + assertTrue(PQSchemeRegistry.isSeedDeterministic(PQScheme.ML_DSA_44)); + } + @Test public void getSeedLengthReturnsRegisteredValue() { assertEquals(FNDSA512.SEED_LENGTH, diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/program/PQWitnessNode.java b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQWitnessNode.java index f354954330f..5096a81a982 100644 --- a/framework/src/test/java/org/tron/common/crypto/pqc/program/PQWitnessNode.java +++ b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQWitnessNode.java @@ -204,9 +204,11 @@ private static byte[] filledSeed(int value) { private static Path writeWitnessConfig(PQSignature witnessKp) throws java.io.IOException { Path conf = Files.createTempFile("pqc-witness-", ".conf"); conf.toFile().deleteOnExit(); - // `localwitness_pq.keys` is the extended priv ‖ pub hex; Falcon exposes that - // explicitly while ML-DSA-44's expanded sk already lets BC recover the pk, - // so we just concatenate getPrivateKey() ‖ getPublicKey() for both schemes. + // `localwitness_pq.keys` entries carry their own scheme so a single node can + // host SRs running different PQ algorithms. The key value is the extended + // priv ‖ pub hex; Falcon exposes that explicitly while ML-DSA-44's expanded + // sk already lets BC recover the pk, so we just concatenate + // getPrivateKey() ‖ getPublicKey() for both schemes. byte[] priv = witnessKp.getPrivateKey(); byte[] pub = witnessKp.getPublicKey(); byte[] extended = new byte[priv.length + pub.length]; @@ -214,9 +216,9 @@ private static Path writeWitnessConfig(PQSignature witnessKp) throws java.io.IOE System.arraycopy(pub, 0, extended, priv.length, pub.length); String body = "include classpath(\"config-test.conf\")\n" + "localwitness_pq = {\n" - + " scheme = \"" + PQ_SCHEME.name() + "\"\n" + " keys = [\n" - + " \"" + Hex.toHexString(extended) + "\"\n" + + " { scheme = \"" + PQ_SCHEME.name() + "\"," + + " key = \"" + Hex.toHexString(extended) + "\" }\n" + " ]\n" + "}\n"; Files.write(conf, body.getBytes(StandardCharsets.UTF_8)); diff --git a/framework/src/test/java/org/tron/common/utils/LocalWitnessesTest.java b/framework/src/test/java/org/tron/common/utils/LocalWitnessesTest.java index 6564ab79be2..32f20453aee 100644 --- a/framework/src/test/java/org/tron/common/utils/LocalWitnessesTest.java +++ b/framework/src/test/java/org/tron/common/utils/LocalWitnessesTest.java @@ -37,17 +37,18 @@ public static void generateKeypairs() { @Test public void fnDsa512AcceptsValidKeypair() { LocalWitnesses lw = new LocalWitnesses(); - lw.setPqKeypairs(Collections.singletonList(new PqKeypair(priv, pub))); - assertEquals(PQScheme.FN_DSA_512, lw.getPqScheme()); + lw.setPqKeypairs(Collections.singletonList( + new PqKeypair(PQScheme.FN_DSA_512, priv, pub))); assertEquals(1, lw.getPqKeypairs().size()); + assertEquals(PQScheme.FN_DSA_512, lw.getPqKeypairs().get(0).getScheme()); } @Test public void fnDsa512AcceptsMultipleKeypairs() { LocalWitnesses lw = new LocalWitnesses(); lw.setPqKeypairs(Arrays.asList( - new PqKeypair(priv, pub), - new PqKeypair(priv2, pub2))); + new PqKeypair(PQScheme.FN_DSA_512, priv, pub), + new PqKeypair(PQScheme.FN_DSA_512, priv2, pub2))); assertEquals(2, lw.getPqKeypairs().size()); } @@ -56,7 +57,8 @@ public void wrongLengthPrivateKeyRejected() { LocalWitnesses lw = new LocalWitnesses(); String shortPriv = priv.substring(2); TronError err = assertThrows(TronError.class, - () -> lw.setPqKeypairs(Collections.singletonList(new PqKeypair(shortPriv, pub)))); + () -> lw.setPqKeypairs(Collections.singletonList( + new PqKeypair(PQScheme.FN_DSA_512, shortPriv, pub)))); assertEquals(TronError.ErrCode.WITNESS_INIT, err.getErrCode()); assertTrue(err.getMessage().contains("PQ private key")); // FN-DSA-512 private key is 1280 bytes = 2560 hex chars. @@ -68,7 +70,8 @@ public void wrongLengthPublicKeyRejected() { LocalWitnesses lw = new LocalWitnesses(); String shortPub = pub.substring(2); TronError err = assertThrows(TronError.class, - () -> lw.setPqKeypairs(Collections.singletonList(new PqKeypair(priv, shortPub)))); + () -> lw.setPqKeypairs(Collections.singletonList( + new PqKeypair(PQScheme.FN_DSA_512, priv, shortPub)))); assertEquals(TronError.ErrCode.WITNESS_INIT, err.getErrCode()); assertTrue(err.getMessage().contains("PQ public key")); // FN-DSA-512 public key is 896 bytes = 1792 hex chars. @@ -80,7 +83,8 @@ public void nonHexPrivateKeyRejected() { LocalWitnesses lw = new LocalWitnesses(); String badPriv = "zz" + priv.substring(2); TronError err = assertThrows(TronError.class, - () -> lw.setPqKeypairs(Collections.singletonList(new PqKeypair(badPriv, pub)))); + () -> lw.setPqKeypairs(Collections.singletonList( + new PqKeypair(PQScheme.FN_DSA_512, badPriv, pub)))); assertEquals(TronError.ErrCode.WITNESS_INIT, err.getErrCode()); assertTrue(err.getMessage().contains("hex")); } @@ -89,7 +93,8 @@ public void nonHexPrivateKeyRejected() { public void unsupportedSchemeRejected() { LocalWitnesses lw = new LocalWitnesses(); TronError err = assertThrows(TronError.class, - () -> lw.setPqScheme(PQScheme.UNRECOGNIZED)); + () -> lw.setPqKeypairs(Collections.singletonList( + new PqKeypair(PQScheme.UNRECOGNIZED, priv, pub)))); assertEquals(TronError.ErrCode.WITNESS_INIT, err.getErrCode()); assertTrue(err.getMessage().contains("unsupported PQ signature scheme")); } @@ -97,18 +102,13 @@ public void unsupportedSchemeRejected() { @Test public void nullSchemeRejected() { LocalWitnesses lw = new LocalWitnesses(); - TronError err = assertThrows(TronError.class, () -> lw.setPqScheme(null)); + TronError err = assertThrows(TronError.class, + () -> lw.setPqKeypairs(Collections.singletonList( + new PqKeypair(null, priv, pub)))); assertEquals(TronError.ErrCode.WITNESS_INIT, err.getErrCode()); assertTrue(err.getMessage().contains("unsupported PQ signature scheme")); } - @Test - public void supportedSchemeAccepted() { - LocalWitnesses lw = new LocalWitnesses(); - lw.setPqScheme(PQScheme.FN_DSA_512); - assertEquals(PQScheme.FN_DSA_512, lw.getPqScheme()); - } - @Test public void emptyKeypairsAreNoop() { LocalWitnesses lw = new LocalWitnesses(); @@ -122,7 +122,8 @@ public void zeroXPrefixedHexAccepted() { // validatePqKey strips a leading "0x" before measuring the length, so // hex strings with the prefix must be accepted. LocalWitnesses lw = new LocalWitnesses(); - lw.setPqKeypairs(Collections.singletonList(new PqKeypair("0x" + priv, "0x" + pub))); + lw.setPqKeypairs(Collections.singletonList( + new PqKeypair(PQScheme.FN_DSA_512, "0x" + priv, "0x" + pub))); assertEquals(1, lw.getPqKeypairs().size()); } @@ -130,7 +131,8 @@ public void zeroXPrefixedHexAccepted() { public void blankKeyRejected() { LocalWitnesses lw = new LocalWitnesses(); TronError err = assertThrows(TronError.class, - () -> lw.setPqKeypairs(Collections.singletonList(new PqKeypair("", pub)))); + () -> lw.setPqKeypairs(Collections.singletonList( + new PqKeypair(PQScheme.FN_DSA_512, "", pub)))); assertEquals(TronError.ErrCode.WITNESS_INIT, err.getErrCode()); assertTrue(err.getMessage().contains("PQ private key")); } diff --git a/framework/src/test/java/org/tron/core/config/args/ArgsPqConfigTest.java b/framework/src/test/java/org/tron/core/config/args/ArgsPqConfigTest.java new file mode 100644 index 00000000000..7c09a452486 --- /dev/null +++ b/framework/src/test/java/org/tron/core/config/args/ArgsPqConfigTest.java @@ -0,0 +1,168 @@ +package org.tron.core.config.args; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import org.bouncycastle.util.encoders.Hex; +import org.junit.After; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.common.TestConstants; +import org.tron.common.crypto.pqc.FNDSA512; +import org.tron.common.crypto.pqc.MLDSA44; +import org.tron.common.crypto.pqc.PQSchemeRegistry; +import org.tron.common.crypto.pqc.PQSignature; +import org.tron.common.crypto.pqc.PqKeypair; +import org.tron.common.utils.LocalWitnesses; +import org.tron.core.exception.TronError; +import org.tron.protos.Protocol.PQScheme; + +/** + * Covers the {@code localwitness_pq.keys} HOCON parsing in + * {@link Args#setParam} — specifically the {@code key} vs {@code seed} entry + * shape and the per-scheme guard that rejects {@code seed} for schemes whose + * keygen is not reproducible across platforms (Falcon-512). + */ +public class ArgsPqConfigTest { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + @After + public void tearDown() { + Args.clearParam(); + } + + @Test + public void mlDsa44SeedEntryDerivesKeypair() throws IOException { + byte[] seed = filled(MLDSA44.SEED_LENGTH, (byte) 0x07); + Path conf = writeConfWithEntry( + "{ scheme = \"ML_DSA_44\", seed = \"" + Hex.toHexString(seed) + "\" }"); + + Args.setParam(new String[]{"--witness"}, conf.toString()); + + LocalWitnesses lw = Args.getLocalWitnesses(); + assertEquals(1, lw.getPqKeypairs().size()); + PqKeypair kp = lw.getPqKeypairs().get(0); + assertEquals(PQScheme.ML_DSA_44, kp.getScheme()); + + PQSignature expected = PQSchemeRegistry.fromSeed(PQScheme.ML_DSA_44, seed); + assertEquals(Hex.toHexString(expected.getPrivateKey()), kp.getPrivateKey()); + assertEquals(Hex.toHexString(expected.getPublicKey()), kp.getPublicKey()); + } + + @Test + public void mlDsa44SeedAcceptsZeroXPrefix() throws IOException { + byte[] seed = filled(MLDSA44.SEED_LENGTH, (byte) 0x09); + Path conf = writeConfWithEntry( + "{ scheme = \"ML_DSA_44\", seed = \"0x" + Hex.toHexString(seed) + "\" }"); + Args.setParam(new String[]{"--witness"}, conf.toString()); + assertEquals(1, Args.getLocalWitnesses().getPqKeypairs().size()); + } + + @Test + public void fnDsa512SeedRejected() throws IOException { + byte[] seed = filled(FNDSA512.SEED_LENGTH, (byte) 0x03); + Path conf = writeConfWithEntry( + "{ scheme = \"FN_DSA_512\", seed = \"" + Hex.toHexString(seed) + "\" }"); + + TronError err = assertThrows(TronError.class, + () -> Args.setParam(new String[]{"--witness"}, conf.toString())); + assertEquals(TronError.ErrCode.WITNESS_INIT, err.getErrCode()); + assertTrue(err.getMessage(), + err.getMessage().contains("seed is not supported for FN_DSA_512")); + } + + @Test + public void keyAndSeedBothSetRejected() throws IOException { + byte[] seed = filled(MLDSA44.SEED_LENGTH, (byte) 0x05); + MLDSA44 ml = new MLDSA44(seed); + byte[] priv = ml.getPrivateKey(); + byte[] pub = ml.getPublicKey(); + byte[] ext = concat(priv, pub); + + Path conf = writeConfWithEntry( + "{ scheme = \"ML_DSA_44\"," + + " key = \"" + Hex.toHexString(ext) + "\"," + + " seed = \"" + Hex.toHexString(seed) + "\" }"); + + TronError err = assertThrows(TronError.class, + () -> Args.setParam(new String[]{"--witness"}, conf.toString())); + assertEquals(TronError.ErrCode.WITNESS_INIT, err.getErrCode()); + assertTrue(err.getMessage(), + err.getMessage().contains("exactly one of `key` or `seed`")); + } + + @Test + public void neitherKeyNorSeedRejected() throws IOException { + Path conf = writeConfWithEntry("{ scheme = \"ML_DSA_44\" }"); + + TronError err = assertThrows(TronError.class, + () -> Args.setParam(new String[]{"--witness"}, conf.toString())); + assertEquals(TronError.ErrCode.WITNESS_INIT, err.getErrCode()); + assertTrue(err.getMessage(), + err.getMessage().contains("exactly one of `key` or `seed`")); + } + + @Test + public void mlDsa44SeedWrongLengthRejected() throws IOException { + String shortSeed = Hex.toHexString(filled(MLDSA44.SEED_LENGTH - 1, (byte) 0x02)); + Path conf = writeConfWithEntry( + "{ scheme = \"ML_DSA_44\", seed = \"" + shortSeed + "\" }"); + + TronError err = assertThrows(TronError.class, + () -> Args.setParam(new String[]{"--witness"}, conf.toString())); + assertEquals(TronError.ErrCode.WITNESS_INIT, err.getErrCode()); + assertTrue(err.getMessage(), err.getMessage().contains("seed must be")); + } + + @Test + public void mlDsa44KeyEntryStillAccepted() throws IOException { + // Regression: adding the `seed` path must not break the existing `key` + // path for the same scheme. + MLDSA44 ml = new MLDSA44(filled(MLDSA44.SEED_LENGTH, (byte) 0x0B)); + byte[] ext = concat(ml.getPrivateKey(), ml.getPublicKey()); + Path conf = writeConfWithEntry( + "{ scheme = \"ML_DSA_44\", key = \"" + Hex.toHexString(ext) + "\" }"); + + Args.setParam(new String[]{"--witness"}, conf.toString()); + LocalWitnesses lw = Args.getLocalWitnesses(); + assertNotNull(lw); + assertEquals(1, lw.getPqKeypairs().size()); + assertEquals(PQScheme.ML_DSA_44, lw.getPqKeypairs().get(0).getScheme()); + } + + private Path writeConfWithEntry(String entry) throws IOException { + Path conf = tmp.newFile("pqc-args-test.conf").toPath(); + String body = "include classpath(\"" + TestConstants.TEST_CONF + "\")\n" + + "localwitness = []\n" + + "localwitness_pq = {\n" + + " keys = [\n" + + " " + entry + "\n" + + " ]\n" + + "}\n"; + Files.write(conf, body.getBytes(StandardCharsets.UTF_8)); + return conf; + } + + private static byte[] filled(int len, byte value) { + byte[] out = new byte[len]; + Arrays.fill(out, value); + return out; + } + + private static byte[] concat(byte[] a, byte[] b) { + byte[] out = new byte[a.length + b.length]; + System.arraycopy(a, 0, out, 0, a.length); + System.arraycopy(b, 0, out, a.length, b.length); + return out; + } +} diff --git a/framework/src/test/java/org/tron/core/net/services/RelayServiceTest.java b/framework/src/test/java/org/tron/core/net/services/RelayServiceTest.java index 35ff252a887..0a571429163 100644 --- a/framework/src/test/java/org/tron/core/net/services/RelayServiceTest.java +++ b/framework/src/test/java/org/tron/core/net/services/RelayServiceTest.java @@ -18,7 +18,6 @@ import org.bouncycastle.util.encoders.Hex; import org.junit.After; import org.junit.Assert; -import org.junit.BeforeClass; import org.junit.Test; import org.mockito.Mockito; import org.springframework.context.ApplicationContext; @@ -65,13 +64,9 @@ public class RelayServiceTest extends BaseTest { @Resource private TronNetService tronNetService; - /** - * init context. - */ - @BeforeClass - public static void init() { + static { Args.setParam(new String[]{"--output-directory", dbPath(), "--debug"}, - TestConstants.TEST_CONF); + TestConstants.TEST_CONF); } @After diff --git a/framework/src/test/resources/config-test.conf b/framework/src/test/resources/config-test.conf index ce3652af4a3..816b5d5b5b1 100644 --- a/framework/src/test/resources/config-test.conf +++ b/framework/src/test/resources/config-test.conf @@ -355,7 +355,6 @@ localwitness = [ ] localwitness_pq = { - scheme = "FN_DSA_512" keys = [ ] } From c16cad8c6c0d65c1ff404e62bfcd50e538328239 Mon Sep 17 00:00:00 2001 From: federico Date: Sun, 24 May 2026 14:54:33 +0800 Subject: [PATCH 27/47] refactor(crypto): express pq signature length as a range --- .../org/tron/common/crypto/pqc/FNDSA512.java | 95 ++++++++++++++++--- .../org/tron/common/crypto/pqc/MLDSA44.java | 27 ++---- .../common/crypto/pqc/PQSchemeRegistry.java | 6 +- .../tron/common/crypto/pqc/PQSignature.java | 23 ++++- .../tron/common/crypto/pqc/FNDSA512Test.java | 26 +++-- 5 files changed, 129 insertions(+), 48 deletions(-) diff --git a/crypto/src/main/java/org/tron/common/crypto/pqc/FNDSA512.java b/crypto/src/main/java/org/tron/common/crypto/pqc/FNDSA512.java index 66495b35630..20fac864cf1 100644 --- a/crypto/src/main/java/org/tron/common/crypto/pqc/FNDSA512.java +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/FNDSA512.java @@ -19,11 +19,15 @@ * / {@link #verify} provide stateless entry points used by * {@link PQSchemeRegistry}. * - *

Falcon signatures are variable-length: {@link #SIGNATURE_LENGTH} - * is the protocol-level upper bound, not an exact length. The - * {@link PQSignature#validateSignature} default treats this as - * {@code <= SIGNATURE_LENGTH}. BouncyCastle 1.79's {@code FalconNIST.CRYPTO_BYTES} - * for Falcon-512 is 690 bytes, well below the 752-byte protocol cap. + *

Falcon signatures are variable-length: every accepted + * signature must fall within {@code [}{@link #SIGNATURE_MIN_LENGTH}{@code ,} + * {@link #SIGNATURE_LENGTH}{@code ]}. {@link #SIGNATURE_LENGTH} (666) matches + * the Falcon Round-3 / FIPS-206 draft canonical maximum ({@code sbytelen}) for + * Falcon-512; {@link #SIGNATURE_MIN_LENGTH} (41) is the smallest syntactically + * well-formed encoding (header byte + 40-byte nonce, before {@code compressed_s2}). + * BouncyCastle does not implement Falcon's spec-mandated rejection sampling + * (its internal buffer permits up to 689 B); {@link #sign(byte[], byte[])} adds + * that loop so produced signatures always respect the canonical cap. */ public final class FNDSA512 implements PQSignature { @@ -49,8 +53,29 @@ public final class FNDSA512 implements PQSignature { */ public static final int PRIVATE_KEY_WITH_PUBLIC_KEY_LENGTH = PRIVATE_KEY_LENGTH + PUBLIC_KEY_LENGTH; - /** Protocol-level upper bound on Falcon-512 signature length (variable). */ - public static final int SIGNATURE_LENGTH = 752; + /** + * Canonical maximum Falcon-512 signature length per Falcon Round-3 / FIPS-206 + * draft ({@code sbytelen}). Also the protocol-level upper bound: anything + * longer is rejected by the verifier and never produced by + * {@link #sign(byte[], byte[])} (which loops with fresh randomness if BC + * exceeds it). + */ + public static final int SIGNATURE_LENGTH = 666; + /** + * Smallest syntactically well-formed Falcon-512 encoding: 1-byte header + + * 40-byte nonce, with {@code compressed_s2} potentially empty. Real valid + * signatures sit well above this — the bound exists to reject obviously + * malformed inputs without invoking BC. + */ + public static final int SIGNATURE_MIN_LENGTH = 41; + /** + * Maximum signing retries before {@link #sign(byte[], byte[])} gives up. + * Empirically BC produces signatures above {@link #SIGNATURE_LENGTH} with + * probability ≪ 1/5000, so 16 attempts is comfortably above the + * spec-targeted rejection rate (~2^-40) — failure probability after 16 + * retries on honest input is astronomically small. + */ + private static final int SIGN_RETRY_BUDGET = 16; /** Falcon keygen seeds an internal SHAKE256 from 48 bytes of randomness. */ public static final int SEED_LENGTH = 48; @@ -125,12 +150,21 @@ public int getPublicKeyLength() { return PUBLIC_KEY_LENGTH; } - /** Returns the protocol-level signature length upper bound (signatures are variable-length). */ + /** Returns the canonical signature length upper bound (signatures are variable-length). */ @Override public int getSignatureLength() { return SIGNATURE_LENGTH; } + /** + * FN-DSA signatures are variable-length; the lower bound is the smallest + * syntactically well-formed encoding (1-byte header + 40-byte nonce). + */ + @Override + public int getSignatureMinLength() { + return SIGNATURE_MIN_LENGTH; + } + @Override public byte[] getPrivateKey() { return privateKey.clone(); @@ -185,9 +219,12 @@ public static boolean verify(byte[] publicKey, byte[] message, byte[] signature) throw new IllegalArgumentException( "FN-DSA public key length must be " + PUBLIC_KEY_LENGTH); } - if (signature == null || signature.length == 0 || signature.length > SIGNATURE_LENGTH) { + if (signature == null + || signature.length < SIGNATURE_MIN_LENGTH + || signature.length > SIGNATURE_LENGTH) { throw new IllegalArgumentException( - "FN-DSA signature length must be 1.." + SIGNATURE_LENGTH); + "FN-DSA signature length must be " + + SIGNATURE_MIN_LENGTH + ".." + SIGNATURE_LENGTH); } if (message == null) { throw new IllegalArgumentException("message must not be null"); @@ -207,6 +244,21 @@ public static boolean verify(byte[] publicKey, byte[] message, byte[] signature) * ({@link #PRIVATE_KEY_LENGTH} bytes, {@code f ‖ g ‖ F}) or the extended form * ({@link #PRIVATE_KEY_WITH_PUBLIC_KEY_LENGTH} bytes, {@code f ‖ g ‖ F ‖ h}). * The trailing {@code h} segment is ignored — only {@code (f, g, F)} feed BC's signer. + * + *

Signing is randomized: the same {@code (privateKey, message)} yields different + * signature bytes on every call. Only keygen is deterministic from the 48-byte seed. + * Downstream code must not cache or dedup by signature-bytes hash; key on the derived + * address instead (see the PQ multisig dedup in {@code PrecompiledContracts}). + * + *

Per Falcon Round-3 / FIPS-206 draft the signature MUST be ≤ + * {@link #SIGNATURE_LENGTH} bytes; if it exceeds, the signer must resample + * with a fresh nonce. BouncyCastle does not implement this + * rejection step — its internal buffer permits up to 689 B and would return + * those longer signatures. This wrapper enforces the spec cap by discarding + * over-length BC outputs (and BC's own {@code IllegalStateException} from + * {@code comp_encode} overflow) and retrying up to {@link #SIGN_RETRY_BUDGET} + * times. Each retry draws fresh randomness from {@code SIGNING_RNG}, so on + * honest input the budget is astronomically unlikely to be exhausted. */ public static byte[] sign(byte[] privateKey, byte[] message) { validatePrivateKeyBytes(privateKey); @@ -222,11 +274,26 @@ public static byte[] sign(byte[] privateKey, byte[] message) { FalconPrivateKeyParameters sk = new FalconPrivateKeyParameters(PARAMS, f, g, bigF, new byte[0]); FalconSigner signer = new FalconSigner(); signer.init(true, new ParametersWithRandom(sk, SIGNING_RNG)); - try { - return signer.generateSignature(message); - } catch (Exception e) { - throw new IllegalStateException("FN-DSA signing failed", e); + Exception lastFailure = null; + for (int attempt = 0; attempt < SIGN_RETRY_BUDGET; attempt++) { + try { + byte[] sig = signer.generateSignature(message); + if (sig.length <= SIGNATURE_LENGTH) { + return sig; + } + // BC produced a spec-overlong signature; retry with fresh randomness. + } catch (IllegalStateException e) { + // BC's comp_encode overflowed its internal buffer — equivalent to + // a spec-overlong signature; retry. + lastFailure = e; + } catch (Exception e) { + throw new IllegalStateException("FN-DSA signing failed", e); + } } + throw new IllegalStateException( + "FN-DSA signing failed: could not produce a signature ≤ " + + SIGNATURE_LENGTH + " bytes after " + SIGN_RETRY_BUDGET + " attempts", + lastFailure); } /** diff --git a/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA44.java b/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA44.java index a3eb8c1cff0..bce303352f3 100644 --- a/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA44.java +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA44.java @@ -3,14 +3,14 @@ import java.security.MessageDigest; import java.security.SecureRandom; import org.bouncycastle.crypto.AsymmetricCipherKeyPair; +import org.bouncycastle.crypto.generators.MLDSAKeyPairGenerator; +import org.bouncycastle.crypto.params.MLDSAKeyGenerationParameters; +import org.bouncycastle.crypto.params.MLDSAParameters; +import org.bouncycastle.crypto.params.MLDSAPrivateKeyParameters; +import org.bouncycastle.crypto.params.MLDSAPublicKeyParameters; import org.bouncycastle.crypto.params.ParametersWithRandom; import org.bouncycastle.crypto.prng.FixedSecureRandom; -import org.bouncycastle.pqc.crypto.mldsa.MLDSAKeyGenerationParameters; -import org.bouncycastle.pqc.crypto.mldsa.MLDSAKeyPairGenerator; -import org.bouncycastle.pqc.crypto.mldsa.MLDSAParameters; -import org.bouncycastle.pqc.crypto.mldsa.MLDSAPrivateKeyParameters; -import org.bouncycastle.pqc.crypto.mldsa.MLDSAPublicKeyParameters; -import org.bouncycastle.pqc.crypto.mldsa.MLDSASigner; +import org.bouncycastle.crypto.signers.MLDSASigner; import org.tron.protos.Protocol.PQScheme; /** @@ -128,21 +128,6 @@ public boolean verify(byte[] message, byte[] signature) { return verify(publicKey, message, signature); } - /** - * Strict fixed-length verify: ML-DSA-44 signatures are exactly - * {@link #SIGNATURE_LENGTH} bytes; any other length is rejected before BC - * is invoked. - */ - @Override - public void validateSignature(byte[] signature) { - if (signature == null || signature.length != SIGNATURE_LENGTH) { - throw new IllegalArgumentException( - "invalid " + getScheme() + " signature length: " - + (signature == null ? "null" : signature.length) - + ", expected " + SIGNATURE_LENGTH); - } - } - public static boolean verify(byte[] publicKey, byte[] message, byte[] signature) { if (publicKey == null || publicKey.length != PUBLIC_KEY_LENGTH) { throw new IllegalArgumentException( diff --git a/crypto/src/main/java/org/tron/common/crypto/pqc/PQSchemeRegistry.java b/crypto/src/main/java/org/tron/common/crypto/pqc/PQSchemeRegistry.java index 9eda9b2af7b..cbbf3a20507 100644 --- a/crypto/src/main/java/org/tron/common/crypto/pqc/PQSchemeRegistry.java +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/PQSchemeRegistry.java @@ -198,13 +198,13 @@ public static boolean isSeedDeterministic(PQScheme scheme) { /** * Per-scheme signature-length predicate. Fixed-length schemes require exact * equality with {@link #getSignatureLength(PQScheme)}; variable-length - * schemes ({@code FN_DSA_512}) treat that value as an upper bound and accept - * any {@code 1..max}. + * schemes ({@code FN_DSA_512}) accept any length in + * [{@link FNDSA512#SIGNATURE_MIN_LENGTH}, {@link FNDSA512#SIGNATURE_LENGTH}]. */ public static boolean isValidSignatureLength(PQScheme scheme, int length) { SchemeInfo info = require(scheme); if (scheme == PQScheme.FN_DSA_512) { - return length > 0 && length <= info.signatureLength; + return length >= FNDSA512.SIGNATURE_MIN_LENGTH && length <= info.signatureLength; } return length == info.signatureLength; } diff --git a/crypto/src/main/java/org/tron/common/crypto/pqc/PQSignature.java b/crypto/src/main/java/org/tron/common/crypto/pqc/PQSignature.java index dbba2683ef0..48c29a8b6a4 100644 --- a/crypto/src/main/java/org/tron/common/crypto/pqc/PQSignature.java +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/PQSignature.java @@ -18,6 +18,17 @@ public interface PQSignature { int getSignatureLength(); + /** + * Signature length is logically a band {@code [min, max]}; fixed-length + * schemes degenerate to the singleton {@code [max, max]}. The default + * returns {@link #getSignatureLength()} so any new fixed-length scheme + * gets exact-equality validation for free; variable-length schemes + * (e.g. FN-DSA-512) override this to return their true lower bound. + */ + default int getSignatureMinLength() { + return getSignatureLength(); + } + byte[] getPrivateKey(); byte[] getPublicKey(); @@ -58,15 +69,19 @@ default void validatePublicKey(byte[] publicKey) { } /** - * Default upper-bound check, sufficient for variable-length schemes (FN_DSA_512). - * Fixed-length schemes override this with strict equality. + * Default band check {@code [getSignatureMinLength(), getSignatureLength()]}. + * Fixed-length schemes inherit the singleton {@code [max, max]} band — no + * override needed; variable-length schemes only need to override + * {@link #getSignatureMinLength()}. */ default void validateSignature(byte[] signature) { - if (signature == null || signature.length == 0 || signature.length > getSignatureLength()) { + int min = getSignatureMinLength(); + int max = getSignatureLength(); + if (signature == null || signature.length < min || signature.length > max) { throw new IllegalArgumentException( "invalid " + getScheme() + " signature length: " + (signature == null ? "null" : signature.length) - + ", expected 1.." + getSignatureLength()); + + ", expected " + (min == max ? String.valueOf(max) : (min + ".." + max))); } } } diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/FNDSA512Test.java b/framework/src/test/java/org/tron/common/crypto/pqc/FNDSA512Test.java index b9422c65928..3deda94e918 100644 --- a/framework/src/test/java/org/tron/common/crypto/pqc/FNDSA512Test.java +++ b/framework/src/test/java/org/tron/common/crypto/pqc/FNDSA512Test.java @@ -108,10 +108,21 @@ public void signatureBoundaryAboveMaxRejected() { @Test public void minimalValidLengthAcceptedByLengthCheck() { - byte[] sig = new byte[1]; + byte[] sig = new byte[FNDSA512.SIGNATURE_MIN_LENGTH]; keypair.validateSignature(sig); } + @Test + public void belowMinLengthRejectedByLengthCheck() { + byte[] sig = new byte[FNDSA512.SIGNATURE_MIN_LENGTH - 1]; + try { + keypair.validateSignature(sig); + fail("signature shorter than min should be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("signature length")); + } + } + @Test public void emptySignatureRejectedByLengthCheck() { byte[] sig = new byte[0]; @@ -151,7 +162,7 @@ public void verifyRejectsEmptySignature() { public void invalidPublicKeyLengthRejected() { byte[] badPk = new byte[FNDSA512.PUBLIC_KEY_LENGTH - 1]; byte[] msg = new byte[] {1}; - byte[] sig = new byte[16]; + byte[] sig = new byte[FNDSA512.SIGNATURE_MIN_LENGTH]; try { FNDSA512.verify(badPk, msg, sig); fail("short public key should be rejected"); @@ -162,7 +173,7 @@ public void invalidPublicKeyLengthRejected() { @Test public void nullMessageRejected() { - byte[] sig = new byte[16]; + byte[] sig = new byte[FNDSA512.SIGNATURE_MIN_LENGTH]; try { FNDSA512.verify(pk.getH(), null, sig); fail("null message should be rejected"); @@ -198,7 +209,7 @@ public void wrongPublicKeyFailsVerification() { @Test public void crossAlgoSignatureRejected() { - // FN-DSA upper bound is 752 bytes; ML-DSA-44 (2420), ML-DSA-65 (3309), + // FN-DSA upper bound is 666 bytes; ML-DSA-44 (2420), ML-DSA-65 (3309), // SLH-DSA (7856) all exceed it and must be rejected at the length check. byte[] msg = "cross-algo".getBytes(); int[] foreignLengths = {2420, 3309, 7856}; @@ -272,11 +283,14 @@ public void registryDispatchMatchesDirectCalls() { } @Test - public void registryIsValidSignatureLengthRespectsUpperBound() { - assertTrue(PQSchemeRegistry.isValidSignatureLength(PQScheme.FN_DSA_512, 1)); + public void registryIsValidSignatureLengthRespectsBounds() { + assertTrue(PQSchemeRegistry.isValidSignatureLength( + PQScheme.FN_DSA_512, FNDSA512.SIGNATURE_MIN_LENGTH)); assertTrue(PQSchemeRegistry.isValidSignatureLength( PQScheme.FN_DSA_512, FNDSA512.SIGNATURE_LENGTH)); assertFalse(PQSchemeRegistry.isValidSignatureLength(PQScheme.FN_DSA_512, 0)); + assertFalse(PQSchemeRegistry.isValidSignatureLength( + PQScheme.FN_DSA_512, FNDSA512.SIGNATURE_MIN_LENGTH - 1)); assertFalse(PQSchemeRegistry.isValidSignatureLength( PQScheme.FN_DSA_512, FNDSA512.SIGNATURE_LENGTH + 1)); } From ac3348fb762a0fb883d5f836a2928ae6ca37b438 Mon Sep 17 00:00:00 2001 From: federico Date: Sun, 24 May 2026 14:56:36 +0800 Subject: [PATCH 28/47] feat(framework): pq witness + unify pq multi-sign --- .../tron/core/vm/PrecompiledContracts.java | 444 +++++---- .../core/config/args/CommitteeConfig.java | 2 + .../core/config/args/LocalWitnessConfig.java | 22 +- .../tron/core/config/args/PqEntryConfig.java | 33 + common/src/main/resources/reference.conf | 2 + .../config/args/LocalWitnessConfigTest.java | 36 + .../java/org/tron/consensus/base/Param.java | 8 - .../java/org/tron/core/config/args/Args.java | 61 +- .../org/tron/core/config/args/ConfigKey.java | 13 - .../tron/core/consensus/ConsensusService.java | 66 +- .../main/java/org/tron/core/db/Manager.java | 24 +- framework/src/main/resources/config.conf | 30 +- .../runtime/vm/BatchValidateFnDsa512Test.java | 64 +- .../runtime/vm/FnDsaPrecompileTest.java | 85 +- .../runtime/vm/ValidateMultiFnDsa512Test.java | 464 ---------- .../runtime/vm/ValidateMultiMlDsa44Test.java | 465 ---------- .../runtime/vm/ValidateMultiPQSigTest.java | 839 ++++++++++++++++++ protocol/src/main/protos/core/Tron.proto | 10 +- 18 files changed, 1300 insertions(+), 1368 deletions(-) create mode 100644 common/src/main/java/org/tron/core/config/args/PqEntryConfig.java delete mode 100644 framework/src/main/java/org/tron/core/config/args/ConfigKey.java delete mode 100644 framework/src/test/java/org/tron/common/runtime/vm/ValidateMultiFnDsa512Test.java delete mode 100644 framework/src/test/java/org/tron/common/runtime/vm/ValidateMultiMlDsa44Test.java create mode 100644 framework/src/test/java/org/tron/common/runtime/vm/ValidateMultiPQSigTest.java diff --git a/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java b/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java index f5954122777..95e1f326da4 100644 --- a/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java +++ b/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java @@ -27,8 +27,10 @@ import java.security.MessageDigest; import java.util.ArrayList; import java.util.Arrays; +import java.util.EnumMap; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.concurrent.Callable; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutionException; @@ -122,13 +124,11 @@ public class PrecompiledContracts { private static final P256Verify p256Verify = new P256Verify(); private static final VerifyFnDsa512 verifyFnDsa512 = new VerifyFnDsa512(); - private static final ValidateMultiFnDsa512 validateMultiFnDsa512 = - new ValidateMultiFnDsa512(); private static final BatchValidateFnDsa512 batchValidateFnDsa512 = new BatchValidateFnDsa512(); private static final VerifyMlDsa44 verifyMlDsa44 = new VerifyMlDsa44(); - private static final ValidateMultiMlDsa44 validateMultiMlDsa44 = new ValidateMultiMlDsa44(); private static final BatchValidateMlDsa44 batchValidateMlDsa44 = new BatchValidateMlDsa44(); + private static final ValidateMultiPQSig validateMultiPqSig = new ValidateMultiPQSig(); // FreezeV2 PrecompileContracts private static final GetChainParameter getChainParameter = new GetChainParameter(); @@ -231,11 +231,10 @@ public class PrecompiledContracts { private static final DataWord verifyFnDsa512Addr = new DataWord( "0000000000000000000000000000000000000000000000000000000000000016"); - // 0x17: algorithm-agnostic Permission multi-sign — accepts both ECDSA and - // Falcon-512 signatures against the same Permission.keys[] in one call, - // matching transaction-side §2.3.5 mixed-weight semantics. - private static final DataWord validateMultiFnDsa512Addr = new DataWord( - "0000000000000000000000000000000000000000000000000000000000000017"); + // 0x17 is intentionally unallocated. An earlier draft used it for a + // Falcon-only multi-sign precompile; that contract was merged into the + // algorithm-agnostic 0x1a ValidateMultiPQSig before either slot was + // activated. Re-allocating 0x17 requires a new TIP. // 0x18: batch independent Falcon-512 verify — bitmap of (sig, pk, addr) // matches; mixed-algorithm contracts call 0x0A and 0x18 separately and OR @@ -245,14 +244,17 @@ public class PrecompiledContracts { // 0x19: ML-DSA-44 single verify (FIPS 204 / CRYSTALS-Dilithium-2). TRON-style // layout uses the standard 1312-byte public key encoding rho‖t1, not the - // EIP-8051 22964-byte expanded form — the standard encoding lets us call + // EIP-8051 20512-byte expanded form — the standard encoding lets us call // BC's stock MLDSASigner directly without re-implementing FIPS 204 §6.5. private static final DataWord verifyMlDsa44Addr = new DataWord( "0000000000000000000000000000000000000000000000000000000000000019"); - // 0x1a: algorithm-agnostic Permission multi-sign with ML-DSA-44, mirroring - // 0x17's mixed ECDSA + PQ semantics for Dilithium signatures. - private static final DataWord validateMultiMlDsa44Addr = new DataWord( + // 0x1a: algorithm-agnostic Permission multi-sign — accepts ECDSA and any + // registered PQ scheme (Falcon-512, ML-DSA-44, ...) against the same + // Permission.keys[] in one call, dispatched by an explicit per-entry scheme + // tag. Replaces the earlier Falcon-only 0x17 and Dilithium-only draft, which + // were never activated. + private static final DataWord validateMultiPqSigAddr = new DataWord( "000000000000000000000000000000000000000000000000000000000000001a"); // 0x1b: batch independent ML-DSA-44 verify — bitmap output, same shape as 0x18. @@ -344,29 +346,33 @@ public static PrecompiledContract getContractForAddress(DataWord address) { return p256Verify; } - // FN-DSA-512 is the first PQ signature scheme supported by TRON, so its proposal flag - // gates every PQ-related precompile (single verify, multisig verify, batch verify). + // 0x1a ValidateMultiPQSig is algorithm-agnostic and dispatches per entry, + // so it is available whenever ANY registered PQ scheme is active. Per-entry + // runtime checks inside the precompile still reject scheme tags whose + // proposal hasn't passed. + if (VMConfig.allowFnDsa512() || VMConfig.allowMlDsa44()) { + if (address.equals(validateMultiPqSigAddr)) { + return validateMultiPqSig; + } + } + + // FN-DSA-512 (Falcon): single verify and batch verify are gated by their + // own proposal flag. if (VMConfig.allowFnDsa512()) { if (address.equals(verifyFnDsa512Addr)) { return verifyFnDsa512; } - if (address.equals(validateMultiFnDsa512Addr)) { - return validateMultiFnDsa512; - } if (address.equals(batchValidateFnDsa512Addr)) { return batchValidateFnDsa512; } } - // ML-DSA-44 (FIPS 204 / Dilithium-2): second registered PQ scheme; gated by - // its own proposal flag so it can be activated independently of FN-DSA-512. + // ML-DSA-44 (FIPS 204 / Dilithium-2): single verify and batch verify are + // gated by their own proposal flag. if (VMConfig.allowMlDsa44()) { if (address.equals(verifyMlDsa44Addr)) { return verifyMlDsa44; } - if (address.equals(validateMultiMlDsa44Addr)) { - return validateMultiMlDsa44; - } if (address.equals(batchValidateMlDsa44Addr)) { return batchValidateMlDsa44; } @@ -541,6 +547,23 @@ private static boolean isValidArrayOffset(DataWord[] words, int offsetWordIndex, return lengthWordIdx < words.length; } + /** + * Returns the logical Falcon-512 signature length packed at the start of a + * fixed slot {@code data[from..to)}: the offset of the last non-zero byte + * (exclusive). Canonical Falcon encodings always end in a non-zero byte + * ({@code compressed_s2}'s unary terminator), so anything beyond is zero + * padding. Returns 0 if the slot is all zero. Shared by 0x16, 0x18, and 0x1a + * because every precompile slot for Falcon sigs is the same 666-byte slot. + */ + static int recoverFalconSigLen(byte[] data, int from, int to) { + for (int i = to - 1; i >= from; i--) { + if (data[i] != 0) { + return i - from + 1; + } + } + return 0; + } + public abstract static class PrecompiledContract { protected static final byte[] DATA_FALSE = new byte[WORD_SIZE]; @@ -2487,23 +2510,31 @@ public Pair execute(byte[] data) { /** * Verifies a FN-DSA / Falcon-512 signature (FIPS-206 draft). EIP-8052 / TRON extension. * - *

Input layout (variable-length, EIP-8052-inspired): + *

Input layout (fixed-length, EIP-8052): *

-   *   [msg 32B | sig_len 2B (big-endian, 1..752) | sig sig_len B | pk 896B]
+   *   [msg 32B | sig 666B (zero-padded) | pk 896B]  total = 1594B
    * 
- * Total length must equal exactly {@code 32 + 2 + sig_len + 896} (no trailing - * bytes; matches 0x100 P256Verify / EIP-7951 strictness). + * Falcon-512 signatures are logically variable in + * [{@code FNDSA512.SIGNATURE_MIN_LENGTH}, {@code FNDSA512.SIGNATURE_LENGTH}] = + * [41, 666]; the precompile slot fixes a 666-byte window. Encoders write the + * canonical signature into the prefix of the slot and zero-pad the tail to + * length 666. The canonical Falcon encoding always ends in a non-zero byte + * (the {@code compressed_s2} unary terminator bit), so the logical length is + * recovered by scanning the slot backwards for the first non-zero byte. Total + * input length must equal exactly 1594 (no trailing bytes; matches 0x100 + * P256Verify / EIP-7951 strictness). * - *

Returns a 32-byte word: 1 on valid signature, 0 otherwise. - * Malformed input (wrong lengths, out-of-range sig_len) returns 0 without error. + *

Returns a 32-byte word: 1 on valid signature, 0 otherwise. Malformed + * input (wrong total length, sig slot all zero, recovered length out of + * range, BC verification failure) returns 0 without error. */ public static class VerifyFnDsa512 extends PrecompiledContract { private static final int MSG_LEN = 32; - private static final int SIG_LEN_FIELD = 2; + private static final int SIG_SLOT_LEN = FNDSA512.SIGNATURE_LENGTH; + private static final int SIG_MIN_LEN = FNDSA512.SIGNATURE_MIN_LENGTH; private static final int PK_LEN = FNDSA512.PUBLIC_KEY_LENGTH; - private static final int MAX_SIG_LEN = FNDSA512.SIGNATURE_LENGTH; - private static final int MIN_INPUT_LEN = MSG_LEN + SIG_LEN_FIELD + 1 + PK_LEN; + private static final int INPUT_LEN = MSG_LEN + SIG_SLOT_LEN + PK_LEN; @Override public long getEnergyForData(byte[] data) { @@ -2512,188 +2543,29 @@ public long getEnergyForData(byte[] data) { @Override public Pair execute(byte[] data) { - if (data == null || data.length < MIN_INPUT_LEN) { + if (data == null || data.length != INPUT_LEN) { return Pair.of(true, DataWord.ZERO().getData()); } try { byte[] msg = copyOfRange(data, 0, MSG_LEN); - int sigLen = ((data[MSG_LEN] & 0xFF) << 8) | (data[MSG_LEN + 1] & 0xFF); - if (sigLen < 1 || sigLen > MAX_SIG_LEN) { + int sigStart = MSG_LEN; + int sigEnd = MSG_LEN + SIG_SLOT_LEN; + int sigLen = recoverFalconSigLen(data, sigStart, sigEnd); + if (sigLen < SIG_MIN_LEN || sigLen > SIG_SLOT_LEN) { return Pair.of(true, DataWord.ZERO().getData()); } - int pkOffset = MSG_LEN + SIG_LEN_FIELD + sigLen; - // Strict equality (cf. 0x100 P256Verify): one logical input ↔ one encoding, - // leaves room for future EIP-8052 trailing fields. - if (data.length != pkOffset + PK_LEN) { - return Pair.of(true, DataWord.ZERO().getData()); - } - byte[] sig = copyOfRange(data, MSG_LEN + SIG_LEN_FIELD, pkOffset); - byte[] pk = copyOfRange(data, pkOffset, pkOffset + PK_LEN); + byte[] sig = copyOfRange(data, sigStart, sigStart + sigLen); + byte[] pk = copyOfRange(data, sigEnd, INPUT_LEN); boolean ok = FNDSA512.verify(pk, msg, sig); return Pair.of(true, ok ? DataWord.ONE().getData() : DataWord.ZERO().getData()); } catch (Throwable t) { return Pair.of(true, DataWord.ZERO().getData()); } } - } - - /** - * 0x17 ValidateMultiSign — algorithm-agnostic Permission multi-sign. - *

Mirrors 0x09 hash construction ({@code SHA-256(address ‖ permissionId(int4B) ‖ data)}) - * and threshold/dedup semantics, while accepting Falcon-512 entries alongside ECDSA - * against the same {@code Permission.keys[]}. The {@code data} field stays {@code bytes32} - * so the hash is bit-identical to 0x09. - * - *

ABI: - *

-   *   validateMultiSign(
-   *       address account,           // word[0]
-   *       uint256 permissionId,      // word[1]
-   *       bytes32 data,              // word[2]
-   *       bytes[] ecdsaSignatures,   // word[3] = offset; each entry 65 B
-   *       bytes[] pqSignatures,      // word[4] = offset; each entry 1..752 B
-   *       bytes[] pqPublicKeys       // word[5] = offset; each entry 896 B
-   *   ) returns (bool)
-   * 
- * - *

{@code MAX_SIZE = 5} applies to the total signature count - * ({@code ecdsaCnt + pqCnt}). Energy is split: {@code ecdsaCnt × 1500 + pqCnt × 2000}. - */ - public static class ValidateMultiFnDsa512 extends PrecompiledContract { - - private static final int ECDSA_ENERGY_PER_SIGN = 1500; - private static final int PQ_ENERGY_PER_SIGN = 2000; - private static final int MAX_SIZE = 5; - private static final int PK_LEN = FNDSA512.PUBLIC_KEY_LENGTH; - private static final int MAX_SIG_LEN = FNDSA512.SIGNATURE_LENGTH; - // address, permissionId, data, ecdsaOffset, pqSigOffset, pqPkOffset. - private static final int ABI_HEAD_WORDS = 6; - - @Override - public long getEnergyForData(byte[] data) { - try { - DataWord[] words = DataWord.parseArray(data); - int ecdsaCnt = words[words[3].intValueSafe() / WORD_SIZE].intValueSafe(); - int pqCnt = words[words[4].intValueSafe() / WORD_SIZE].intValueSafe(); - return (long) ecdsaCnt * ECDSA_ENERGY_PER_SIGN - + (long) pqCnt * PQ_ENERGY_PER_SIGN; - } catch (Throwable t) { - return (long) MAX_SIZE * PQ_ENERGY_PER_SIGN; - } - } - - @Override - public Pair execute(byte[] rawData) { - if (!isValidAbiHead(rawData, ABI_HEAD_WORDS)) { - return Pair.of(false, EMPTY_BYTE_ARRAY); - } - try { - DataWord[] words = DataWord.parseArray(rawData); - if (!isValidArrayOffset(words, 3, ABI_HEAD_WORDS) - || !isValidArrayOffset(words, 4, ABI_HEAD_WORDS) - || !isValidArrayOffset(words, 5, ABI_HEAD_WORDS)) { - return Pair.of(false, EMPTY_BYTE_ARRAY); - } - byte[] address = words[0].toTronAddress(); - int permissionId = words[1].intValueSafe(); - byte[] data = words[2].getData(); - - byte[] combine = ByteUtil.merge(address, ByteArray.fromInt(permissionId), data); - byte[] hash = Sha256Hash.hash(CommonParameter - .getInstance().isECKeyCryptoEngine(), combine); - - int ecdsaArrayWord = words[3].intValueSafe() / WORD_SIZE; - int pqSigArrayWord = words[4].intValueSafe() / WORD_SIZE; - int pqPkArrayWord = words[5].intValueSafe() / WORD_SIZE; - - int ecdsaCnt = words[ecdsaArrayWord].intValueSafe(); - int pqSigCnt = words[pqSigArrayWord].intValueSafe(); - int pqPkCnt = words[pqPkArrayWord].intValueSafe(); - - if (pqSigCnt != pqPkCnt - || ecdsaCnt + pqSigCnt == 0 - || ecdsaCnt + pqSigCnt > MAX_SIZE) { - return Pair.of(true, DATA_FALSE); - } - - byte[][] ecdsaSigs = extractSigArray(words, ecdsaArrayWord, rawData); - byte[][] pqSigs = extractBytesArray(words, pqSigArrayWord, rawData); - byte[][] pqPks = extractBytesArray(words, pqPkArrayWord, rawData); - - AccountCapsule account = this.getDeposit().getAccount(address); - if (account == null) { - return Pair.of(true, DATA_FALSE); - } - Permission permission = account.getPermissionById(permissionId); - if (permission == null) { - return Pair.of(true, DATA_FALSE); - } - - long totalWeight = 0L; - List executedSignList = new ArrayList<>(); - - for (byte[] sign : ecdsaSigs) { - byte[] recoveredAddr = recoverAddrBySign(sign, hash); - byte[] dedupKey = merge(recoveredAddr, sign); - if (ByteArray.matrixContains(executedSignList, recoveredAddr)) { - if (ByteArray.matrixContains(executedSignList, dedupKey)) { - continue; - } - MUtil.checkCPUTime(); - } - long weight = TransactionCapsule.getWeight(permission, recoveredAddr); - if (weight == 0) { - return Pair.of(true, DATA_FALSE); - } - totalWeight += weight; - executedSignList.add(dedupKey); - executedSignList.add(recoveredAddr); - } - - for (int i = 0; i < pqSigs.length; i++) { - byte[] sig = pqSigs[i]; - byte[] pk = pqPks[i]; - if (pk == null || pk.length != PK_LEN - || sig == null || sig.length < 1 || sig.length > MAX_SIG_LEN) { - return Pair.of(true, DATA_FALSE); - } - byte[] derivedAddr; - try { - derivedAddr = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pk); - } catch (Throwable t) { - return Pair.of(true, DATA_FALSE); - } - // Falcon-512 signing is randomized: the same key can produce many distinct - // valid signatures for the same hash. Dedup must therefore key on the - // derived address alone, otherwise an attacker could replay one key into - // the threshold N times via N different signatures. - if (ByteArray.matrixContains(executedSignList, derivedAddr)) { - continue; - } - long weight = TransactionCapsule.getWeight(permission, derivedAddr); - if (weight == 0) { - return Pair.of(true, DATA_FALSE); - } - if (!FNDSA512.verify(pk, hash, sig)) { - return Pair.of(true, DATA_FALSE); - } - totalWeight += weight; - executedSignList.add(derivedAddr); - } - - if (totalWeight >= permission.getThreshold()) { - return Pair.of(true, dataOne()); - } - } catch (Throwable t) { - if (t instanceof OutOfTimeException) { - throw t; - } - } - return Pair.of(true, DATA_FALSE); - } } + /** * 0x18 BatchValidateFnDsa512 — independent per-element Falcon-512 verify. *

Returns a 256-bit bitmap (matching 0x0A) where bit {@code i} is set iff @@ -2703,12 +2575,17 @@ public Pair execute(byte[] rawData) { *

    *   batchValidateFnDsa512(
    *       bytes32   hash,                  // word[0]
-   *       bytes[]   signatures,            // word[1] = offset; each 1..752 B
+   *       bytes[]   signatures,            // word[1] = offset; each 666 B (zero-padded slot,
+   *                                        //          logical sig ends at last non-zero byte)
    *       bytes[]   publicKeys,            // word[2] = offset; each 896 B
    *       bytes32[] expectedAddresses      // word[3] = offset; 21-byte addr in low 21 bytes
    *   ) returns (bytes32)
    * 
* + *

Falcon sigs are pinned to the 666-byte slot from {@code VerifyFnDsa512} (0x16) + * for cross-precompile consistency; {@link #recoverFalconSigLen} trims the slot to + * the canonical {@code [41, 666]} length before BC verification. + * *

Reuses the {@code BatchValidateSign.workers} pool when not in a constant * call and enforces {@code getCPUTimeLeftInNanoSecond()} timeout. {@code MAX_SIZE = 16}. * Energy is {@code cnt × 2000}. @@ -2718,7 +2595,8 @@ public static class BatchValidateFnDsa512 extends PrecompiledContract { private static final int ENERGY_PER_SIGN = 2000; private static final int MAX_SIZE = 16; private static final int PK_LEN = FNDSA512.PUBLIC_KEY_LENGTH; - private static final int MAX_SIG_LEN = FNDSA512.SIGNATURE_LENGTH; + private static final int SIG_SLOT_LEN = FNDSA512.SIGNATURE_LENGTH; + private static final int SIG_MIN_LEN = FNDSA512.SIGNATURE_MIN_LENGTH; // hash, sigArrayOffset, pkArrayOffset, addrArrayOffset. private static final int ABI_HEAD_WORDS = 4; @@ -2823,15 +2701,20 @@ private Pair doExecute(byte[] data) private static boolean verifyOne(byte[] sig, byte[] pk, byte[] hash, byte[] expectedAddr) { if (pk == null || pk.length != PK_LEN - || sig == null || sig.length < 1 || sig.length > MAX_SIG_LEN) { + || sig == null || sig.length != SIG_SLOT_LEN) { + return false; + } + int logical = recoverFalconSigLen(sig, 0, sig.length); + if (logical < SIG_MIN_LEN) { return false; } + byte[] canonicalSig = Arrays.copyOf(sig, logical); try { byte[] derived = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pk); if (!DataWord.equalAddressByteArray(derived, expectedAddr)) { return false; } - return FNDSA512.verify(pk, hash, sig); + return FNDSA512.verify(pk, hash, canonicalSig); } catch (Throwable t) { return false; } @@ -2869,20 +2752,17 @@ private static class PqVerifyResult { /** * Verifies an ML-DSA-44 signature (FIPS 204 / CRYSTALS-Dilithium-2). * - *

Input layout (fixed-length): - *

-   *   [msg 32B | sig 2420B | pk 1312B]   // total 3764 B, strict equality
-   * 
- * Uses the standard 1312-byte public key encoding {@code rho ‖ t1}. EIP-8051 - * defines an alternative 22964-byte "expanded" public-key layout - * ({@code A_hat ‖ tr ‖ t1_NTT}) that lets the verifier skip {@code ExpandA(rho)}; - * we deliberately diverge from that to call BC's stock {@code MLDSASigner} - * directly. Solidity callers that already produce 1312-byte standard keys - * can use this precompile unchanged; an expanded-pk variant can be added - * later without re-numbering this slot. + *

Input layout: {@code [msg 32B | sig 2420B | pk 1312B]} — total 3764 B, + * strict equality. Returns a 32-byte word (1 on valid, 0 otherwise); + * malformed input returns 0 without error. * - *

Returns a 32-byte word: 1 on valid signature, 0 otherwise. Malformed - * input (wrong length) returns 0 without error. + *

Diverges from EIP-8051 on pk only. {@code msg} and {@code sig} + * match EIP-8051; {@code pk} uses the standard FIPS-204 §4 encoding + * {@code rho ‖ t1} (1312 B) instead of EIP-8051's 20512 B expanded form + * (precomputed {@code A_hat = ExpandA(rho)}). BC 1.84's {@code MLDSASigner} + * only accepts the standard form; we pay the per-call {@code ExpandA} + * cost so 1312 B Dilithium-2 keys work unchanged. An expanded-pk variant, + * if added later, will get a new precompile slot — 0x19 stays as-is. */ public static class VerifyMlDsa44 extends PrecompiledContract { @@ -2914,32 +2794,77 @@ public Pair execute(byte[] data) { } /** - * 0x1a ValidateMultiMlDsa44 — algorithm-agnostic Permission multi-sign for - * ML-DSA-44, mirroring 0x17's ABI and mixed-weight semantics. Each pq - * signature is exactly 2420 B and each pq public key is exactly 1312 B. - * {@code MAX_SIZE = 5}; energy is {@code ecdsaCnt × 1500 + pqCnt × 4000} - * (Dilithium verify is ~2× a Falcon verify in our microbenchmarks). + * 0x1a ValidateMultiPQSig — algorithm-agnostic Permission multi-sign. Accepts + * ECDSA plus any registered post-quantum scheme (FN-DSA-512, ML-DSA-44, ...) + * against {@link Permission}{@code .keys[]} in a single call, dispatched per + * entry by an explicit {@code uint8[]} scheme tag array (PQScheme number). + * + *

ABI: + *

+   *   validateMultiPqSign(
+   *       address account,        // word[0]
+   *       uint256 permissionId,   // word[1]
+   *       bytes32 data,           // word[2]
+   *       bytes[] ecdsaSigs,      // word[3] = offset; 65 B each
+   *       uint8[] pqSchemes,      // word[4] = offset; FN_DSA_512=1, ML_DSA_44=2
+   *       bytes[] pqSigs,         // word[5] = offset; per-scheme fixed slot
+   *       bytes[] pqPks           // word[6] = offset; per-scheme exact length
+   *   ) returns (bytes32)         // 1 on (totalWeight >= threshold), 0 otherwise
+   * 
+ * + *

Falcon sigs follow the EIP-8052 666-byte fixed slot convention (matches + * 0x16/0x18): the slot is zero-padded and the logical sig ends at the last + * non-zero byte (Falcon's canonical encoding always ends with a non-zero + * {@code compressed_s2} terminator). Dilithium sigs are exactly 2420 B and + * Dilithium pks 1312 B. + * + *

{@code MAX_SIZE = 5} across ECDSA + PQ entries combined. Energy is + * {@code ecdsaCnt × 1500 + sum_i pqEnergy(scheme_i)} with FN-DSA-512 = 2000 + * and ML-DSA-44 = 4000. Unknown tags are charged at worst case so an attacker + * cannot underpay by encoding a tag the dispatcher will then reject. + * + *

Per-entry runtime gate: a Falcon entry returns {@code DATA_FALSE} when + * {@code allowFnDsa512()} is false even though 0x1a itself is registered as + * long as one PQ proposal is active. Same for ML-DSA-44. */ - public static class ValidateMultiMlDsa44 extends PrecompiledContract { + public static class ValidateMultiPQSig extends PrecompiledContract { private static final int ECDSA_ENERGY_PER_SIGN = 1500; - private static final int PQ_ENERGY_PER_SIGN = 4000; + private static final int FN_DSA_512_ENERGY = 2000; + private static final int ML_DSA_44_ENERGY = 4000; + private static final int WORST_PQ_ENERGY = ML_DSA_44_ENERGY; private static final int MAX_SIZE = 5; - private static final int PK_LEN = MLDSA44.PUBLIC_KEY_LENGTH; - private static final int SIG_LEN = MLDSA44.SIGNATURE_LENGTH; - // address, permissionId, data, ecdsaOffset, pqSigOffset, pqPkOffset. - private static final int ABI_HEAD_WORDS = 6; + // address, permissionId, data, ecdsaOff, schemeOff, pqSigOff, pqPkOff. + private static final int ABI_HEAD_WORDS = 7; + + private static final Map PQ_ENERGY; + + static { + EnumMap m = new EnumMap<>(PQScheme.class); + m.put(PQScheme.FN_DSA_512, FN_DSA_512_ENERGY); + m.put(PQScheme.ML_DSA_44, ML_DSA_44_ENERGY); + PQ_ENERGY = m; + } @Override public long getEnergyForData(byte[] data) { try { DataWord[] words = DataWord.parseArray(data); int ecdsaCnt = words[words[3].intValueSafe() / WORD_SIZE].intValueSafe(); - int pqCnt = words[words[4].intValueSafe() / WORD_SIZE].intValueSafe(); - return (long) ecdsaCnt * ECDSA_ENERGY_PER_SIGN - + (long) pqCnt * PQ_ENERGY_PER_SIGN; + int schemeOff = words[4].intValueSafe() / WORD_SIZE; + int pqCnt = words[schemeOff].intValueSafe(); + long energy = (long) ecdsaCnt * ECDSA_ENERGY_PER_SIGN; + for (int i = 0; i < pqCnt; i++) { + int tag = words[schemeOff + 1 + i].intValueSafe(); + PQScheme s = PQScheme.forNumber(tag); + Integer cost = s == null ? null : PQ_ENERGY.get(s); + // Unknown / unregistered tag → charge worst case so a caller can't + // encode a junk tag to underpay before execute() rejects it. + energy += cost == null ? WORST_PQ_ENERGY : cost; + } + return energy; } catch (Throwable t) { - return (long) MAX_SIZE * PQ_ENERGY_PER_SIGN; + return (long) MAX_SIZE * WORST_PQ_ENERGY; } } @@ -2952,7 +2877,8 @@ public Pair execute(byte[] rawData) { DataWord[] words = DataWord.parseArray(rawData); if (!isValidArrayOffset(words, 3, ABI_HEAD_WORDS) || !isValidArrayOffset(words, 4, ABI_HEAD_WORDS) - || !isValidArrayOffset(words, 5, ABI_HEAD_WORDS)) { + || !isValidArrayOffset(words, 5, ABI_HEAD_WORDS) + || !isValidArrayOffset(words, 6, ABI_HEAD_WORDS)) { return Pair.of(false, EMPTY_BYTE_ARRAY); } byte[] address = words[0].toTronAddress(); @@ -2964,22 +2890,28 @@ public Pair execute(byte[] rawData) { .getInstance().isECKeyCryptoEngine(), combine); int ecdsaArrayWord = words[3].intValueSafe() / WORD_SIZE; - int pqSigArrayWord = words[4].intValueSafe() / WORD_SIZE; - int pqPkArrayWord = words[5].intValueSafe() / WORD_SIZE; + int schemeArrayWord = words[4].intValueSafe() / WORD_SIZE; + int pqSigArrayWord = words[5].intValueSafe() / WORD_SIZE; + int pqPkArrayWord = words[6].intValueSafe() / WORD_SIZE; int ecdsaCnt = words[ecdsaArrayWord].intValueSafe(); + int schemeCnt = words[schemeArrayWord].intValueSafe(); int pqSigCnt = words[pqSigArrayWord].intValueSafe(); int pqPkCnt = words[pqPkArrayWord].intValueSafe(); - if (pqSigCnt != pqPkCnt - || ecdsaCnt + pqSigCnt == 0 - || ecdsaCnt + pqSigCnt > MAX_SIZE) { + if (schemeCnt != pqSigCnt || schemeCnt != pqPkCnt + || ecdsaCnt + schemeCnt == 0 + || ecdsaCnt + schemeCnt > MAX_SIZE) { return Pair.of(true, DATA_FALSE); } byte[][] ecdsaSigs = extractSigArray(words, ecdsaArrayWord, rawData); byte[][] pqSigs = extractBytesArray(words, pqSigArrayWord, rawData); byte[][] pqPks = extractBytesArray(words, pqPkArrayWord, rawData); + int[] schemes = new int[schemeCnt]; + for (int i = 0; i < schemeCnt; i++) { + schemes[i] = words[schemeArrayWord + 1 + i].intValueSafe(); + } AccountCapsule account = this.getDeposit().getAccount(address); if (account == null) { @@ -3011,22 +2943,46 @@ public Pair execute(byte[] rawData) { executedSignList.add(recoveredAddr); } - for (int i = 0; i < pqSigs.length; i++) { + for (int i = 0; i < schemes.length; i++) { + PQScheme scheme = PQScheme.forNumber(schemes[i]); + if (scheme == null || scheme == PQScheme.UNKNOWN_PQ_SCHEME + || !PQSchemeRegistry.contains(scheme)) { + return Pair.of(true, DATA_FALSE); + } + // Per-entry runtime gate: the scheme's proposal must be active even + // though 0x1a was registered under (allowFnDsa512 || allowMlDsa44). + if (scheme == PQScheme.FN_DSA_512 && !VMConfig.allowFnDsa512()) { + return Pair.of(true, DATA_FALSE); + } + if (scheme == PQScheme.ML_DSA_44 && !VMConfig.allowMlDsa44()) { + return Pair.of(true, DATA_FALSE); + } byte[] sig = pqSigs[i]; byte[] pk = pqPks[i]; - if (pk == null || pk.length != PK_LEN - || sig == null || sig.length != SIG_LEN) { + int expectedPkLen = PQSchemeRegistry.getPublicKeyLength(scheme); + int expectedSigSlot = PQSchemeRegistry.getSignatureLength(scheme); + if (pk == null || pk.length != expectedPkLen + || sig == null || sig.length != expectedSigSlot) { + // Slot lengths are exact here (Falcon = 666, Dilithium = 2420) — + // a Falcon sig mislabelled as Dilithium fails this check. return Pair.of(true, DATA_FALSE); } + if (scheme == PQScheme.FN_DSA_512) { + int logical = recoverFalconSigLen(sig, 0, sig.length); + if (logical < FNDSA512.SIGNATURE_MIN_LENGTH) { + return Pair.of(true, DATA_FALSE); + } + sig = Arrays.copyOf(sig, logical); + } byte[] derivedAddr; try { - derivedAddr = PQSchemeRegistry.computeAddress(PQScheme.ML_DSA_44, pk); + derivedAddr = PQSchemeRegistry.computeAddress(scheme, pk); } catch (Throwable t) { return Pair.of(true, DATA_FALSE); } - // ML-DSA signing is randomized (rho' is hashed from the seeded RNG), - // so the same key can produce many valid signatures for one message. - // Dedup keyed on the derived address — same reasoning as 0x17. + // Both Falcon and Dilithium signing are randomized → the same key + // can produce many valid sigs for one message, so dedup keys on the + // derived address only (the sig blob is not a stable identity). if (ByteArray.matrixContains(executedSignList, derivedAddr)) { continue; } @@ -3034,7 +2990,7 @@ public Pair execute(byte[] rawData) { if (weight == 0) { return Pair.of(true, DATA_FALSE); } - if (!MLDSA44.verify(pk, hash, sig)) { + if (!PQSchemeRegistry.verify(scheme, pk, hash, sig)) { return Pair.of(true, DATA_FALSE); } totalWeight += weight; diff --git a/common/src/main/java/org/tron/core/config/args/CommitteeConfig.java b/common/src/main/java/org/tron/core/config/args/CommitteeConfig.java index 5cd9de842a0..e281083d77c 100644 --- a/common/src/main/java/org/tron/core/config/args/CommitteeConfig.java +++ b/common/src/main/java/org/tron/core/config/args/CommitteeConfig.java @@ -83,6 +83,8 @@ public class CommitteeConfig { private long dynamicEnergyThreshold = 0; private long dynamicEnergyIncreaseFactor = 0; private long dynamicEnergyMaxFactor = 0; + private long allowFnDsa512 = 0; + private long allowMlDsa44 = 0; // proposalExpireTime is NOT a committee field — it's in block.* and handled by BlockConfig diff --git a/common/src/main/java/org/tron/core/config/args/LocalWitnessConfig.java b/common/src/main/java/org/tron/core/config/args/LocalWitnessConfig.java index 8a2cd2ce9e4..f96a2216341 100644 --- a/common/src/main/java/org/tron/core/config/args/LocalWitnessConfig.java +++ b/common/src/main/java/org/tron/core/config/args/LocalWitnessConfig.java @@ -2,22 +2,28 @@ import com.typesafe.config.Config; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import lombok.Getter; import lombok.extern.slf4j.Slf4j; /** * Local witness configuration bean. - * Reads top-level config keys: localwitness, localWitnessAccountAddress, localwitnesskeystore. - * These are not under a sub-section — they are at the root of config.conf. + * Reads top-level config keys: localwitness, localWitnessAccountAddress, + * localwitnesskeystore, and localwitness_pq.keys. These are not under a + * sub-section — they are at the root of config.conf. */ @Slf4j @Getter public class LocalWitnessConfig { + /** Path of the PQ witness key list within config.conf. */ + public static final String PQ_KEYS_PATH = "localwitness_pq.keys"; + private List privateKeys = new ArrayList<>(); private String accountAddress = null; private List keystores = new ArrayList<>(); + private List pqEntries = Collections.emptyList(); public static LocalWitnessConfig fromConfig(Config config) { LocalWitnessConfig lw = new LocalWitnessConfig(); @@ -30,6 +36,18 @@ public static LocalWitnessConfig fromConfig(Config config) { if (config.hasPath("localwitnesskeystore")) { lw.keystores = config.getStringList("localwitnesskeystore"); } + if (config.hasPath(PQ_KEYS_PATH)) { + List raw = config.getConfigList(PQ_KEYS_PATH); + List entries = new ArrayList<>(raw.size()); + for (int i = 0; i < raw.size(); i++) { + Config entry = raw.get(i); + String scheme = entry.hasPath("scheme") ? entry.getString("scheme") : null; + String key = entry.hasPath("key") ? entry.getString("key") : null; + String seed = entry.hasPath("seed") ? entry.getString("seed") : null; + entries.add(new PqEntryConfig(i, scheme, key, seed)); + } + lw.pqEntries = entries; + } return lw; } } diff --git a/common/src/main/java/org/tron/core/config/args/PqEntryConfig.java b/common/src/main/java/org/tron/core/config/args/PqEntryConfig.java new file mode 100644 index 00000000000..2de0ecc4f4e --- /dev/null +++ b/common/src/main/java/org/tron/core/config/args/PqEntryConfig.java @@ -0,0 +1,33 @@ +package org.tron.core.config.args; + +import lombok.Getter; + +/** + * Raw HOCON shape of a single {@code localwitness_pq.keys[i]} entry. + * Carries the unparsed string fields so module {@code common} stays free + * of any crypto-module dependency; scheme-registry lookups and key/seed + * decoding live in {@link Args#buildPqWitnesses}. + */ +@Getter +public class PqEntryConfig { + + private final int index; + private final String scheme; + private final String key; + private final String seed; + + public PqEntryConfig(int index, String scheme, String key, String seed) { + this.index = index; + this.scheme = scheme; + this.key = key; + this.seed = seed; + } + + public boolean hasKey() { + return key != null; + } + + public boolean hasSeed() { + return seed != null; + } +} diff --git a/common/src/main/resources/reference.conf b/common/src/main/resources/reference.conf index 76225aa0bed..c4a599765b6 100644 --- a/common/src/main/resources/reference.conf +++ b/common/src/main/resources/reference.conf @@ -756,6 +756,8 @@ committee = { dynamicEnergyThreshold = 0 dynamicEnergyIncreaseFactor = 0 dynamicEnergyMaxFactor = 0 + allowFnDsa512 = 0 + allowMlDsa44 = 0 } event.subscribe = { diff --git a/common/src/test/java/org/tron/core/config/args/LocalWitnessConfigTest.java b/common/src/test/java/org/tron/core/config/args/LocalWitnessConfigTest.java index 0c163ef31f7..8eb3a30ded9 100644 --- a/common/src/test/java/org/tron/core/config/args/LocalWitnessConfigTest.java +++ b/common/src/test/java/org/tron/core/config/args/LocalWitnessConfigTest.java @@ -1,6 +1,7 @@ package org.tron.core.config.args; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; @@ -25,6 +26,7 @@ public void testDefaults() { assertTrue(lw.getPrivateKeys().isEmpty()); assertNull(lw.getAccountAddress()); assertTrue(lw.getKeystores().isEmpty()); + assertTrue(lw.getPqEntries().isEmpty()); } @Test @@ -45,4 +47,38 @@ public void testWithKeystores() { LocalWitnessConfig lw = LocalWitnessConfig.fromConfig(config); assertEquals(1, lw.getKeystores().size()); } + + @Test + public void testWithPqEntries() { + Config config = withRef( + "localwitness_pq.keys = [\n" + + " { scheme = \"FN_DSA_512\", key = \"deadbeef\" },\n" + + " { scheme = \"ML_DSA_44\", seed = \"cafebabe\" },\n" + + " { scheme = \"FN_DSA_512\" }\n" + + "]"); + LocalWitnessConfig lw = LocalWitnessConfig.fromConfig(config); + assertEquals(3, lw.getPqEntries().size()); + + PqEntryConfig first = lw.getPqEntries().get(0); + assertEquals(0, first.getIndex()); + assertEquals("FN_DSA_512", first.getScheme()); + assertEquals("deadbeef", first.getKey()); + assertNull(first.getSeed()); + assertTrue(first.hasKey()); + assertFalse(first.hasSeed()); + + PqEntryConfig second = lw.getPqEntries().get(1); + assertEquals(1, second.getIndex()); + assertEquals("ML_DSA_44", second.getScheme()); + assertNull(second.getKey()); + assertEquals("cafebabe", second.getSeed()); + + // Shape validation (e.g. missing key/seed, unknown scheme) is left to Args; + // the bean only normalizes presence into nullable fields. + PqEntryConfig third = lw.getPqEntries().get(2); + assertEquals(2, third.getIndex()); + assertEquals("FN_DSA_512", third.getScheme()); + assertFalse(third.hasKey()); + assertFalse(third.hasSeed()); + } } diff --git a/consensus/src/main/java/org/tron/consensus/base/Param.java b/consensus/src/main/java/org/tron/consensus/base/Param.java index fd9b130a3c3..4c7d075f061 100644 --- a/consensus/src/main/java/org/tron/consensus/base/Param.java +++ b/consensus/src/main/java/org/tron/consensus/base/Param.java @@ -54,14 +54,6 @@ public static Param getInstance() { return param; } - /** Signing key family carried by a {@link Miner}. */ - public enum MinerType { - /** Legacy ECDSA / SM2 witness; signs blocks via {@code BlockCapsule.sign}. */ - ECDSA, - /** Post-quantum witness; signs blocks via {@code signWitnessAuth}. */ - PQ - } - public class Miner { @Getter diff --git a/framework/src/main/java/org/tron/core/config/args/Args.java b/framework/src/main/java/org/tron/core/config/args/Args.java index 1e2681880d7..02c56e6eb88 100644 --- a/framework/src/main/java/org/tron/core/config/args/Args.java +++ b/framework/src/main/java/org/tron/core/config/args/Args.java @@ -497,6 +497,8 @@ private static void applyCommitteeConfig(CommitteeConfig cc) { PARAMETER.dynamicEnergyThreshold = cc.getDynamicEnergyThreshold(); PARAMETER.dynamicEnergyIncreaseFactor = cc.getDynamicEnergyIncreaseFactor(); PARAMETER.dynamicEnergyMaxFactor = cc.getDynamicEnergyMaxFactor(); + PARAMETER.allowFnDsa512 = cc.getAllowFnDsa512(); + PARAMETER.allowMlDsa44 = cc.getAllowMlDsa44(); } /** @@ -783,14 +785,6 @@ public static void applyConfigParams( eventConfig = EventConfig.fromConfig(config); applyEventConfig(eventConfig); - PARAMETER.allowFnDsa512 = - config.hasPath(ConfigKey.COMMITTEE_ALLOW_FN_DSA_512) ? config - .getInt(ConfigKey.COMMITTEE_ALLOW_FN_DSA_512) : 0; - - PARAMETER.allowMlDsa44 = - config.hasPath(ConfigKey.COMMITTEE_ALLOW_ML_DSA_44) ? config - .getInt(ConfigKey.COMMITTEE_ALLOW_ML_DSA_44) : 0; - logConfig(); } @@ -942,8 +936,7 @@ private static void initLocalWitnesses(Config config, CLIParameter cmd) { boolean hasCliPriv = StringUtils.isNotBlank(cmd.privateKey); boolean hasCfgPriv = !lwConfig.getPrivateKeys().isEmpty(); boolean hasKeystore = !lwConfig.getKeystores().isEmpty(); - boolean hasPqKeys = config.hasPath(ConfigKey.LOCAL_WITNESS_PQ_KEYS) - && !config.getConfigList(ConfigKey.LOCAL_WITNESS_PQ_KEYS).isEmpty(); + boolean hasPqKeys = !lwConfig.getPqEntries().isEmpty(); // Load the ECDSA source. CLI > config localwitness > keystore — the three // legacy sources stay mutually exclusive among themselves. @@ -974,11 +967,11 @@ private static void initLocalWitnesses(Config config, CLIParameter cmd) { && StringUtils.isNotBlank(lwConfig.getAccountAddress())) { throw new TronError( "localWitnessAccountAddress cannot be combined with both legacy and " - + ConfigKey.LOCAL_WITNESS_PQ_KEYS + "; remove the override or " + + LocalWitnessConfig.PQ_KEYS_PATH + "; remove the override or " + "configure only one key source", TronError.ErrCode.WITNESS_INIT); } - pqWitnesses = buildPqWitnesses(config, pqAccountAddress); + pqWitnesses = buildPqWitnesses(lwConfig.getPqEntries(), pqAccountAddress); } if (ecdsaWitnesses == null && pqWitnesses == null) { @@ -1000,57 +993,51 @@ private static void initLocalWitnesses(Config config, CLIParameter cmd) { } } - private static LocalWitnesses buildPqWitnesses(Config config, String accountAddress) { + private static LocalWitnesses buildPqWitnesses(List pqEntries, + String accountAddress) { // Each entry is an object { scheme = "", key | seed = "" } // so a single node can host SRs running different PQ algorithms (e.g. // Falcon-512 and ML-DSA-44 side by side). `key` carries the expanded // priv‖pub hex (any scheme); `seed` carries the keygen seed hex and is // accepted only when PQSchemeRegistry.isSeedDeterministic(scheme) is true. - List pqEntries = - config.getConfigList(ConfigKey.LOCAL_WITNESS_PQ_KEYS); + String path = LocalWitnessConfig.PQ_KEYS_PATH; List pqKeypairs = new ArrayList<>(pqEntries.size()); - for (int i = 0; i < pqEntries.size(); i++) { - Config entry = pqEntries.get(i); - if (!entry.hasPath("scheme")) { + for (PqEntryConfig entry : pqEntries) { + int i = entry.getIndex(); + if (entry.getScheme() == null) { throw new TronError(String.format( - "%s[%d] must define `scheme`", - ConfigKey.LOCAL_WITNESS_PQ_KEYS, i), + "%s[%d] must define `scheme`", path, i), TronError.ErrCode.WITNESS_INIT); } - boolean hasKey = entry.hasPath("key"); - boolean hasSeed = entry.hasPath("seed"); - if (hasKey == hasSeed) { + if (entry.hasKey() == entry.hasSeed()) { throw new TronError(String.format( - "%s[%d] must define exactly one of `key` or `seed`", - ConfigKey.LOCAL_WITNESS_PQ_KEYS, i), + "%s[%d] must define exactly one of `key` or `seed`", path, i), TronError.ErrCode.WITNESS_INIT); } - String schemeName = entry.getString("scheme"); PQScheme scheme; try { - scheme = PQScheme.valueOf(schemeName); + scheme = PQScheme.valueOf(entry.getScheme()); } catch (IllegalArgumentException e) { throw new TronError(String.format("invalid %s[%d].scheme: %s", - ConfigKey.LOCAL_WITNESS_PQ_KEYS, i, schemeName), + path, i, entry.getScheme()), TronError.ErrCode.WITNESS_INIT); } if (!PQSchemeRegistry.contains(scheme)) { throw new TronError(String.format( "unsupported %s[%d].scheme: %s; registered schemes: %s", - ConfigKey.LOCAL_WITNESS_PQ_KEYS, i, schemeName, - PQSchemeRegistry.registeredSchemes()), + path, i, entry.getScheme(), PQSchemeRegistry.registeredSchemes()), TronError.ErrCode.WITNESS_INIT); } String privHex; String pubHex; - if (hasKey) { + if (entry.hasKey()) { int privHexLen = PQSchemeRegistry.getPrivateKeyLength(scheme) * 2; int extHexLen = privHexLen + PQSchemeRegistry.getPublicKeyLength(scheme) * 2; - String stripped = stripHexPrefix(entry.getString("key")); + String stripped = stripHexPrefix(entry.getKey()); if (stripped == null || stripped.length() != extHexLen) { throw new TronError(String.format( "%s[%d].key must be %d hex chars (extended priv‖pub for %s), actual: %d", - ConfigKey.LOCAL_WITNESS_PQ_KEYS, i, extHexLen, scheme, + path, i, extHexLen, scheme, stripped == null ? 0 : stripped.length()), TronError.ErrCode.WITNESS_INIT); } @@ -1064,15 +1051,15 @@ private static LocalWitnesses buildPqWitnesses(Config config, String accountAddr throw new TronError(String.format( "%s[%d].seed is not supported for %s (non-deterministic keygen); " + "use `key` with the extended priv‖pub hex instead", - ConfigKey.LOCAL_WITNESS_PQ_KEYS, i, scheme), + path, i, scheme), TronError.ErrCode.WITNESS_INIT); } int seedHexLen = PQSchemeRegistry.getSeedLength(scheme) * 2; - String stripped = stripHexPrefix(entry.getString("seed")); + String stripped = stripHexPrefix(entry.getSeed()); if (stripped == null || stripped.length() != seedHexLen) { throw new TronError(String.format( "%s[%d].seed must be %d hex chars for %s, actual: %d", - ConfigKey.LOCAL_WITNESS_PQ_KEYS, i, seedHexLen, scheme, + path, i, seedHexLen, scheme, stripped == null ? 0 : stripped.length()), TronError.ErrCode.WITNESS_INIT); } @@ -1082,7 +1069,7 @@ private static LocalWitnesses buildPqWitnesses(Config config, String accountAddr } catch (RuntimeException e) { throw new TronError(String.format( "%s[%d].seed is not valid hex for %s: %s", - ConfigKey.LOCAL_WITNESS_PQ_KEYS, i, scheme, e.getMessage()), + path, i, scheme, e.getMessage()), TronError.ErrCode.WITNESS_INIT); } PQSignature derived = PQSchemeRegistry.fromSeed(scheme, seedBytes); diff --git a/framework/src/main/java/org/tron/core/config/args/ConfigKey.java b/framework/src/main/java/org/tron/core/config/args/ConfigKey.java deleted file mode 100644 index 47df21e33ec..00000000000 --- a/framework/src/main/java/org/tron/core/config/args/ConfigKey.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.tron.core.config.args; - -public final class ConfigKey { - - public static final String COMMITTEE_ALLOW_FN_DSA_512 = "committee.allowFnDsa512"; - - public static final String COMMITTEE_ALLOW_ML_DSA_44 = "committee.allowMlDsa44"; - - public static final String LOCAL_WITNESS_PQ_KEYS = "localwitness_pq.keys"; - - private ConfigKey() { - } -} diff --git a/framework/src/main/java/org/tron/core/consensus/ConsensusService.java b/framework/src/main/java/org/tron/core/consensus/ConsensusService.java index c6752aedc16..25e2593f365 100644 --- a/framework/src/main/java/org/tron/core/consensus/ConsensusService.java +++ b/framework/src/main/java/org/tron/core/consensus/ConsensusService.java @@ -93,29 +93,18 @@ public void start() { if (pqKeypairs.size() > 1) { for (PqKeypair kp : pqKeypairs) { - PQScheme scheme = kp.getScheme(); - requireSupportedPqScheme(scheme); - byte[] privBytes = fromHexString(kp.getPrivateKey()); - byte[] pubBytes = fromHexString(kp.getPublicKey()); - PQSignature keypair = PQSchemeRegistry.fromKeypair(scheme, privBytes, pubBytes); - byte[] sk = keypair.getPrivateKey(); - byte[] pk = keypair.getPublicKey(); - byte[] pqAddress = keypair.getAddress(); - WitnessCapsule witnessCapsule = witnessStore.get(pqAddress); - if (null == witnessCapsule) { - logger.warn("Witness {} is not in witnessStore.", Hex.toHexString(pqAddress)); - } - ByteString pqAddressBs = ByteString.copyFrom(pqAddress); - Miner miner = param.new Miner(null, pqAddressBs, pqAddressBs); - miner.setPQPrivateKey(sk); - miner.setPQPublicKey(pk); - miner.setPqScheme(scheme); + Miner miner = buildPQMiner(param, kp, null); miners.add(miner); logger.info("Add {} witness (from configured keypair): {}, size: {}", - scheme, Hex.toHexString(pqAddress), miners.size()); + kp.getScheme(), Hex.toHexString(miner.getWitnessAddress().toByteArray()), + miners.size()); } } else if (pqKeypairs.size() == 1) { - miners.add(buildPQOnlyMinerFromKeypair(param, pqKeypairs.get(0))); + Miner miner = buildPQMiner(param, pqKeypairs.get(0), + Args.getLocalWitnesses().getWitnessAccountAddress()); + miners.add(miner); + logger.info("Add {} witness (from configured keypair): {}", + miner.getPqScheme(), Hex.toHexString(miner.getWitnessAddress().toByteArray())); } param.setMiners(miners); @@ -125,31 +114,32 @@ public void start() { logger.info("consensus service start success"); } - private Miner buildPQOnlyMinerFromKeypair(Param param, PqKeypair pqKeypair) { + /** + * Builds a PQ-only miner from a configured keypair. When {@code witnessAddressOverride} + * is non-empty (single-witness mode), the override is used as the witness account + * address while the PQ-derived address fills the key-address slot — letting multi-sig + * permission setups route signing through a witness account distinct from the key. + * In multi-witness mode the override does not apply (a single config value cannot + * address N witnesses), so callers pass {@code null} and the PQ-derived address + * fills both slots. + */ + private Miner buildPQMiner(Param param, PqKeypair pqKeypair, byte[] witnessAddressOverride) { PQScheme scheme = pqKeypair.getScheme(); requireSupportedPqScheme(scheme); - byte[] privBytes = fromHexString(pqKeypair.getPrivateKey()); - byte[] pubBytes = fromHexString(pqKeypair.getPublicKey()); - PQSignature keypair = PQSchemeRegistry.fromKeypair(scheme, privBytes, pubBytes); - byte[] sk = keypair.getPrivateKey(); - byte[] pk = keypair.getPublicKey(); + PQSignature keypair = PQSchemeRegistry.fromKeypair(scheme, + fromHexString(pqKeypair.getPrivateKey()), fromHexString(pqKeypair.getPublicKey())); byte[] pqAddress = keypair.getAddress(); - byte[] witnessAddress = Args.getLocalWitnesses().getWitnessAccountAddress(); - if (witnessAddress == null || witnessAddress.length == 0) { - witnessAddress = pqAddress; - } - WitnessCapsule witnessCapsule = witnessStore.get(witnessAddress); - if (null == witnessCapsule) { + byte[] witnessAddress = + (witnessAddressOverride != null && witnessAddressOverride.length > 0) + ? witnessAddressOverride : pqAddress; + if (witnessStore.get(witnessAddress) == null) { logger.warn("Witness {} is not in witnessStore.", Hex.toHexString(witnessAddress)); } - // In multi-signature mode, the address derived from the PQ key may differ from witnessAddress. - Miner miner = param.new Miner(null, ByteString.copyFrom(pqAddress), - ByteString.copyFrom(witnessAddress)); - miner.setPQPrivateKey(sk); - miner.setPQPublicKey(pk); + Miner miner = param.new Miner(null, + ByteString.copyFrom(pqAddress), ByteString.copyFrom(witnessAddress)); + miner.setPQPrivateKey(keypair.getPrivateKey()); + miner.setPQPublicKey(keypair.getPublicKey()); miner.setPqScheme(scheme); - logger.info("Add {} witness (from configured keypair): {}", - scheme, Hex.toHexString(witnessAddress)); return miner; } diff --git a/framework/src/main/java/org/tron/core/db/Manager.java b/framework/src/main/java/org/tron/core/db/Manager.java index a5d716cfd08..8b88047065d 100644 --- a/framework/src/main/java/org/tron/core/db/Manager.java +++ b/framework/src/main/java/org/tron/core/db/Manager.java @@ -1747,8 +1747,17 @@ public BlockCapsule generateBlock(Miner miner, long blockTime, long timeout) { session.reset(); blockCapsule.setMerkleRoot(); - if (getDynamicPropertiesStore().isAnyPqSchemeAllowed() - && miner.getPqScheme() != null) { + if (miner.getPqScheme() != null) { + // PQ-only miner: never fall back to ECDSA signing — miner.getPrivateKey() is + // null on this path, and a silent fallback would NPE inside blockCapsule.sign. + // Fail fast with a clear cause; DposTask's Throwable handler logs it and the + // witness misses this slot, but the producer thread stays alive. + if (!getDynamicPropertiesStore().isAnyPqSchemeAllowed()) { + throw new IllegalStateException( + "PQ miner " + Hex.toHexString(miner.getWitnessAddress().toByteArray()) + + " has scheme " + miner.getPqScheme() + + " configured but no PQ scheme is allowed by dynamic properties"); + } signBlockCapsuleWithPQ(blockCapsule, miner); } else { blockCapsule.sign(miner.getPrivateKey()); @@ -1772,19 +1781,16 @@ private void signBlockCapsuleWithPQ(BlockCapsule blockCapsule, Miner miner) { PQScheme scheme = miner.getPqScheme(); if (scheme == null || !PQSchemeRegistry.contains(scheme)) { throw new IllegalStateException( - "PQ-only miner " + Hex.toHexString(miner.getWitnessAddress().toByteArray()) + "PQ miner " + Hex.toHexString(miner.getWitnessAddress().toByteArray()) + " has scheme " + miner.getPqScheme() - + " configured but it is not currently usable " - + "or witness permission is missing/empty)"); + + " which is not registered in PQSchemeRegistry"); } if (!chainBaseManager.getDynamicPropertiesStore().isPqSchemeAllowed(scheme)) { throw new IllegalStateException( - "PQ-only miner " + Hex.toHexString(miner.getWitnessAddress().toByteArray()) - + " has scheme " + miner.getPqScheme() + "PQ miner " + Hex.toHexString(miner.getWitnessAddress().toByteArray()) + + " has scheme " + scheme + " but it is not allowed by dynamic properties"); } - - byte[] pqPrivateKey = miner.getPQPrivateKey(); byte[] pqPublicKey = miner.getPQPublicKey(); if (pqPrivateKey == null || pqPublicKey == null) { diff --git a/framework/src/main/resources/config.conf b/framework/src/main/resources/config.conf index fc563623a79..653b00b4647 100644 --- a/framework/src/main/resources/config.conf +++ b/framework/src/main/resources/config.conf @@ -702,30 +702,16 @@ localwitness = [ # "localwitnesskeystore.json" # ] -# Post-quantum witness signing. Each `keys` entry pins its own `scheme` (the -# PQ algorithm) and exactly one of `key` or `seed`, so a single node can host -# SRs running different PQ algorithms side by side. +# Post-quantum witness signing. Each entry pins its own `scheme` and exactly +# one of `key` or `seed`. Keypairs must be generated off-line. # -# `key` — hex-encoded extended private key (priv‖pub). Required for any -# scheme whose keygen is not reproducible across platforms. -# Length is scheme-specific: 4352 hex chars for FN_DSA_512, -# 11648 hex chars for ML_DSA_44. -# `seed` — hex-encoded keygen seed. Accepted only for schemes whose keygen -# is deterministic across JVMs/architectures (currently ML_DSA_44, -# 64 hex chars). Rejected for FN_DSA_512 because Falcon's FFT-based -# keygen drifts across platforms and would produce divergent keys. +# `key` — hex-encoded priv‖pub. FN_DSA_512: 4352 hex chars; ML_DSA_44: 7744. +# `seed` — 64 hex chars. ML_DSA_44 only (Falcon keygen is not deterministic +# across platforms). # -# Keypairs must be generated off-line — on-node keygen is intentionally -# bypassed. Each scheme is effective only after its activation proposal -# (e.g. ALLOW_FN_DSA_512, ALLOW_ML_DSA_44) is active and the corresponding -# witness Permission has been upgraded. -# -# A single node may host both legacy ECDSA witnesses (via `localwitness` or -# `localwitnesskeystore`) and PQ witnesses (via `localwitness_pq.keys`) at -# the same time; each entry signs blocks under its own scheme. The -# `localWitnessAccountAddress` override is only available when exactly one -# witness is configured — drop it when mixing schemes or running multiple -# SRs. +# Effective only after the scheme's activation proposal passes and the +# witness Permission is upgraded. ECDSA and PQ witnesses may coexist on one +# node; drop `localWitnessAccountAddress` when more than one witness is set. # localwitness_pq = { # keys = [ # { scheme = "FN_DSA_512", key = "<4352 hex chars>" }, diff --git a/framework/src/test/java/org/tron/common/runtime/vm/BatchValidateFnDsa512Test.java b/framework/src/test/java/org/tron/common/runtime/vm/BatchValidateFnDsa512Test.java index a08ddf41fd1..a052f2bd99b 100644 --- a/framework/src/test/java/org/tron/common/runtime/vm/BatchValidateFnDsa512Test.java +++ b/framework/src/test/java/org/tron/common/runtime/vm/BatchValidateFnDsa512Test.java @@ -77,7 +77,7 @@ public void constantCall_allValid_setsAllBits() { List addrs = new ArrayList<>(n); for (int i = 0; i < n; i++) { FNDSA512 k = new FNDSA512(); - sigs.add(Hex.toHexString(k.sign(HASH))); + sigs.add(Hex.toHexString(padSlot(k.sign(HASH)))); pks.add(Hex.toHexString(k.getPublicKey())); addrs.add(addrAsBytes32Hex(k.getPublicKey())); } @@ -96,8 +96,8 @@ public void constantCall_mismatchedAddress_clearsBit() { FNDSA512 k1 = new FNDSA512(); FNDSA512 k2 = new FNDSA512(); List sigs = Arrays.asList( - Hex.toHexString(k1.sign(HASH)), - Hex.toHexString(k2.sign(HASH))); + Hex.toHexString(padSlot(k1.sign(HASH))), + Hex.toHexString(padSlot(k2.sign(HASH)))); List pks = Arrays.asList( Hex.toHexString(k1.getPublicKey()), Hex.toHexString(k2.getPublicKey())); @@ -115,7 +115,7 @@ public void constantCall_mismatchedAddress_clearsBit() { public void constantCall_tamperedSignature_clearsBit() { contract.setConstantCall(true); FNDSA512 k = new FNDSA512(); - byte[] sig = k.sign(HASH); + byte[] sig = padSlot(k.sign(HASH)); sig[0] ^= 0x01; List sigs = Collections1(Hex.toHexString(sig)); List pks = Collections1(Hex.toHexString(k.getPublicKey())); @@ -130,7 +130,7 @@ public void constantCall_wrongPkLength_clearsBit() { contract.setConstantCall(true); FNDSA512 k = new FNDSA512(); byte[] truncatedPk = Arrays.copyOf(k.getPublicKey(), k.getPublicKey().length - 1); - List sigs = Collections1(Hex.toHexString(k.sign(HASH))); + List sigs = Collections1(Hex.toHexString(padSlot(k.sign(HASH)))); List pks = Collections1(Hex.toHexString(truncatedPk)); List addrs = Collections1(addrAsBytes32Hex(k.getPublicKey())); @@ -148,7 +148,7 @@ public void asyncPath_allValid_setsAllBits() { List addrs = new ArrayList<>(n); for (int i = 0; i < n; i++) { FNDSA512 k = new FNDSA512(); - sigs.add(Hex.toHexString(k.sign(HASH))); + sigs.add(Hex.toHexString(padSlot(k.sign(HASH)))); pks.add(Hex.toHexString(k.getPublicKey())); addrs.add(addrAsBytes32Hex(k.getPublicKey())); } @@ -162,7 +162,7 @@ public void asyncPath_allValid_setsAllBits() { public void mismatchedArrayLengths_returnsZero() { contract.setConstantCall(true); FNDSA512 k = new FNDSA512(); - List sigs = Collections1(Hex.toHexString(k.sign(HASH))); + List sigs = Collections1(Hex.toHexString(padSlot(k.sign(HASH)))); List pks = Arrays.asList( Hex.toHexString(k.getPublicKey()), Hex.toHexString(k.getPublicKey())); List addrs = Collections1(addrAsBytes32Hex(k.getPublicKey())); @@ -181,7 +181,7 @@ public void overMaxSize_returnsZero() { List addrs = new ArrayList<>(n); for (int i = 0; i < n; i++) { FNDSA512 k = new FNDSA512(); - sigs.add(Hex.toHexString(k.sign(HASH))); + sigs.add(Hex.toHexString(padSlot(k.sign(HASH)))); pks.add(Hex.toHexString(k.getPublicKey())); addrs.add(addrAsBytes32Hex(k.getPublicKey())); } @@ -198,7 +198,7 @@ public void energyScalesWithCount() { List addrs = new ArrayList<>(n); for (int i = 0; i < n; i++) { FNDSA512 k = new FNDSA512(); - sigs.add(Hex.toHexString(k.sign(HASH))); + sigs.add(Hex.toHexString(padSlot(k.sign(HASH)))); pks.add(Hex.toHexString(k.getPublicKey())); addrs.add(addrAsBytes32Hex(k.getPublicKey())); } @@ -223,7 +223,7 @@ public void differentHash_clearsAllBits() { for (int i = 0; i < n; i++) { FNDSA512 k = new FNDSA512(); // Sign HASH... - sigs.add(Hex.toHexString(k.sign(HASH))); + sigs.add(Hex.toHexString(padSlot(k.sign(HASH)))); pks.add(Hex.toHexString(k.getPublicKey())); addrs.add(addrAsBytes32Hex(k.getPublicKey())); } @@ -244,7 +244,7 @@ public void atMaxSize16_setsAllBits() { List addrs = new ArrayList<>(n); for (int i = 0; i < n; i++) { FNDSA512 k = new FNDSA512(); - sigs.add(Hex.toHexString(k.sign(HASH))); + sigs.add(Hex.toHexString(padSlot(k.sign(HASH)))); pks.add(Hex.toHexString(k.getPublicKey())); addrs.add(addrAsBytes32Hex(k.getPublicKey())); } @@ -268,7 +268,7 @@ public void asyncPath_mixedValidInvalid() { List addrs = new ArrayList<>(n); for (int i = 0; i < n; i++) { FNDSA512 k = new FNDSA512(); - byte[] sig = k.sign(HASH); + byte[] sig = padSlot(k.sign(HASH)); // Tamper entries 1 and 3. if (i == 1 || i == 3) { sig[0] ^= 0x01; @@ -298,8 +298,48 @@ public void sigTooLong_clearsBit() { Assert.assertEquals(0, res[0]); } + @Test + public void slotShorterThan666_clearsBit() { + contract.setConstantCall(true); + FNDSA512 k = new FNDSA512(); + byte[] sig = k.sign(HASH); + byte[] shortSlot = Arrays.copyOf(sig, FNDSA512.SIGNATURE_LENGTH - 1); + List sigs = Collections1(Hex.toHexString(shortSlot)); + List pks = Collections1(Hex.toHexString(k.getPublicKey())); + List addrs = Collections1(addrAsBytes32Hex(k.getPublicKey())); + + byte[] res = run(HASH, sigs, pks, addrs).getRight(); + Assert.assertEquals(0, res[0]); + } + + @Test + public void allZeroSlot_clearsBit() { + contract.setConstantCall(true); + FNDSA512 k = new FNDSA512(); + byte[] zeroSlot = new byte[FNDSA512.SIGNATURE_LENGTH]; + List sigs = Collections1(Hex.toHexString(zeroSlot)); + List pks = Collections1(Hex.toHexString(k.getPublicKey())); + List addrs = Collections1(addrAsBytes32Hex(k.getPublicKey())); + + byte[] res = run(HASH, sigs, pks, addrs).getRight(); + Assert.assertEquals(0, res[0]); + } + // -------- helpers -------- + /** + * Pin a canonical Falcon-512 signature into the precompile's fixed 666-byte slot. + * BC's variable-length encoding is preserved at the head; the tail is zero-padded. + * Mirrors the EIP-8052 slot convention enforced by 0x16 / 0x1a / 0x18. + */ + private static byte[] padSlot(byte[] sig) { + if (sig.length > FNDSA512.SIGNATURE_LENGTH) { + throw new IllegalStateException("Falcon sig longer than slot: " + sig.length); + } + return Arrays.copyOf(sig, FNDSA512.SIGNATURE_LENGTH); + } + + private Pair run(byte[] hash, List sigs, List pks, List addrs) { byte[] input = encode(hash, sigs, pks, addrs); diff --git a/framework/src/test/java/org/tron/common/runtime/vm/FnDsaPrecompileTest.java b/framework/src/test/java/org/tron/common/runtime/vm/FnDsaPrecompileTest.java index e8fa8bc27d0..7e6cc5a4d99 100644 --- a/framework/src/test/java/org/tron/common/runtime/vm/FnDsaPrecompileTest.java +++ b/framework/src/test/java/org/tron/common/runtime/vm/FnDsaPrecompileTest.java @@ -12,13 +12,17 @@ /** * Unit tests for the FN-DSA / Falcon-512 (0x16) verify precompile (EIP-8052 / TRON extension). - * Input layout: [msg 32B | sig_len 2B | sig sig_len B | pk 896B]. Stateless — no chain DB. + * Input layout (fixed-length): [msg 32B | sig 666B (zero-padded) | pk 896B] = 1594B total. + * Stateless — no chain DB. */ public class FnDsaPrecompileTest { private static final DataWord FNDSA_ADDR = new DataWord( "0000000000000000000000000000000000000000000000000000000000000016"); + private static final int INPUT_LEN = + 32 + FNDSA512.SIGNATURE_LENGTH + FNDSA512.PUBLIC_KEY_LENGTH; + private static final byte[] MESSAGE_HASH = new byte[32]; static { @@ -117,53 +121,37 @@ public void nullInput_returnsZero() { @Test public void shortInput_returnsZero() { Pair result = - PrecompiledContracts.getContractForAddress(FNDSA_ADDR).execute(new byte[100]); + PrecompiledContracts.getContractForAddress(FNDSA_ADDR).execute(new byte[INPUT_LEN - 1]); Assert.assertTrue(result.getLeft()); Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); } @Test - public void zeroSigLen_returnsZero() { + public void trailingBytes_returnsZero() { + // Strict equality (matches 0x100 P256Verify / EIP-7951): appending even one byte + // to an otherwise-valid input must be rejected to prevent non-canonical encodings. FNDSA512 key = new FNDSA512(); - byte[] pk = key.getPublicKey(); - // sig_len = 0 is invalid (must be >= 1) - // input must be >= MIN_INPUT_LEN (931 = 32 + 2 + 1 + 896) to reach the sigLen check - byte[] input = new byte[32 + 2 + pk.length + 1]; - System.arraycopy(MESSAGE_HASH, 0, input, 0, 32); - // sig_len bytes = 0x00 0x00 → sigLen = 0 - System.arraycopy(pk, 0, input, 34, pk.length); - - Pair result = - PrecompiledContracts.getContractForAddress(FNDSA_ADDR).execute(input); - - Assert.assertTrue(result.getLeft()); - Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); - } + byte[] sig = key.sign(MESSAGE_HASH); + byte[] valid = buildInput(MESSAGE_HASH, sig, key.getPublicKey()); + byte[] padded = new byte[valid.length + 1]; + System.arraycopy(valid, 0, padded, 0, valid.length); - @Test - public void oversizedSigLen_returnsZero() { - // sig_len = 753, which exceeds FNDSA512.SIGNATURE_LENGTH (752) - byte[] input = new byte[32 + 2 + 753 + FNDSA512.PUBLIC_KEY_LENGTH]; - input[32] = 0x02; // high byte - input[33] = (byte) 0xF1; // low byte → 0x02F1 = 753 Pair result = - PrecompiledContracts.getContractForAddress(FNDSA_ADDR).execute(input); + PrecompiledContracts.getContractForAddress(FNDSA_ADDR).execute(padded); Assert.assertTrue(result.getLeft()); Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); } @Test - public void sigLenLargerThanActualData_returnsZero() { + public void emptySigSlot_returnsZero() { + // All-zero sig slot → recovered length 0 → below SIGNATURE_MIN_LENGTH (41). FNDSA512 key = new FNDSA512(); - byte[] sig = key.sign(MESSAGE_HASH); - // claim sig is 100 bytes longer than it is - byte[] input = buildInput(MESSAGE_HASH, sig, key.getPublicKey()); - // corrupt sig_len field to claim a larger sig - int claimedLen = sig.length + 100; - input[32] = (byte) ((claimedLen >> 8) & 0xFF); - input[33] = (byte) (claimedLen & 0xFF); + byte[] input = new byte[INPUT_LEN]; + System.arraycopy(MESSAGE_HASH, 0, input, 0, 32); + System.arraycopy(key.getPublicKey(), 0, input, + 32 + FNDSA512.SIGNATURE_LENGTH, FNDSA512.PUBLIC_KEY_LENGTH); Pair result = PrecompiledContracts.getContractForAddress(FNDSA_ADDR).execute(input); @@ -173,31 +161,34 @@ public void sigLenLargerThanActualData_returnsZero() { } @Test - public void trailingBytes_returnsZero() { - // Strict equality (matches 0x100 P256Verify / EIP-7951): appending even one byte - // to an otherwise-valid input must be rejected to prevent non-canonical encodings. + public void sigSlotShorterThanMin_returnsZero() { + // Recovered logical length 32 (last non-zero at offset 31 of sig slot) is below + // SIGNATURE_MIN_LENGTH (41) — too short to contain header + nonce. FNDSA512 key = new FNDSA512(); - byte[] sig = key.sign(MESSAGE_HASH); - byte[] valid = buildInput(MESSAGE_HASH, sig, key.getPublicKey()); - byte[] padded = new byte[valid.length + 1]; - System.arraycopy(valid, 0, padded, 0, valid.length); + byte[] input = new byte[INPUT_LEN]; + System.arraycopy(MESSAGE_HASH, 0, input, 0, 32); + input[32 + 31] = (byte) 0xFF; + System.arraycopy(key.getPublicKey(), 0, input, + 32 + FNDSA512.SIGNATURE_LENGTH, FNDSA512.PUBLIC_KEY_LENGTH); Pair result = - PrecompiledContracts.getContractForAddress(FNDSA_ADDR).execute(padded); + PrecompiledContracts.getContractForAddress(FNDSA_ADDR).execute(input); Assert.assertTrue(result.getLeft()); Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); } - /** Encodes input as [msg 32B | sig_len 2B | sig | pk]. */ + /** + * Encodes input as [msg 32B | sig 666B (zero-padded) | pk 896B]. The caller's + * {@code sig} must satisfy {@code FNDSA512.SIGNATURE_MIN_LENGTH <= sig.length + * <= FNDSA512.SIGNATURE_LENGTH}; bytes beyond {@code sig.length} are zero-padded + * to fill the 666-byte slot. + */ private static byte[] buildInput(byte[] msg, byte[] sig, byte[] pk) { - int sigLen = sig.length; - byte[] out = new byte[32 + 2 + sigLen + pk.length]; + byte[] out = new byte[INPUT_LEN]; System.arraycopy(msg, 0, out, 0, 32); - out[32] = (byte) ((sigLen >> 8) & 0xFF); - out[33] = (byte) (sigLen & 0xFF); - System.arraycopy(sig, 0, out, 34, sigLen); - System.arraycopy(pk, 0, out, 34 + sigLen, pk.length); + System.arraycopy(sig, 0, out, 32, sig.length); + System.arraycopy(pk, 0, out, 32 + FNDSA512.SIGNATURE_LENGTH, pk.length); return out; } } diff --git a/framework/src/test/java/org/tron/common/runtime/vm/ValidateMultiFnDsa512Test.java b/framework/src/test/java/org/tron/common/runtime/vm/ValidateMultiFnDsa512Test.java deleted file mode 100644 index 3d820644b7c..00000000000 --- a/framework/src/test/java/org/tron/common/runtime/vm/ValidateMultiFnDsa512Test.java +++ /dev/null @@ -1,464 +0,0 @@ -package org.tron.common.runtime.vm; - -import com.google.protobuf.ByteString; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.tuple.Pair; -import org.bouncycastle.util.encoders.Hex; -import org.junit.Assert; -import org.junit.Before; -import org.junit.Test; -import org.tron.common.BaseTest; -import org.tron.common.TestConstants; -import org.tron.common.crypto.ECKey; -import org.tron.common.crypto.pqc.FNDSA512; -import org.tron.common.crypto.pqc.PQSchemeRegistry; -import org.tron.common.parameter.CommonParameter; -import org.tron.common.utils.ByteArray; -import org.tron.common.utils.ByteUtil; -import org.tron.common.utils.Sha256Hash; -import org.tron.common.utils.StringUtil; -import org.tron.common.utils.client.utils.AbiUtil; -import org.tron.core.capsule.AccountCapsule; -import org.tron.core.config.args.Args; -import org.tron.core.store.StoreFactory; -import org.tron.core.vm.PrecompiledContracts; -import org.tron.core.vm.PrecompiledContracts.PrecompiledContract; -import org.tron.core.vm.PrecompiledContracts.ValidateMultiFnDsa512; -import org.tron.core.vm.config.VMConfig; -import org.tron.core.vm.repository.Repository; -import org.tron.core.vm.repository.RepositoryImpl; -import org.tron.protos.Protocol; -import org.tron.protos.Protocol.PQScheme; - -/** - * Unit tests for the 0x17 algorithm-agnostic Permission multi-sign precompile. - * Mirrors 0x09 hash construction and threshold semantics, while supporting - * Falcon-512 entries alongside ECDSA against the same Permission.keys[]. - */ -@Slf4j -public class ValidateMultiFnDsa512Test extends BaseTest { - - private static final DataWord ADDR_0X17 = new DataWord( - "0000000000000000000000000000000000000000000000000000000000000017"); - - private static final String METHOD_SIGN = - "validatemultisign(address,uint256,bytes32,bytes[],bytes[],bytes[])"; - - private static final byte[] longData; - - static { - Args.setParam(new String[]{"--output-directory", dbPath(), "--debug"}, TestConstants.TEST_CONF); - longData = new byte[1000]; - Arrays.fill(longData, (byte) 7); - } - - private final ValidateMultiFnDsa512 contract = new ValidateMultiFnDsa512(); - - @Before - public void before() { - dbManager.getDynamicPropertiesStore().saveAllowMultiSign(1); - dbManager.getDynamicPropertiesStore().saveTotalSignNum(5); - VMConfig.initAllowFnDsa512(1L); - } - - @Test - public void switchOff_returnsNull() { - VMConfig.initAllowFnDsa512(0L); - Assert.assertNull(PrecompiledContracts.getContractForAddress(ADDR_0X17)); - } - - @Test - public void switchOn_returnsContract() { - PrecompiledContract pc = PrecompiledContracts.getContractForAddress(ADDR_0X17); - Assert.assertNotNull(pc); - Assert.assertTrue(pc instanceof ValidateMultiFnDsa512); - } - - @Test - public void unknownAccount_returnsZero() { - ECKey owner = new ECKey(); - byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); - byte[] toSign = computeHash(owner.getAddress(), 2, data); - List ecdsaSigs = Collections.singletonList( - Hex.toHexString(new ECKey().sign(toSign).toByteArray())); - Assert.assertArrayEquals(DataWord.ZERO().getData(), - runContract(owner.getAddress(), 2, data, ecdsaSigs, - Collections.emptyList(), Collections.emptyList()).getRight()); - } - - @Test - public void pureEcdsaThresholdReached_returnsOne() { - ECKey k1 = new ECKey(); - ECKey k2 = new ECKey(); - ECKey owner = new ECKey(); - setupPermission(owner, Arrays.asList(k1.getAddress(), k2.getAddress()), - Arrays.asList(1, 1), 2, Collections.emptyList(), Collections.emptyList()); - - byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); - byte[] toSign = computeHash(owner.getAddress(), 2, data); - List ecdsaSigs = Arrays.asList( - Hex.toHexString(k1.sign(toSign).toByteArray()), - Hex.toHexString(k2.sign(toSign).toByteArray())); - - Assert.assertArrayEquals(DataWord.ONE().getData(), - runContract(owner.getAddress(), 2, data, ecdsaSigs, - Collections.emptyList(), Collections.emptyList()).getRight()); - } - - @Test - public void purePqThresholdReached_returnsOne() { - FNDSA512 pq1 = new FNDSA512(); - FNDSA512 pq2 = new FNDSA512(); - ECKey owner = new ECKey(); - byte[] addr1 = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pq1.getPublicKey()); - byte[] addr2 = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pq2.getPublicKey()); - setupPermission(owner, Collections.emptyList(), Collections.emptyList(), - 2, Arrays.asList(addr1, addr2), Arrays.asList(1, 1)); - - byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); - byte[] toSign = computeHash(owner.getAddress(), 2, data); - - List pqSigs = Arrays.asList( - Hex.toHexString(pq1.sign(toSign)), - Hex.toHexString(pq2.sign(toSign))); - List pqPks = Arrays.asList( - Hex.toHexString(pq1.getPublicKey()), - Hex.toHexString(pq2.getPublicKey())); - - Assert.assertArrayEquals(DataWord.ONE().getData(), - runContract(owner.getAddress(), 2, data, - Collections.emptyList(), pqSigs, pqPks).getRight()); - } - - @Test - public void mixedEcdsaAndPq_returnsOne() { - ECKey k1 = new ECKey(); - FNDSA512 pq1 = new FNDSA512(); - ECKey owner = new ECKey(); - byte[] pqAddr = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pq1.getPublicKey()); - setupPermission(owner, Collections.singletonList(k1.getAddress()), Collections.singletonList(1), - 2, Collections.singletonList(pqAddr), Collections.singletonList(1)); - - byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); - byte[] toSign = computeHash(owner.getAddress(), 2, data); - - List ecdsaSigs = Collections.singletonList( - Hex.toHexString(k1.sign(toSign).toByteArray())); - List pqSigs = Collections.singletonList(Hex.toHexString(pq1.sign(toSign))); - List pqPks = Collections.singletonList(Hex.toHexString(pq1.getPublicKey())); - - Assert.assertArrayEquals(DataWord.ONE().getData(), - runContract(owner.getAddress(), 2, data, ecdsaSigs, pqSigs, pqPks).getRight()); - } - - @Test - public void pqSignatureForgery_returnsZero() { - FNDSA512 pq1 = new FNDSA512(); - ECKey owner = new ECKey(); - byte[] pqAddr = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pq1.getPublicKey()); - setupPermission(owner, Collections.emptyList(), Collections.emptyList(), - 1, Collections.singletonList(pqAddr), Collections.singletonList(1)); - - byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); - byte[] toSign = computeHash(owner.getAddress(), 2, data); - byte[] forgedSig = pq1.sign(toSign); - forgedSig[10] ^= 0x01; - - List pqSigs = Collections.singletonList(Hex.toHexString(forgedSig)); - List pqPks = Collections.singletonList(Hex.toHexString(pq1.getPublicKey())); - - Assert.assertArrayEquals(DataWord.ZERO().getData(), - runContract(owner.getAddress(), 2, data, - Collections.emptyList(), pqSigs, pqPks).getRight()); - } - - @Test - public void wrongPqPublicKeyLength_returnsZero() { - FNDSA512 pq1 = new FNDSA512(); - ECKey owner = new ECKey(); - byte[] pqAddr = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pq1.getPublicKey()); - setupPermission(owner, Collections.emptyList(), Collections.emptyList(), - 1, Collections.singletonList(pqAddr), Collections.singletonList(1)); - - byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); - byte[] toSign = computeHash(owner.getAddress(), 2, data); - byte[] truncatedPk = Arrays.copyOf(pq1.getPublicKey(), pq1.getPublicKey().length - 1); - - List pqSigs = Collections.singletonList(Hex.toHexString(pq1.sign(toSign))); - List pqPks = Collections.singletonList(Hex.toHexString(truncatedPk)); - - Assert.assertArrayEquals(DataWord.ZERO().getData(), - runContract(owner.getAddress(), 2, data, - Collections.emptyList(), pqSigs, pqPks).getRight()); - } - - @Test - public void mismatchedPqArrayLengths_returnsZero() { - FNDSA512 pq1 = new FNDSA512(); - FNDSA512 pq2 = new FNDSA512(); - ECKey owner = new ECKey(); - byte[] addr1 = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pq1.getPublicKey()); - setupPermission(owner, Collections.emptyList(), Collections.emptyList(), - 1, Collections.singletonList(addr1), Collections.singletonList(1)); - - byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); - byte[] toSign = computeHash(owner.getAddress(), 2, data); - - List pqSigs = Arrays.asList( - Hex.toHexString(pq1.sign(toSign)), - Hex.toHexString(pq2.sign(toSign))); - List pqPks = Collections.singletonList(Hex.toHexString(pq1.getPublicKey())); - - Assert.assertArrayEquals(DataWord.ZERO().getData(), - runContract(owner.getAddress(), 2, data, - Collections.emptyList(), pqSigs, pqPks).getRight()); - } - - @Test - public void totalCountOverMaxSize_returnsZero() { - ECKey owner = new ECKey(); - List ecdsaAddrs = new ArrayList<>(); - List ecdsaWeights = new ArrayList<>(); - List ecdsaKeys = new ArrayList<>(); - for (int i = 0; i < 6; i++) { - ECKey k = new ECKey(); - ecdsaKeys.add(k); - ecdsaAddrs.add(k.getAddress()); - ecdsaWeights.add(1); - } - setupPermission(owner, ecdsaAddrs, ecdsaWeights, 6, - Collections.emptyList(), Collections.emptyList()); - - byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); - byte[] toSign = computeHash(owner.getAddress(), 2, data); - List ecdsaSigs = new ArrayList<>(); - for (ECKey k : ecdsaKeys) { - ecdsaSigs.add(Hex.toHexString(k.sign(toSign).toByteArray())); - } - - Assert.assertArrayEquals(DataWord.ZERO().getData(), - runContract(owner.getAddress(), 2, data, ecdsaSigs, - Collections.emptyList(), Collections.emptyList()).getRight()); - } - - @Test - public void duplicatePqSig_doesNotDoubleCount() { - FNDSA512 pq1 = new FNDSA512(); - ECKey owner = new ECKey(); - byte[] pqAddr = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pq1.getPublicKey()); - setupPermission(owner, Collections.emptyList(), Collections.emptyList(), - 2, Collections.singletonList(pqAddr), Collections.singletonList(1)); - - byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); - byte[] toSign = computeHash(owner.getAddress(), 2, data); - byte[] sig = pq1.sign(toSign); - - List pqSigs = Arrays.asList(Hex.toHexString(sig), Hex.toHexString(sig)); - List pqPks = Arrays.asList( - Hex.toHexString(pq1.getPublicKey()), Hex.toHexString(pq1.getPublicKey())); - - Assert.assertArrayEquals(DataWord.ZERO().getData(), - runContract(owner.getAddress(), 2, data, - Collections.emptyList(), pqSigs, pqPks).getRight()); - } - - @Test - public void energyChargesEcdsaAndPqSeparately() { - FNDSA512 pq1 = new FNDSA512(); - ECKey k1 = new ECKey(); - ECKey owner = new ECKey(); - byte[] pqAddr = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pq1.getPublicKey()); - setupPermission(owner, Collections.singletonList(k1.getAddress()), Collections.singletonList(1), - 2, Collections.singletonList(pqAddr), Collections.singletonList(1)); - - byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); - byte[] toSign = computeHash(owner.getAddress(), 2, data); - List ecdsaSigs = Collections.singletonList( - Hex.toHexString(k1.sign(toSign).toByteArray())); - List pqSigs = Collections.singletonList(Hex.toHexString(pq1.sign(toSign))); - List pqPks = Collections.singletonList(Hex.toHexString(pq1.getPublicKey())); - - byte[] input = encodeInput(owner.getAddress(), 2, data, ecdsaSigs, pqSigs, pqPks); - // 1 ECDSA × 1500 + 1 PQ × 2000 = 3500 - Assert.assertEquals(3500L, contract.getEnergyForData(input)); - } - - @Test - public void thresholdNotReached_returnsZero() { - ECKey k1 = new ECKey(); - ECKey k2 = new ECKey(); - ECKey owner = new ECKey(); - setupPermission(owner, Arrays.asList(k1.getAddress(), k2.getAddress()), - Arrays.asList(1, 1), 2, Collections.emptyList(), Collections.emptyList()); - - byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); - byte[] toSign = computeHash(owner.getAddress(), 2, data); - // Only one valid signature; threshold is 2. - List ecdsaSigs = Collections.singletonList( - Hex.toHexString(k1.sign(toSign).toByteArray())); - - Assert.assertArrayEquals(DataWord.ZERO().getData(), - runContract(owner.getAddress(), 2, data, ecdsaSigs, - Collections.emptyList(), Collections.emptyList()).getRight()); - } - - @Test - public void pqKeyNotInPermission_returnsZero() { - FNDSA512 inPerm = new FNDSA512(); - FNDSA512 outsider = new FNDSA512(); - ECKey owner = new ECKey(); - byte[] inAddr = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, inPerm.getPublicKey()); - setupPermission(owner, Collections.emptyList(), Collections.emptyList(), - 1, Collections.singletonList(inAddr), Collections.singletonList(1)); - - byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); - byte[] toSign = computeHash(owner.getAddress(), 2, data); - - // Outsider produces a perfectly valid Falcon signature, but its derived - // address is not in Permission.keys[] → weight 0 → not counted. - List pqSigs = Collections.singletonList(Hex.toHexString(outsider.sign(toSign))); - List pqPks = Collections.singletonList(Hex.toHexString(outsider.getPublicKey())); - - Assert.assertArrayEquals(DataWord.ZERO().getData(), - runContract(owner.getAddress(), 2, data, - Collections.emptyList(), pqSigs, pqPks).getRight()); - } - - @Test - public void pqSigTooLong_returnsZero() { - FNDSA512 pq1 = new FNDSA512(); - ECKey owner = new ECKey(); - byte[] pqAddr = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pq1.getPublicKey()); - setupPermission(owner, Collections.emptyList(), Collections.emptyList(), - 1, Collections.singletonList(pqAddr), Collections.singletonList(1)); - - byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); - - // Pad sig past the 752-byte cap. - byte[] oversized = new byte[800]; - Arrays.fill(oversized, (byte) 0x42); - List pqSigs = Collections.singletonList(Hex.toHexString(oversized)); - List pqPks = Collections.singletonList(Hex.toHexString(pq1.getPublicKey())); - - Assert.assertArrayEquals(DataWord.ZERO().getData(), - runContract(owner.getAddress(), 2, data, - Collections.emptyList(), pqSigs, pqPks).getRight()); - } - - @Test - public void bothArraysEmpty_returnsZero() { - ECKey k1 = new ECKey(); - ECKey owner = new ECKey(); - setupPermission(owner, Collections.singletonList(k1.getAddress()), - Collections.singletonList(1), 1, - Collections.emptyList(), Collections.emptyList()); - - byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); - - Assert.assertArrayEquals(DataWord.ZERO().getData(), - runContract(owner.getAddress(), 2, data, - Collections.emptyList(), Collections.emptyList(), - Collections.emptyList()).getRight()); - } - - @Test - public void mixedFailingPqAborts_returnsZero() { - // Mirrors 0x09 semantics: a verify failure on any submitted entry aborts - // the whole call with DATA_FALSE — even if other entries would alone meet - // threshold. Verifies 0x17 does not silently skip a forged PQ signature. - ECKey k1 = new ECKey(); - ECKey k2 = new ECKey(); - FNDSA512 pq1 = new FNDSA512(); - ECKey owner = new ECKey(); - byte[] pqAddr = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pq1.getPublicKey()); - setupPermission(owner, - Arrays.asList(k1.getAddress(), k2.getAddress()), Arrays.asList(1, 1), - 2, Collections.singletonList(pqAddr), Collections.singletonList(1)); - - byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); - byte[] toSign = computeHash(owner.getAddress(), 2, data); - - List ecdsaSigs = Arrays.asList( - Hex.toHexString(k1.sign(toSign).toByteArray()), - Hex.toHexString(k2.sign(toSign).toByteArray())); - byte[] forged = pq1.sign(toSign); - forged[0] ^= 0x55; - List pqSigs = Collections.singletonList(Hex.toHexString(forged)); - List pqPks = Collections.singletonList(Hex.toHexString(pq1.getPublicKey())); - - Assert.assertArrayEquals(DataWord.ZERO().getData(), - runContract(owner.getAddress(), 2, data, ecdsaSigs, pqSigs, pqPks).getRight()); - } - - // -------- helpers -------- - - private void setupPermission(ECKey owner, - List ecdsaKeyAddrs, List ecdsaWeights, - int threshold, - List pqKeyAddrs, List pqWeights) { - AccountCapsule account = new AccountCapsule(ByteString.copyFrom(owner.getAddress()), - Protocol.AccountType.Normal, System.currentTimeMillis(), true, - dbManager.getDynamicPropertiesStore()); - - Protocol.Permission.Builder perm = Protocol.Permission.newBuilder() - .setType(Protocol.Permission.PermissionType.Active) - .setId(2) - .setPermissionName("active") - .setThreshold(threshold) - .setOperations(ByteString.copyFrom(ByteArray.fromHexString( - "0000000000000000000000000000000000000000000000000000000000000000"))); - for (int i = 0; i < ecdsaKeyAddrs.size(); i++) { - perm.addKeys(Protocol.Key.newBuilder() - .setAddress(ByteString.copyFrom(ecdsaKeyAddrs.get(i))) - .setWeight(ecdsaWeights.get(i)).build()); - } - for (int i = 0; i < pqKeyAddrs.size(); i++) { - perm.addKeys(Protocol.Key.newBuilder() - .setAddress(ByteString.copyFrom(pqKeyAddrs.get(i))) - .setWeight(pqWeights.get(i)).build()); - } - account.updatePermissions(account.getPermissionById(0), null, - Collections.singletonList(perm.build())); - dbManager.getAccountStore().put(owner.getAddress(), account); - } - - private byte[] computeHash(byte[] address, int permissionId, byte[] data) { - byte[] combined = ByteUtil.merge(address, ByteArray.fromInt(permissionId), data); - return Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), combined); - } - - private byte[] encodeInput(byte[] ownerAddr, int permissionId, byte[] data, - List ecdsaSigs, List pqSigs, List pqPks) { - List parameters = Arrays.asList( - StringUtil.encode58Check(ownerAddr), - permissionId, - "0x" + Hex.toHexString(data), - toHexList(ecdsaSigs), - toHexList(pqSigs), - toHexList(pqPks)); - return Hex.decode(AbiUtil.parseParameters(METHOD_SIGN, parameters)); - } - - private Pair runContract(byte[] ownerAddr, int permissionId, byte[] data, - List ecdsaSigs, List pqSigs, - List pqPks) { - byte[] input = encodeInput(ownerAddr, permissionId, data, ecdsaSigs, pqSigs, pqPks); - Repository deposit = RepositoryImpl.createRoot(StoreFactory.getInstance()); - contract.setRepository(deposit); - Pair ret = contract.execute(input); - logger.info("0x17 result: {}", Hex.toHexString(ret.getRight())); - return ret; - } - - private static List toHexList(List hexes) { - List out = new ArrayList<>(hexes.size()); - for (String h : hexes) { - out.add(h.startsWith("0x") ? h : ("0x" + h)); - } - return out; - } -} diff --git a/framework/src/test/java/org/tron/common/runtime/vm/ValidateMultiMlDsa44Test.java b/framework/src/test/java/org/tron/common/runtime/vm/ValidateMultiMlDsa44Test.java deleted file mode 100644 index 1ff31d16805..00000000000 --- a/framework/src/test/java/org/tron/common/runtime/vm/ValidateMultiMlDsa44Test.java +++ /dev/null @@ -1,465 +0,0 @@ -package org.tron.common.runtime.vm; - -import com.google.protobuf.ByteString; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.tuple.Pair; -import org.bouncycastle.util.encoders.Hex; -import org.junit.Assert; -import org.junit.Before; -import org.junit.Test; -import org.tron.common.BaseTest; -import org.tron.common.TestConstants; -import org.tron.common.crypto.ECKey; -import org.tron.common.crypto.pqc.MLDSA44; -import org.tron.common.crypto.pqc.PQSchemeRegistry; -import org.tron.common.parameter.CommonParameter; -import org.tron.common.utils.ByteArray; -import org.tron.common.utils.ByteUtil; -import org.tron.common.utils.Sha256Hash; -import org.tron.common.utils.StringUtil; -import org.tron.common.utils.client.utils.AbiUtil; -import org.tron.core.capsule.AccountCapsule; -import org.tron.core.config.args.Args; -import org.tron.core.store.StoreFactory; -import org.tron.core.vm.PrecompiledContracts; -import org.tron.core.vm.PrecompiledContracts.PrecompiledContract; -import org.tron.core.vm.PrecompiledContracts.ValidateMultiMlDsa44; -import org.tron.core.vm.config.VMConfig; -import org.tron.core.vm.repository.Repository; -import org.tron.core.vm.repository.RepositoryImpl; -import org.tron.protos.Protocol; -import org.tron.protos.Protocol.PQScheme; - -/** - * Unit tests for the 0x1a algorithm-agnostic Permission multi-sign precompile. - * Mirrors 0x09 hash construction and threshold semantics, while supporting - * ML-DSA-44 entries alongside ECDSA against the same Permission.keys[]. - */ -@Slf4j -public class ValidateMultiMlDsa44Test extends BaseTest { - - private static final DataWord ADDR_0X1A = new DataWord( - "000000000000000000000000000000000000000000000000000000000000001a"); - - private static final String METHOD_SIGN = - "validatemultisign(address,uint256,bytes32,bytes[],bytes[],bytes[])"; - - private static final byte[] longData; - - static { - Args.setParam(new String[]{"--output-directory", dbPath(), "--debug"}, TestConstants.TEST_CONF); - longData = new byte[1000]; - Arrays.fill(longData, (byte) 7); - } - - private final ValidateMultiMlDsa44 contract = new ValidateMultiMlDsa44(); - - @Before - public void before() { - dbManager.getDynamicPropertiesStore().saveAllowMultiSign(1); - dbManager.getDynamicPropertiesStore().saveTotalSignNum(5); - VMConfig.initAllowMlDsa44(1L); - } - - @Test - public void switchOff_returnsNull() { - VMConfig.initAllowMlDsa44(0L); - Assert.assertNull(PrecompiledContracts.getContractForAddress(ADDR_0X1A)); - } - - @Test - public void switchOn_returnsContract() { - PrecompiledContract pc = PrecompiledContracts.getContractForAddress(ADDR_0X1A); - Assert.assertNotNull(pc); - Assert.assertTrue(pc instanceof ValidateMultiMlDsa44); - } - - @Test - public void unknownAccount_returnsZero() { - ECKey owner = new ECKey(); - byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); - byte[] toSign = computeHash(owner.getAddress(), 2, data); - List ecdsaSigs = Collections.singletonList( - Hex.toHexString(new ECKey().sign(toSign).toByteArray())); - Assert.assertArrayEquals(DataWord.ZERO().getData(), - runContract(owner.getAddress(), 2, data, ecdsaSigs, - Collections.emptyList(), Collections.emptyList()).getRight()); - } - - @Test - public void pureEcdsaThresholdReached_returnsOne() { - ECKey k1 = new ECKey(); - ECKey k2 = new ECKey(); - ECKey owner = new ECKey(); - setupPermission(owner, Arrays.asList(k1.getAddress(), k2.getAddress()), - Arrays.asList(1, 1), 2, Collections.emptyList(), Collections.emptyList()); - - byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); - byte[] toSign = computeHash(owner.getAddress(), 2, data); - List ecdsaSigs = Arrays.asList( - Hex.toHexString(k1.sign(toSign).toByteArray()), - Hex.toHexString(k2.sign(toSign).toByteArray())); - - Assert.assertArrayEquals(DataWord.ONE().getData(), - runContract(owner.getAddress(), 2, data, ecdsaSigs, - Collections.emptyList(), Collections.emptyList()).getRight()); - } - - @Test - public void purePqThresholdReached_returnsOne() { - MLDSA44 pq1 = new MLDSA44(); - MLDSA44 pq2 = new MLDSA44(); - ECKey owner = new ECKey(); - byte[] addr1 = PQSchemeRegistry.computeAddress(PQScheme.ML_DSA_44, pq1.getPublicKey()); - byte[] addr2 = PQSchemeRegistry.computeAddress(PQScheme.ML_DSA_44, pq2.getPublicKey()); - setupPermission(owner, Collections.emptyList(), Collections.emptyList(), - 2, Arrays.asList(addr1, addr2), Arrays.asList(1, 1)); - - byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); - byte[] toSign = computeHash(owner.getAddress(), 2, data); - - List pqSigs = Arrays.asList( - Hex.toHexString(pq1.sign(toSign)), - Hex.toHexString(pq2.sign(toSign))); - List pqPks = Arrays.asList( - Hex.toHexString(pq1.getPublicKey()), - Hex.toHexString(pq2.getPublicKey())); - - Assert.assertArrayEquals(DataWord.ONE().getData(), - runContract(owner.getAddress(), 2, data, - Collections.emptyList(), pqSigs, pqPks).getRight()); - } - - @Test - public void mixedEcdsaAndPq_returnsOne() { - ECKey k1 = new ECKey(); - MLDSA44 pq1 = new MLDSA44(); - ECKey owner = new ECKey(); - byte[] pqAddr = PQSchemeRegistry.computeAddress(PQScheme.ML_DSA_44, pq1.getPublicKey()); - setupPermission(owner, Collections.singletonList(k1.getAddress()), Collections.singletonList(1), - 2, Collections.singletonList(pqAddr), Collections.singletonList(1)); - - byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); - byte[] toSign = computeHash(owner.getAddress(), 2, data); - - List ecdsaSigs = Collections.singletonList( - Hex.toHexString(k1.sign(toSign).toByteArray())); - List pqSigs = Collections.singletonList(Hex.toHexString(pq1.sign(toSign))); - List pqPks = Collections.singletonList(Hex.toHexString(pq1.getPublicKey())); - - Assert.assertArrayEquals(DataWord.ONE().getData(), - runContract(owner.getAddress(), 2, data, ecdsaSigs, pqSigs, pqPks).getRight()); - } - - @Test - public void pqSignatureForgery_returnsZero() { - MLDSA44 pq1 = new MLDSA44(); - ECKey owner = new ECKey(); - byte[] pqAddr = PQSchemeRegistry.computeAddress(PQScheme.ML_DSA_44, pq1.getPublicKey()); - setupPermission(owner, Collections.emptyList(), Collections.emptyList(), - 1, Collections.singletonList(pqAddr), Collections.singletonList(1)); - - byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); - byte[] toSign = computeHash(owner.getAddress(), 2, data); - byte[] forgedSig = pq1.sign(toSign); - forgedSig[10] ^= 0x01; - - List pqSigs = Collections.singletonList(Hex.toHexString(forgedSig)); - List pqPks = Collections.singletonList(Hex.toHexString(pq1.getPublicKey())); - - Assert.assertArrayEquals(DataWord.ZERO().getData(), - runContract(owner.getAddress(), 2, data, - Collections.emptyList(), pqSigs, pqPks).getRight()); - } - - @Test - public void wrongPqPublicKeyLength_returnsZero() { - MLDSA44 pq1 = new MLDSA44(); - ECKey owner = new ECKey(); - byte[] pqAddr = PQSchemeRegistry.computeAddress(PQScheme.ML_DSA_44, pq1.getPublicKey()); - setupPermission(owner, Collections.emptyList(), Collections.emptyList(), - 1, Collections.singletonList(pqAddr), Collections.singletonList(1)); - - byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); - byte[] toSign = computeHash(owner.getAddress(), 2, data); - byte[] truncatedPk = Arrays.copyOf(pq1.getPublicKey(), pq1.getPublicKey().length - 1); - - List pqSigs = Collections.singletonList(Hex.toHexString(pq1.sign(toSign))); - List pqPks = Collections.singletonList(Hex.toHexString(truncatedPk)); - - Assert.assertArrayEquals(DataWord.ZERO().getData(), - runContract(owner.getAddress(), 2, data, - Collections.emptyList(), pqSigs, pqPks).getRight()); - } - - @Test - public void mismatchedPqArrayLengths_returnsZero() { - MLDSA44 pq1 = new MLDSA44(); - MLDSA44 pq2 = new MLDSA44(); - ECKey owner = new ECKey(); - byte[] addr1 = PQSchemeRegistry.computeAddress(PQScheme.ML_DSA_44, pq1.getPublicKey()); - setupPermission(owner, Collections.emptyList(), Collections.emptyList(), - 1, Collections.singletonList(addr1), Collections.singletonList(1)); - - byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); - byte[] toSign = computeHash(owner.getAddress(), 2, data); - - List pqSigs = Arrays.asList( - Hex.toHexString(pq1.sign(toSign)), - Hex.toHexString(pq2.sign(toSign))); - List pqPks = Collections.singletonList(Hex.toHexString(pq1.getPublicKey())); - - Assert.assertArrayEquals(DataWord.ZERO().getData(), - runContract(owner.getAddress(), 2, data, - Collections.emptyList(), pqSigs, pqPks).getRight()); - } - - @Test - public void totalCountOverMaxSize_returnsZero() { - ECKey owner = new ECKey(); - List ecdsaAddrs = new ArrayList<>(); - List ecdsaWeights = new ArrayList<>(); - List ecdsaKeys = new ArrayList<>(); - for (int i = 0; i < 6; i++) { - ECKey k = new ECKey(); - ecdsaKeys.add(k); - ecdsaAddrs.add(k.getAddress()); - ecdsaWeights.add(1); - } - setupPermission(owner, ecdsaAddrs, ecdsaWeights, 6, - Collections.emptyList(), Collections.emptyList()); - - byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); - byte[] toSign = computeHash(owner.getAddress(), 2, data); - List ecdsaSigs = new ArrayList<>(); - for (ECKey k : ecdsaKeys) { - ecdsaSigs.add(Hex.toHexString(k.sign(toSign).toByteArray())); - } - - Assert.assertArrayEquals(DataWord.ZERO().getData(), - runContract(owner.getAddress(), 2, data, ecdsaSigs, - Collections.emptyList(), Collections.emptyList()).getRight()); - } - - @Test - public void duplicatePqSig_doesNotDoubleCount() { - MLDSA44 pq1 = new MLDSA44(); - ECKey owner = new ECKey(); - byte[] pqAddr = PQSchemeRegistry.computeAddress(PQScheme.ML_DSA_44, pq1.getPublicKey()); - setupPermission(owner, Collections.emptyList(), Collections.emptyList(), - 2, Collections.singletonList(pqAddr), Collections.singletonList(1)); - - byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); - byte[] toSign = computeHash(owner.getAddress(), 2, data); - byte[] sig = pq1.sign(toSign); - - List pqSigs = Arrays.asList(Hex.toHexString(sig), Hex.toHexString(sig)); - List pqPks = Arrays.asList( - Hex.toHexString(pq1.getPublicKey()), Hex.toHexString(pq1.getPublicKey())); - - Assert.assertArrayEquals(DataWord.ZERO().getData(), - runContract(owner.getAddress(), 2, data, - Collections.emptyList(), pqSigs, pqPks).getRight()); - } - - @Test - public void energyChargesEcdsaAndPqSeparately() { - MLDSA44 pq1 = new MLDSA44(); - ECKey k1 = new ECKey(); - ECKey owner = new ECKey(); - byte[] pqAddr = PQSchemeRegistry.computeAddress(PQScheme.ML_DSA_44, pq1.getPublicKey()); - setupPermission(owner, Collections.singletonList(k1.getAddress()), Collections.singletonList(1), - 2, Collections.singletonList(pqAddr), Collections.singletonList(1)); - - byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); - byte[] toSign = computeHash(owner.getAddress(), 2, data); - List ecdsaSigs = Collections.singletonList( - Hex.toHexString(k1.sign(toSign).toByteArray())); - List pqSigs = Collections.singletonList(Hex.toHexString(pq1.sign(toSign))); - List pqPks = Collections.singletonList(Hex.toHexString(pq1.getPublicKey())); - - byte[] input = encodeInput(owner.getAddress(), 2, data, ecdsaSigs, pqSigs, pqPks); - // 1 ECDSA × 1500 + 1 PQ × 4000 = 5500 - Assert.assertEquals(5500L, contract.getEnergyForData(input)); - } - - @Test - public void thresholdNotReached_returnsZero() { - ECKey k1 = new ECKey(); - ECKey k2 = new ECKey(); - ECKey owner = new ECKey(); - setupPermission(owner, Arrays.asList(k1.getAddress(), k2.getAddress()), - Arrays.asList(1, 1), 2, Collections.emptyList(), Collections.emptyList()); - - byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); - byte[] toSign = computeHash(owner.getAddress(), 2, data); - // Only one valid signature; threshold is 2. - List ecdsaSigs = Collections.singletonList( - Hex.toHexString(k1.sign(toSign).toByteArray())); - - Assert.assertArrayEquals(DataWord.ZERO().getData(), - runContract(owner.getAddress(), 2, data, ecdsaSigs, - Collections.emptyList(), Collections.emptyList()).getRight()); - } - - @Test - public void pqKeyNotInPermission_returnsZero() { - MLDSA44 inPerm = new MLDSA44(); - MLDSA44 outsider = new MLDSA44(); - ECKey owner = new ECKey(); - byte[] inAddr = PQSchemeRegistry.computeAddress(PQScheme.ML_DSA_44, inPerm.getPublicKey()); - setupPermission(owner, Collections.emptyList(), Collections.emptyList(), - 1, Collections.singletonList(inAddr), Collections.singletonList(1)); - - byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); - byte[] toSign = computeHash(owner.getAddress(), 2, data); - - // Outsider produces a perfectly valid ML-DSA signature, but its derived - // address is not in Permission.keys[] → weight 0 → not counted. - List pqSigs = Collections.singletonList(Hex.toHexString(outsider.sign(toSign))); - List pqPks = Collections.singletonList(Hex.toHexString(outsider.getPublicKey())); - - Assert.assertArrayEquals(DataWord.ZERO().getData(), - runContract(owner.getAddress(), 2, data, - Collections.emptyList(), pqSigs, pqPks).getRight()); - } - - @Test - public void pqSigWrongLength_returnsZero() { - // ML-DSA-44 signatures are fixed-length 2420 B; any other length must be - // rejected before reaching BC's verifier. Differs from FN-DSA-512 (variable). - MLDSA44 pq1 = new MLDSA44(); - ECKey owner = new ECKey(); - byte[] pqAddr = PQSchemeRegistry.computeAddress(PQScheme.ML_DSA_44, pq1.getPublicKey()); - setupPermission(owner, Collections.emptyList(), Collections.emptyList(), - 1, Collections.singletonList(pqAddr), Collections.singletonList(1)); - - byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); - - byte[] wrongLen = new byte[MLDSA44.SIGNATURE_LENGTH - 1]; - Arrays.fill(wrongLen, (byte) 0x42); - List pqSigs = Collections.singletonList(Hex.toHexString(wrongLen)); - List pqPks = Collections.singletonList(Hex.toHexString(pq1.getPublicKey())); - - Assert.assertArrayEquals(DataWord.ZERO().getData(), - runContract(owner.getAddress(), 2, data, - Collections.emptyList(), pqSigs, pqPks).getRight()); - } - - @Test - public void bothArraysEmpty_returnsZero() { - ECKey k1 = new ECKey(); - ECKey owner = new ECKey(); - setupPermission(owner, Collections.singletonList(k1.getAddress()), - Collections.singletonList(1), 1, - Collections.emptyList(), Collections.emptyList()); - - byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); - - Assert.assertArrayEquals(DataWord.ZERO().getData(), - runContract(owner.getAddress(), 2, data, - Collections.emptyList(), Collections.emptyList(), - Collections.emptyList()).getRight()); - } - - @Test - public void mixedFailingPqAborts_returnsZero() { - // Mirrors 0x09 semantics: a verify failure on any submitted entry aborts - // the whole call with DATA_FALSE — even if other entries would alone meet - // threshold. Verifies 0x1a does not silently skip a forged PQ signature. - ECKey k1 = new ECKey(); - ECKey k2 = new ECKey(); - MLDSA44 pq1 = new MLDSA44(); - ECKey owner = new ECKey(); - byte[] pqAddr = PQSchemeRegistry.computeAddress(PQScheme.ML_DSA_44, pq1.getPublicKey()); - setupPermission(owner, - Arrays.asList(k1.getAddress(), k2.getAddress()), Arrays.asList(1, 1), - 2, Collections.singletonList(pqAddr), Collections.singletonList(1)); - - byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); - byte[] toSign = computeHash(owner.getAddress(), 2, data); - - List ecdsaSigs = Arrays.asList( - Hex.toHexString(k1.sign(toSign).toByteArray()), - Hex.toHexString(k2.sign(toSign).toByteArray())); - byte[] forged = pq1.sign(toSign); - forged[0] ^= 0x55; - List pqSigs = Collections.singletonList(Hex.toHexString(forged)); - List pqPks = Collections.singletonList(Hex.toHexString(pq1.getPublicKey())); - - Assert.assertArrayEquals(DataWord.ZERO().getData(), - runContract(owner.getAddress(), 2, data, ecdsaSigs, pqSigs, pqPks).getRight()); - } - - // -------- helpers -------- - - private void setupPermission(ECKey owner, - List ecdsaKeyAddrs, List ecdsaWeights, - int threshold, - List pqKeyAddrs, List pqWeights) { - AccountCapsule account = new AccountCapsule(ByteString.copyFrom(owner.getAddress()), - Protocol.AccountType.Normal, System.currentTimeMillis(), true, - dbManager.getDynamicPropertiesStore()); - - Protocol.Permission.Builder perm = Protocol.Permission.newBuilder() - .setType(Protocol.Permission.PermissionType.Active) - .setId(2) - .setPermissionName("active") - .setThreshold(threshold) - .setOperations(ByteString.copyFrom(ByteArray.fromHexString( - "0000000000000000000000000000000000000000000000000000000000000000"))); - for (int i = 0; i < ecdsaKeyAddrs.size(); i++) { - perm.addKeys(Protocol.Key.newBuilder() - .setAddress(ByteString.copyFrom(ecdsaKeyAddrs.get(i))) - .setWeight(ecdsaWeights.get(i)).build()); - } - for (int i = 0; i < pqKeyAddrs.size(); i++) { - perm.addKeys(Protocol.Key.newBuilder() - .setAddress(ByteString.copyFrom(pqKeyAddrs.get(i))) - .setWeight(pqWeights.get(i)).build()); - } - account.updatePermissions(account.getPermissionById(0), null, - Collections.singletonList(perm.build())); - dbManager.getAccountStore().put(owner.getAddress(), account); - } - - private byte[] computeHash(byte[] address, int permissionId, byte[] data) { - byte[] combined = ByteUtil.merge(address, ByteArray.fromInt(permissionId), data); - return Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), combined); - } - - private byte[] encodeInput(byte[] ownerAddr, int permissionId, byte[] data, - List ecdsaSigs, List pqSigs, List pqPks) { - List parameters = Arrays.asList( - StringUtil.encode58Check(ownerAddr), - permissionId, - "0x" + Hex.toHexString(data), - toHexList(ecdsaSigs), - toHexList(pqSigs), - toHexList(pqPks)); - return Hex.decode(AbiUtil.parseParameters(METHOD_SIGN, parameters)); - } - - private Pair runContract(byte[] ownerAddr, int permissionId, byte[] data, - List ecdsaSigs, List pqSigs, - List pqPks) { - byte[] input = encodeInput(ownerAddr, permissionId, data, ecdsaSigs, pqSigs, pqPks); - Repository deposit = RepositoryImpl.createRoot(StoreFactory.getInstance()); - contract.setRepository(deposit); - Pair ret = contract.execute(input); - logger.info("0x1a result: {}", Hex.toHexString(ret.getRight())); - return ret; - } - - private static List toHexList(List hexes) { - List out = new ArrayList<>(hexes.size()); - for (String h : hexes) { - out.add(h.startsWith("0x") ? h : ("0x" + h)); - } - return out; - } -} diff --git a/framework/src/test/java/org/tron/common/runtime/vm/ValidateMultiPQSigTest.java b/framework/src/test/java/org/tron/common/runtime/vm/ValidateMultiPQSigTest.java new file mode 100644 index 00000000000..401c2e75bde --- /dev/null +++ b/framework/src/test/java/org/tron/common/runtime/vm/ValidateMultiPQSigTest.java @@ -0,0 +1,839 @@ +package org.tron.common.runtime.vm; + +import com.google.protobuf.ByteString; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.tuple.Pair; +import org.bouncycastle.util.encoders.Hex; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.tron.common.BaseTest; +import org.tron.common.TestConstants; +import org.tron.common.crypto.ECKey; +import org.tron.common.crypto.pqc.FNDSA512; +import org.tron.common.crypto.pqc.MLDSA44; +import org.tron.common.crypto.pqc.PQSchemeRegistry; +import org.tron.common.parameter.CommonParameter; +import org.tron.common.utils.ByteArray; +import org.tron.common.utils.ByteUtil; +import org.tron.common.utils.Sha256Hash; +import org.tron.common.utils.StringUtil; +import org.tron.common.utils.client.utils.AbiUtil; +import org.tron.core.capsule.AccountCapsule; +import org.tron.core.config.args.Args; +import org.tron.core.store.StoreFactory; +import org.tron.core.vm.PrecompiledContracts; +import org.tron.core.vm.PrecompiledContracts.PrecompiledContract; +import org.tron.core.vm.PrecompiledContracts.ValidateMultiPQSig; +import org.tron.core.vm.config.VMConfig; +import org.tron.core.vm.repository.Repository; +import org.tron.core.vm.repository.RepositoryImpl; +import org.tron.protos.Protocol; +import org.tron.protos.Protocol.PQScheme; + +/** + * Unit tests for the unified 0x1a algorithm-agnostic Permission multi-sign + * precompile. Replaces the per-scheme {@code ValidateMultiFnDsa512Test} and + * {@code ValidateMultiMlDsa44Test}: a single call may now mix ECDSA, FN-DSA-512 + * and ML-DSA-44 entries against the same {@code Permission.keys[]}, dispatched + * per entry by an explicit {@code uint8[]} scheme tag. + */ +@Slf4j +public class ValidateMultiPQSigTest extends BaseTest { + + private static final DataWord ADDR_0X1A = new DataWord( + "000000000000000000000000000000000000000000000000000000000000001a"); + + // Old per-scheme slot — must NOT resolve to anything after the 0x17 → 0x1a merge. + private static final DataWord ADDR_0X17 = new DataWord( + "0000000000000000000000000000000000000000000000000000000000000017"); + + private static final String METHOD_SIGN = + "validatemultipqsign(address,uint256,bytes32,bytes[],uint8[],bytes[],bytes[])"; + + private static final int TAG_FN_DSA_512 = PQScheme.FN_DSA_512.getNumber(); + private static final int TAG_ML_DSA_44 = PQScheme.ML_DSA_44.getNumber(); + + private static final byte[] longData; + + static { + Args.setParam(new String[]{"--output-directory", dbPath(), "--debug"}, TestConstants.TEST_CONF); + longData = new byte[1000]; + Arrays.fill(longData, (byte) 7); + } + + private final ValidateMultiPQSig contract = new ValidateMultiPQSig(); + + @Before + public void before() { + dbManager.getDynamicPropertiesStore().saveAllowMultiSign(1); + dbManager.getDynamicPropertiesStore().saveTotalSignNum(5); + VMConfig.initAllowFnDsa512(1L); + VMConfig.initAllowMlDsa44(1L); + } + + @After + public void after() { + VMConfig.initAllowFnDsa512(0L); + VMConfig.initAllowMlDsa44(0L); + } + + // ---------- registration / gating ---------- + + @Test + public void bothSwitchesOff_returnsNull() { + VMConfig.initAllowFnDsa512(0L); + VMConfig.initAllowMlDsa44(0L); + Assert.assertNull(PrecompiledContracts.getContractForAddress(ADDR_0X1A)); + } + + @Test + public void onlyFalconSwitchOn_returnsContract() { + VMConfig.initAllowMlDsa44(0L); + PrecompiledContract pc = PrecompiledContracts.getContractForAddress(ADDR_0X1A); + Assert.assertNotNull(pc); + Assert.assertTrue(pc instanceof ValidateMultiPQSig); + } + + @Test + public void onlyDilithiumSwitchOn_returnsContract() { + VMConfig.initAllowFnDsa512(0L); + PrecompiledContract pc = PrecompiledContracts.getContractForAddress(ADDR_0X1A); + Assert.assertNotNull(pc); + Assert.assertTrue(pc instanceof ValidateMultiPQSig); + } + + @Test + public void bothSwitchesOn_returnsContract() { + PrecompiledContract pc = PrecompiledContracts.getContractForAddress(ADDR_0X1A); + Assert.assertNotNull(pc); + Assert.assertTrue(pc instanceof ValidateMultiPQSig); + } + + @Test + public void legacy0x17SlotIsUnallocated() { + // 0x17 used to host a Falcon-only multi-sign; after the merge it must + // resolve to nothing so calls fall through to the empty-precompile path. + Assert.assertNull(PrecompiledContracts.getContractForAddress(ADDR_0X17)); + } + + // ---------- happy paths ---------- + + @Test + public void unknownAccount_returnsZero() { + ECKey owner = new ECKey(); + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + List ecdsaSigs = Collections.singletonList( + Hex.toHexString(new ECKey().sign(toSign).toByteArray())); + Assert.assertArrayEquals(DataWord.ZERO().getData(), + runContract(owner.getAddress(), 2, data, ecdsaSigs, + Collections.emptyList(), Collections.emptyList(), + Collections.emptyList()).getRight()); + } + + @Test + public void pureEcdsaThresholdReached_returnsOne() { + ECKey k1 = new ECKey(); + ECKey k2 = new ECKey(); + ECKey owner = new ECKey(); + setupPermission(owner, Arrays.asList(k1.getAddress(), k2.getAddress()), + Arrays.asList(1, 1), 2, + Collections.emptyList(), Collections.emptyList()); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + List ecdsaSigs = Arrays.asList( + Hex.toHexString(k1.sign(toSign).toByteArray()), + Hex.toHexString(k2.sign(toSign).toByteArray())); + + Assert.assertArrayEquals(DataWord.ONE().getData(), + runContract(owner.getAddress(), 2, data, ecdsaSigs, + Collections.emptyList(), Collections.emptyList(), + Collections.emptyList()).getRight()); + } + + @Test + public void pureFalconThresholdReached_returnsOne() { + FNDSA512 pq1 = new FNDSA512(); + FNDSA512 pq2 = new FNDSA512(); + ECKey owner = new ECKey(); + byte[] addr1 = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pq1.getPublicKey()); + byte[] addr2 = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pq2.getPublicKey()); + setupPermission(owner, Collections.emptyList(), Collections.emptyList(), + 2, Arrays.asList(addr1, addr2), Arrays.asList(1, 1)); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + + List pqSigs = Arrays.asList( + Hex.toHexString(padFalconSig(pq1.sign(toSign))), + Hex.toHexString(padFalconSig(pq2.sign(toSign)))); + List pqPks = Arrays.asList( + Hex.toHexString(pq1.getPublicKey()), + Hex.toHexString(pq2.getPublicKey())); + List schemes = Arrays.asList(TAG_FN_DSA_512, TAG_FN_DSA_512); + + Assert.assertArrayEquals(DataWord.ONE().getData(), + runContract(owner.getAddress(), 2, data, + Collections.emptyList(), schemes, pqSigs, pqPks).getRight()); + } + + @Test + public void pureDilithiumThresholdReached_returnsOne() { + MLDSA44 pq1 = new MLDSA44(); + MLDSA44 pq2 = new MLDSA44(); + ECKey owner = new ECKey(); + byte[] addr1 = PQSchemeRegistry.computeAddress(PQScheme.ML_DSA_44, pq1.getPublicKey()); + byte[] addr2 = PQSchemeRegistry.computeAddress(PQScheme.ML_DSA_44, pq2.getPublicKey()); + setupPermission(owner, Collections.emptyList(), Collections.emptyList(), + 2, Arrays.asList(addr1, addr2), Arrays.asList(1, 1)); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + + List pqSigs = Arrays.asList( + Hex.toHexString(pq1.sign(toSign)), + Hex.toHexString(pq2.sign(toSign))); + List pqPks = Arrays.asList( + Hex.toHexString(pq1.getPublicKey()), + Hex.toHexString(pq2.getPublicKey())); + List schemes = Arrays.asList(TAG_ML_DSA_44, TAG_ML_DSA_44); + + Assert.assertArrayEquals(DataWord.ONE().getData(), + runContract(owner.getAddress(), 2, data, + Collections.emptyList(), schemes, pqSigs, pqPks).getRight()); + } + + @Test + public void mixedEcdsaFalconDilithium_returnsOne() { + // Core motivation: a single permission whose keys[] mixes ECDSA, Falcon + // and Dilithium entries can now reach threshold in one precompile call. + ECKey k1 = new ECKey(); + FNDSA512 falcon = new FNDSA512(); + MLDSA44 dilithium = new MLDSA44(); + ECKey owner = new ECKey(); + byte[] falconAddr = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, falcon.getPublicKey()); + byte[] dilithiumAddr = PQSchemeRegistry.computeAddress( + PQScheme.ML_DSA_44, dilithium.getPublicKey()); + + setupPermission(owner, + Collections.singletonList(k1.getAddress()), Collections.singletonList(1), + 3, Arrays.asList(falconAddr, dilithiumAddr), Arrays.asList(1, 1)); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + + List ecdsaSigs = Collections.singletonList( + Hex.toHexString(k1.sign(toSign).toByteArray())); + List schemes = Arrays.asList(TAG_FN_DSA_512, TAG_ML_DSA_44); + List pqSigs = Arrays.asList( + Hex.toHexString(padFalconSig(falcon.sign(toSign))), + Hex.toHexString(dilithium.sign(toSign))); + List pqPks = Arrays.asList( + Hex.toHexString(falcon.getPublicKey()), + Hex.toHexString(dilithium.getPublicKey())); + + Assert.assertArrayEquals(DataWord.ONE().getData(), + runContract(owner.getAddress(), 2, data, ecdsaSigs, schemes, pqSigs, pqPks).getRight()); + } + + // ---------- energy ---------- + + @Test + public void energyChargesPerSchemeTag() { + // 1 × ECDSA (1500) + 1 × Falcon (2000) + 1 × Dilithium (4000) = 7500 + ECKey k1 = new ECKey(); + FNDSA512 falcon = new FNDSA512(); + MLDSA44 dilithium = new MLDSA44(); + ECKey owner = new ECKey(); + byte[] falconAddr = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, falcon.getPublicKey()); + byte[] dilithiumAddr = PQSchemeRegistry.computeAddress( + PQScheme.ML_DSA_44, dilithium.getPublicKey()); + setupPermission(owner, + Collections.singletonList(k1.getAddress()), Collections.singletonList(1), + 3, Arrays.asList(falconAddr, dilithiumAddr), Arrays.asList(1, 1)); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + + List ecdsaSigs = Collections.singletonList( + Hex.toHexString(k1.sign(toSign).toByteArray())); + List schemes = Arrays.asList(TAG_FN_DSA_512, TAG_ML_DSA_44); + List pqSigs = Arrays.asList( + Hex.toHexString(padFalconSig(falcon.sign(toSign))), + Hex.toHexString(dilithium.sign(toSign))); + List pqPks = Arrays.asList( + Hex.toHexString(falcon.getPublicKey()), + Hex.toHexString(dilithium.getPublicKey())); + + byte[] input = encodeInput(owner.getAddress(), 2, data, ecdsaSigs, schemes, pqSigs, pqPks); + Assert.assertEquals(7500L, contract.getEnergyForData(input)); + } + + @Test + public void energyUnknownTagChargesWorstCase() { + // A junk tag must be priced at the worst-case PQ cost so an attacker + // cannot underpay by submitting tags the dispatcher will reject. + ECKey k1 = new ECKey(); + FNDSA512 falcon = new FNDSA512(); + ECKey owner = new ECKey(); + byte[] falconAddr = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, falcon.getPublicKey()); + setupPermission(owner, + Collections.singletonList(k1.getAddress()), Collections.singletonList(1), + 2, Collections.singletonList(falconAddr), Collections.singletonList(1)); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + + List ecdsaSigs = Collections.singletonList( + Hex.toHexString(k1.sign(toSign).toByteArray())); + // Two PQ entries: one legit Falcon, one junk-tagged. Junk slot still occupies + // a sig + pk slot (we use Falcon-shaped bytes so encodeBytesArray is happy). + List schemes = Arrays.asList(TAG_FN_DSA_512, 99); + List pqSigs = Arrays.asList( + Hex.toHexString(padFalconSig(falcon.sign(toSign))), + Hex.toHexString(padFalconSig(falcon.sign(toSign)))); + List pqPks = Arrays.asList( + Hex.toHexString(falcon.getPublicKey()), + Hex.toHexString(falcon.getPublicKey())); + + byte[] input = encodeInput(owner.getAddress(), 2, data, ecdsaSigs, schemes, pqSigs, pqPks); + // 1500 + 2000 (Falcon) + 4000 (junk priced at worst case) = 7500 + Assert.assertEquals(7500L, contract.getEnergyForData(input)); + } + + // ---------- per-entry rejection ---------- + + @Test + public void unknownPqSchemeTag_returnsZero() { + FNDSA512 falcon = new FNDSA512(); + ECKey owner = new ECKey(); + byte[] addr = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, falcon.getPublicKey()); + setupPermission(owner, Collections.emptyList(), Collections.emptyList(), + 1, Collections.singletonList(addr), Collections.singletonList(1)); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + + List schemes = Collections.singletonList(99); // unregistered tag + List pqSigs = Collections.singletonList( + Hex.toHexString(padFalconSig(falcon.sign(toSign)))); + List pqPks = Collections.singletonList(Hex.toHexString(falcon.getPublicKey())); + + Assert.assertArrayEquals(DataWord.ZERO().getData(), + runContract(owner.getAddress(), 2, data, + Collections.emptyList(), schemes, pqSigs, pqPks).getRight()); + } + + @Test + public void unknownPqSchemeZeroTag_returnsZero() { + // Proto3 default UNKNOWN_PQ_SCHEME (=0) must be rejected explicitly so + // producers can't sneak through unset scheme tags. + FNDSA512 falcon = new FNDSA512(); + ECKey owner = new ECKey(); + byte[] addr = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, falcon.getPublicKey()); + setupPermission(owner, Collections.emptyList(), Collections.emptyList(), + 1, Collections.singletonList(addr), Collections.singletonList(1)); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + + List schemes = Collections.singletonList(0); + List pqSigs = Collections.singletonList( + Hex.toHexString(padFalconSig(falcon.sign(toSign)))); + List pqPks = Collections.singletonList(Hex.toHexString(falcon.getPublicKey())); + + Assert.assertArrayEquals(DataWord.ZERO().getData(), + runContract(owner.getAddress(), 2, data, + Collections.emptyList(), schemes, pqSigs, pqPks).getRight()); + } + + @Test + public void mismatchedSchemeAndPqSigArrayLengths_returnsZero() { + FNDSA512 falcon = new FNDSA512(); + ECKey owner = new ECKey(); + byte[] addr = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, falcon.getPublicKey()); + setupPermission(owner, Collections.emptyList(), Collections.emptyList(), + 1, Collections.singletonList(addr), Collections.singletonList(1)); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + + // 2 schemes but only 1 sig / 1 pk → schemeCnt mismatch. + List schemes = Arrays.asList(TAG_FN_DSA_512, TAG_FN_DSA_512); + List pqSigs = Collections.singletonList( + Hex.toHexString(padFalconSig(falcon.sign(toSign)))); + List pqPks = Collections.singletonList(Hex.toHexString(falcon.getPublicKey())); + + Assert.assertArrayEquals(DataWord.ZERO().getData(), + runContract(owner.getAddress(), 2, data, + Collections.emptyList(), schemes, pqSigs, pqPks).getRight()); + } + + @Test + public void falconEntryWhileFalconDisabled_returnsZero() { + // 0x1a stays registered because ML-DSA is still active, but a Falcon entry + // must be rejected per-entry when its proposal isn't passed. + VMConfig.initAllowFnDsa512(0L); + FNDSA512 falcon = new FNDSA512(); + ECKey owner = new ECKey(); + byte[] addr = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, falcon.getPublicKey()); + setupPermission(owner, Collections.emptyList(), Collections.emptyList(), + 1, Collections.singletonList(addr), Collections.singletonList(1)); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + + List schemes = Collections.singletonList(TAG_FN_DSA_512); + List pqSigs = Collections.singletonList( + Hex.toHexString(padFalconSig(falcon.sign(toSign)))); + List pqPks = Collections.singletonList(Hex.toHexString(falcon.getPublicKey())); + + Assert.assertArrayEquals(DataWord.ZERO().getData(), + runContract(owner.getAddress(), 2, data, + Collections.emptyList(), schemes, pqSigs, pqPks).getRight()); + } + + @Test + public void dilithiumEntryWhileDilithiumDisabled_returnsZero() { + VMConfig.initAllowMlDsa44(0L); + MLDSA44 dilithium = new MLDSA44(); + ECKey owner = new ECKey(); + byte[] addr = PQSchemeRegistry.computeAddress(PQScheme.ML_DSA_44, dilithium.getPublicKey()); + setupPermission(owner, Collections.emptyList(), Collections.emptyList(), + 1, Collections.singletonList(addr), Collections.singletonList(1)); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + + List schemes = Collections.singletonList(TAG_ML_DSA_44); + List pqSigs = Collections.singletonList(Hex.toHexString(dilithium.sign(toSign))); + List pqPks = Collections.singletonList(Hex.toHexString(dilithium.getPublicKey())); + + Assert.assertArrayEquals(DataWord.ZERO().getData(), + runContract(owner.getAddress(), 2, data, + Collections.emptyList(), schemes, pqSigs, pqPks).getRight()); + } + + @Test + public void onlyAllowedSchemeStillWorksWhenOtherDisabled() { + // Falcon disabled, Dilithium active; pure-Dilithium call must still succeed. + VMConfig.initAllowFnDsa512(0L); + MLDSA44 d1 = new MLDSA44(); + MLDSA44 d2 = new MLDSA44(); + ECKey owner = new ECKey(); + byte[] addr1 = PQSchemeRegistry.computeAddress(PQScheme.ML_DSA_44, d1.getPublicKey()); + byte[] addr2 = PQSchemeRegistry.computeAddress(PQScheme.ML_DSA_44, d2.getPublicKey()); + setupPermission(owner, Collections.emptyList(), Collections.emptyList(), + 2, Arrays.asList(addr1, addr2), Arrays.asList(1, 1)); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + + List schemes = Arrays.asList(TAG_ML_DSA_44, TAG_ML_DSA_44); + List pqSigs = Arrays.asList( + Hex.toHexString(d1.sign(toSign)), Hex.toHexString(d2.sign(toSign))); + List pqPks = Arrays.asList( + Hex.toHexString(d1.getPublicKey()), Hex.toHexString(d2.getPublicKey())); + + Assert.assertArrayEquals(DataWord.ONE().getData(), + runContract(owner.getAddress(), 2, data, + Collections.emptyList(), schemes, pqSigs, pqPks).getRight()); + } + + // ---------- length / slot rules ---------- + + @Test + public void falconSigSlotExact666_returnsOne() { + FNDSA512 falcon = new FNDSA512(); + ECKey owner = new ECKey(); + byte[] addr = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, falcon.getPublicKey()); + setupPermission(owner, Collections.emptyList(), Collections.emptyList(), + 1, Collections.singletonList(addr), Collections.singletonList(1)); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + + byte[] padded = padFalconSig(falcon.sign(toSign)); + Assert.assertEquals(FNDSA512.SIGNATURE_LENGTH, padded.length); + + List schemes = Collections.singletonList(TAG_FN_DSA_512); + List pqSigs = Collections.singletonList(Hex.toHexString(padded)); + List pqPks = Collections.singletonList(Hex.toHexString(falcon.getPublicKey())); + + Assert.assertArrayEquals(DataWord.ONE().getData(), + runContract(owner.getAddress(), 2, data, + Collections.emptyList(), schemes, pqSigs, pqPks).getRight()); + } + + @Test + public void falconSigSlotNot666_returnsZero() { + FNDSA512 falcon = new FNDSA512(); + ECKey owner = new ECKey(); + byte[] addr = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, falcon.getPublicKey()); + setupPermission(owner, Collections.emptyList(), Collections.emptyList(), + 1, Collections.singletonList(addr), Collections.singletonList(1)); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + + // Trim the slot one byte short of 666 — must be rejected (slot length exact). + byte[] shortSlot = Arrays.copyOf(padFalconSig(falcon.sign(toSign)), + FNDSA512.SIGNATURE_LENGTH - 1); + + List schemes = Collections.singletonList(TAG_FN_DSA_512); + List pqSigs = Collections.singletonList(Hex.toHexString(shortSlot)); + List pqPks = Collections.singletonList(Hex.toHexString(falcon.getPublicKey())); + + Assert.assertArrayEquals(DataWord.ZERO().getData(), + runContract(owner.getAddress(), 2, data, + Collections.emptyList(), schemes, pqSigs, pqPks).getRight()); + } + + @Test + public void falconSigAllZero_returnsZero() { + // All-zero 666-byte slot — recoverFalconSigLen returns 0, below SIGNATURE_MIN_LENGTH. + FNDSA512 falcon = new FNDSA512(); + ECKey owner = new ECKey(); + byte[] addr = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, falcon.getPublicKey()); + setupPermission(owner, Collections.emptyList(), Collections.emptyList(), + 1, Collections.singletonList(addr), Collections.singletonList(1)); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + + byte[] zeros = new byte[FNDSA512.SIGNATURE_LENGTH]; + List schemes = Collections.singletonList(TAG_FN_DSA_512); + List pqSigs = Collections.singletonList(Hex.toHexString(zeros)); + List pqPks = Collections.singletonList(Hex.toHexString(falcon.getPublicKey())); + + Assert.assertArrayEquals(DataWord.ZERO().getData(), + runContract(owner.getAddress(), 2, data, + Collections.emptyList(), schemes, pqSigs, pqPks).getRight()); + } + + @Test + public void dilithiumSigWrongLength_returnsZero() { + MLDSA44 d1 = new MLDSA44(); + ECKey owner = new ECKey(); + byte[] addr = PQSchemeRegistry.computeAddress(PQScheme.ML_DSA_44, d1.getPublicKey()); + setupPermission(owner, Collections.emptyList(), Collections.emptyList(), + 1, Collections.singletonList(addr), Collections.singletonList(1)); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + + byte[] wrongLen = new byte[MLDSA44.SIGNATURE_LENGTH - 1]; + Arrays.fill(wrongLen, (byte) 0x42); + List schemes = Collections.singletonList(TAG_ML_DSA_44); + List pqSigs = Collections.singletonList(Hex.toHexString(wrongLen)); + List pqPks = Collections.singletonList(Hex.toHexString(d1.getPublicKey())); + + Assert.assertArrayEquals(DataWord.ZERO().getData(), + runContract(owner.getAddress(), 2, data, + Collections.emptyList(), schemes, pqSigs, pqPks).getRight()); + } + + @Test + public void falconSigLabelledDilithium_returnsZero() { + // Falcon sig in a Dilithium-tagged entry → slot length 666 != 2420 → reject. + FNDSA512 falcon = new FNDSA512(); + MLDSA44 d1 = new MLDSA44(); + ECKey owner = new ECKey(); + byte[] addr = PQSchemeRegistry.computeAddress(PQScheme.ML_DSA_44, d1.getPublicKey()); + setupPermission(owner, Collections.emptyList(), Collections.emptyList(), + 1, Collections.singletonList(addr), Collections.singletonList(1)); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + + List schemes = Collections.singletonList(TAG_ML_DSA_44); + List pqSigs = Collections.singletonList( + Hex.toHexString(padFalconSig(falcon.sign(toSign)))); + List pqPks = Collections.singletonList(Hex.toHexString(d1.getPublicKey())); + + Assert.assertArrayEquals(DataWord.ZERO().getData(), + runContract(owner.getAddress(), 2, data, + Collections.emptyList(), schemes, pqSigs, pqPks).getRight()); + } + + @Test + public void wrongPqPublicKeyLength_returnsZero() { + MLDSA44 d1 = new MLDSA44(); + ECKey owner = new ECKey(); + byte[] addr = PQSchemeRegistry.computeAddress(PQScheme.ML_DSA_44, d1.getPublicKey()); + setupPermission(owner, Collections.emptyList(), Collections.emptyList(), + 1, Collections.singletonList(addr), Collections.singletonList(1)); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + byte[] truncatedPk = Arrays.copyOf(d1.getPublicKey(), d1.getPublicKey().length - 1); + + List schemes = Collections.singletonList(TAG_ML_DSA_44); + List pqSigs = Collections.singletonList(Hex.toHexString(d1.sign(toSign))); + List pqPks = Collections.singletonList(Hex.toHexString(truncatedPk)); + + Assert.assertArrayEquals(DataWord.ZERO().getData(), + runContract(owner.getAddress(), 2, data, + Collections.emptyList(), schemes, pqSigs, pqPks).getRight()); + } + + // ---------- dedup / failure semantics ---------- + + @Test + public void crossEntryDedupSameAddress_doesNotDoubleCount() { + // Same Falcon key submitted twice — dedup keys on derived address (PQ + // signing is randomized so two valid sigs from one key are normal). + // Threshold 2, weight 1 → second occurrence is ignored, threshold not met. + FNDSA512 falcon = new FNDSA512(); + ECKey owner = new ECKey(); + byte[] addr = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, falcon.getPublicKey()); + setupPermission(owner, Collections.emptyList(), Collections.emptyList(), + 2, Collections.singletonList(addr), Collections.singletonList(1)); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + + List schemes = Arrays.asList(TAG_FN_DSA_512, TAG_FN_DSA_512); + List pqSigs = Arrays.asList( + Hex.toHexString(padFalconSig(falcon.sign(toSign))), + Hex.toHexString(padFalconSig(falcon.sign(toSign)))); + List pqPks = Arrays.asList( + Hex.toHexString(falcon.getPublicKey()), + Hex.toHexString(falcon.getPublicKey())); + + Assert.assertArrayEquals(DataWord.ZERO().getData(), + runContract(owner.getAddress(), 2, data, + Collections.emptyList(), schemes, pqSigs, pqPks).getRight()); + } + + @Test + public void pqSignatureForgery_returnsZero() { + MLDSA44 d1 = new MLDSA44(); + ECKey owner = new ECKey(); + byte[] addr = PQSchemeRegistry.computeAddress(PQScheme.ML_DSA_44, d1.getPublicKey()); + setupPermission(owner, Collections.emptyList(), Collections.emptyList(), + 1, Collections.singletonList(addr), Collections.singletonList(1)); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + byte[] forged = d1.sign(toSign); + forged[10] ^= 0x01; + + List schemes = Collections.singletonList(TAG_ML_DSA_44); + List pqSigs = Collections.singletonList(Hex.toHexString(forged)); + List pqPks = Collections.singletonList(Hex.toHexString(d1.getPublicKey())); + + Assert.assertArrayEquals(DataWord.ZERO().getData(), + runContract(owner.getAddress(), 2, data, + Collections.emptyList(), schemes, pqSigs, pqPks).getRight()); + } + + @Test + public void pqKeyNotInPermission_returnsZero() { + MLDSA44 inPerm = new MLDSA44(); + MLDSA44 outsider = new MLDSA44(); + ECKey owner = new ECKey(); + byte[] inAddr = PQSchemeRegistry.computeAddress(PQScheme.ML_DSA_44, inPerm.getPublicKey()); + setupPermission(owner, Collections.emptyList(), Collections.emptyList(), + 1, Collections.singletonList(inAddr), Collections.singletonList(1)); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + + List schemes = Collections.singletonList(TAG_ML_DSA_44); + List pqSigs = Collections.singletonList(Hex.toHexString(outsider.sign(toSign))); + List pqPks = Collections.singletonList(Hex.toHexString(outsider.getPublicKey())); + + Assert.assertArrayEquals(DataWord.ZERO().getData(), + runContract(owner.getAddress(), 2, data, + Collections.emptyList(), schemes, pqSigs, pqPks).getRight()); + } + + @Test + public void totalCountOverMaxSize_returnsZero() { + ECKey owner = new ECKey(); + List ecdsaAddrs = new ArrayList<>(); + List ecdsaWeights = new ArrayList<>(); + List ecdsaKeys = new ArrayList<>(); + for (int i = 0; i < 6; i++) { + ECKey k = new ECKey(); + ecdsaKeys.add(k); + ecdsaAddrs.add(k.getAddress()); + ecdsaWeights.add(1); + } + setupPermission(owner, ecdsaAddrs, ecdsaWeights, 6, + Collections.emptyList(), Collections.emptyList()); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + List ecdsaSigs = new ArrayList<>(); + for (ECKey k : ecdsaKeys) { + ecdsaSigs.add(Hex.toHexString(k.sign(toSign).toByteArray())); + } + + Assert.assertArrayEquals(DataWord.ZERO().getData(), + runContract(owner.getAddress(), 2, data, ecdsaSigs, + Collections.emptyList(), Collections.emptyList(), + Collections.emptyList()).getRight()); + } + + @Test + public void bothArraysEmpty_returnsZero() { + ECKey k1 = new ECKey(); + ECKey owner = new ECKey(); + setupPermission(owner, Collections.singletonList(k1.getAddress()), + Collections.singletonList(1), 1, + Collections.emptyList(), Collections.emptyList()); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + + Assert.assertArrayEquals(DataWord.ZERO().getData(), + runContract(owner.getAddress(), 2, data, + Collections.emptyList(), Collections.emptyList(), + Collections.emptyList(), Collections.emptyList()).getRight()); + } + + @Test + public void mixedFailingPqAborts_returnsZero() { + // Mirrors 0x09 semantics: a verify failure on any entry aborts the whole + // call with DATA_FALSE even if other entries would alone reach threshold. + ECKey k1 = new ECKey(); + ECKey k2 = new ECKey(); + MLDSA44 d1 = new MLDSA44(); + ECKey owner = new ECKey(); + byte[] pqAddr = PQSchemeRegistry.computeAddress(PQScheme.ML_DSA_44, d1.getPublicKey()); + setupPermission(owner, + Arrays.asList(k1.getAddress(), k2.getAddress()), Arrays.asList(1, 1), + 2, Collections.singletonList(pqAddr), Collections.singletonList(1)); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + + List ecdsaSigs = Arrays.asList( + Hex.toHexString(k1.sign(toSign).toByteArray()), + Hex.toHexString(k2.sign(toSign).toByteArray())); + byte[] forged = d1.sign(toSign); + forged[0] ^= 0x55; + List schemes = Collections.singletonList(TAG_ML_DSA_44); + List pqSigs = Collections.singletonList(Hex.toHexString(forged)); + List pqPks = Collections.singletonList(Hex.toHexString(d1.getPublicKey())); + + Assert.assertArrayEquals(DataWord.ZERO().getData(), + runContract(owner.getAddress(), 2, data, ecdsaSigs, schemes, pqSigs, pqPks).getRight()); + } + + @Test + public void thresholdNotReached_returnsZero() { + ECKey k1 = new ECKey(); + ECKey k2 = new ECKey(); + ECKey owner = new ECKey(); + setupPermission(owner, Arrays.asList(k1.getAddress(), k2.getAddress()), + Arrays.asList(1, 1), 2, Collections.emptyList(), Collections.emptyList()); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + List ecdsaSigs = Collections.singletonList( + Hex.toHexString(k1.sign(toSign).toByteArray())); + + Assert.assertArrayEquals(DataWord.ZERO().getData(), + runContract(owner.getAddress(), 2, data, ecdsaSigs, + Collections.emptyList(), Collections.emptyList(), + Collections.emptyList()).getRight()); + } + + // -------- helpers -------- + + /** + * Zero-pad a logical Falcon signature out to the precompile's 666-byte slot. + * Canonical Falcon encodings always end in a non-zero {@code compressed_s2} + * terminator, so {@code recoverFalconSigLen} can recover the logical length + * inside the precompile. + */ + private static byte[] padFalconSig(byte[] sig) { + if (sig.length > FNDSA512.SIGNATURE_LENGTH) { + throw new IllegalStateException("Falcon sig longer than slot: " + sig.length); + } + return Arrays.copyOf(sig, FNDSA512.SIGNATURE_LENGTH); + } + + private void setupPermission(ECKey owner, + List ecdsaKeyAddrs, List ecdsaWeights, + int threshold, + List pqKeyAddrs, List pqWeights) { + AccountCapsule account = new AccountCapsule(ByteString.copyFrom(owner.getAddress()), + Protocol.AccountType.Normal, System.currentTimeMillis(), true, + dbManager.getDynamicPropertiesStore()); + + Protocol.Permission.Builder perm = Protocol.Permission.newBuilder() + .setType(Protocol.Permission.PermissionType.Active) + .setId(2) + .setPermissionName("active") + .setThreshold(threshold) + .setOperations(ByteString.copyFrom(ByteArray.fromHexString( + "0000000000000000000000000000000000000000000000000000000000000000"))); + for (int i = 0; i < ecdsaKeyAddrs.size(); i++) { + perm.addKeys(Protocol.Key.newBuilder() + .setAddress(ByteString.copyFrom(ecdsaKeyAddrs.get(i))) + .setWeight(ecdsaWeights.get(i)).build()); + } + for (int i = 0; i < pqKeyAddrs.size(); i++) { + perm.addKeys(Protocol.Key.newBuilder() + .setAddress(ByteString.copyFrom(pqKeyAddrs.get(i))) + .setWeight(pqWeights.get(i)).build()); + } + account.updatePermissions(account.getPermissionById(0), null, + Collections.singletonList(perm.build())); + dbManager.getAccountStore().put(owner.getAddress(), account); + } + + private byte[] computeHash(byte[] address, int permissionId, byte[] data) { + byte[] combined = ByteUtil.merge(address, ByteArray.fromInt(permissionId), data); + return Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), combined); + } + + private byte[] encodeInput(byte[] ownerAddr, int permissionId, byte[] data, + List ecdsaSigs, List schemes, + List pqSigs, List pqPks) { + List parameters = Arrays.asList( + StringUtil.encode58Check(ownerAddr), + permissionId, + "0x" + Hex.toHexString(data), + toHexList(ecdsaSigs), + toObjList(schemes), + toHexList(pqSigs), + toHexList(pqPks)); + return Hex.decode(AbiUtil.parseParameters(METHOD_SIGN, parameters)); + } + + private Pair runContract(byte[] ownerAddr, int permissionId, byte[] data, + List ecdsaSigs, List schemes, + List pqSigs, List pqPks) { + byte[] input = encodeInput(ownerAddr, permissionId, data, ecdsaSigs, schemes, pqSigs, pqPks); + Repository deposit = RepositoryImpl.createRoot(StoreFactory.getInstance()); + contract.setRepository(deposit); + Pair ret = contract.execute(input); + logger.info("0x1a result: {}", Hex.toHexString(ret.getRight())); + return ret; + } + + private static List toHexList(List hexes) { + List out = new ArrayList<>(hexes.size()); + for (String h : hexes) { + out.add(h.startsWith("0x") ? h : ("0x" + h)); + } + return out; + } + + private static List toObjList(List ints) { + List out = new ArrayList<>(ints.size()); + for (Integer i : ints) { + out.add(i); + } + return out; + } +} diff --git a/protocol/src/main/protos/core/Tron.proto b/protocol/src/main/protos/core/Tron.proto index a7066584e73..6d940a165d0 100644 --- a/protocol/src/main/protos/core/Tron.proto +++ b/protocol/src/main/protos/core/Tron.proto @@ -17,17 +17,13 @@ enum AccountType { } // Post-quantum signature scheme identifier used by PQAuthSig. -// UNKNOWN_PQ_SCHEME = 0 is the proto3 default and is reserved per the -// java-tron API evolution standard (issue #6515) so unset / unrecognized -// values are detectable by JSON consumers; it MUST never be registered -// in PQSchemeRegistry. FN_DSA_512 = 1 is the V2 launch scheme; -// ML_DSA_44 = 2 (FIPS 204 / CRYSTALS-Dilithium-2) is the second scheme. -// New schemes are reserved for future activation (see PQSchemeRegistry). +// 0 = proto3 default, never registered. +// Values 3..15 are unassigned; allocation requires a TIP + governance proposal. +// proto3 `reserved` is deliberately not used here — it would block future allocation. enum PQScheme { UNKNOWN_PQ_SCHEME = 0; FN_DSA_512 = 1; ML_DSA_44 = 2; - reserved 3 to 15; } // AccountId, (name, address) use name, (null, address) use address, (name, null) use name, From 8de0873b79a68f37a3cc3eb3fcca8276d004000d Mon Sep 17 00:00:00 2001 From: federico Date: Sun, 24 May 2026 15:36:23 +0800 Subject: [PATCH 29/47] fix: address pq signature review feedback 1. generalize pq signature length predicate across schemes 2. drop fndsa512 private-key length override 3. correct 0x16 fn-dsa slot abi comment 4. document precompile return-semantics convention 5. clarify mixed-mode localwitnessaddress error message 6. guard relayservice pq branch against empty keypair list 7. gate pq miner on its specific scheme flag --- .../tron/core/vm/PrecompiledContracts.java | 26 +++++++++++++-- .../org/tron/common/crypto/pqc/FNDSA512.java | 10 ------ .../common/crypto/pqc/PQSchemeRegistry.java | 33 ++++++++++++------- .../java/org/tron/core/config/args/Args.java | 12 +++---- .../main/java/org/tron/core/db/Manager.java | 7 ++-- .../core/net/service/relay/RelayService.java | 7 ++++ 6 files changed, 64 insertions(+), 31 deletions(-) diff --git a/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java b/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java index 95e1f326da4..e9c31e3d92b 100644 --- a/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java +++ b/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java @@ -226,8 +226,8 @@ public class PrecompiledContracts { "0000000000000000000000000000000000000000000000000000000000000100"); // EIP-8052 0x16: FN-DSA / Falcon-512 verify (FIPS-206 draft). Input layout: - // [msg 32B | sig_len 2B (big-endian) | sig sig_len B (1..752) | pk 896B]. - // Variable-length signature is prefixed with a 2-byte length field. + // [msg 32B | sig 666B (zero-padded slot, logical sig ends at last non-zero byte) | pk 896B]. + // Total 1594 B. Logical sig length is recovered by trimming trailing zeros. private static final DataWord verifyFnDsa512Addr = new DataWord( "0000000000000000000000000000000000000000000000000000000000000016"); @@ -564,8 +564,30 @@ static int recoverFalconSigLen(byte[] data, int from, int to) { return 0; } + /** + * Base class for precompiled contracts. Subclasses follow one of two + * return-semantics conventions; mixing them within a single precompile + * breaks caller expectations and must be avoided. + * + *

Single-verify convention (e.g. {@code VerifyFnDsa512} 0x16, + * {@code VerifyMlDsa44} 0x19): {@code execute} always returns + * {@code Pair.of(true, X)} where {@code X} is a 32-byte word — {@code dataOne()} + * on cryptographic success, {@code DATA_FALSE} on any malformed input or + * verification failure. The caller never observes an ABI/structural error; + * everything is a boolean. Energy is a flat constant. + * + *

Multi-verify convention (e.g. {@code BatchValidateFnDsa512} 0x18, + * {@code BatchValidateMlDsa44} 0x1b, {@code ValidateMultiPQSig} 0x1a): + * {@code execute} returns {@code Pair.of(false, EMPTY_BYTE_ARRAY)} on + * structural ABI errors (head too short, out-of-range offsets, length + * cross-check failures) so the VM aborts the call and refunds gas; and + * {@code Pair.of(true, DATA_FALSE)} or {@code Pair.of(true, dataOne())} + * for per-entry verification outcomes that the caller is expected to + * branch on. Energy is computed from array lengths up front. + */ public abstract static class PrecompiledContract { + /** 32-byte zero word — see class Javadoc for return-semantics conventions. */ protected static final byte[] DATA_FALSE = new byte[WORD_SIZE]; private byte[] callerAddress; private Repository deposit; diff --git a/crypto/src/main/java/org/tron/common/crypto/pqc/FNDSA512.java b/crypto/src/main/java/org/tron/common/crypto/pqc/FNDSA512.java index 20fac864cf1..267405ded29 100644 --- a/crypto/src/main/java/org/tron/common/crypto/pqc/FNDSA512.java +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/FNDSA512.java @@ -170,16 +170,6 @@ public byte[] getPrivateKey() { return privateKey.clone(); } - /** - * FN-DSA accepts the bare {@link #PRIVATE_KEY_LENGTH} form as well as the - * extended {@link #PRIVATE_KEY_WITH_PUBLIC_KEY_LENGTH} form used for local - * witness config. Override of {@link PQSignature#validatePrivateKey}. - */ - @Override - public void validatePrivateKey(byte[] privateKey) { - validatePrivateKeyBytes(privateKey); - } - /** * Returns the private key with the 896-byte public key {@code h} appended: * {@code f ‖ g ‖ F ‖ h} (total {@link #PRIVATE_KEY_WITH_PUBLIC_KEY_LENGTH} bytes). diff --git a/crypto/src/main/java/org/tron/common/crypto/pqc/PQSchemeRegistry.java b/crypto/src/main/java/org/tron/common/crypto/pqc/PQSchemeRegistry.java index cbbf3a20507..6c0ce27955c 100644 --- a/crypto/src/main/java/org/tron/common/crypto/pqc/PQSchemeRegistry.java +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/PQSchemeRegistry.java @@ -58,6 +58,10 @@ private static final class SchemeInfo { final int privateKeyLength; final int publicKeyLength; final int signatureLength; + // Lower bound of the signature-length band. Equal to signatureLength for + // fixed-length schemes (Dilithium); strictly less for variable-length + // schemes (Falcon). Mirrors PQSignature#getSignatureMinLength. + final int signatureMinLength; final int seedLength; // Whether seed -> (priv, pub) derivation is bit-for-bit reproducible // across platforms. Falcon's reference keygen uses FFT and is not stable @@ -68,10 +72,12 @@ private static final class SchemeInfo { final SignatureOps ops; SchemeInfo(int privateKeyLength, int publicKeyLength, int signatureLength, - int seedLength, boolean seedDeterministic, FingerprintHash hash, SignatureOps ops) { + int signatureMinLength, int seedLength, boolean seedDeterministic, + FingerprintHash hash, SignatureOps ops) { this.privateKeyLength = privateKeyLength; this.publicKeyLength = publicKeyLength; this.signatureLength = signatureLength; + this.signatureMinLength = signatureMinLength; this.seedLength = seedLength; this.seedDeterministic = seedDeterministic; this.hash = hash; @@ -85,7 +91,8 @@ private static final class SchemeInfo { EnumMap m = new EnumMap<>(PQScheme.class); m.put(PQScheme.FN_DSA_512, new SchemeInfo( FNDSA512.PRIVATE_KEY_LENGTH, FNDSA512.PUBLIC_KEY_LENGTH, - FNDSA512.SIGNATURE_LENGTH, FNDSA512.SEED_LENGTH, + FNDSA512.SIGNATURE_LENGTH, FNDSA512.SIGNATURE_MIN_LENGTH, + FNDSA512.SEED_LENGTH, false, // Falcon keygen is FFT-based, not bit-stable across platforms. KECCAK_256, new SignatureOps() { @@ -111,7 +118,8 @@ public PQSignature fromKeypair(byte[] privateKey, byte[] publicKey) { })); m.put(PQScheme.ML_DSA_44, new SchemeInfo( MLDSA44.PRIVATE_KEY_LENGTH, MLDSA44.PUBLIC_KEY_LENGTH, - MLDSA44.SIGNATURE_LENGTH, MLDSA44.SEED_LENGTH, + MLDSA44.SIGNATURE_LENGTH, MLDSA44.SIGNATURE_LENGTH, // fixed-length scheme + MLDSA44.SEED_LENGTH, true, // FIPS-204 keygen is pure integer arithmetic and reproducible. KECCAK_256, new SignatureOps() { @@ -196,17 +204,20 @@ public static boolean isSeedDeterministic(PQScheme scheme) { } /** - * Per-scheme signature-length predicate. Fixed-length schemes require exact - * equality with {@link #getSignatureLength(PQScheme)}; variable-length - * schemes ({@code FN_DSA_512}) accept any length in - * [{@link FNDSA512#SIGNATURE_MIN_LENGTH}, {@link FNDSA512#SIGNATURE_LENGTH}]. + * Per-scheme signature-length predicate. Each scheme carries its own band + * {@code [signatureMinLength, signatureLength]}; fixed-length schemes + * degenerate to the singleton {@code [max, max]}. Mirrors + * {@link PQSignature#validateSignature} so adding a new variable-length + * scheme requires no edit here. */ public static boolean isValidSignatureLength(PQScheme scheme, int length) { SchemeInfo info = require(scheme); - if (scheme == PQScheme.FN_DSA_512) { - return length >= FNDSA512.SIGNATURE_MIN_LENGTH && length <= info.signatureLength; - } - return length == info.signatureLength; + return length >= info.signatureMinLength && length <= info.signatureLength; + } + + /** Lower bound of the per-scheme signature-length band. */ + public static int getSignatureMinLength(PQScheme scheme) { + return require(scheme).signatureMinLength; } public static byte[] sign(PQScheme scheme, byte[] privateKey, byte[] message) { diff --git a/framework/src/main/java/org/tron/core/config/args/Args.java b/framework/src/main/java/org/tron/core/config/args/Args.java index 02c56e6eb88..fdcf30e9b14 100644 --- a/framework/src/main/java/org/tron/core/config/args/Args.java +++ b/framework/src/main/java/org/tron/core/config/args/Args.java @@ -958,17 +958,17 @@ private static void initLocalWitnesses(Config config, CLIParameter cmd) { LocalWitnesses pqWitnesses = null; if (hasPqKeys) { // localWitnessAccountAddress overrides the on-chain witness address for - // the single-witness case. In mixed mode (ECDSA + PQ) it is ambiguous, - // so refuse it and require each entry's address to be derived from its - // own key material. + // the single-witness case. In mixed mode (ECDSA + PQ) total witness + // count is ≥ 2 and per config.conf the override must be dropped; each + // entry derives its address from its own key material instead. String pqAccountAddress = ecdsaWitnesses == null ? lwConfig.getAccountAddress() : null; if (ecdsaWitnesses != null && StringUtils.isNotBlank(lwConfig.getAccountAddress())) { throw new TronError( - "localWitnessAccountAddress cannot be combined with both legacy and " - + LocalWitnessConfig.PQ_KEYS_PATH + "; remove the override or " - + "configure only one key source", + "localWitnessAccountAddress can only be set with a single witness; " + + "drop it when combining legacy localwitness with " + + LocalWitnessConfig.PQ_KEYS_PATH, TronError.ErrCode.WITNESS_INIT); } pqWitnesses = buildPqWitnesses(lwConfig.getPqEntries(), pqAccountAddress); diff --git a/framework/src/main/java/org/tron/core/db/Manager.java b/framework/src/main/java/org/tron/core/db/Manager.java index 8b88047065d..be9fe097bc9 100644 --- a/framework/src/main/java/org/tron/core/db/Manager.java +++ b/framework/src/main/java/org/tron/core/db/Manager.java @@ -1752,11 +1752,14 @@ public BlockCapsule generateBlock(Miner miner, long blockTime, long timeout) { // null on this path, and a silent fallback would NPE inside blockCapsule.sign. // Fail fast with a clear cause; DposTask's Throwable handler logs it and the // witness misses this slot, but the producer thread stays alive. - if (!getDynamicPropertiesStore().isAnyPqSchemeAllowed()) { + // Gate on this miner's specific scheme, not on the broader "any PQ scheme + // allowed" flag — a Falcon-configured miner must not produce while only + // ML-DSA is active (and vice versa). + if (!getDynamicPropertiesStore().isPqSchemeAllowed(miner.getPqScheme())) { throw new IllegalStateException( "PQ miner " + Hex.toHexString(miner.getWitnessAddress().toByteArray()) + " has scheme " + miner.getPqScheme() - + " configured but no PQ scheme is allowed by dynamic properties"); + + " configured but that scheme is not allowed by dynamic properties"); } signBlockCapsuleWithPQ(blockCapsule, miner); } else { diff --git a/framework/src/main/java/org/tron/core/net/service/relay/RelayService.java b/framework/src/main/java/org/tron/core/net/service/relay/RelayService.java index 3178a722e0d..9dae920b4da 100644 --- a/framework/src/main/java/org/tron/core/net/service/relay/RelayService.java +++ b/framework/src/main/java/org/tron/core/net/service/relay/RelayService.java @@ -134,7 +134,14 @@ public void fillHelloMessage(HelloMessage message, Channel channel) { cryptoEngine.Base64toBytes(cryptoEngine.signHash(digest))); builder.setSignature(sig).clearPqAuthSig(); } else { + // isActiveWitness() guarantees keySize > 0 || pqKeySize > 0; reaching + // this branch with keySize == 0 implies pqKeySize > 0. Guard anyway + // so a stale or mutated witness list fails loud instead of with IOOB. LocalWitnesses lw = Args.getLocalWitnesses(); + if (lw.getPqKeypairs().isEmpty()) { + logger.warn("HelloMessage fill skipped: no PQ keypair available"); + return; + } PqKeypair kp = lw.getPqKeypairs().get(0); PQScheme scheme = kp.getScheme(); byte[] privKey = ByteArray.fromHexString(kp.getPrivateKey()); From 07aaf6505782b84ab0c4d90967c4556693ba2a84 Mon Sep 17 00:00:00 2001 From: federico Date: Sun, 24 May 2026 16:22:26 +0800 Subject: [PATCH 30/47] fix(vm): cancel pending pq batch verify futures on timeout --- .../org/tron/core/vm/PrecompiledContracts.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java b/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java index e9c31e3d92b..90ea6a158f1 100644 --- a/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java +++ b/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java @@ -547,6 +547,18 @@ private static boolean isValidArrayOffset(DataWord[] words, int offsetWordIndex, return lengthWordIdx < words.length; } + /** + * Best-effort cancellation of all submitted batch-verify tasks. Tasks that + * have not yet started execution are removed from the worker queue; tasks + * already running receive an interrupt but BouncyCastle's PQ verify routines + * do not poll the interrupt flag and will run to completion. + */ + private static void cancelAll(List> futures) { + for (Future f : futures) { + f.cancel(true); + } + } + /** * Returns the logical Falcon-512 signature length packed at the start of a * fixed slot {@code data[from..to)}: the offset of the last non-zero byte @@ -2706,6 +2718,7 @@ private Pair doExecute(byte[] data) .await(getCPUTimeLeftInNanoSecond(), TimeUnit.NANOSECONDS); if (!withNoTimeout) { + cancelAll(futures); logger.info("BatchValidateFnDsa512 timeout"); throw Program.Exception.notEnoughTime("call BatchValidateFnDsa512 precompile method"); } @@ -3131,6 +3144,7 @@ private Pair doExecute(byte[] data) .await(getCPUTimeLeftInNanoSecond(), TimeUnit.NANOSECONDS); if (!withNoTimeout) { + cancelAll(futures); logger.info("BatchValidateMlDsa44 timeout"); throw Program.Exception.notEnoughTime("call BatchValidateMlDsa44 precompile method"); } From 54235acf488853c9c2afa104976731ee3e6a2818 Mon Sep 17 00:00:00 2001 From: federico Date: Sun, 24 May 2026 16:22:31 +0800 Subject: [PATCH 31/47] fix(vm): cap precompile dynamic-array allocations --- .../tron/core/vm/PrecompiledContracts.java | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java b/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java index 90ea6a158f1..b5bbe2ab4cd 100644 --- a/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java +++ b/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java @@ -477,11 +477,20 @@ private static byte[][] extractBytes32Array(DataWord[] words, int offset) { return bytes32Array; } + // Hard cap on the outer array element count. All callers separately enforce + // their own MAX_SIZE (≤ 16); this is a defense-in-depth ceiling so that a + // length word recovered as Integer.MAX_VALUE cannot trigger a ~17 GB + // byte[][] reference allocation before any per-precompile check runs. + static final int MAX_DYNAMIC_ARRAY = 64; + private static byte[][] extractBytesArray(DataWord[] words, int offset, byte[] data) { if (offset > words.length - 1) { return new byte[0][]; } int len = words[offset].intValueSafe(); + if (len < 0 || len > MAX_DYNAMIC_ARRAY) { + return new byte[0][]; + } byte[][] bytesArray = new byte[len][]; for (int i = 0; i < len; i++) { int bytesOffset = words[offset + i + 1].intValueSafe() / WORD_SIZE; @@ -507,7 +516,16 @@ private static byte[][] extractSigArray(DataWord[] words, int offset, byte[] dat } private static byte[] extractBytes(byte[] data, int offset, int len) { - return Arrays.copyOfRange(data, offset, offset + len); + // Cap the allocation by remaining calldata. Without this, a single ABI + // length word can request an Integer.MAX_VALUE byte[] which Arrays.copyOfRange + // happily zero-pads — a sub-30 k gas call could allocate ~2 GB. Callers + // strictly compare returned length against expected slot size, so trimming + // here just routes malformed calldata to the caller's normal reject path. + if (offset < 0 || len < 0 || offset > data.length) { + return EMPTY_BYTE_ARRAY; + } + int safe = Math.min(len, data.length - offset); + return Arrays.copyOfRange(data, offset, offset + safe); } private static boolean isValidAbiEncoding(byte[] data, int headerWords, int itemWords) { @@ -2934,7 +2952,12 @@ public Pair execute(byte[] rawData) { int pqSigCnt = words[pqSigArrayWord].intValueSafe(); int pqPkCnt = words[pqPkArrayWord].intValueSafe(); - if (schemeCnt != pqSigCnt || schemeCnt != pqPkCnt + // Per-variable bounds first to defeat int overflow in the sum below + // (e.g. Integer.MAX_VALUE + 1 wraps to Integer.MIN_VALUE and slips past + // a naive `> MAX_SIZE` check). + if (ecdsaCnt < 0 || schemeCnt < 0 + || ecdsaCnt > MAX_SIZE || schemeCnt > MAX_SIZE + || schemeCnt != pqSigCnt || schemeCnt != pqPkCnt || ecdsaCnt + schemeCnt == 0 || ecdsaCnt + schemeCnt > MAX_SIZE) { return Pair.of(true, DATA_FALSE); From 96c386f4fa32faf6e4dc90a634ee476b60e3e29f Mon Sep 17 00:00:00 2001 From: federico Date: Sun, 24 May 2026 17:46:46 +0800 Subject: [PATCH 32/47] fix(framework): split pq witness address and null-guard lookups --- .../org/tron/common/utils/LocalWitnesses.java | 39 +++++++++++++++- .../org/tron/core/capsule/BlockCapsule.java | 9 +++- .../core/config/args/LocalWitnessConfig.java | 10 ++++- .../config/args/LocalWitnessConfigTest.java | 11 +++++ .../java/org/tron/core/config/args/Args.java | 30 ++++++------- .../core/config/args/WitnessInitializer.java | 31 +++++++------ .../tron/core/consensus/ConsensusService.java | 5 +-- .../core/net/service/relay/RelayService.java | 45 +++++++++++++++---- framework/src/main/resources/config.conf | 12 ++++- .../core/net/services/RelayServiceTest.java | 11 +++-- 10 files changed, 147 insertions(+), 56 deletions(-) diff --git a/chainbase/src/main/java/org/tron/common/utils/LocalWitnesses.java b/chainbase/src/main/java/org/tron/common/utils/LocalWitnesses.java index bfc9ddf1c38..c234d5d4595 100644 --- a/chainbase/src/main/java/org/tron/common/utils/LocalWitnesses.java +++ b/chainbase/src/main/java/org/tron/common/utils/LocalWitnesses.java @@ -56,6 +56,16 @@ public class LocalWitnesses { @Getter private byte[] witnessAccountAddress; + /** + * PQ-side counterpart to {@link #witnessAccountAddress}. Distinct from the + * ECDSA address so a node can host two different SRs (one ECDSA + one PQ). + * When the same SR account authorises both an ECDSA key and a PQ key, both + * fields point to the same address. + */ + @Setter + @Getter + private byte[] pqWitnessAccountAddress; + public LocalWitnesses() { } @@ -67,11 +77,19 @@ public LocalWitnesses(List privateKeys) { setPrivateKeys(privateKeys); } + /** + * Resolve the ECDSA witness account address from an explicit override, or + * fall back to the first ECDSA private key. PQ-side resolution is handled + * separately by {@link #initPqWitnessAccountAddress(byte[])} so the two + * consensus paths do not interfere on nodes hosting one SR per scheme. + */ public void initWitnessAccountAddress(final byte[] witnessAddress, boolean isECKeyCryptoEngine) { if (witnessAddress != null) { this.witnessAccountAddress = witnessAddress; - } else if (!CollectionUtils.isEmpty(privateKeys)) { + return; + } + if (!CollectionUtils.isEmpty(privateKeys)) { byte[] privateKey = ByteArray.fromHexString(getPrivateKey()); final SignInterface ecKey = SignUtils.fromPrivate(privateKey, isECKeyCryptoEngine); @@ -79,6 +97,25 @@ public void initWitnessAccountAddress(final byte[] witnessAddress, } } + /** + * Resolve the PQ witness account address from an explicit override, or fall + * back to the first configured PQ keypair's public key. Kept separate from + * {@link #initWitnessAccountAddress} so a node running two SRs (one ECDSA + + * one PQ) can carry both addresses without one path overwriting the other. + */ + public void initPqWitnessAccountAddress(final byte[] explicit) { + if (explicit != null) { + this.pqWitnessAccountAddress = explicit; + return; + } + if (!CollectionUtils.isEmpty(pqKeypairs)) { + PqKeypair first = pqKeypairs.get(0); + byte[] pubKey = ByteArray.fromHexString(first.getPublicKey()); + this.pqWitnessAccountAddress = PQSchemeRegistry.computeAddress( + first.getScheme(), pubKey); + } + } + /** * Private key of ECKey. */ diff --git a/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java b/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java index 4ffd08b81b9..484ab159219 100755 --- a/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java +++ b/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java @@ -208,8 +208,13 @@ public boolean validateSignature(DynamicPropertiesStore dynamicPropertiesStore, if (dynamicPropertiesStore.getAllowMultiSign() != 1) { witnessPermissionAddress = witnessAccountAddress; } else { - witnessPermissionAddress = accountStore.get(witnessAccountAddress) - .getWitnessPermissionAddress(); + AccountCapsule account = accountStore.get(witnessAccountAddress); + if (account == null) { + throw new ValidateSignatureException( + "witness account not found: " + + ByteArray.toHexString(witnessAccountAddress)); + } + witnessPermissionAddress = account.getWitnessPermissionAddress(); } boolean hasLegacy = !header.getWitnessSignature().isEmpty(); diff --git a/common/src/main/java/org/tron/core/config/args/LocalWitnessConfig.java b/common/src/main/java/org/tron/core/config/args/LocalWitnessConfig.java index f96a2216341..9adb5944584 100644 --- a/common/src/main/java/org/tron/core/config/args/LocalWitnessConfig.java +++ b/common/src/main/java/org/tron/core/config/args/LocalWitnessConfig.java @@ -10,8 +10,10 @@ /** * Local witness configuration bean. * Reads top-level config keys: localwitness, localWitnessAccountAddress, - * localwitnesskeystore, and localwitness_pq.keys. These are not under a - * sub-section — they are at the root of config.conf. + * localPqWitnessAccountAddress, localwitnesskeystore, and + * localwitness_pq.keys. These are not under a sub-section — they are at the + * root of config.conf. ECDSA and PQ witness accounts use independent + * `*AccountAddress` keys so the two consensus paths do not interfere. */ @Slf4j @Getter @@ -22,6 +24,7 @@ public class LocalWitnessConfig { private List privateKeys = new ArrayList<>(); private String accountAddress = null; + private String pqAccountAddress = null; private List keystores = new ArrayList<>(); private List pqEntries = Collections.emptyList(); @@ -33,6 +36,9 @@ public static LocalWitnessConfig fromConfig(Config config) { if (config.hasPath("localWitnessAccountAddress")) { lw.accountAddress = config.getString("localWitnessAccountAddress"); } + if (config.hasPath("localPqWitnessAccountAddress")) { + lw.pqAccountAddress = config.getString("localPqWitnessAccountAddress"); + } if (config.hasPath("localwitnesskeystore")) { lw.keystores = config.getStringList("localwitnesskeystore"); } diff --git a/common/src/test/java/org/tron/core/config/args/LocalWitnessConfigTest.java b/common/src/test/java/org/tron/core/config/args/LocalWitnessConfigTest.java index 8eb3a30ded9..2e1769ea401 100644 --- a/common/src/test/java/org/tron/core/config/args/LocalWitnessConfigTest.java +++ b/common/src/test/java/org/tron/core/config/args/LocalWitnessConfigTest.java @@ -25,10 +25,21 @@ public void testDefaults() { LocalWitnessConfig lw = LocalWitnessConfig.fromConfig(empty); assertTrue(lw.getPrivateKeys().isEmpty()); assertNull(lw.getAccountAddress()); + assertNull(lw.getPqAccountAddress()); assertTrue(lw.getKeystores().isEmpty()); assertTrue(lw.getPqEntries().isEmpty()); } + @Test + public void testWithPqAccountAddress() { + Config config = withRef( + "localWitnessAccountAddress = \"TEcdsaAddr\"\n" + + "localPqWitnessAccountAddress = \"TPqAddr\""); + LocalWitnessConfig lw = LocalWitnessConfig.fromConfig(config); + assertEquals("TEcdsaAddr", lw.getAccountAddress()); + assertEquals("TPqAddr", lw.getPqAccountAddress()); + } + @Test public void testWithPrivateKeys() { Config config = withRef( diff --git a/framework/src/main/java/org/tron/core/config/args/Args.java b/framework/src/main/java/org/tron/core/config/args/Args.java index fdcf30e9b14..13af4495bf1 100644 --- a/framework/src/main/java/org/tron/core/config/args/Args.java +++ b/framework/src/main/java/org/tron/core/config/args/Args.java @@ -954,24 +954,13 @@ private static void initLocalWitnesses(Config config, CLIParameter cmd) { // Load PQ keypairs independently so a node can host a mix of ECDSA and PQ // SRs (e.g. during a rolling migration where some SRs have moved to PQ and - // others have not yet). + // others have not yet). The PQ side has its own *AccountAddress key + // (localPqWitnessAccountAddress) so mixed-mode configs do not have to drop + // the legacy override for the ECDSA side. LocalWitnesses pqWitnesses = null; if (hasPqKeys) { - // localWitnessAccountAddress overrides the on-chain witness address for - // the single-witness case. In mixed mode (ECDSA + PQ) total witness - // count is ≥ 2 and per config.conf the override must be dropped; each - // entry derives its address from its own key material instead. - String pqAccountAddress = - ecdsaWitnesses == null ? lwConfig.getAccountAddress() : null; - if (ecdsaWitnesses != null - && StringUtils.isNotBlank(lwConfig.getAccountAddress())) { - throw new TronError( - "localWitnessAccountAddress can only be set with a single witness; " - + "drop it when combining legacy localwitness with " - + LocalWitnessConfig.PQ_KEYS_PATH, - TronError.ErrCode.WITNESS_INIT); - } - pqWitnesses = buildPqWitnesses(lwConfig.getPqEntries(), pqAccountAddress); + pqWitnesses = buildPqWitnesses( + lwConfig.getPqEntries(), lwConfig.getPqAccountAddress()); } if (ecdsaWitnesses == null && pqWitnesses == null) { @@ -984,7 +973,14 @@ private static void initLocalWitnesses(Config config, CLIParameter cmd) { LocalWitnesses merged = new LocalWitnesses(); merged.setPrivateKeys(ecdsaWitnesses.getPrivateKeys()); merged.setPqKeypairs(pqWitnesses.getPqKeypairs()); - // No witnessAccountAddress in mixed mode: each entry derives its own. + // Carry both addresses so a node hosting one ECDSA SR + one PQ SR can + // match either schedule slot. Consumers consult the field that matches + // their signing path (ECDSA address for ECDSA sigs, PQ address for PQ). + merged.initWitnessAccountAddress( + ecdsaWitnesses.getWitnessAccountAddress(), + PARAMETER.isECKeyCryptoEngine()); + merged.initPqWitnessAccountAddress( + pqWitnesses.getPqWitnessAccountAddress()); localWitnesses = merged; } else if (ecdsaWitnesses != null) { localWitnesses = ecdsaWitnesses; diff --git a/framework/src/main/java/org/tron/core/config/args/WitnessInitializer.java b/framework/src/main/java/org/tron/core/config/args/WitnessInitializer.java index 7089a003eb4..e6bdd8c0a66 100644 --- a/framework/src/main/java/org/tron/core/config/args/WitnessInitializer.java +++ b/framework/src/main/java/org/tron/core/config/args/WitnessInitializer.java @@ -117,13 +117,15 @@ public static LocalWitnesses initFromKeystore( /** * Init for PQ-only witness nodes (no legacy ECDSA key). Each PqKeypair - * carries its own PQScheme. When {@code witnessAccountAddress} is blank, + * carries its own PQScheme. When {@code pqWitnessAccountAddress} is blank, * the address is derived from the first PQ public key via * {@link PQSchemeRegistry#computeAddress(PQScheme, byte[])} using that - * entry's scheme. + * entry's scheme. Only {@code pqWitnessAccountAddress} is populated; the + * legacy ECDSA-side field stays {@code null} so downstream callers must + * decide which identity (ECDSA vs PQ) to consult. */ public static LocalWitnesses initFromPQOnly( - List pqKeypairs, String witnessAccountAddress) { + List pqKeypairs, String pqWitnessAccountAddress) { if (pqKeypairs == null || pqKeypairs.isEmpty()) { throw new TronError( "PQ keypairs must be set for PQ-only witness nodes", @@ -132,27 +134,24 @@ public static LocalWitnesses initFromPQOnly( LocalWitnesses witnesses = new LocalWitnesses(); witnesses.setPqKeypairs(pqKeypairs); - byte[] address; - if (StringUtils.isBlank(witnessAccountAddress)) { - PqKeypair first = pqKeypairs.get(0); - byte[] firstPubKey = ByteArray.fromHexString(first.getPublicKey()); - address = PQSchemeRegistry.computeAddress(first.getScheme(), firstPubKey); - logger.debug("Derived PQ-only witness address from public key"); - } else { + byte[] explicit = null; + if (StringUtils.isNotBlank(pqWitnessAccountAddress)) { if (pqKeypairs.size() != 1) { throw new TronError( - "LocalWitnessAccountAddress can only be set when there is only one PQ keypair", + "localPqWitnessAccountAddress can only be set when there is only one PQ keypair", TronError.ErrCode.WITNESS_INIT); } - address = Commons.decodeFromBase58Check(witnessAccountAddress); - if (address == null) { + explicit = Commons.decodeFromBase58Check(pqWitnessAccountAddress); + if (explicit == null) { throw new TronError( - "LocalWitnessAccountAddress format is incorrect", + "localPqWitnessAccountAddress format is incorrect", TronError.ErrCode.WITNESS_INIT); } - logger.debug("Got localWitnessAccountAddress from config.conf"); + logger.debug("Got localPqWitnessAccountAddress from config.conf"); + } else { + logger.debug("Derived PQ-only witness address from public key"); } - witnesses.setWitnessAccountAddress(address); + witnesses.initPqWitnessAccountAddress(explicit); return witnesses; } diff --git a/framework/src/main/java/org/tron/core/consensus/ConsensusService.java b/framework/src/main/java/org/tron/core/consensus/ConsensusService.java index 25e2593f365..1087d1d4757 100644 --- a/framework/src/main/java/org/tron/core/consensus/ConsensusService.java +++ b/framework/src/main/java/org/tron/core/consensus/ConsensusService.java @@ -74,9 +74,6 @@ public void start() { byte[] privateKeyAddress = SignUtils.fromPrivate(privateKey, Args.getInstance().isECKeyCryptoEngine()).getAddress(); byte[] witnessAddress = Args.getLocalWitnesses().getWitnessAccountAddress(); - // In mixed (ECDSA + PQ) mode Args refuses the localWitnessAccountAddress - // override and leaves witnessAccountAddress null — fall back to the - // derived address so the single-witness path stays valid. if (witnessAddress == null || witnessAddress.length == 0) { witnessAddress = privateKeyAddress; } @@ -101,7 +98,7 @@ public void start() { } } else if (pqKeypairs.size() == 1) { Miner miner = buildPQMiner(param, pqKeypairs.get(0), - Args.getLocalWitnesses().getWitnessAccountAddress()); + Args.getLocalWitnesses().getPqWitnessAccountAddress()); miners.add(miner); logger.info("Add {} witness (from configured keypair): {}", miner.getPqScheme(), Hex.toHexString(miner.getWitnessAddress().toByteArray())); diff --git a/framework/src/main/java/org/tron/core/net/service/relay/RelayService.java b/framework/src/main/java/org/tron/core/net/service/relay/RelayService.java index 9dae920b4da..659617c67ce 100644 --- a/framework/src/main/java/org/tron/core/net/service/relay/RelayService.java +++ b/framework/src/main/java/org/tron/core/net/service/relay/RelayService.java @@ -26,6 +26,7 @@ import org.tron.common.utils.LocalWitnesses; import org.tron.common.utils.Sha256Hash; import org.tron.core.ChainBaseManager; +import org.tron.core.capsule.AccountCapsule; import org.tron.core.capsule.TransactionCapsule; import org.tron.core.config.args.Args; import org.tron.core.db.Manager; @@ -75,10 +76,17 @@ public class RelayService { private final int pqKeySize = Args.getLocalWitnesses().getPqKeypairs().size(); - private final ByteString witnessAddress = + // A node may carry an ECDSA witness, a PQ witness, or both (mixed multi-SR). + // Either-or-both must be matched against the active schedule, and + // fillHelloMessage must announce the address matching the signing path. + private final ByteString ecdsaWitnessAddress = Args.getLocalWitnesses().getWitnessAccountAddress() != null ? ByteString .copyFrom(Args.getLocalWitnesses().getWitnessAccountAddress()) : null; + private final ByteString pqWitnessAddress = + Args.getLocalWitnesses().getPqWitnessAccountAddress() != null ? ByteString + .copyFrom(Args.getLocalWitnesses().getPqWitnessAccountAddress()) : null; + private int maxFastForwardNum = Args.getInstance().getMaxFastForwardNum(); public void init() { @@ -97,7 +105,7 @@ public void init() { executorService.scheduleWithFixedDelay(() -> { try { - if (witnessScheduleStore.getActiveWitnesses().contains(witnessAddress) + if (scheduledHere() && backupManager.getStatus().equals(BackupStatusEnum.MASTER)) { connect(); } else { @@ -124,8 +132,11 @@ public void fillHelloMessage(HelloMessage message, Channel channel) { byte[] digest = Sha256Hash.of(CommonParameter.getInstance() .isECKeyCryptoEngine(), ByteArray.fromLong(message.getTimestamp())) .getBytes(); + // Announce the address matching the sig path we are about to take so + // the receiving fast-forward node verifies against the right identity. + ByteString announceAddress = keySize > 0 ? ecdsaWitnessAddress : pqWitnessAddress; Protocol.HelloMessage.Builder builder = message.getHelloMessage().toBuilder() - .setAddress(witnessAddress); + .setAddress(announceAddress); if (keySize > 0) { SignInterface cryptoEngine = SignUtils.fromPrivate( ByteArray.fromHexString(Args.getLocalWitnesses().getPrivateKey()), @@ -221,9 +232,13 @@ private boolean verifyLegacySignature(byte[] digest, ByteString signature, if (manager.getDynamicPropertiesStore().getAllowMultiSign() != 1) { return Arrays.equals(sigAddress, witnessAddr.toByteArray()); } - byte[] witnessPermissionAddress = manager.getAccountStore() - .get(witnessAddr.toByteArray()).getWitnessPermissionAddress(); - return Arrays.equals(sigAddress, witnessPermissionAddress); + AccountCapsule account = manager.getAccountStore().get(witnessAddr.toByteArray()); + if (account == null) { + logger.warn("HelloMessage witness account {} not found in accountStore.", + ByteArray.toHexString(witnessAddr.toByteArray())); + return false; + } + return Arrays.equals(sigAddress, account.getWitnessPermissionAddress()); } private boolean verifyPqAuthSig(byte[] digest, PQAuthSig pqAuthSig, @@ -257,8 +272,13 @@ private boolean verifyPqAuthSig(byte[] digest, PQAuthSig pqAuthSig, if (manager.getDynamicPropertiesStore().getAllowMultiSign() != 1) { expected = witnessAddr.toByteArray(); } else { - expected = manager.getAccountStore().get(witnessAddr.toByteArray()) - .getWitnessPermissionAddress(); + AccountCapsule account = manager.getAccountStore().get(witnessAddr.toByteArray()); + if (account == null) { + logger.warn("HelloMessage from {}, witness account {} not found in accountStore.", + channel.getInetAddress(), ByteArray.toHexString(witnessAddr.toByteArray())); + return false; + } + expected = account.getWitnessPermissionAddress(); } if (!Arrays.equals(derivedAddr, expected)) { logger.warn("HelloMessage from {}, pq_auth_sig public key does not bind witness {}.", @@ -278,10 +298,17 @@ private boolean isActiveWitness() { return parameter.isWitness() && (keySize > 0 || pqKeySize > 0) && fastForwardNodes.size() > 0 - && witnessScheduleStore.getActiveWitnesses().contains(witnessAddress) + && scheduledHere() && backupManager.getStatus().equals(BackupStatusEnum.MASTER); } + // True iff either of this node's witness identities is in the active schedule. + private boolean scheduledHere() { + List active = witnessScheduleStore.getActiveWitnesses(); + return (ecdsaWitnessAddress != null && active.contains(ecdsaWitnessAddress)) + || (pqWitnessAddress != null && active.contains(pqWitnessAddress)); + } + private void connect() { for (InetSocketAddress fastForwardNode : fastForwardNodes) { if (!TronNetService.getP2pConfig().getActiveNodes().contains(fastForwardNode)) { diff --git a/framework/src/main/resources/config.conf b/framework/src/main/resources/config.conf index 653b00b4647..90810aad4a9 100644 --- a/framework/src/main/resources/config.conf +++ b/framework/src/main/resources/config.conf @@ -693,6 +693,8 @@ genesis.block = { # When it is not empty, the localWitnessAccountAddress represents the address of the witness account, # and the localwitness is configured with the private key of the witnessPermissionAddress in the witness account. # When it is empty,the localwitness is configured with the private key of the witness account. +# Only the ECDSA witness path consults this key — set localPqWitnessAccountAddress +# for the PQ witness path below. # localWitnessAccountAddress = localwitness = [ @@ -702,6 +704,14 @@ localwitness = [ # "localwitnesskeystore.json" # ] +# Optional. Counterpart to localWitnessAccountAddress for the PQ witness path: +# overrides the on-chain witness account address for the single-PQ-witness case +# when the PQ keypair authorises a witnessPermissionAddress different from the +# witness account itself. Independent of localWitnessAccountAddress so mixed +# mode (one ECDSA witness + one PQ witness on the same node) can set either, +# both, or neither without interfering. +# localPqWitnessAccountAddress = + # Post-quantum witness signing. Each entry pins its own `scheme` and exactly # one of `key` or `seed`. Keypairs must be generated off-line. # @@ -711,7 +721,7 @@ localwitness = [ # # Effective only after the scheme's activation proposal passes and the # witness Permission is upgraded. ECDSA and PQ witnesses may coexist on one -# node; drop `localWitnessAccountAddress` when more than one witness is set. +# node. # localwitness_pq = { # keys = [ # { scheme = "FN_DSA_512", key = "<4352 hex chars>" }, diff --git a/framework/src/test/java/org/tron/core/net/services/RelayServiceTest.java b/framework/src/test/java/org/tron/core/net/services/RelayServiceTest.java index 0a571429163..8b1dda7a480 100644 --- a/framework/src/test/java/org/tron/core/net/services/RelayServiceTest.java +++ b/framework/src/test/java/org/tron/core/net/services/RelayServiceTest.java @@ -346,9 +346,12 @@ public void testNullWitnessAddress() { keySizeField.setAccessible(true); keySizeField.set(service, 0); - Field witnessAddressField = clazz.getDeclaredField("witnessAddress"); - witnessAddressField.setAccessible(true); - witnessAddressField.set(service, null); + Field ecdsaField = clazz.getDeclaredField("ecdsaWitnessAddress"); + ecdsaField.setAccessible(true); + Field pqField = clazz.getDeclaredField("pqWitnessAddress"); + pqField.setAccessible(true); + ecdsaField.set(service, null); + pqField.set(service, null); Method isActiveWitnessMethod = clazz.getDeclaredMethod("isActiveWitness"); isActiveWitnessMethod.setAccessible(true); @@ -356,7 +359,7 @@ public void testNullWitnessAddress() { Boolean result = (Boolean) isActiveWitnessMethod.invoke(service); Assert.assertNotEquals(Boolean.TRUE, result); - witnessAddressField.set(service, ByteString.copyFrom(new byte[21])); + ecdsaField.set(service, ByteString.copyFrom(new byte[21])); result = (Boolean) isActiveWitnessMethod.invoke(service); Assert.assertNotEquals(Boolean.TRUE, result); } catch (NoSuchMethodException | NoSuchFieldException From 4a64aa1664378566fd7b66144a3bc6b88455c697 Mon Sep 17 00:00:00 2001 From: federico Date: Sun, 24 May 2026 23:22:06 +0800 Subject: [PATCH 33/47] feat(framework): accept priv-only key for pq witness ml-dsa-44 --- .../common/crypto/pqc/PQSchemeRegistry.java | 44 +++++++++++++++++++ .../java/org/tron/core/config/args/Args.java | 38 +++++++++++++--- .../common/crypto/pqc/program/PQClient.java | 2 +- .../crypto/pqc/program/PQWitnessNode.java | 28 +++++++----- .../core/config/args/ArgsPqConfigTest.java | 42 ++++++++++++++++++ 5 files changed, 137 insertions(+), 17 deletions(-) diff --git a/crypto/src/main/java/org/tron/common/crypto/pqc/PQSchemeRegistry.java b/crypto/src/main/java/org/tron/common/crypto/pqc/PQSchemeRegistry.java index 6c0ce27955c..87be8ac1095 100644 --- a/crypto/src/main/java/org/tron/common/crypto/pqc/PQSchemeRegistry.java +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/PQSchemeRegistry.java @@ -39,6 +39,17 @@ public interface SignatureOps { PQSignature fromSeed(byte[] seed); PQSignature fromKeypair(byte[] privateKey, byte[] publicKey); + + /** + * Recover the public key from the (expanded) private key. Schemes whose + * BC encoding lets the verifier reconstruct {@code pk} from {@code sk} + * (e.g. ML-DSA-44, whose {@code rho ‖ t0} component suffices to re-derive + * {@code t1}) return the canonical pk bytes; schemes without such a path + * (e.g. Falcon-512 — see bcgit/bc-java#2297) return {@code null}. + */ + default byte[] derivePublicKey(byte[] privateKey) { + return null; + } } /** @@ -68,11 +79,16 @@ private static final class SchemeInfo { // across JVMs/architectures, so operators must persist the expanded // priv‖pub rather than a seed. final boolean seedDeterministic; + // Whether the scheme's expanded private key encoding carries enough state + // to recover the public key on its own. ML-DSA-44 keeps rho ‖ t0 in the + // sk; Falcon-512 does not (BC has no public path from (f,g) to h). + final boolean publicKeyRecoverable; final FingerprintHash hash; final SignatureOps ops; SchemeInfo(int privateKeyLength, int publicKeyLength, int signatureLength, int signatureMinLength, int seedLength, boolean seedDeterministic, + boolean publicKeyRecoverable, FingerprintHash hash, SignatureOps ops) { this.privateKeyLength = privateKeyLength; this.publicKeyLength = publicKeyLength; @@ -80,6 +96,7 @@ private static final class SchemeInfo { this.signatureMinLength = signatureMinLength; this.seedLength = seedLength; this.seedDeterministic = seedDeterministic; + this.publicKeyRecoverable = publicKeyRecoverable; this.hash = hash; this.ops = ops; } @@ -94,6 +111,7 @@ private static final class SchemeInfo { FNDSA512.SIGNATURE_LENGTH, FNDSA512.SIGNATURE_MIN_LENGTH, FNDSA512.SEED_LENGTH, false, // Falcon keygen is FFT-based, not bit-stable across platforms. + false, // BC has no public path from (f,g) to h (bcgit/bc-java#2297). KECCAK_256, new SignatureOps() { @Override @@ -121,6 +139,7 @@ public PQSignature fromKeypair(byte[] privateKey, byte[] publicKey) { MLDSA44.SIGNATURE_LENGTH, MLDSA44.SIGNATURE_LENGTH, // fixed-length scheme MLDSA44.SEED_LENGTH, true, // FIPS-204 keygen is pure integer arithmetic and reproducible. + true, // expanded sk carries rho ‖ t0; t1 is re-derived in BC ctor. KECCAK_256, new SignatureOps() { @Override @@ -142,6 +161,11 @@ public PQSignature fromSeed(byte[] seed) { public PQSignature fromKeypair(byte[] privateKey, byte[] publicKey) { return new MLDSA44(privateKey, publicKey); } + + @Override + public byte[] derivePublicKey(byte[] privateKey) { + return MLDSA44.derivePublicKey(privateKey); + } })); SCHEMES = Collections.unmodifiableMap(m); } @@ -246,6 +270,26 @@ public static PQSignature fromKeypair( return require(scheme).ops.fromKeypair(privateKey, publicKey); } + /** + * Recover the public key from the expanded private key, or {@code null} when + * the scheme has no such recovery path (Falcon-512). Callers that need to + * decide format eligibility ahead of time should use + * {@link #canDerivePublicKey}. + */ + public static byte[] derivePublicKey(PQScheme scheme, byte[] privateKey) { + return require(scheme).ops.derivePublicKey(privateKey); + } + + /** + * Whether {@link #derivePublicKey} can recover {@code pk} from {@code sk} + * for this scheme. {@code true} for ML-DSA-44 (the expanded sk carries + * {@code rho ‖ t0}, sufficient to re-derive {@code t1}); {@code false} for + * Falcon-512. + */ + public static boolean canDerivePublicKey(PQScheme scheme) { + return require(scheme).publicKeyRecoverable; + } + /** * Scheme-dispatched fingerprint hash of a PQ public key. Returns the full * digest; callers truncate to 20 bytes when deriving the address suffix. diff --git a/framework/src/main/java/org/tron/core/config/args/Args.java b/framework/src/main/java/org/tron/core/config/args/Args.java index 13af4495bf1..f0890b0593c 100644 --- a/framework/src/main/java/org/tron/core/config/args/Args.java +++ b/framework/src/main/java/org/tron/core/config/args/Args.java @@ -1029,16 +1029,44 @@ private static LocalWitnesses buildPqWitnesses(List pqEntries, if (entry.hasKey()) { int privHexLen = PQSchemeRegistry.getPrivateKeyLength(scheme) * 2; int extHexLen = privHexLen + PQSchemeRegistry.getPublicKeyLength(scheme) * 2; + boolean canRecoverPk = PQSchemeRegistry.canDerivePublicKey(scheme); String stripped = stripHexPrefix(entry.getKey()); - if (stripped == null || stripped.length() != extHexLen) { + int len = stripped == null ? 0 : stripped.length(); + boolean shortForm = canRecoverPk && len == privHexLen; + if (stripped == null || (len != extHexLen && !shortForm)) { + String expected = canRecoverPk + ? String.format("%d (priv-only) or %d (extended priv‖pub)", + privHexLen, extHexLen) + : String.format("%d (extended priv‖pub)", extHexLen); throw new TronError(String.format( - "%s[%d].key must be %d hex chars (extended priv‖pub for %s), actual: %d", - path, i, extHexLen, scheme, - stripped == null ? 0 : stripped.length()), + "%s[%d].key must be %s hex chars for %s, actual: %d", + path, i, expected, scheme, len), TronError.ErrCode.WITNESS_INIT); } privHex = stripped.substring(0, privHexLen); - pubHex = stripped.substring(privHexLen); + if (shortForm) { + byte[] privBytes; + try { + privBytes = Hex.decode(privHex); + } catch (RuntimeException e) { + throw new TronError(String.format( + "%s[%d].key is not valid hex for %s: %s", + path, i, scheme, e.getMessage()), + TronError.ErrCode.WITNESS_INIT); + } + byte[] pubBytes; + try { + pubBytes = PQSchemeRegistry.derivePublicKey(scheme, privBytes); + } catch (RuntimeException e) { + throw new TronError(String.format( + "%s[%d].key cannot recover public key for %s: %s", + path, i, scheme, e.getMessage()), + TronError.ErrCode.WITNESS_INIT); + } + pubHex = Hex.toHexString(pubBytes); + } else { + pubHex = stripped.substring(privHexLen); + } } else { if (!PQSchemeRegistry.isSeedDeterministic(scheme)) { // Falcon's FFT-based keygen drifts across JVMs/architectures, so diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/program/PQClient.java b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQClient.java index 2f1369dbb41..4b7ce5b79e6 100644 --- a/framework/src/test/java/org/tron/common/crypto/pqc/program/PQClient.java +++ b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQClient.java @@ -45,7 +45,7 @@ public class PQClient { private static final PQScheme PQ_SCHEME = PQScheme.valueOf( - System.getProperty("pqc.scheme", PQScheme.FN_DSA_512.name())); + System.getProperty("pqc.scheme", PQWitnessNode.PQ_SCHEME.name())); private static final String HOST = System.getProperty("pqc.host", "localhost"); private static final int PORT = diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/program/PQWitnessNode.java b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQWitnessNode.java index 5096a81a982..459d26ad9fd 100644 --- a/framework/src/test/java/org/tron/common/crypto/pqc/program/PQWitnessNode.java +++ b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQWitnessNode.java @@ -51,7 +51,7 @@ public class PQWitnessNode { /** Active PQ scheme, selectable via {@code -Dpqc.scheme}. */ static final PQScheme PQ_SCHEME = PQScheme.valueOf( - System.getProperty("pqc.scheme", PQScheme.FN_DSA_512.name())); + System.getProperty("pqc.scheme", PQScheme.ML_DSA_44.name())); /** Fixed seed for the PQ witness keypair (shared with PQClient for derivation). */ static final byte[] WITNESS_SEED = filledSeed(0x01); @@ -204,21 +204,27 @@ private static byte[] filledSeed(int value) { private static Path writeWitnessConfig(PQSignature witnessKp) throws java.io.IOException { Path conf = Files.createTempFile("pqc-witness-", ".conf"); conf.toFile().deleteOnExit(); - // `localwitness_pq.keys` entries carry their own scheme so a single node can - // host SRs running different PQ algorithms. The key value is the extended - // priv ‖ pub hex; Falcon exposes that explicitly while ML-DSA-44's expanded - // sk already lets BC recover the pk, so we just concatenate - // getPrivateKey() ‖ getPublicKey() for both schemes. + // `localwitness_pq.keys` entries carry their own scheme so a single node + // can host SRs running different PQ algorithms. For schemes whose expanded + // sk lets BC recover the pk (ML-DSA-44), persist only the private key; + // otherwise persist the extended priv ‖ pub (Falcon-512, since BC has no + // public path from (f, g) to h — see bcgit/bc-java#2297). Both forms are + // accepted by the witness-config parser. byte[] priv = witnessKp.getPrivateKey(); - byte[] pub = witnessKp.getPublicKey(); - byte[] extended = new byte[priv.length + pub.length]; - System.arraycopy(priv, 0, extended, 0, priv.length); - System.arraycopy(pub, 0, extended, priv.length, pub.length); + byte[] keyBytes; + if (PQSchemeRegistry.canDerivePublicKey(PQ_SCHEME)) { + keyBytes = priv; + } else { + byte[] pub = witnessKp.getPublicKey(); + keyBytes = new byte[priv.length + pub.length]; + System.arraycopy(priv, 0, keyBytes, 0, priv.length); + System.arraycopy(pub, 0, keyBytes, priv.length, pub.length); + } String body = "include classpath(\"config-test.conf\")\n" + "localwitness_pq = {\n" + " keys = [\n" + " { scheme = \"" + PQ_SCHEME.name() + "\"," - + " key = \"" + Hex.toHexString(extended) + "\" }\n" + + " key = \"" + Hex.toHexString(keyBytes) + "\" }\n" + " ]\n" + "}\n"; Files.write(conf, body.getBytes(StandardCharsets.UTF_8)); diff --git a/framework/src/test/java/org/tron/core/config/args/ArgsPqConfigTest.java b/framework/src/test/java/org/tron/core/config/args/ArgsPqConfigTest.java index 7c09a452486..209fda04273 100644 --- a/framework/src/test/java/org/tron/core/config/args/ArgsPqConfigTest.java +++ b/framework/src/test/java/org/tron/core/config/args/ArgsPqConfigTest.java @@ -124,6 +124,48 @@ public void mlDsa44SeedWrongLengthRejected() throws IOException { assertTrue(err.getMessage(), err.getMessage().contains("seed must be")); } + @Test + public void mlDsa44PrivOnlyKeyDerivesPublicKey() throws IOException { + MLDSA44 ml = new MLDSA44(filled(MLDSA44.SEED_LENGTH, (byte) 0x0C)); + byte[] priv = ml.getPrivateKey(); + Path conf = writeConfWithEntry( + "{ scheme = \"ML_DSA_44\", key = \"" + Hex.toHexString(priv) + "\" }"); + + Args.setParam(new String[]{"--witness"}, conf.toString()); + + LocalWitnesses lw = Args.getLocalWitnesses(); + assertEquals(1, lw.getPqKeypairs().size()); + PqKeypair kp = lw.getPqKeypairs().get(0); + assertEquals(Hex.toHexString(priv), kp.getPrivateKey()); + assertEquals(Hex.toHexString(ml.getPublicKey()), kp.getPublicKey()); + } + + @Test + public void mlDsa44KeyWrongLengthRejected() throws IOException { + String shortKey = Hex.toHexString(filled(MLDSA44.PRIVATE_KEY_LENGTH - 1, (byte) 0x0D)); + Path conf = writeConfWithEntry( + "{ scheme = \"ML_DSA_44\", key = \"" + shortKey + "\" }"); + + TronError err = assertThrows(TronError.class, + () -> Args.setParam(new String[]{"--witness"}, conf.toString())); + assertEquals(TronError.ErrCode.WITNESS_INIT, err.getErrCode()); + assertTrue(err.getMessage(), err.getMessage().contains("priv-only")); + } + + @Test + public void fnDsa512PrivOnlyKeyRejected() throws IOException { + String privOnly = Hex.toHexString(filled(FNDSA512.PRIVATE_KEY_LENGTH, (byte) 0x0E)); + Path conf = writeConfWithEntry( + "{ scheme = \"FN_DSA_512\", key = \"" + privOnly + "\" }"); + + TronError err = assertThrows(TronError.class, + () -> Args.setParam(new String[]{"--witness"}, conf.toString())); + assertEquals(TronError.ErrCode.WITNESS_INIT, err.getErrCode()); + assertTrue(err.getMessage(), + err.getMessage().contains("extended priv‖pub")); + assertTrue(err.getMessage(), !err.getMessage().contains("priv-only")); + } + @Test public void mlDsa44KeyEntryStillAccepted() throws IOException { // Regression: adding the `seed` path must not break the existing `key` From ae3df1b39a05b513e941bbaa1f9bdc675bbd981c Mon Sep 17 00:00:00 2001 From: federico Date: Mon, 25 May 2026 01:56:58 +0800 Subject: [PATCH 34/47] refactor(framework): wrap pq miner identity and add 0x12 verifier --- .../org/tron/core/actuator/VMActuator.java | 8 +- .../tron/core/vm/PrecompiledContracts.java | 59 ++++++- .../org/tron/common/utils/LocalWitnesses.java | 8 +- .../org/tron/core/capsule/BlockCapsule.java | 22 +-- .../tron/core/capsule/TransactionCapsule.java | 7 +- .../src/main/java/org/tron/core/Constant.java | 8 - .../java/org/tron/consensus/base/Param.java | 94 ++++++++-- .../org/tron/consensus/dpos/DposService.java | 3 +- .../consensus/pbft/PbftMessageHandle.java | 2 +- .../signers/mldsa/MLDSA44Eip8051Verifier.java | 162 ++++++++++++++++++ .../org/tron/common/crypto/pqc/MLDSA44.java | 4 +- .../tron/core/consensus/ConsensusService.java | 13 +- .../main/java/org/tron/core/db/Manager.java | 33 ++-- .../runtime/vm/MlDsa44PrecompileTest.java | 84 ++++++--- .../core/capsule/TransactionCapsuleTest.java | 33 +++- 15 files changed, 430 insertions(+), 110 deletions(-) create mode 100644 crypto/src/main/java/org/bouncycastle/crypto/signers/mldsa/MLDSA44Eip8051Verifier.java diff --git a/actuator/src/main/java/org/tron/core/actuator/VMActuator.java b/actuator/src/main/java/org/tron/core/actuator/VMActuator.java index 0a9045a1586..ef454af5e98 100644 --- a/actuator/src/main/java/org/tron/core/actuator/VMActuator.java +++ b/actuator/src/main/java/org/tron/core/actuator/VMActuator.java @@ -178,8 +178,9 @@ public void execute(Object object) throws ContractExeException { ProgramResult result = context.getProgramResult(); try { if (program != null) { - if (null != blockCap && blockCap.generatedByMyself && blockCap.hasWitnessSignature(context.getStoreFactory().getChainBaseManager() - .getDynamicPropertiesStore()) + if (null != blockCap && blockCap.generatedByMyself && blockCap.hasWitnessSignature( + context.getStoreFactory().getChainBaseManager() + .getDynamicPropertiesStore()) && null != TransactionUtil.getContractRet(trx) && contractResult.OUT_OF_TIME == TransactionUtil.getContractRet(trx)) { result = program.getResult(); @@ -402,7 +403,8 @@ private void create() long thisTxCPULimitInUs = calculateCpuLimitInUs(isConstantCall, rootRepository.getDynamicPropertiesStore().getMaxCpuTimeOfOneTx(), - getCpuLimitInUsRatio(rootRepository.getDynamicPropertiesStore()), CommonParameter.getInstance().getConstantCallTimeoutMs()); + getCpuLimitInUsRatio(rootRepository.getDynamicPropertiesStore()), + CommonParameter.getInstance().getConstantCallTimeoutMs()); long vmStartInUs = System.nanoTime() / VMConstant.ONE_THOUSAND; long vmShouldEndInUs = vmStartInUs + thisTxCPULimitInUs; ProgramInvoke programInvoke = ProgramInvokeFactory diff --git a/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java b/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java index b5bbe2ab4cd..75a14298654 100644 --- a/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java +++ b/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java @@ -49,6 +49,7 @@ import org.bouncycastle.crypto.params.ECDomainParameters; import org.bouncycastle.crypto.params.ECPublicKeyParameters; import org.bouncycastle.crypto.signers.ECDSASigner; +import org.bouncycastle.crypto.signers.mldsa.MLDSA44Eip8051Verifier; import org.bouncycastle.math.ec.ECPoint; import org.tron.common.crypto.Blake2bfMessageDigest; import org.tron.common.crypto.Hash; @@ -126,6 +127,7 @@ public class PrecompiledContracts { private static final VerifyFnDsa512 verifyFnDsa512 = new VerifyFnDsa512(); private static final BatchValidateFnDsa512 batchValidateFnDsa512 = new BatchValidateFnDsa512(); + private static final VerifyMlDsa44Eip8051 verifyMlDsa44Eip8051 = new VerifyMlDsa44Eip8051(); private static final VerifyMlDsa44 verifyMlDsa44 = new VerifyMlDsa44(); private static final BatchValidateMlDsa44 batchValidateMlDsa44 = new BatchValidateMlDsa44(); private static final ValidateMultiPQSig validateMultiPqSig = new ValidateMultiPQSig(); @@ -242,10 +244,13 @@ public class PrecompiledContracts { private static final DataWord batchValidateFnDsa512Addr = new DataWord( "0000000000000000000000000000000000000000000000000000000000000018"); - // 0x19: ML-DSA-44 single verify (FIPS 204 / CRYSTALS-Dilithium-2). TRON-style - // layout uses the standard 1312-byte public key encoding rho‖t1, not the - // EIP-8051 20512-byte expanded form — the standard encoding lets us call - // BC's stock MLDSASigner directly without re-implementing FIPS 204 §6.5. + // 0x12: EIP-8051 VERIFY_MLDSA. Uses the EIP expanded public key layout + // [A_hat 16384B | tr 32B | t1_ntt 4096B], not the 1312B FIPS public key. + private static final DataWord verifyMlDsa44Eip8051Addr = new DataWord( + "0000000000000000000000000000000000000000000000000000000000000012"); + + // 0x19: existing TRON draft address for ML-DSA-44 single verify. Kept for + // compatibility with contracts/tests already targeting this PR branch. private static final DataWord verifyMlDsa44Addr = new DataWord( "0000000000000000000000000000000000000000000000000000000000000019"); @@ -370,6 +375,9 @@ public static PrecompiledContract getContractForAddress(DataWord address) { // ML-DSA-44 (FIPS 204 / Dilithium-2): single verify and batch verify are // gated by their own proposal flag. if (VMConfig.allowMlDsa44()) { + if (address.equals(verifyMlDsa44Eip8051Addr)) { + return verifyMlDsa44Eip8051; + } if (address.equals(verifyMlDsa44Addr)) { return verifyMlDsa44; } @@ -524,7 +532,7 @@ private static byte[] extractBytes(byte[] data, int offset, int len) { if (offset < 0 || len < 0 || offset > data.length) { return EMPTY_BYTE_ARRAY; } - int safe = Math.min(len, data.length - offset); + int safe = StrictMathWrapper.min(len, data.length - offset); return Arrays.copyOfRange(data, offset, offset + safe); } @@ -600,7 +608,8 @@ static int recoverFalconSigLen(byte[] data, int from, int to) { * breaks caller expectations and must be avoided. * *

Single-verify convention (e.g. {@code VerifyFnDsa512} 0x16, - * {@code VerifyMlDsa44} 0x19): {@code execute} always returns + * {@code VerifyMlDsa44Eip8051} 0x12, {@code VerifyMlDsa44} 0x19): + * {@code execute} always returns * {@code Pair.of(true, X)} where {@code X} is a 32-byte word — {@code dataOne()} * on cryptographic success, {@code DATA_FALSE} on any malformed input or * verification failure. The caller never observes an ABI/structural error; @@ -2814,8 +2823,8 @@ private static class PqVerifyResult { * {@code rho ‖ t1} (1312 B) instead of EIP-8051's 20512 B expanded form * (precomputed {@code A_hat = ExpandA(rho)}). BC 1.84's {@code MLDSASigner} * only accepts the standard form; we pay the per-call {@code ExpandA} - * cost so 1312 B Dilithium-2 keys work unchanged. An expanded-pk variant, - * if added later, will get a new precompile slot — 0x19 stays as-is. + * cost so 1312 B Dilithium-2 keys work unchanged. The EIP-8051 expanded-pk + * variant is implemented separately at 0x12 — 0x19 stays as-is. */ public static class VerifyMlDsa44 extends PrecompiledContract { @@ -2846,6 +2855,40 @@ public Pair execute(byte[] data) { } } + /** + * 0x12 EIP-8051 VERIFY_MLDSA for ML-DSA-44 expanded public keys. + * + *

Input layout: {@code [msg 32B | sig 2420B | expandedPk 20512B]}, where + * {@code expandedPk = A_hat(16384B) || tr(32B) || t1_ntt(4096B)}. Field + * elements inside {@code A_hat} and {@code t1_ntt} are 32-bit big-endian + * values and must be canonical ({@code < q}). + */ + public static class VerifyMlDsa44Eip8051 extends PrecompiledContract { + + @Override + public long getEnergyForData(byte[] data) { + return 4500; + } + + @Override + public Pair execute(byte[] data) { + if (data == null || data.length != MLDSA44Eip8051Verifier.INPUT_LENGTH) { + return Pair.of(true, DataWord.ZERO().getData()); + } + try { + int msgLen = MLDSA44Eip8051Verifier.MESSAGE_LENGTH; + int sigLen = MLDSA44Eip8051Verifier.SIGNATURE_LENGTH; + byte[] msg = copyOfRange(data, 0, msgLen); + byte[] sig = copyOfRange(data, msgLen, msgLen + sigLen); + byte[] pk = copyOfRange(data, msgLen + sigLen, data.length); + boolean ok = MLDSA44Eip8051Verifier.verify(msg, sig, pk); + return Pair.of(true, ok ? DataWord.ONE().getData() : DataWord.ZERO().getData()); + } catch (Throwable t) { + return Pair.of(true, DataWord.ZERO().getData()); + } + } + } + /** * 0x1a ValidateMultiPQSig — algorithm-agnostic Permission multi-sign. Accepts * ECDSA plus any registered post-quantum scheme (FN-DSA-512, ML-DSA-44, ...) diff --git a/chainbase/src/main/java/org/tron/common/utils/LocalWitnesses.java b/chainbase/src/main/java/org/tron/common/utils/LocalWitnesses.java index c234d5d4595..cd7de299ee2 100644 --- a/chainbase/src/main/java/org/tron/common/utils/LocalWitnesses.java +++ b/chainbase/src/main/java/org/tron/common/utils/LocalWitnesses.java @@ -37,6 +37,10 @@ public class LocalWitnesses { @Getter private List privateKeys = Lists.newArrayList(); + @Setter + @Getter + private byte[] witnessAccountAddress; + /** * Pre-derived PQ keypairs (scheme + private + public, hex), one per witness. * Each keypair declares its own PQ scheme so a single node can host SRs @@ -52,10 +56,6 @@ public class LocalWitnesses { @Getter private List pqKeypairs = Lists.newArrayList(); - @Setter - @Getter - private byte[] witnessAccountAddress; - /** * PQ-side counterpart to {@link #witnessAccountAddress}. Distinct from the * ECDSA address so a node can host two different SRs (one ECDSA + one PQ). diff --git a/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java b/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java index 484ab159219..13b49ef973f 100755 --- a/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java +++ b/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java @@ -220,22 +220,18 @@ public boolean validateSignature(DynamicPropertiesStore dynamicPropertiesStore, boolean hasLegacy = !header.getWitnessSignature().isEmpty(); boolean hasPq = header.hasPqAuthSig(); - if (!dynamicPropertiesStore.isAnyPqSchemeAllowed()) { - if (hasPq) { - throw new ValidateSignatureException( - "pq_auth_sig not allowed: no post-quantum scheme is activated"); - } - return validateLegacySignature(header, witnessPermissionAddress); - } - - if (hasLegacy && hasPq) { + if (hasLegacy == hasPq) { throw new ValidateSignatureException( - "witness_signature and pq_auth_sig are mutually exclusive"); - } - if (!hasLegacy && !hasPq) { - throw new ValidateSignatureException("missing witness signature"); + hasLegacy + ? "witness_signature and pq_auth_sig are mutually exclusive" + : "missing witness signature"); } + if (hasPq) { + if (!dynamicPropertiesStore.isAnyPqSchemeAllowed()) { + throw new ValidateSignatureException( + "pq_auth_sig not allowed: no post-quantum scheme is activated"); + } return validatePQSignature(dynamicPropertiesStore, witnessPermissionAddress, header.getPqAuthSig()); } diff --git a/chainbase/src/main/java/org/tron/core/capsule/TransactionCapsule.java b/chainbase/src/main/java/org/tron/core/capsule/TransactionCapsule.java index ecf14e10dee..5987073cb7d 100755 --- a/chainbase/src/main/java/org/tron/core/capsule/TransactionCapsule.java +++ b/chainbase/src/main/java/org/tron/core/capsule/TransactionCapsule.java @@ -503,7 +503,8 @@ public static boolean validateSignature(Transaction transaction, if (dynamicPropertiesStore.isAnyPqSchemeAllowed() && transaction.getPqAuthSigCount() > 0) { try { weight = StrictMathWrapper.addExact(weight, - validatePQSignatureGetWeight(transaction, permission, dynamicPropertiesStore, approveList)); + validatePQSignatureGetWeight(transaction, permission, dynamicPropertiesStore, + approveList)); } catch (ArithmeticException e) { throw new PermissionException("weight overflow"); } @@ -637,7 +638,7 @@ public void addSign(byte[] privateKey, AccountStore accountStore) .signHash(getTransactionId().getBytes()))); this.transaction = this.transaction.toBuilder().addSignature(sig).build(); } - + private static void checkPermission(int permissionId, Permission permission, Transaction.Contract contract) throws PermissionException { if (permissionId != 0) { if (permission.getType() != PermissionType.Active) { @@ -810,7 +811,7 @@ public boolean validateSignature(AccountStore accountStore, } } isVerified = true; - } + } return true; } diff --git a/common/src/main/java/org/tron/core/Constant.java b/common/src/main/java/org/tron/core/Constant.java index 370a442fc06..1437d319346 100644 --- a/common/src/main/java/org/tron/core/Constant.java +++ b/common/src/main/java/org/tron/core/Constant.java @@ -60,14 +60,6 @@ public class Constant { // Crypto engine public static final String ECKey_ENGINE = "ECKey"; - // Post-quantum (FIPS 206 draft) FN-DSA / Falcon-512 signature constants. - // Falcon signatures are variable-length; SIGNATURE_MAX_LENGTH is the protocol-level - // upper bound, not an exact length. - public static final int FN_DSA_PUBLIC_KEY_LENGTH = 896; - public static final int FN_DSA_SIGNATURE_MAX_LENGTH = 752; - public static final String PQ_TX_AUTH_DOMAIN = "TRON_TX_AUTH_V1"; - public static final String PQ_BLOCK_AUTH_DOMAIN = "TRON_BLOCK_AUTH_V1"; - // Network public static final String LOCAL_HOST = "127.0.0.1"; diff --git a/consensus/src/main/java/org/tron/consensus/base/Param.java b/consensus/src/main/java/org/tron/consensus/base/Param.java index 4c7d075f061..b08648c47ba 100644 --- a/consensus/src/main/java/org/tron/consensus/base/Param.java +++ b/consensus/src/main/java/org/tron/consensus/base/Param.java @@ -68,40 +68,98 @@ public class Miner { @Setter private ByteString witnessAddress; - private byte[] pqPrivateKey; - - private byte[] pqPublicKey; - /** - * Post-quantum signature scheme for this miner. When unset (null), the - * miner signs blocks with the legacy ECDSA path using {@link #privateKey}; - * when set (e.g. {@code FN_DSA_512}), the PQ path is used with - * {@link #pqPrivateKey} / {@link #pqPublicKey}. + * Post-quantum identity for this miner — non-null iff the miner signs + * blocks via the PQ path. ECDSA fields above are left null when this is + * set so the two miner kinds never share a slot. */ @Getter - @Setter - private PQScheme pqScheme; + private final PQMiner pq; public Miner(byte[] privateKey, ByteString privateKeyAddress, ByteString witnessAddress) { this.privateKey = privateKey; this.privateKeyAddress = privateKeyAddress; this.witnessAddress = witnessAddress; + this.pq = null; + } + + /** + * PQ-miner constructor. {@code privateKeyAddress} carries the PQ-derived + * address (the key-slot identity), {@code witnessAddress} carries the + * on-chain witness identity (often the same, but may differ in multi-sig + * setups). The ECDSA fields {@link #privateKey} / {@link #privateKeyAddress} + * / {@link #witnessAddress} are left null on purpose so ECDSA-only code + * paths cannot accidentally consume a PQ identity. + */ + public Miner(PQScheme scheme, byte[] privateKey, byte[] publicKey, + ByteString privateKeyAddress, ByteString witnessAddress) { + this.pq = new PQMiner(scheme, privateKey, publicKey, privateKeyAddress, witnessAddress); } - public byte[] getPQPrivateKey() { - return pqPrivateKey == null ? null : pqPrivateKey.clone(); + /** True iff this miner signs via the PQ path (i.e. has a {@link PQMiner}). */ + public boolean isPq() { + return pq != null; } - public void setPQPrivateKey(byte[] pqPrivateKey) { - this.pqPrivateKey = pqPrivateKey == null ? null : pqPrivateKey.clone(); + /** + * Returns the on-chain witness address regardless of signing scheme — PQ + * miners route to {@link PQMiner#getWitnessAddress()}, ECDSA miners to + * {@link #witnessAddress}. Use this from scheme-agnostic call sites + * (block-producer map keys, witness-set filters, generic logging). + */ + public ByteString getEffectiveWitnessAddress() { + return pq != null ? pq.getWitnessAddress() : witnessAddress; } - public byte[] getPQPublicKey() { - return pqPublicKey == null ? null : pqPublicKey.clone(); + /** + * Returns the signing-key-derived address regardless of signing scheme — + * PQ miners route to {@link PQMiner#getPrivateKeyAddress()}, ECDSA miners to + * {@link #privateKeyAddress}. Use this from scheme-agnostic call sites + * (e.g. multi-sign permission checks). + */ + public ByteString getEffectivePrivateKeyAddress() { + return pq != null ? pq.getPrivateKeyAddress() : privateKeyAddress; } - public void setPQPublicKey(byte[] pqPublicKey) { - this.pqPublicKey = pqPublicKey == null ? null : pqPublicKey.clone(); + /** + * Post-quantum identity bundle: scheme + key material + derived addresses. + * Immutable; key bytes are defensively copied on the way in and out so the + * stored material can't be mutated by callers. + */ + public class PQMiner { + + @Getter + private final PQScheme scheme; + + private final byte[] privateKey; + + private final byte[] publicKey; + + /** Address derived from the PQ public key (key-slot identity). */ + @Getter + private final ByteString privateKeyAddress; + + /** On-chain witness identity — may differ from {@link #privateKeyAddress} + * in multi-sig setups, otherwise equal to it. */ + @Getter + private final ByteString witnessAddress; + + public PQMiner(PQScheme scheme, byte[] privateKey, byte[] publicKey, + ByteString privateKeyAddress, ByteString witnessAddress) { + this.scheme = scheme; + this.privateKey = privateKey == null ? null : privateKey.clone(); + this.publicKey = publicKey == null ? null : publicKey.clone(); + this.privateKeyAddress = privateKeyAddress; + this.witnessAddress = witnessAddress; + } + + public byte[] getPrivateKey() { + return privateKey == null ? null : privateKey.clone(); + } + + public byte[] getPublicKey() { + return publicKey == null ? null : publicKey.clone(); + } } } diff --git a/consensus/src/main/java/org/tron/consensus/dpos/DposService.java b/consensus/src/main/java/org/tron/consensus/dpos/DposService.java index 397c9d0835c..56f029b6dd6 100644 --- a/consensus/src/main/java/org/tron/consensus/dpos/DposService.java +++ b/consensus/src/main/java/org/tron/consensus/dpos/DposService.java @@ -77,7 +77,8 @@ public void start(Param param) { this.blockHandle = param.getBlockHandle(); this.genesisBlock = param.getGenesisBlock(); this.genesisBlockTime = Long.parseLong(param.getGenesisBlock().getTimestamp()); - param.getMiners().forEach(miner -> miners.put(miner.getWitnessAddress(), miner)); + param.getMiners().forEach(miner -> + miners.put(miner.getEffectiveWitnessAddress(), miner)); dposTask.setDposService(this); dposSlot.setDposService(this); diff --git a/consensus/src/main/java/org/tron/consensus/pbft/PbftMessageHandle.java b/consensus/src/main/java/org/tron/consensus/pbft/PbftMessageHandle.java index 523ffac4d61..dfed063352b 100644 --- a/consensus/src/main/java/org/tron/consensus/pbft/PbftMessageHandle.java +++ b/consensus/src/main/java/org/tron/consensus/pbft/PbftMessageHandle.java @@ -99,7 +99,7 @@ public List getSrMinerList(long epoch) { compareList = maintenanceManager.getBeforeWitness(); } return Param.getInstance().getMiners().stream() - .filter(miner -> compareList.contains(miner.getWitnessAddress())) + .filter(miner -> compareList.contains(miner.getEffectiveWitnessAddress())) .collect(Collectors.toList()); } diff --git a/crypto/src/main/java/org/bouncycastle/crypto/signers/mldsa/MLDSA44Eip8051Verifier.java b/crypto/src/main/java/org/bouncycastle/crypto/signers/mldsa/MLDSA44Eip8051Verifier.java new file mode 100644 index 00000000000..1adbba9e806 --- /dev/null +++ b/crypto/src/main/java/org/bouncycastle/crypto/signers/mldsa/MLDSA44Eip8051Verifier.java @@ -0,0 +1,162 @@ +package org.bouncycastle.crypto.signers.mldsa; + +import org.bouncycastle.crypto.digests.SHAKEDigest; +import org.bouncycastle.crypto.params.MLDSAParameters; +import org.bouncycastle.util.Arrays; + +/** + * EIP-8051 VERIFY_MLDSA verifier for ML-DSA-44 expanded public keys. + * + *

This class intentionally lives in Bouncy Castle's ML-DSA internal package so it can reuse + * the package-private polynomial primitives. Bouncy Castle 1.84 exposes only the standard + * FIPS-204 public key verifier ({@code rho || t1}); EIP-8051 instead supplies + * {@code A_hat || tr || t1_ntt}. + */ +public final class MLDSA44Eip8051Verifier { + + public static final int MESSAGE_LENGTH = 32; + public static final int SIGNATURE_LENGTH = 2420; + public static final int EXPANDED_PUBLIC_KEY_LENGTH = 20512; + public static final int INPUT_LENGTH = + MESSAGE_LENGTH + SIGNATURE_LENGTH + EXPANDED_PUBLIC_KEY_LENGTH; + + private static final int K = 4; + private static final int L = 4; + private static final int FIELD_ELEMENT_BYTES = 4; + private static final int MATRIX_BYTES = + K * L * MLDSAEngine.DilithiumN * FIELD_ELEMENT_BYTES; + private static final int TR_BYTES = 32; + private static final int TWO_POWER_D = 1 << MLDSAEngine.DilithiumD; + + private MLDSA44Eip8051Verifier() { + } + + public static boolean verify(byte[] message, byte[] signature, byte[] expandedPublicKey) { + if (message == null || message.length != MESSAGE_LENGTH + || signature == null || signature.length != SIGNATURE_LENGTH + || expandedPublicKey == null + || expandedPublicKey.length != EXPANDED_PUBLIC_KEY_LENGTH) { + return false; + } + + try { + MLDSAEngine engine = MLDSAEngine.getInstance(MLDSAParameters.ml_dsa_44, null); + PolyVecL[] aHat = decodeMatrix(expandedPublicKey, 0, engine); + byte[] tr = Arrays.copyOfRange(expandedPublicKey, MATRIX_BYTES, MATRIX_BYTES + TR_BYTES); + PolyVecK t1Ntt = decodePolyVecK( + expandedPublicKey, MATRIX_BYTES + TR_BYTES, engine); + + return verifyInternal(message, signature, aHat, tr, t1Ntt, engine); + } catch (RuntimeException e) { + return false; + } + } + + private static boolean verifyInternal(byte[] message, byte[] signature, PolyVecL[] aHat, + byte[] tr, PolyVecK t1Ntt, MLDSAEngine engine) { + PolyVecK h = new PolyVecK(engine); + PolyVecL z = new PolyVecL(engine); + if (!Packing.unpackSignature(z, h, signature, engine)) { + return false; + } + if (z.checkNorm(engine.getDilithiumGamma1() - engine.getDilithiumBeta())) { + return false; + } + + byte[] buf = new byte[StrictMath.max( + MLDSAEngine.CrhBytes + K * engine.getDilithiumPolyW1PackedBytes(), + engine.getDilithiumCTilde())]; + SHAKEDigest shake = new SHAKEDigest(256); + shake.update(tr, 0, TR_BYTES); + shake.update(message, 0, MESSAGE_LENGTH); + shake.doFinal(buf, 0, MLDSAEngine.CrhBytes); + + Poly c = new Poly(engine); + c.challenge(signature, 0, engine.getDilithiumCTilde()); + + z.polyVecNtt(); + PolyVecK w1 = pointwiseMontgomery(aHat, z, engine); + + c.polyNtt(); + PolyVecK ct1 = new PolyVecK(engine); + ct1.pointwisePolyMontgomery(c, t1Ntt); + multiplyByTwoPowerD(ct1); + + w1.subtract(ct1); + w1.reduce(); + w1.invNttToMont(); + w1.conditionalAddQ(); + w1.useHint(w1, h); + w1.packW1(engine, buf, MLDSAEngine.CrhBytes); + + shake = new SHAKEDigest(256); + shake.update(buf, 0, MLDSAEngine.CrhBytes + K * engine.getDilithiumPolyW1PackedBytes()); + shake.doFinal(buf, 0, engine.getDilithiumCTilde()); + return Arrays.constantTimeAreEqual(engine.getDilithiumCTilde(), signature, 0, buf, 0); + } + + private static PolyVecK pointwiseMontgomery(PolyVecL[] aHat, PolyVecL z, + MLDSAEngine engine) { + PolyVecK out = new PolyVecK(engine); + Poly tmp = new Poly(engine); + for (int i = 0; i < K; i++) { + out.getVectorIndex(i).pointwiseMontgomery(aHat[i].getVectorIndex(0), z.getVectorIndex(0)); + for (int j = 1; j < L; j++) { + tmp.pointwiseMontgomery(aHat[i].getVectorIndex(j), z.getVectorIndex(j)); + out.getVectorIndex(i).addPoly(tmp); + } + } + return out; + } + + private static PolyVecL[] decodeMatrix(byte[] in, int offset, MLDSAEngine engine) { + PolyVecL[] matrix = new PolyVecL[K]; + int pos = offset; + for (int i = 0; i < K; i++) { + matrix[i] = new PolyVecL(engine); + for (int j = 0; j < L; j++) { + decodePoly(in, pos, matrix[i].getVectorIndex(j)); + pos += MLDSAEngine.DilithiumN * FIELD_ELEMENT_BYTES; + } + } + return matrix; + } + + private static PolyVecK decodePolyVecK(byte[] in, int offset, MLDSAEngine engine) { + PolyVecK out = new PolyVecK(engine); + int pos = offset; + for (int i = 0; i < K; i++) { + decodePoly(in, pos, out.getVectorIndex(i)); + pos += MLDSAEngine.DilithiumN * FIELD_ELEMENT_BYTES; + } + return out; + } + + private static void decodePoly(byte[] in, int offset, Poly out) { + int pos = offset; + for (int i = 0; i < MLDSAEngine.DilithiumN; i++) { + int coeff = ((in[pos] & 0xff) << 24) + | ((in[pos + 1] & 0xff) << 16) + | ((in[pos + 2] & 0xff) << 8) + | (in[pos + 3] & 0xff); + if (coeff >= MLDSAEngine.DilithiumQ) { + throw new IllegalArgumentException("invalid ML-DSA field element"); + } + out.setCoeffIndex(i, coeff); + pos += FIELD_ELEMENT_BYTES; + } + } + + private static void multiplyByTwoPowerD(PolyVecK v) { + for (int i = 0; i < K; i++) { + Poly poly = v.getVectorIndex(i); + for (int j = 0; j < MLDSAEngine.DilithiumN; j++) { + long coeff = poly.getCoeffIndex(j) % (long) MLDSAEngine.DilithiumQ; + if (coeff < 0) { + coeff += MLDSAEngine.DilithiumQ; + } + poly.setCoeffIndex(j, (int) ((coeff * TWO_POWER_D) % MLDSAEngine.DilithiumQ)); + } + } + } +} diff --git a/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA44.java b/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA44.java index bce303352f3..e21bdd9eb1e 100644 --- a/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA44.java +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA44.java @@ -29,8 +29,8 @@ * derived {@code t1} stays in memory after instantiation). * *

Pure ML-DSA only (no SHA2-512 pre-hash variant). The "pure" mode signs - * the raw message under SHAKE-256 per FIPS 204 §5.2, matching the verify - * side of the EVM precompile at address 0x19 (EIP-8051). + * the raw message under SHAKE-256 per FIPS 204 §5.2, matching the standard + * 1312-byte public key verify path used by the 0x19 precompile. */ public final class MLDSA44 implements PQSignature { diff --git a/framework/src/main/java/org/tron/core/consensus/ConsensusService.java b/framework/src/main/java/org/tron/core/consensus/ConsensusService.java index 1087d1d4757..0356c6e3cbf 100644 --- a/framework/src/main/java/org/tron/core/consensus/ConsensusService.java +++ b/framework/src/main/java/org/tron/core/consensus/ConsensusService.java @@ -93,7 +93,8 @@ public void start() { Miner miner = buildPQMiner(param, kp, null); miners.add(miner); logger.info("Add {} witness (from configured keypair): {}, size: {}", - kp.getScheme(), Hex.toHexString(miner.getWitnessAddress().toByteArray()), + kp.getScheme(), + Hex.toHexString(miner.getPq().getWitnessAddress().toByteArray()), miners.size()); } } else if (pqKeypairs.size() == 1) { @@ -101,7 +102,8 @@ public void start() { Args.getLocalWitnesses().getPqWitnessAccountAddress()); miners.add(miner); logger.info("Add {} witness (from configured keypair): {}", - miner.getPqScheme(), Hex.toHexString(miner.getWitnessAddress().toByteArray())); + miner.getPq().getScheme(), + Hex.toHexString(miner.getPq().getWitnessAddress().toByteArray())); } param.setMiners(miners); @@ -132,12 +134,9 @@ private Miner buildPQMiner(Param param, PqKeypair pqKeypair, byte[] witnessAddre if (witnessStore.get(witnessAddress) == null) { logger.warn("Witness {} is not in witnessStore.", Hex.toHexString(witnessAddress)); } - Miner miner = param.new Miner(null, + return param.new Miner(scheme, + keypair.getPrivateKey(), keypair.getPublicKey(), ByteString.copyFrom(pqAddress), ByteString.copyFrom(witnessAddress)); - miner.setPQPrivateKey(keypair.getPrivateKey()); - miner.setPQPublicKey(keypair.getPublicKey()); - miner.setPqScheme(scheme); - return miner; } private static void requireSupportedPqScheme(PQScheme scheme) { diff --git a/framework/src/main/java/org/tron/core/db/Manager.java b/framework/src/main/java/org/tron/core/db/Manager.java index be9fe097bc9..8db9777f1bb 100644 --- a/framework/src/main/java/org/tron/core/db/Manager.java +++ b/framework/src/main/java/org/tron/core/db/Manager.java @@ -1617,7 +1617,8 @@ public TransactionInfo processTransaction(final TransactionCapsule trxCap, Block * Generate a block. */ public BlockCapsule generateBlock(Miner miner, long blockTime, long timeout) { - String address = StringUtil.encode58Check(miner.getWitnessAddress().toByteArray()); + ByteString witnessAddress = miner.getEffectiveWitnessAddress(); + String address = StringUtil.encode58Check(witnessAddress.toByteArray()); final Histogram.Timer timer = Metrics.histogramStartTimer( MetricKeys.Histogram.BLOCK_GENERATE_LATENCY, address); Metrics.histogramObserve(MetricKeys.Histogram.MINER_DELAY, @@ -1627,7 +1628,7 @@ public BlockCapsule generateBlock(Miner miner, long blockTime, long timeout) { BlockCapsule blockCapsule = new BlockCapsule(chainBaseManager.getHeadBlockNum() + 1, chainBaseManager.getHeadBlockId(), - blockTime, miner.getWitnessAddress()); + blockTime, witnessAddress); blockCapsule.generatedByMyself = true; session.reset(); session.setValue(revokingStore.buildSession()); @@ -1636,9 +1637,9 @@ public BlockCapsule generateBlock(Miner miner, long blockTime, long timeout) { accountStateCallBack.preExecute(blockCapsule); if (getDynamicPropertiesStore().getAllowMultiSign() == 1) { - byte[] privateKeyAddress = miner.getPrivateKeyAddress().toByteArray(); + byte[] privateKeyAddress = miner.getEffectivePrivateKeyAddress().toByteArray(); AccountCapsule witnessAccount = getAccountStore() - .get(miner.getWitnessAddress().toByteArray()); + .get(witnessAddress.toByteArray()); if (!Arrays.equals(privateKeyAddress, witnessAccount.getWitnessPermissionAddress())) { logger.warn("Witness permission is wrong."); return null; @@ -1747,7 +1748,7 @@ public BlockCapsule generateBlock(Miner miner, long blockTime, long timeout) { session.reset(); blockCapsule.setMerkleRoot(); - if (miner.getPqScheme() != null) { + if (miner.isPq()) { // PQ-only miner: never fall back to ECDSA signing — miner.getPrivateKey() is // null on this path, and a silent fallback would NPE inside blockCapsule.sign. // Fail fast with a clear cause; DposTask's Throwable handler logs it and the @@ -1755,10 +1756,11 @@ public BlockCapsule generateBlock(Miner miner, long blockTime, long timeout) { // Gate on this miner's specific scheme, not on the broader "any PQ scheme // allowed" flag — a Falcon-configured miner must not produce while only // ML-DSA is active (and vice versa). - if (!getDynamicPropertiesStore().isPqSchemeAllowed(miner.getPqScheme())) { + Param.Miner.PQMiner pq = miner.getPq(); + if (!getDynamicPropertiesStore().isPqSchemeAllowed(pq.getScheme())) { throw new IllegalStateException( - "PQ miner " + Hex.toHexString(miner.getWitnessAddress().toByteArray()) - + " has scheme " + miner.getPqScheme() + "PQ miner " + Hex.toHexString(pq.getWitnessAddress().toByteArray()) + + " has scheme " + pq.getScheme() + " configured but that scheme is not allowed by dynamic properties"); } signBlockCapsuleWithPQ(blockCapsule, miner); @@ -1781,24 +1783,25 @@ public BlockCapsule generateBlock(Miner miner, long blockTime, long timeout) { } private void signBlockCapsuleWithPQ(BlockCapsule blockCapsule, Miner miner) { - PQScheme scheme = miner.getPqScheme(); + Param.Miner.PQMiner pq = miner.getPq(); + PQScheme scheme = pq.getScheme(); if (scheme == null || !PQSchemeRegistry.contains(scheme)) { throw new IllegalStateException( - "PQ miner " + Hex.toHexString(miner.getWitnessAddress().toByteArray()) - + " has scheme " + miner.getPqScheme() + "PQ miner " + Hex.toHexString(pq.getWitnessAddress().toByteArray()) + + " has scheme " + scheme + " which is not registered in PQSchemeRegistry"); } if (!chainBaseManager.getDynamicPropertiesStore().isPqSchemeAllowed(scheme)) { throw new IllegalStateException( - "PQ miner " + Hex.toHexString(miner.getWitnessAddress().toByteArray()) + "PQ miner " + Hex.toHexString(pq.getWitnessAddress().toByteArray()) + " has scheme " + scheme + " but it is not allowed by dynamic properties"); } - byte[] pqPrivateKey = miner.getPQPrivateKey(); - byte[] pqPublicKey = miner.getPQPublicKey(); + byte[] pqPrivateKey = pq.getPrivateKey(); + byte[] pqPublicKey = pq.getPublicKey(); if (pqPrivateKey == null || pqPublicKey == null) { throw new IllegalStateException( - "miner " + Hex.toHexString(miner.getWitnessAddress().toByteArray()) + "miner " + Hex.toHexString(pq.getWitnessAddress().toByteArray()) + " has scheme " + scheme + " set but local PQ key material is missing"); } diff --git a/framework/src/test/java/org/tron/common/runtime/vm/MlDsa44PrecompileTest.java b/framework/src/test/java/org/tron/common/runtime/vm/MlDsa44PrecompileTest.java index 4728bea42d6..79bf982df68 100644 --- a/framework/src/test/java/org/tron/common/runtime/vm/MlDsa44PrecompileTest.java +++ b/framework/src/test/java/org/tron/common/runtime/vm/MlDsa44PrecompileTest.java @@ -11,14 +11,20 @@ import org.tron.core.vm.config.VMConfig; /** - * Unit tests for the ML-DSA-44 (0x19) verify precompile (FIPS 204 / Dilithium-2). - * Input layout: [msg 32B | sig 2420B | pk 1312B]. Stateless — no chain DB. + * Unit tests for the ML-DSA-44 verify precompile (FIPS 204 / Dilithium-2). + * Address 0x12 follows EIP-8051 with expanded public keys; 0x19 remains the + * existing TRON draft path with standard 1312-byte public keys. */ public class MlDsa44PrecompileTest { - private static final DataWord MLDSA_ADDR = new DataWord( + private static final DataWord MLDSA_EIP8051_ADDR = new DataWord( + "0000000000000000000000000000000000000000000000000000000000000012"); + + private static final DataWord MLDSA_DRAFT_ADDR = new DataWord( "0000000000000000000000000000000000000000000000000000000000000019"); + private static final int EIP8051_INPUT_LENGTH = 32 + MLDSA44.SIGNATURE_LENGTH + 20512; + private static final byte[] MESSAGE_HASH = new byte[32]; static { @@ -40,21 +46,26 @@ public void disableProposal() { @Test public void switchOff_returnsNull() { VMConfig.initAllowMlDsa44(0L); - Assert.assertNull(PrecompiledContracts.getContractForAddress(MLDSA_ADDR)); + Assert.assertNull(PrecompiledContracts.getContractForAddress(MLDSA_EIP8051_ADDR)); + } + + @Test + public void switchOn_returnsEip8051Contract() { + Assert.assertNotNull(PrecompiledContracts.getContractForAddress(MLDSA_EIP8051_ADDR)); } @Test - public void switchOn_returnsContract() { - Assert.assertNotNull(PrecompiledContracts.getContractForAddress(MLDSA_ADDR)); + public void draftAddress19StillReturnsContract() { + Assert.assertNotNull(PrecompiledContracts.getContractForAddress(MLDSA_DRAFT_ADDR)); } @Test - public void validSignature_returnsOne() { + public void draftAddress19ValidSignature_returnsOne() { MLDSA44 key = new MLDSA44(); byte[] sig = key.sign(MESSAGE_HASH); byte[] input = buildInput(MESSAGE_HASH, sig, key.getPublicKey()); - PrecompiledContract pc = PrecompiledContracts.getContractForAddress(MLDSA_ADDR); + PrecompiledContract pc = PrecompiledContracts.getContractForAddress(MLDSA_DRAFT_ADDR); Pair result = pc.execute(input); Assert.assertTrue(result.getLeft()); @@ -63,7 +74,7 @@ public void validSignature_returnsOne() { } @Test - public void tamperedMessage_returnsZero() { + public void draftAddress19TamperedMessage_returnsZero() { MLDSA44 key = new MLDSA44(); byte[] sig = key.sign(MESSAGE_HASH); byte[] tampered = MESSAGE_HASH.clone(); @@ -71,71 +82,71 @@ public void tamperedMessage_returnsZero() { byte[] input = buildInput(tampered, sig, key.getPublicKey()); Pair result = - PrecompiledContracts.getContractForAddress(MLDSA_ADDR).execute(input); + PrecompiledContracts.getContractForAddress(MLDSA_DRAFT_ADDR).execute(input); Assert.assertTrue(result.getLeft()); Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); } @Test - public void tamperedSignature_returnsZero() { + public void draftAddress19TamperedSignature_returnsZero() { MLDSA44 key = new MLDSA44(); byte[] sig = key.sign(MESSAGE_HASH); sig[0] ^= 0x01; byte[] input = buildInput(MESSAGE_HASH, sig, key.getPublicKey()); Pair result = - PrecompiledContracts.getContractForAddress(MLDSA_ADDR).execute(input); + PrecompiledContracts.getContractForAddress(MLDSA_DRAFT_ADDR).execute(input); Assert.assertTrue(result.getLeft()); Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); } @Test - public void wrongPublicKey_returnsZero() { + public void draftAddress19WrongPublicKey_returnsZero() { MLDSA44 signer = new MLDSA44(); MLDSA44 other = new MLDSA44(); byte[] sig = signer.sign(MESSAGE_HASH); byte[] input = buildInput(MESSAGE_HASH, sig, other.getPublicKey()); Pair result = - PrecompiledContracts.getContractForAddress(MLDSA_ADDR).execute(input); + PrecompiledContracts.getContractForAddress(MLDSA_DRAFT_ADDR).execute(input); Assert.assertTrue(result.getLeft()); Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); } @Test - public void nullInput_returnsZero() { + public void draftAddress19NullInput_returnsZero() { Pair result = - PrecompiledContracts.getContractForAddress(MLDSA_ADDR).execute(null); + PrecompiledContracts.getContractForAddress(MLDSA_DRAFT_ADDR).execute(null); Assert.assertTrue(result.getLeft()); Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); } @Test - public void shortInput_returnsZero() { + public void draftAddress19ShortInput_returnsZero() { Pair result = - PrecompiledContracts.getContractForAddress(MLDSA_ADDR).execute(new byte[100]); + PrecompiledContracts.getContractForAddress(MLDSA_DRAFT_ADDR).execute(new byte[100]); Assert.assertTrue(result.getLeft()); Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); } @Test - public void wrongLengthInput_returnsZero() { + public void draftAddress19WrongLengthInput_returnsZero() { // ML-DSA-44 input is fixed-length 3764B; any other length must be rejected. int expected = 32 + MLDSA44.SIGNATURE_LENGTH + MLDSA44.PUBLIC_KEY_LENGTH; byte[] oneByteShort = new byte[expected - 1]; Pair r1 = - PrecompiledContracts.getContractForAddress(MLDSA_ADDR).execute(oneByteShort); + PrecompiledContracts.getContractForAddress(MLDSA_DRAFT_ADDR).execute(oneByteShort); Assert.assertTrue(r1.getLeft()); Assert.assertArrayEquals(DataWord.ZERO().getData(), r1.getRight()); } @Test - public void trailingBytes_returnsZero() { + public void draftAddress19TrailingBytes_returnsZero() { // Strict equality: even one extra trailing byte must be rejected. MLDSA44 key = new MLDSA44(); byte[] sig = key.sign(MESSAGE_HASH); @@ -144,7 +155,36 @@ public void trailingBytes_returnsZero() { System.arraycopy(valid, 0, padded, 0, valid.length); Pair result = - PrecompiledContracts.getContractForAddress(MLDSA_ADDR).execute(padded); + PrecompiledContracts.getContractForAddress(MLDSA_DRAFT_ADDR).execute(padded); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + @Test + public void eip8051Address12RejectsStandardPublicKeyLayout() { + MLDSA44 key = new MLDSA44(); + byte[] sig = key.sign(MESSAGE_HASH); + byte[] standardInput = buildInput(MESSAGE_HASH, sig, key.getPublicKey()); + + Pair result = + PrecompiledContracts.getContractForAddress(MLDSA_EIP8051_ADDR).execute(standardInput); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + @Test + public void eip8051Address12RejectsNonCanonicalFieldElement() { + byte[] input = new byte[EIP8051_INPUT_LENGTH]; + int pkOffset = 32 + MLDSA44.SIGNATURE_LENGTH; + input[pkOffset] = 0x00; + input[pkOffset + 1] = 0x7f; + input[pkOffset + 2] = (byte) 0xe0; + input[pkOffset + 3] = 0x01; + + Pair result = + PrecompiledContracts.getContractForAddress(MLDSA_EIP8051_ADDR).execute(input); Assert.assertTrue(result.getLeft()); Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); diff --git a/framework/src/test/java/org/tron/core/capsule/TransactionCapsuleTest.java b/framework/src/test/java/org/tron/core/capsule/TransactionCapsuleTest.java index 7c1c7356383..ea019835181 100644 --- a/framework/src/test/java/org/tron/core/capsule/TransactionCapsuleTest.java +++ b/framework/src/test/java/org/tron/core/capsule/TransactionCapsuleTest.java @@ -23,6 +23,7 @@ import org.tron.common.TestConstants; import org.tron.common.crypto.ECKey; import org.tron.common.crypto.pqc.FNDSA512; +import org.tron.common.crypto.pqc.MLDSA44; import org.tron.common.crypto.pqc.PQSchemeRegistry; import org.tron.common.utils.ByteArray; import org.tron.common.utils.Sha256Hash; @@ -365,7 +366,7 @@ private Transaction buildTrc20TransferTx(String ownerHex, int permissionId) { /** * Returns [serializedSize, packSize, maxTxPerBlock] rows ordered by signature size: - * ECKey, FN-DSA-512. + * ECKey, FN-DSA-512, ML-DSA-44. */ private long[][] measureSizes(Transaction baseTx) { final long blockLimit = 2_000_000L; @@ -377,10 +378,11 @@ private long[][] measureSizes(Transaction baseTx) { long ecSerial = ecCap.getInstance().toByteArray().length; long ecPack = ecCap.computeTrxSizeForBlockMessage(); + byte[] txid = txId(baseTx); + // FN-DSA-512: variable-length signature (<= 752 bytes) + 897-byte public key FNDSA512 kpFn = new FNDSA512(); - byte[] txidFn = txId(baseTx); - byte[] sigFn = FNDSA512.sign(kpFn.getPrivateKey(), txidFn); + byte[] sigFn = FNDSA512.sign(kpFn.getPrivateKey(), txid); Transaction txFn = baseTx.toBuilder() .addPqAuthSig(PQAuthSig.newBuilder() .setScheme(PQScheme.FN_DSA_512) @@ -392,9 +394,24 @@ private long[][] measureSizes(Transaction baseTx) { long dFnSerial = txFn.toByteArray().length; long dFnPack = capFn.computeTrxSizeForBlockMessage(); + // ML-DSA-44: fixed 2420-byte signature + 1312-byte public key + MLDSA44 kpMl = new MLDSA44(); + byte[] sigMl = MLDSA44.sign(kpMl.getPrivateKey(), txid); + Transaction txMl = baseTx.toBuilder() + .addPqAuthSig(PQAuthSig.newBuilder() + .setScheme(PQScheme.ML_DSA_44) + .setPublicKey(ByteString.copyFrom(kpMl.getPublicKey())) + .setSignature(ByteString.copyFrom(sigMl)) + .build()) + .build(); + TransactionCapsule capMl = new TransactionCapsule(txMl); + long dMlSerial = txMl.toByteArray().length; + long dMlPack = capMl.computeTrxSizeForBlockMessage(); + return new long[][]{ {ecSerial, ecPack, blockLimit / ecPack}, {dFnSerial, dFnPack, blockLimit / dFnPack}, + {dMlSerial, dMlPack, blockLimit / dMlPack}, }; } @@ -403,7 +420,7 @@ public void transactionSizeComparisonByScheme() { long[][] trx = measureSizes(buildTransferTx(PQ_OWNER_HEX, 0)); long[][] trc20 = measureSizes(buildTrc20TransferTx(PQ_OWNER_HEX, 0)); - String[] labels = {"ECKey (ECDSA)", "FN-DSA-512"}; + String[] labels = {"ECKey (ECDSA)", "FN-DSA-512", "ML-DSA-44"}; System.out.println("=== TRX transfer ==="); for (int i = 0; i < labels.length; i++) { System.out.printf(" %s: serial=%d B pack=%d B maxTx/block=%d%n", @@ -415,11 +432,17 @@ public void transactionSizeComparisonByScheme() { labels[i], trc20[i][0], trc20[i][1], trc20[i][2]); } - // FN-DSA-512 envelope is larger than ECKey, so it fits fewer txs per block. + // Both PQ envelopes are larger than ECKey, so they fit fewer txs per block. + // ML-DSA-44 (2420 B sig + 1312 B pk) is the heaviest, FN-DSA-512 sits between. Assert.assertTrue(trx[1][0] > trx[0][0]); Assert.assertTrue(trc20[1][0] > trc20[0][0]); Assert.assertTrue(trx[1][2] < trx[0][2]); Assert.assertTrue(trc20[1][2] < trc20[0][2]); + + Assert.assertTrue(trx[2][0] > trx[1][0]); + Assert.assertTrue(trc20[2][0] > trc20[1][0]); + Assert.assertTrue(trx[2][2] < trx[1][2]); + Assert.assertTrue(trc20[2][2] < trc20[1][2]); } @Test From 1d6068d11ff1dfece8eaf85a85abb177cd47e9b8 Mon Sep 17 00:00:00 2001 From: federico Date: Mon, 25 May 2026 01:56:58 +0800 Subject: [PATCH 35/47] refactor(framework): wrap pq miner identity and add 0x12 verifier --- .../org/tron/core/actuator/VMActuator.java | 8 +- .../tron/core/vm/PrecompiledContracts.java | 59 ++++++- .../org/tron/common/utils/LocalWitnesses.java | 8 +- .../org/tron/core/capsule/BlockCapsule.java | 22 +-- .../tron/core/capsule/TransactionCapsule.java | 7 +- .../src/main/java/org/tron/core/Constant.java | 8 - .../java/org/tron/consensus/base/Param.java | 94 ++++++++-- .../org/tron/consensus/dpos/DposService.java | 3 +- .../consensus/pbft/PbftMessageHandle.java | 2 +- .../signers/mldsa/MLDSA44Eip8051Verifier.java | 162 ++++++++++++++++++ .../org/tron/common/crypto/pqc/MLDSA44.java | 4 +- .../tron/core/consensus/ConsensusService.java | 13 +- .../main/java/org/tron/core/db/Manager.java | 33 ++-- .../core/net/service/relay/RelayService.java | 19 +- .../runtime/vm/MlDsa44PrecompileTest.java | 84 ++++++--- .../core/capsule/TransactionCapsuleTest.java | 33 +++- 16 files changed, 442 insertions(+), 117 deletions(-) create mode 100644 crypto/src/main/java/org/bouncycastle/crypto/signers/mldsa/MLDSA44Eip8051Verifier.java diff --git a/actuator/src/main/java/org/tron/core/actuator/VMActuator.java b/actuator/src/main/java/org/tron/core/actuator/VMActuator.java index 0a9045a1586..ef454af5e98 100644 --- a/actuator/src/main/java/org/tron/core/actuator/VMActuator.java +++ b/actuator/src/main/java/org/tron/core/actuator/VMActuator.java @@ -178,8 +178,9 @@ public void execute(Object object) throws ContractExeException { ProgramResult result = context.getProgramResult(); try { if (program != null) { - if (null != blockCap && blockCap.generatedByMyself && blockCap.hasWitnessSignature(context.getStoreFactory().getChainBaseManager() - .getDynamicPropertiesStore()) + if (null != blockCap && blockCap.generatedByMyself && blockCap.hasWitnessSignature( + context.getStoreFactory().getChainBaseManager() + .getDynamicPropertiesStore()) && null != TransactionUtil.getContractRet(trx) && contractResult.OUT_OF_TIME == TransactionUtil.getContractRet(trx)) { result = program.getResult(); @@ -402,7 +403,8 @@ private void create() long thisTxCPULimitInUs = calculateCpuLimitInUs(isConstantCall, rootRepository.getDynamicPropertiesStore().getMaxCpuTimeOfOneTx(), - getCpuLimitInUsRatio(rootRepository.getDynamicPropertiesStore()), CommonParameter.getInstance().getConstantCallTimeoutMs()); + getCpuLimitInUsRatio(rootRepository.getDynamicPropertiesStore()), + CommonParameter.getInstance().getConstantCallTimeoutMs()); long vmStartInUs = System.nanoTime() / VMConstant.ONE_THOUSAND; long vmShouldEndInUs = vmStartInUs + thisTxCPULimitInUs; ProgramInvoke programInvoke = ProgramInvokeFactory diff --git a/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java b/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java index b5bbe2ab4cd..75a14298654 100644 --- a/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java +++ b/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java @@ -49,6 +49,7 @@ import org.bouncycastle.crypto.params.ECDomainParameters; import org.bouncycastle.crypto.params.ECPublicKeyParameters; import org.bouncycastle.crypto.signers.ECDSASigner; +import org.bouncycastle.crypto.signers.mldsa.MLDSA44Eip8051Verifier; import org.bouncycastle.math.ec.ECPoint; import org.tron.common.crypto.Blake2bfMessageDigest; import org.tron.common.crypto.Hash; @@ -126,6 +127,7 @@ public class PrecompiledContracts { private static final VerifyFnDsa512 verifyFnDsa512 = new VerifyFnDsa512(); private static final BatchValidateFnDsa512 batchValidateFnDsa512 = new BatchValidateFnDsa512(); + private static final VerifyMlDsa44Eip8051 verifyMlDsa44Eip8051 = new VerifyMlDsa44Eip8051(); private static final VerifyMlDsa44 verifyMlDsa44 = new VerifyMlDsa44(); private static final BatchValidateMlDsa44 batchValidateMlDsa44 = new BatchValidateMlDsa44(); private static final ValidateMultiPQSig validateMultiPqSig = new ValidateMultiPQSig(); @@ -242,10 +244,13 @@ public class PrecompiledContracts { private static final DataWord batchValidateFnDsa512Addr = new DataWord( "0000000000000000000000000000000000000000000000000000000000000018"); - // 0x19: ML-DSA-44 single verify (FIPS 204 / CRYSTALS-Dilithium-2). TRON-style - // layout uses the standard 1312-byte public key encoding rho‖t1, not the - // EIP-8051 20512-byte expanded form — the standard encoding lets us call - // BC's stock MLDSASigner directly without re-implementing FIPS 204 §6.5. + // 0x12: EIP-8051 VERIFY_MLDSA. Uses the EIP expanded public key layout + // [A_hat 16384B | tr 32B | t1_ntt 4096B], not the 1312B FIPS public key. + private static final DataWord verifyMlDsa44Eip8051Addr = new DataWord( + "0000000000000000000000000000000000000000000000000000000000000012"); + + // 0x19: existing TRON draft address for ML-DSA-44 single verify. Kept for + // compatibility with contracts/tests already targeting this PR branch. private static final DataWord verifyMlDsa44Addr = new DataWord( "0000000000000000000000000000000000000000000000000000000000000019"); @@ -370,6 +375,9 @@ public static PrecompiledContract getContractForAddress(DataWord address) { // ML-DSA-44 (FIPS 204 / Dilithium-2): single verify and batch verify are // gated by their own proposal flag. if (VMConfig.allowMlDsa44()) { + if (address.equals(verifyMlDsa44Eip8051Addr)) { + return verifyMlDsa44Eip8051; + } if (address.equals(verifyMlDsa44Addr)) { return verifyMlDsa44; } @@ -524,7 +532,7 @@ private static byte[] extractBytes(byte[] data, int offset, int len) { if (offset < 0 || len < 0 || offset > data.length) { return EMPTY_BYTE_ARRAY; } - int safe = Math.min(len, data.length - offset); + int safe = StrictMathWrapper.min(len, data.length - offset); return Arrays.copyOfRange(data, offset, offset + safe); } @@ -600,7 +608,8 @@ static int recoverFalconSigLen(byte[] data, int from, int to) { * breaks caller expectations and must be avoided. * *

Single-verify convention (e.g. {@code VerifyFnDsa512} 0x16, - * {@code VerifyMlDsa44} 0x19): {@code execute} always returns + * {@code VerifyMlDsa44Eip8051} 0x12, {@code VerifyMlDsa44} 0x19): + * {@code execute} always returns * {@code Pair.of(true, X)} where {@code X} is a 32-byte word — {@code dataOne()} * on cryptographic success, {@code DATA_FALSE} on any malformed input or * verification failure. The caller never observes an ABI/structural error; @@ -2814,8 +2823,8 @@ private static class PqVerifyResult { * {@code rho ‖ t1} (1312 B) instead of EIP-8051's 20512 B expanded form * (precomputed {@code A_hat = ExpandA(rho)}). BC 1.84's {@code MLDSASigner} * only accepts the standard form; we pay the per-call {@code ExpandA} - * cost so 1312 B Dilithium-2 keys work unchanged. An expanded-pk variant, - * if added later, will get a new precompile slot — 0x19 stays as-is. + * cost so 1312 B Dilithium-2 keys work unchanged. The EIP-8051 expanded-pk + * variant is implemented separately at 0x12 — 0x19 stays as-is. */ public static class VerifyMlDsa44 extends PrecompiledContract { @@ -2846,6 +2855,40 @@ public Pair execute(byte[] data) { } } + /** + * 0x12 EIP-8051 VERIFY_MLDSA for ML-DSA-44 expanded public keys. + * + *

Input layout: {@code [msg 32B | sig 2420B | expandedPk 20512B]}, where + * {@code expandedPk = A_hat(16384B) || tr(32B) || t1_ntt(4096B)}. Field + * elements inside {@code A_hat} and {@code t1_ntt} are 32-bit big-endian + * values and must be canonical ({@code < q}). + */ + public static class VerifyMlDsa44Eip8051 extends PrecompiledContract { + + @Override + public long getEnergyForData(byte[] data) { + return 4500; + } + + @Override + public Pair execute(byte[] data) { + if (data == null || data.length != MLDSA44Eip8051Verifier.INPUT_LENGTH) { + return Pair.of(true, DataWord.ZERO().getData()); + } + try { + int msgLen = MLDSA44Eip8051Verifier.MESSAGE_LENGTH; + int sigLen = MLDSA44Eip8051Verifier.SIGNATURE_LENGTH; + byte[] msg = copyOfRange(data, 0, msgLen); + byte[] sig = copyOfRange(data, msgLen, msgLen + sigLen); + byte[] pk = copyOfRange(data, msgLen + sigLen, data.length); + boolean ok = MLDSA44Eip8051Verifier.verify(msg, sig, pk); + return Pair.of(true, ok ? DataWord.ONE().getData() : DataWord.ZERO().getData()); + } catch (Throwable t) { + return Pair.of(true, DataWord.ZERO().getData()); + } + } + } + /** * 0x1a ValidateMultiPQSig — algorithm-agnostic Permission multi-sign. Accepts * ECDSA plus any registered post-quantum scheme (FN-DSA-512, ML-DSA-44, ...) diff --git a/chainbase/src/main/java/org/tron/common/utils/LocalWitnesses.java b/chainbase/src/main/java/org/tron/common/utils/LocalWitnesses.java index c234d5d4595..cd7de299ee2 100644 --- a/chainbase/src/main/java/org/tron/common/utils/LocalWitnesses.java +++ b/chainbase/src/main/java/org/tron/common/utils/LocalWitnesses.java @@ -37,6 +37,10 @@ public class LocalWitnesses { @Getter private List privateKeys = Lists.newArrayList(); + @Setter + @Getter + private byte[] witnessAccountAddress; + /** * Pre-derived PQ keypairs (scheme + private + public, hex), one per witness. * Each keypair declares its own PQ scheme so a single node can host SRs @@ -52,10 +56,6 @@ public class LocalWitnesses { @Getter private List pqKeypairs = Lists.newArrayList(); - @Setter - @Getter - private byte[] witnessAccountAddress; - /** * PQ-side counterpart to {@link #witnessAccountAddress}. Distinct from the * ECDSA address so a node can host two different SRs (one ECDSA + one PQ). diff --git a/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java b/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java index 484ab159219..13b49ef973f 100755 --- a/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java +++ b/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java @@ -220,22 +220,18 @@ public boolean validateSignature(DynamicPropertiesStore dynamicPropertiesStore, boolean hasLegacy = !header.getWitnessSignature().isEmpty(); boolean hasPq = header.hasPqAuthSig(); - if (!dynamicPropertiesStore.isAnyPqSchemeAllowed()) { - if (hasPq) { - throw new ValidateSignatureException( - "pq_auth_sig not allowed: no post-quantum scheme is activated"); - } - return validateLegacySignature(header, witnessPermissionAddress); - } - - if (hasLegacy && hasPq) { + if (hasLegacy == hasPq) { throw new ValidateSignatureException( - "witness_signature and pq_auth_sig are mutually exclusive"); - } - if (!hasLegacy && !hasPq) { - throw new ValidateSignatureException("missing witness signature"); + hasLegacy + ? "witness_signature and pq_auth_sig are mutually exclusive" + : "missing witness signature"); } + if (hasPq) { + if (!dynamicPropertiesStore.isAnyPqSchemeAllowed()) { + throw new ValidateSignatureException( + "pq_auth_sig not allowed: no post-quantum scheme is activated"); + } return validatePQSignature(dynamicPropertiesStore, witnessPermissionAddress, header.getPqAuthSig()); } diff --git a/chainbase/src/main/java/org/tron/core/capsule/TransactionCapsule.java b/chainbase/src/main/java/org/tron/core/capsule/TransactionCapsule.java index ecf14e10dee..5987073cb7d 100755 --- a/chainbase/src/main/java/org/tron/core/capsule/TransactionCapsule.java +++ b/chainbase/src/main/java/org/tron/core/capsule/TransactionCapsule.java @@ -503,7 +503,8 @@ public static boolean validateSignature(Transaction transaction, if (dynamicPropertiesStore.isAnyPqSchemeAllowed() && transaction.getPqAuthSigCount() > 0) { try { weight = StrictMathWrapper.addExact(weight, - validatePQSignatureGetWeight(transaction, permission, dynamicPropertiesStore, approveList)); + validatePQSignatureGetWeight(transaction, permission, dynamicPropertiesStore, + approveList)); } catch (ArithmeticException e) { throw new PermissionException("weight overflow"); } @@ -637,7 +638,7 @@ public void addSign(byte[] privateKey, AccountStore accountStore) .signHash(getTransactionId().getBytes()))); this.transaction = this.transaction.toBuilder().addSignature(sig).build(); } - + private static void checkPermission(int permissionId, Permission permission, Transaction.Contract contract) throws PermissionException { if (permissionId != 0) { if (permission.getType() != PermissionType.Active) { @@ -810,7 +811,7 @@ public boolean validateSignature(AccountStore accountStore, } } isVerified = true; - } + } return true; } diff --git a/common/src/main/java/org/tron/core/Constant.java b/common/src/main/java/org/tron/core/Constant.java index 370a442fc06..1437d319346 100644 --- a/common/src/main/java/org/tron/core/Constant.java +++ b/common/src/main/java/org/tron/core/Constant.java @@ -60,14 +60,6 @@ public class Constant { // Crypto engine public static final String ECKey_ENGINE = "ECKey"; - // Post-quantum (FIPS 206 draft) FN-DSA / Falcon-512 signature constants. - // Falcon signatures are variable-length; SIGNATURE_MAX_LENGTH is the protocol-level - // upper bound, not an exact length. - public static final int FN_DSA_PUBLIC_KEY_LENGTH = 896; - public static final int FN_DSA_SIGNATURE_MAX_LENGTH = 752; - public static final String PQ_TX_AUTH_DOMAIN = "TRON_TX_AUTH_V1"; - public static final String PQ_BLOCK_AUTH_DOMAIN = "TRON_BLOCK_AUTH_V1"; - // Network public static final String LOCAL_HOST = "127.0.0.1"; diff --git a/consensus/src/main/java/org/tron/consensus/base/Param.java b/consensus/src/main/java/org/tron/consensus/base/Param.java index 4c7d075f061..b08648c47ba 100644 --- a/consensus/src/main/java/org/tron/consensus/base/Param.java +++ b/consensus/src/main/java/org/tron/consensus/base/Param.java @@ -68,40 +68,98 @@ public class Miner { @Setter private ByteString witnessAddress; - private byte[] pqPrivateKey; - - private byte[] pqPublicKey; - /** - * Post-quantum signature scheme for this miner. When unset (null), the - * miner signs blocks with the legacy ECDSA path using {@link #privateKey}; - * when set (e.g. {@code FN_DSA_512}), the PQ path is used with - * {@link #pqPrivateKey} / {@link #pqPublicKey}. + * Post-quantum identity for this miner — non-null iff the miner signs + * blocks via the PQ path. ECDSA fields above are left null when this is + * set so the two miner kinds never share a slot. */ @Getter - @Setter - private PQScheme pqScheme; + private final PQMiner pq; public Miner(byte[] privateKey, ByteString privateKeyAddress, ByteString witnessAddress) { this.privateKey = privateKey; this.privateKeyAddress = privateKeyAddress; this.witnessAddress = witnessAddress; + this.pq = null; + } + + /** + * PQ-miner constructor. {@code privateKeyAddress} carries the PQ-derived + * address (the key-slot identity), {@code witnessAddress} carries the + * on-chain witness identity (often the same, but may differ in multi-sig + * setups). The ECDSA fields {@link #privateKey} / {@link #privateKeyAddress} + * / {@link #witnessAddress} are left null on purpose so ECDSA-only code + * paths cannot accidentally consume a PQ identity. + */ + public Miner(PQScheme scheme, byte[] privateKey, byte[] publicKey, + ByteString privateKeyAddress, ByteString witnessAddress) { + this.pq = new PQMiner(scheme, privateKey, publicKey, privateKeyAddress, witnessAddress); } - public byte[] getPQPrivateKey() { - return pqPrivateKey == null ? null : pqPrivateKey.clone(); + /** True iff this miner signs via the PQ path (i.e. has a {@link PQMiner}). */ + public boolean isPq() { + return pq != null; } - public void setPQPrivateKey(byte[] pqPrivateKey) { - this.pqPrivateKey = pqPrivateKey == null ? null : pqPrivateKey.clone(); + /** + * Returns the on-chain witness address regardless of signing scheme — PQ + * miners route to {@link PQMiner#getWitnessAddress()}, ECDSA miners to + * {@link #witnessAddress}. Use this from scheme-agnostic call sites + * (block-producer map keys, witness-set filters, generic logging). + */ + public ByteString getEffectiveWitnessAddress() { + return pq != null ? pq.getWitnessAddress() : witnessAddress; } - public byte[] getPQPublicKey() { - return pqPublicKey == null ? null : pqPublicKey.clone(); + /** + * Returns the signing-key-derived address regardless of signing scheme — + * PQ miners route to {@link PQMiner#getPrivateKeyAddress()}, ECDSA miners to + * {@link #privateKeyAddress}. Use this from scheme-agnostic call sites + * (e.g. multi-sign permission checks). + */ + public ByteString getEffectivePrivateKeyAddress() { + return pq != null ? pq.getPrivateKeyAddress() : privateKeyAddress; } - public void setPQPublicKey(byte[] pqPublicKey) { - this.pqPublicKey = pqPublicKey == null ? null : pqPublicKey.clone(); + /** + * Post-quantum identity bundle: scheme + key material + derived addresses. + * Immutable; key bytes are defensively copied on the way in and out so the + * stored material can't be mutated by callers. + */ + public class PQMiner { + + @Getter + private final PQScheme scheme; + + private final byte[] privateKey; + + private final byte[] publicKey; + + /** Address derived from the PQ public key (key-slot identity). */ + @Getter + private final ByteString privateKeyAddress; + + /** On-chain witness identity — may differ from {@link #privateKeyAddress} + * in multi-sig setups, otherwise equal to it. */ + @Getter + private final ByteString witnessAddress; + + public PQMiner(PQScheme scheme, byte[] privateKey, byte[] publicKey, + ByteString privateKeyAddress, ByteString witnessAddress) { + this.scheme = scheme; + this.privateKey = privateKey == null ? null : privateKey.clone(); + this.publicKey = publicKey == null ? null : publicKey.clone(); + this.privateKeyAddress = privateKeyAddress; + this.witnessAddress = witnessAddress; + } + + public byte[] getPrivateKey() { + return privateKey == null ? null : privateKey.clone(); + } + + public byte[] getPublicKey() { + return publicKey == null ? null : publicKey.clone(); + } } } diff --git a/consensus/src/main/java/org/tron/consensus/dpos/DposService.java b/consensus/src/main/java/org/tron/consensus/dpos/DposService.java index 397c9d0835c..56f029b6dd6 100644 --- a/consensus/src/main/java/org/tron/consensus/dpos/DposService.java +++ b/consensus/src/main/java/org/tron/consensus/dpos/DposService.java @@ -77,7 +77,8 @@ public void start(Param param) { this.blockHandle = param.getBlockHandle(); this.genesisBlock = param.getGenesisBlock(); this.genesisBlockTime = Long.parseLong(param.getGenesisBlock().getTimestamp()); - param.getMiners().forEach(miner -> miners.put(miner.getWitnessAddress(), miner)); + param.getMiners().forEach(miner -> + miners.put(miner.getEffectiveWitnessAddress(), miner)); dposTask.setDposService(this); dposSlot.setDposService(this); diff --git a/consensus/src/main/java/org/tron/consensus/pbft/PbftMessageHandle.java b/consensus/src/main/java/org/tron/consensus/pbft/PbftMessageHandle.java index 523ffac4d61..dfed063352b 100644 --- a/consensus/src/main/java/org/tron/consensus/pbft/PbftMessageHandle.java +++ b/consensus/src/main/java/org/tron/consensus/pbft/PbftMessageHandle.java @@ -99,7 +99,7 @@ public List getSrMinerList(long epoch) { compareList = maintenanceManager.getBeforeWitness(); } return Param.getInstance().getMiners().stream() - .filter(miner -> compareList.contains(miner.getWitnessAddress())) + .filter(miner -> compareList.contains(miner.getEffectiveWitnessAddress())) .collect(Collectors.toList()); } diff --git a/crypto/src/main/java/org/bouncycastle/crypto/signers/mldsa/MLDSA44Eip8051Verifier.java b/crypto/src/main/java/org/bouncycastle/crypto/signers/mldsa/MLDSA44Eip8051Verifier.java new file mode 100644 index 00000000000..1adbba9e806 --- /dev/null +++ b/crypto/src/main/java/org/bouncycastle/crypto/signers/mldsa/MLDSA44Eip8051Verifier.java @@ -0,0 +1,162 @@ +package org.bouncycastle.crypto.signers.mldsa; + +import org.bouncycastle.crypto.digests.SHAKEDigest; +import org.bouncycastle.crypto.params.MLDSAParameters; +import org.bouncycastle.util.Arrays; + +/** + * EIP-8051 VERIFY_MLDSA verifier for ML-DSA-44 expanded public keys. + * + *

This class intentionally lives in Bouncy Castle's ML-DSA internal package so it can reuse + * the package-private polynomial primitives. Bouncy Castle 1.84 exposes only the standard + * FIPS-204 public key verifier ({@code rho || t1}); EIP-8051 instead supplies + * {@code A_hat || tr || t1_ntt}. + */ +public final class MLDSA44Eip8051Verifier { + + public static final int MESSAGE_LENGTH = 32; + public static final int SIGNATURE_LENGTH = 2420; + public static final int EXPANDED_PUBLIC_KEY_LENGTH = 20512; + public static final int INPUT_LENGTH = + MESSAGE_LENGTH + SIGNATURE_LENGTH + EXPANDED_PUBLIC_KEY_LENGTH; + + private static final int K = 4; + private static final int L = 4; + private static final int FIELD_ELEMENT_BYTES = 4; + private static final int MATRIX_BYTES = + K * L * MLDSAEngine.DilithiumN * FIELD_ELEMENT_BYTES; + private static final int TR_BYTES = 32; + private static final int TWO_POWER_D = 1 << MLDSAEngine.DilithiumD; + + private MLDSA44Eip8051Verifier() { + } + + public static boolean verify(byte[] message, byte[] signature, byte[] expandedPublicKey) { + if (message == null || message.length != MESSAGE_LENGTH + || signature == null || signature.length != SIGNATURE_LENGTH + || expandedPublicKey == null + || expandedPublicKey.length != EXPANDED_PUBLIC_KEY_LENGTH) { + return false; + } + + try { + MLDSAEngine engine = MLDSAEngine.getInstance(MLDSAParameters.ml_dsa_44, null); + PolyVecL[] aHat = decodeMatrix(expandedPublicKey, 0, engine); + byte[] tr = Arrays.copyOfRange(expandedPublicKey, MATRIX_BYTES, MATRIX_BYTES + TR_BYTES); + PolyVecK t1Ntt = decodePolyVecK( + expandedPublicKey, MATRIX_BYTES + TR_BYTES, engine); + + return verifyInternal(message, signature, aHat, tr, t1Ntt, engine); + } catch (RuntimeException e) { + return false; + } + } + + private static boolean verifyInternal(byte[] message, byte[] signature, PolyVecL[] aHat, + byte[] tr, PolyVecK t1Ntt, MLDSAEngine engine) { + PolyVecK h = new PolyVecK(engine); + PolyVecL z = new PolyVecL(engine); + if (!Packing.unpackSignature(z, h, signature, engine)) { + return false; + } + if (z.checkNorm(engine.getDilithiumGamma1() - engine.getDilithiumBeta())) { + return false; + } + + byte[] buf = new byte[StrictMath.max( + MLDSAEngine.CrhBytes + K * engine.getDilithiumPolyW1PackedBytes(), + engine.getDilithiumCTilde())]; + SHAKEDigest shake = new SHAKEDigest(256); + shake.update(tr, 0, TR_BYTES); + shake.update(message, 0, MESSAGE_LENGTH); + shake.doFinal(buf, 0, MLDSAEngine.CrhBytes); + + Poly c = new Poly(engine); + c.challenge(signature, 0, engine.getDilithiumCTilde()); + + z.polyVecNtt(); + PolyVecK w1 = pointwiseMontgomery(aHat, z, engine); + + c.polyNtt(); + PolyVecK ct1 = new PolyVecK(engine); + ct1.pointwisePolyMontgomery(c, t1Ntt); + multiplyByTwoPowerD(ct1); + + w1.subtract(ct1); + w1.reduce(); + w1.invNttToMont(); + w1.conditionalAddQ(); + w1.useHint(w1, h); + w1.packW1(engine, buf, MLDSAEngine.CrhBytes); + + shake = new SHAKEDigest(256); + shake.update(buf, 0, MLDSAEngine.CrhBytes + K * engine.getDilithiumPolyW1PackedBytes()); + shake.doFinal(buf, 0, engine.getDilithiumCTilde()); + return Arrays.constantTimeAreEqual(engine.getDilithiumCTilde(), signature, 0, buf, 0); + } + + private static PolyVecK pointwiseMontgomery(PolyVecL[] aHat, PolyVecL z, + MLDSAEngine engine) { + PolyVecK out = new PolyVecK(engine); + Poly tmp = new Poly(engine); + for (int i = 0; i < K; i++) { + out.getVectorIndex(i).pointwiseMontgomery(aHat[i].getVectorIndex(0), z.getVectorIndex(0)); + for (int j = 1; j < L; j++) { + tmp.pointwiseMontgomery(aHat[i].getVectorIndex(j), z.getVectorIndex(j)); + out.getVectorIndex(i).addPoly(tmp); + } + } + return out; + } + + private static PolyVecL[] decodeMatrix(byte[] in, int offset, MLDSAEngine engine) { + PolyVecL[] matrix = new PolyVecL[K]; + int pos = offset; + for (int i = 0; i < K; i++) { + matrix[i] = new PolyVecL(engine); + for (int j = 0; j < L; j++) { + decodePoly(in, pos, matrix[i].getVectorIndex(j)); + pos += MLDSAEngine.DilithiumN * FIELD_ELEMENT_BYTES; + } + } + return matrix; + } + + private static PolyVecK decodePolyVecK(byte[] in, int offset, MLDSAEngine engine) { + PolyVecK out = new PolyVecK(engine); + int pos = offset; + for (int i = 0; i < K; i++) { + decodePoly(in, pos, out.getVectorIndex(i)); + pos += MLDSAEngine.DilithiumN * FIELD_ELEMENT_BYTES; + } + return out; + } + + private static void decodePoly(byte[] in, int offset, Poly out) { + int pos = offset; + for (int i = 0; i < MLDSAEngine.DilithiumN; i++) { + int coeff = ((in[pos] & 0xff) << 24) + | ((in[pos + 1] & 0xff) << 16) + | ((in[pos + 2] & 0xff) << 8) + | (in[pos + 3] & 0xff); + if (coeff >= MLDSAEngine.DilithiumQ) { + throw new IllegalArgumentException("invalid ML-DSA field element"); + } + out.setCoeffIndex(i, coeff); + pos += FIELD_ELEMENT_BYTES; + } + } + + private static void multiplyByTwoPowerD(PolyVecK v) { + for (int i = 0; i < K; i++) { + Poly poly = v.getVectorIndex(i); + for (int j = 0; j < MLDSAEngine.DilithiumN; j++) { + long coeff = poly.getCoeffIndex(j) % (long) MLDSAEngine.DilithiumQ; + if (coeff < 0) { + coeff += MLDSAEngine.DilithiumQ; + } + poly.setCoeffIndex(j, (int) ((coeff * TWO_POWER_D) % MLDSAEngine.DilithiumQ)); + } + } + } +} diff --git a/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA44.java b/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA44.java index bce303352f3..e21bdd9eb1e 100644 --- a/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA44.java +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA44.java @@ -29,8 +29,8 @@ * derived {@code t1} stays in memory after instantiation). * *

Pure ML-DSA only (no SHA2-512 pre-hash variant). The "pure" mode signs - * the raw message under SHAKE-256 per FIPS 204 §5.2, matching the verify - * side of the EVM precompile at address 0x19 (EIP-8051). + * the raw message under SHAKE-256 per FIPS 204 §5.2, matching the standard + * 1312-byte public key verify path used by the 0x19 precompile. */ public final class MLDSA44 implements PQSignature { diff --git a/framework/src/main/java/org/tron/core/consensus/ConsensusService.java b/framework/src/main/java/org/tron/core/consensus/ConsensusService.java index 1087d1d4757..0356c6e3cbf 100644 --- a/framework/src/main/java/org/tron/core/consensus/ConsensusService.java +++ b/framework/src/main/java/org/tron/core/consensus/ConsensusService.java @@ -93,7 +93,8 @@ public void start() { Miner miner = buildPQMiner(param, kp, null); miners.add(miner); logger.info("Add {} witness (from configured keypair): {}, size: {}", - kp.getScheme(), Hex.toHexString(miner.getWitnessAddress().toByteArray()), + kp.getScheme(), + Hex.toHexString(miner.getPq().getWitnessAddress().toByteArray()), miners.size()); } } else if (pqKeypairs.size() == 1) { @@ -101,7 +102,8 @@ public void start() { Args.getLocalWitnesses().getPqWitnessAccountAddress()); miners.add(miner); logger.info("Add {} witness (from configured keypair): {}", - miner.getPqScheme(), Hex.toHexString(miner.getWitnessAddress().toByteArray())); + miner.getPq().getScheme(), + Hex.toHexString(miner.getPq().getWitnessAddress().toByteArray())); } param.setMiners(miners); @@ -132,12 +134,9 @@ private Miner buildPQMiner(Param param, PqKeypair pqKeypair, byte[] witnessAddre if (witnessStore.get(witnessAddress) == null) { logger.warn("Witness {} is not in witnessStore.", Hex.toHexString(witnessAddress)); } - Miner miner = param.new Miner(null, + return param.new Miner(scheme, + keypair.getPrivateKey(), keypair.getPublicKey(), ByteString.copyFrom(pqAddress), ByteString.copyFrom(witnessAddress)); - miner.setPQPrivateKey(keypair.getPrivateKey()); - miner.setPQPublicKey(keypair.getPublicKey()); - miner.setPqScheme(scheme); - return miner; } private static void requireSupportedPqScheme(PQScheme scheme) { diff --git a/framework/src/main/java/org/tron/core/db/Manager.java b/framework/src/main/java/org/tron/core/db/Manager.java index be9fe097bc9..8db9777f1bb 100644 --- a/framework/src/main/java/org/tron/core/db/Manager.java +++ b/framework/src/main/java/org/tron/core/db/Manager.java @@ -1617,7 +1617,8 @@ public TransactionInfo processTransaction(final TransactionCapsule trxCap, Block * Generate a block. */ public BlockCapsule generateBlock(Miner miner, long blockTime, long timeout) { - String address = StringUtil.encode58Check(miner.getWitnessAddress().toByteArray()); + ByteString witnessAddress = miner.getEffectiveWitnessAddress(); + String address = StringUtil.encode58Check(witnessAddress.toByteArray()); final Histogram.Timer timer = Metrics.histogramStartTimer( MetricKeys.Histogram.BLOCK_GENERATE_LATENCY, address); Metrics.histogramObserve(MetricKeys.Histogram.MINER_DELAY, @@ -1627,7 +1628,7 @@ public BlockCapsule generateBlock(Miner miner, long blockTime, long timeout) { BlockCapsule blockCapsule = new BlockCapsule(chainBaseManager.getHeadBlockNum() + 1, chainBaseManager.getHeadBlockId(), - blockTime, miner.getWitnessAddress()); + blockTime, witnessAddress); blockCapsule.generatedByMyself = true; session.reset(); session.setValue(revokingStore.buildSession()); @@ -1636,9 +1637,9 @@ public BlockCapsule generateBlock(Miner miner, long blockTime, long timeout) { accountStateCallBack.preExecute(blockCapsule); if (getDynamicPropertiesStore().getAllowMultiSign() == 1) { - byte[] privateKeyAddress = miner.getPrivateKeyAddress().toByteArray(); + byte[] privateKeyAddress = miner.getEffectivePrivateKeyAddress().toByteArray(); AccountCapsule witnessAccount = getAccountStore() - .get(miner.getWitnessAddress().toByteArray()); + .get(witnessAddress.toByteArray()); if (!Arrays.equals(privateKeyAddress, witnessAccount.getWitnessPermissionAddress())) { logger.warn("Witness permission is wrong."); return null; @@ -1747,7 +1748,7 @@ public BlockCapsule generateBlock(Miner miner, long blockTime, long timeout) { session.reset(); blockCapsule.setMerkleRoot(); - if (miner.getPqScheme() != null) { + if (miner.isPq()) { // PQ-only miner: never fall back to ECDSA signing — miner.getPrivateKey() is // null on this path, and a silent fallback would NPE inside blockCapsule.sign. // Fail fast with a clear cause; DposTask's Throwable handler logs it and the @@ -1755,10 +1756,11 @@ public BlockCapsule generateBlock(Miner miner, long blockTime, long timeout) { // Gate on this miner's specific scheme, not on the broader "any PQ scheme // allowed" flag — a Falcon-configured miner must not produce while only // ML-DSA is active (and vice versa). - if (!getDynamicPropertiesStore().isPqSchemeAllowed(miner.getPqScheme())) { + Param.Miner.PQMiner pq = miner.getPq(); + if (!getDynamicPropertiesStore().isPqSchemeAllowed(pq.getScheme())) { throw new IllegalStateException( - "PQ miner " + Hex.toHexString(miner.getWitnessAddress().toByteArray()) - + " has scheme " + miner.getPqScheme() + "PQ miner " + Hex.toHexString(pq.getWitnessAddress().toByteArray()) + + " has scheme " + pq.getScheme() + " configured but that scheme is not allowed by dynamic properties"); } signBlockCapsuleWithPQ(blockCapsule, miner); @@ -1781,24 +1783,25 @@ public BlockCapsule generateBlock(Miner miner, long blockTime, long timeout) { } private void signBlockCapsuleWithPQ(BlockCapsule blockCapsule, Miner miner) { - PQScheme scheme = miner.getPqScheme(); + Param.Miner.PQMiner pq = miner.getPq(); + PQScheme scheme = pq.getScheme(); if (scheme == null || !PQSchemeRegistry.contains(scheme)) { throw new IllegalStateException( - "PQ miner " + Hex.toHexString(miner.getWitnessAddress().toByteArray()) - + " has scheme " + miner.getPqScheme() + "PQ miner " + Hex.toHexString(pq.getWitnessAddress().toByteArray()) + + " has scheme " + scheme + " which is not registered in PQSchemeRegistry"); } if (!chainBaseManager.getDynamicPropertiesStore().isPqSchemeAllowed(scheme)) { throw new IllegalStateException( - "PQ miner " + Hex.toHexString(miner.getWitnessAddress().toByteArray()) + "PQ miner " + Hex.toHexString(pq.getWitnessAddress().toByteArray()) + " has scheme " + scheme + " but it is not allowed by dynamic properties"); } - byte[] pqPrivateKey = miner.getPQPrivateKey(); - byte[] pqPublicKey = miner.getPQPublicKey(); + byte[] pqPrivateKey = pq.getPrivateKey(); + byte[] pqPublicKey = pq.getPublicKey(); if (pqPrivateKey == null || pqPublicKey == null) { throw new IllegalStateException( - "miner " + Hex.toHexString(miner.getWitnessAddress().toByteArray()) + "miner " + Hex.toHexString(pq.getWitnessAddress().toByteArray()) + " has scheme " + scheme + " set but local PQ key material is missing"); } diff --git a/framework/src/main/java/org/tron/core/net/service/relay/RelayService.java b/framework/src/main/java/org/tron/core/net/service/relay/RelayService.java index 659617c67ce..f898cafdb49 100644 --- a/framework/src/main/java/org/tron/core/net/service/relay/RelayService.java +++ b/framework/src/main/java/org/tron/core/net/service/relay/RelayService.java @@ -132,12 +132,16 @@ public void fillHelloMessage(HelloMessage message, Channel channel) { byte[] digest = Sha256Hash.of(CommonParameter.getInstance() .isECKeyCryptoEngine(), ByteArray.fromLong(message.getTimestamp())) .getBytes(); - // Announce the address matching the sig path we are about to take so - // the receiving fast-forward node verifies against the right identity. - ByteString announceAddress = keySize > 0 ? ecdsaWitnessAddress : pqWitnessAddress; + // In a mixed-witness node (ECDSA + PQ), pick the path whose address + // is currently in the active schedule — otherwise the receiver + // rejects on the "not a schedule witness" check in checkHelloMessage. + List active = witnessScheduleStore.getActiveWitnesses(); + boolean useEcdsa = keySize > 0 && ecdsaWitnessAddress != null + && active.contains(ecdsaWitnessAddress); + ByteString announceAddress = useEcdsa ? ecdsaWitnessAddress : pqWitnessAddress; Protocol.HelloMessage.Builder builder = message.getHelloMessage().toBuilder() .setAddress(announceAddress); - if (keySize > 0) { + if (useEcdsa) { SignInterface cryptoEngine = SignUtils.fromPrivate( ByteArray.fromHexString(Args.getLocalWitnesses().getPrivateKey()), Args.getInstance().isECKeyCryptoEngine()); @@ -145,9 +149,10 @@ public void fillHelloMessage(HelloMessage message, Channel channel) { cryptoEngine.Base64toBytes(cryptoEngine.signHash(digest))); builder.setSignature(sig).clearPqAuthSig(); } else { - // isActiveWitness() guarantees keySize > 0 || pqKeySize > 0; reaching - // this branch with keySize == 0 implies pqKeySize > 0. Guard anyway - // so a stale or mutated witness list fails loud instead of with IOOB. + // scheduledHere() guarantees at least one of ECDSA/PQ is active; + // since useEcdsa is false here, the PQ identity must be the active one. + // Guard the keypair list anyway so a stale/mutated config fails loud + // instead of with IOOB. LocalWitnesses lw = Args.getLocalWitnesses(); if (lw.getPqKeypairs().isEmpty()) { logger.warn("HelloMessage fill skipped: no PQ keypair available"); diff --git a/framework/src/test/java/org/tron/common/runtime/vm/MlDsa44PrecompileTest.java b/framework/src/test/java/org/tron/common/runtime/vm/MlDsa44PrecompileTest.java index 4728bea42d6..79bf982df68 100644 --- a/framework/src/test/java/org/tron/common/runtime/vm/MlDsa44PrecompileTest.java +++ b/framework/src/test/java/org/tron/common/runtime/vm/MlDsa44PrecompileTest.java @@ -11,14 +11,20 @@ import org.tron.core.vm.config.VMConfig; /** - * Unit tests for the ML-DSA-44 (0x19) verify precompile (FIPS 204 / Dilithium-2). - * Input layout: [msg 32B | sig 2420B | pk 1312B]. Stateless — no chain DB. + * Unit tests for the ML-DSA-44 verify precompile (FIPS 204 / Dilithium-2). + * Address 0x12 follows EIP-8051 with expanded public keys; 0x19 remains the + * existing TRON draft path with standard 1312-byte public keys. */ public class MlDsa44PrecompileTest { - private static final DataWord MLDSA_ADDR = new DataWord( + private static final DataWord MLDSA_EIP8051_ADDR = new DataWord( + "0000000000000000000000000000000000000000000000000000000000000012"); + + private static final DataWord MLDSA_DRAFT_ADDR = new DataWord( "0000000000000000000000000000000000000000000000000000000000000019"); + private static final int EIP8051_INPUT_LENGTH = 32 + MLDSA44.SIGNATURE_LENGTH + 20512; + private static final byte[] MESSAGE_HASH = new byte[32]; static { @@ -40,21 +46,26 @@ public void disableProposal() { @Test public void switchOff_returnsNull() { VMConfig.initAllowMlDsa44(0L); - Assert.assertNull(PrecompiledContracts.getContractForAddress(MLDSA_ADDR)); + Assert.assertNull(PrecompiledContracts.getContractForAddress(MLDSA_EIP8051_ADDR)); + } + + @Test + public void switchOn_returnsEip8051Contract() { + Assert.assertNotNull(PrecompiledContracts.getContractForAddress(MLDSA_EIP8051_ADDR)); } @Test - public void switchOn_returnsContract() { - Assert.assertNotNull(PrecompiledContracts.getContractForAddress(MLDSA_ADDR)); + public void draftAddress19StillReturnsContract() { + Assert.assertNotNull(PrecompiledContracts.getContractForAddress(MLDSA_DRAFT_ADDR)); } @Test - public void validSignature_returnsOne() { + public void draftAddress19ValidSignature_returnsOne() { MLDSA44 key = new MLDSA44(); byte[] sig = key.sign(MESSAGE_HASH); byte[] input = buildInput(MESSAGE_HASH, sig, key.getPublicKey()); - PrecompiledContract pc = PrecompiledContracts.getContractForAddress(MLDSA_ADDR); + PrecompiledContract pc = PrecompiledContracts.getContractForAddress(MLDSA_DRAFT_ADDR); Pair result = pc.execute(input); Assert.assertTrue(result.getLeft()); @@ -63,7 +74,7 @@ public void validSignature_returnsOne() { } @Test - public void tamperedMessage_returnsZero() { + public void draftAddress19TamperedMessage_returnsZero() { MLDSA44 key = new MLDSA44(); byte[] sig = key.sign(MESSAGE_HASH); byte[] tampered = MESSAGE_HASH.clone(); @@ -71,71 +82,71 @@ public void tamperedMessage_returnsZero() { byte[] input = buildInput(tampered, sig, key.getPublicKey()); Pair result = - PrecompiledContracts.getContractForAddress(MLDSA_ADDR).execute(input); + PrecompiledContracts.getContractForAddress(MLDSA_DRAFT_ADDR).execute(input); Assert.assertTrue(result.getLeft()); Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); } @Test - public void tamperedSignature_returnsZero() { + public void draftAddress19TamperedSignature_returnsZero() { MLDSA44 key = new MLDSA44(); byte[] sig = key.sign(MESSAGE_HASH); sig[0] ^= 0x01; byte[] input = buildInput(MESSAGE_HASH, sig, key.getPublicKey()); Pair result = - PrecompiledContracts.getContractForAddress(MLDSA_ADDR).execute(input); + PrecompiledContracts.getContractForAddress(MLDSA_DRAFT_ADDR).execute(input); Assert.assertTrue(result.getLeft()); Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); } @Test - public void wrongPublicKey_returnsZero() { + public void draftAddress19WrongPublicKey_returnsZero() { MLDSA44 signer = new MLDSA44(); MLDSA44 other = new MLDSA44(); byte[] sig = signer.sign(MESSAGE_HASH); byte[] input = buildInput(MESSAGE_HASH, sig, other.getPublicKey()); Pair result = - PrecompiledContracts.getContractForAddress(MLDSA_ADDR).execute(input); + PrecompiledContracts.getContractForAddress(MLDSA_DRAFT_ADDR).execute(input); Assert.assertTrue(result.getLeft()); Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); } @Test - public void nullInput_returnsZero() { + public void draftAddress19NullInput_returnsZero() { Pair result = - PrecompiledContracts.getContractForAddress(MLDSA_ADDR).execute(null); + PrecompiledContracts.getContractForAddress(MLDSA_DRAFT_ADDR).execute(null); Assert.assertTrue(result.getLeft()); Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); } @Test - public void shortInput_returnsZero() { + public void draftAddress19ShortInput_returnsZero() { Pair result = - PrecompiledContracts.getContractForAddress(MLDSA_ADDR).execute(new byte[100]); + PrecompiledContracts.getContractForAddress(MLDSA_DRAFT_ADDR).execute(new byte[100]); Assert.assertTrue(result.getLeft()); Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); } @Test - public void wrongLengthInput_returnsZero() { + public void draftAddress19WrongLengthInput_returnsZero() { // ML-DSA-44 input is fixed-length 3764B; any other length must be rejected. int expected = 32 + MLDSA44.SIGNATURE_LENGTH + MLDSA44.PUBLIC_KEY_LENGTH; byte[] oneByteShort = new byte[expected - 1]; Pair r1 = - PrecompiledContracts.getContractForAddress(MLDSA_ADDR).execute(oneByteShort); + PrecompiledContracts.getContractForAddress(MLDSA_DRAFT_ADDR).execute(oneByteShort); Assert.assertTrue(r1.getLeft()); Assert.assertArrayEquals(DataWord.ZERO().getData(), r1.getRight()); } @Test - public void trailingBytes_returnsZero() { + public void draftAddress19TrailingBytes_returnsZero() { // Strict equality: even one extra trailing byte must be rejected. MLDSA44 key = new MLDSA44(); byte[] sig = key.sign(MESSAGE_HASH); @@ -144,7 +155,36 @@ public void trailingBytes_returnsZero() { System.arraycopy(valid, 0, padded, 0, valid.length); Pair result = - PrecompiledContracts.getContractForAddress(MLDSA_ADDR).execute(padded); + PrecompiledContracts.getContractForAddress(MLDSA_DRAFT_ADDR).execute(padded); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + @Test + public void eip8051Address12RejectsStandardPublicKeyLayout() { + MLDSA44 key = new MLDSA44(); + byte[] sig = key.sign(MESSAGE_HASH); + byte[] standardInput = buildInput(MESSAGE_HASH, sig, key.getPublicKey()); + + Pair result = + PrecompiledContracts.getContractForAddress(MLDSA_EIP8051_ADDR).execute(standardInput); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + @Test + public void eip8051Address12RejectsNonCanonicalFieldElement() { + byte[] input = new byte[EIP8051_INPUT_LENGTH]; + int pkOffset = 32 + MLDSA44.SIGNATURE_LENGTH; + input[pkOffset] = 0x00; + input[pkOffset + 1] = 0x7f; + input[pkOffset + 2] = (byte) 0xe0; + input[pkOffset + 3] = 0x01; + + Pair result = + PrecompiledContracts.getContractForAddress(MLDSA_EIP8051_ADDR).execute(input); Assert.assertTrue(result.getLeft()); Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); diff --git a/framework/src/test/java/org/tron/core/capsule/TransactionCapsuleTest.java b/framework/src/test/java/org/tron/core/capsule/TransactionCapsuleTest.java index 7c1c7356383..ea019835181 100644 --- a/framework/src/test/java/org/tron/core/capsule/TransactionCapsuleTest.java +++ b/framework/src/test/java/org/tron/core/capsule/TransactionCapsuleTest.java @@ -23,6 +23,7 @@ import org.tron.common.TestConstants; import org.tron.common.crypto.ECKey; import org.tron.common.crypto.pqc.FNDSA512; +import org.tron.common.crypto.pqc.MLDSA44; import org.tron.common.crypto.pqc.PQSchemeRegistry; import org.tron.common.utils.ByteArray; import org.tron.common.utils.Sha256Hash; @@ -365,7 +366,7 @@ private Transaction buildTrc20TransferTx(String ownerHex, int permissionId) { /** * Returns [serializedSize, packSize, maxTxPerBlock] rows ordered by signature size: - * ECKey, FN-DSA-512. + * ECKey, FN-DSA-512, ML-DSA-44. */ private long[][] measureSizes(Transaction baseTx) { final long blockLimit = 2_000_000L; @@ -377,10 +378,11 @@ private long[][] measureSizes(Transaction baseTx) { long ecSerial = ecCap.getInstance().toByteArray().length; long ecPack = ecCap.computeTrxSizeForBlockMessage(); + byte[] txid = txId(baseTx); + // FN-DSA-512: variable-length signature (<= 752 bytes) + 897-byte public key FNDSA512 kpFn = new FNDSA512(); - byte[] txidFn = txId(baseTx); - byte[] sigFn = FNDSA512.sign(kpFn.getPrivateKey(), txidFn); + byte[] sigFn = FNDSA512.sign(kpFn.getPrivateKey(), txid); Transaction txFn = baseTx.toBuilder() .addPqAuthSig(PQAuthSig.newBuilder() .setScheme(PQScheme.FN_DSA_512) @@ -392,9 +394,24 @@ private long[][] measureSizes(Transaction baseTx) { long dFnSerial = txFn.toByteArray().length; long dFnPack = capFn.computeTrxSizeForBlockMessage(); + // ML-DSA-44: fixed 2420-byte signature + 1312-byte public key + MLDSA44 kpMl = new MLDSA44(); + byte[] sigMl = MLDSA44.sign(kpMl.getPrivateKey(), txid); + Transaction txMl = baseTx.toBuilder() + .addPqAuthSig(PQAuthSig.newBuilder() + .setScheme(PQScheme.ML_DSA_44) + .setPublicKey(ByteString.copyFrom(kpMl.getPublicKey())) + .setSignature(ByteString.copyFrom(sigMl)) + .build()) + .build(); + TransactionCapsule capMl = new TransactionCapsule(txMl); + long dMlSerial = txMl.toByteArray().length; + long dMlPack = capMl.computeTrxSizeForBlockMessage(); + return new long[][]{ {ecSerial, ecPack, blockLimit / ecPack}, {dFnSerial, dFnPack, blockLimit / dFnPack}, + {dMlSerial, dMlPack, blockLimit / dMlPack}, }; } @@ -403,7 +420,7 @@ public void transactionSizeComparisonByScheme() { long[][] trx = measureSizes(buildTransferTx(PQ_OWNER_HEX, 0)); long[][] trc20 = measureSizes(buildTrc20TransferTx(PQ_OWNER_HEX, 0)); - String[] labels = {"ECKey (ECDSA)", "FN-DSA-512"}; + String[] labels = {"ECKey (ECDSA)", "FN-DSA-512", "ML-DSA-44"}; System.out.println("=== TRX transfer ==="); for (int i = 0; i < labels.length; i++) { System.out.printf(" %s: serial=%d B pack=%d B maxTx/block=%d%n", @@ -415,11 +432,17 @@ public void transactionSizeComparisonByScheme() { labels[i], trc20[i][0], trc20[i][1], trc20[i][2]); } - // FN-DSA-512 envelope is larger than ECKey, so it fits fewer txs per block. + // Both PQ envelopes are larger than ECKey, so they fit fewer txs per block. + // ML-DSA-44 (2420 B sig + 1312 B pk) is the heaviest, FN-DSA-512 sits between. Assert.assertTrue(trx[1][0] > trx[0][0]); Assert.assertTrue(trc20[1][0] > trc20[0][0]); Assert.assertTrue(trx[1][2] < trx[0][2]); Assert.assertTrue(trc20[1][2] < trc20[0][2]); + + Assert.assertTrue(trx[2][0] > trx[1][0]); + Assert.assertTrue(trc20[2][0] > trc20[1][0]); + Assert.assertTrue(trx[2][2] < trx[1][2]); + Assert.assertTrue(trc20[2][2] < trc20[1][2]); } @Test From fe995e9477cda6bf3a753a64a1330e45663123e1 Mon Sep 17 00:00:00 2001 From: federico Date: Mon, 25 May 2026 14:18:22 +0800 Subject: [PATCH 36/47] fix(block): drop pq gate in hasWitnessSignature --- .../java/org/tron/core/actuator/VMActuator.java | 13 +++++-------- .../java/org/tron/core/capsule/BlockCapsule.java | 11 +++-------- .../src/main/java/org/tron/core/db/Manager.java | 8 +++----- .../org/tron/core/capsule/BlockCapsulePQTest.java | 5 ++--- .../org/tron/core/capsule/BlockCapsuleTest.java | 7 ++----- 5 files changed, 15 insertions(+), 29 deletions(-) diff --git a/actuator/src/main/java/org/tron/core/actuator/VMActuator.java b/actuator/src/main/java/org/tron/core/actuator/VMActuator.java index ef454af5e98..f2eb59850d1 100644 --- a/actuator/src/main/java/org/tron/core/actuator/VMActuator.java +++ b/actuator/src/main/java/org/tron/core/actuator/VMActuator.java @@ -37,7 +37,6 @@ import org.tron.core.db.TransactionContext; import org.tron.core.exception.ContractExeException; import org.tron.core.exception.ContractValidateException; -import org.tron.core.store.DynamicPropertiesStore; import org.tron.core.utils.TransactionUtil; import org.tron.core.vm.EnergyCost; import org.tron.core.vm.LogInfoTriggerParser; @@ -178,9 +177,7 @@ public void execute(Object object) throws ContractExeException { ProgramResult result = context.getProgramResult(); try { if (program != null) { - if (null != blockCap && blockCap.generatedByMyself && blockCap.hasWitnessSignature( - context.getStoreFactory().getChainBaseManager() - .getDynamicPropertiesStore()) + if (null != blockCap && blockCap.generatedByMyself && blockCap.hasWitnessSignature() && null != TransactionUtil.getContractRet(trx) && contractResult.OUT_OF_TIME == TransactionUtil.getContractRet(trx)) { result = program.getResult(); @@ -403,7 +400,7 @@ private void create() long thisTxCPULimitInUs = calculateCpuLimitInUs(isConstantCall, rootRepository.getDynamicPropertiesStore().getMaxCpuTimeOfOneTx(), - getCpuLimitInUsRatio(rootRepository.getDynamicPropertiesStore()), + getCpuLimitInUsRatio(), CommonParameter.getInstance().getConstantCallTimeoutMs()); long vmStartInUs = System.nanoTime() / VMConstant.ONE_THOUSAND; long vmShouldEndInUs = vmStartInUs + thisTxCPULimitInUs; @@ -518,7 +515,7 @@ private void call() long thisTxCPULimitInUs = calculateCpuLimitInUs(isConstantCall, rootRepository.getDynamicPropertiesStore().getMaxCpuTimeOfOneTx(), - getCpuLimitInUsRatio(rootRepository.getDynamicPropertiesStore()), CommonParameter.getInstance().getConstantCallTimeoutMs()); + getCpuLimitInUsRatio(), CommonParameter.getInstance().getConstantCallTimeoutMs()); long vmStartInUs = System.nanoTime() / VMConstant.ONE_THOUSAND; long vmShouldEndInUs = vmStartInUs + thisTxCPULimitInUs; ProgramInvoke programInvoke = ProgramInvokeFactory @@ -670,14 +667,14 @@ public void checkTokenValueAndId(long tokenValue, long tokenId) throws ContractV } - private double getCpuLimitInUsRatio(DynamicPropertiesStore dynamicPropertiesStore) { + private double getCpuLimitInUsRatio() { double cpuLimitRatio; if (ExecutorType.ET_NORMAL_TYPE == executorType) { // self witness generates block if (blockCap != null && blockCap.generatedByMyself - && !blockCap.hasWitnessSignature(dynamicPropertiesStore)) { + && !blockCap.hasWitnessSignature()) { cpuLimitRatio = 1.0; } else { // self witness or other witness or fullnode verifies block diff --git a/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java b/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java index 13b49ef973f..64923a9bf53 100755 --- a/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java +++ b/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java @@ -413,15 +413,10 @@ public long getTimeStamp() { return this.block.getBlockHeader().getRawData().getTimestamp(); } - public boolean hasWitnessSignature(DynamicPropertiesStore dynamicPropertiesStore) { + public boolean hasWitnessSignature() { BlockHeader header = getInstance().getBlockHeader(); - if (!header.getWitnessSignature().isEmpty()) { - return true; - } - if (!dynamicPropertiesStore.isAnyPqSchemeAllowed()) { - return false; - } - return !header.getPqAuthSig().getSignature().isEmpty(); + return !header.getWitnessSignature().isEmpty() + || !header.getPqAuthSig().getSignature().isEmpty(); } @Override diff --git a/framework/src/main/java/org/tron/core/db/Manager.java b/framework/src/main/java/org/tron/core/db/Manager.java index 8db9777f1bb..60f06aae3c3 100644 --- a/framework/src/main/java/org/tron/core/db/Manager.java +++ b/framework/src/main/java/org/tron/core/db/Manager.java @@ -1553,10 +1553,10 @@ public TransactionInfo processTransaction(final TransactionCapsule trxCap, Block trace.exec(); trace.setResult(); logger.info("Retry result when push: {}, for tx id: {}, tx resultCode in receipt: {}.", - blockCap.hasWitnessSignature(chainBaseManager.getDynamicPropertiesStore()), txId, + blockCap.hasWitnessSignature(), txId, trace.getReceipt().getResult()); } - if (blockCap.hasWitnessSignature(chainBaseManager.getDynamicPropertiesStore())) { + if (blockCap.hasWitnessSignature()) { trace.check(); } } @@ -1602,9 +1602,7 @@ public TransactionInfo processTransaction(final TransactionCapsule trxCap, Block if (cost > 100) { String type = "broadcast"; if (Objects.nonNull(blockCap)) { - type = - blockCap.hasWitnessSignature(chainBaseManager.getDynamicPropertiesStore()) ? "apply" : - "pack"; + type = blockCap.hasWitnessSignature() ? "apply" : "pack"; } logger.info("Process transaction {} cost {} ms during {}, {}", Hex.toHexString(transactionInfo.getId()), cost, type, contract.getType().name()); diff --git a/framework/src/test/java/org/tron/core/capsule/BlockCapsulePQTest.java b/framework/src/test/java/org/tron/core/capsule/BlockCapsulePQTest.java index 32d3841be5e..30f9a5764f5 100644 --- a/framework/src/test/java/org/tron/core/capsule/BlockCapsulePQTest.java +++ b/framework/src/test/java/org/tron/core/capsule/BlockCapsulePQTest.java @@ -125,11 +125,10 @@ private PQAuthSig buildPQAuthSig(byte[] signature) { public void hasWitnessSignatureTrueForPqOnlyBlock() { byte[] parentHash = new byte[32]; BlockCapsule block = buildUnsignedBlock(parentHash); - Assert.assertFalse(block.hasWitnessSignature(dbManager.getDynamicPropertiesStore())); + Assert.assertFalse(block.hasWitnessSignature()); - dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(1L); block.setPqAuthSig(buildPQAuthSig(signPQ(block.getRawHashBytes()))); - Assert.assertTrue(block.hasWitnessSignature(dbManager.getDynamicPropertiesStore())); + Assert.assertTrue(block.hasWitnessSignature()); } @Test diff --git a/framework/src/test/java/org/tron/core/capsule/BlockCapsuleTest.java b/framework/src/test/java/org/tron/core/capsule/BlockCapsuleTest.java index e3d366e84ab..ca0844c2c16 100644 --- a/framework/src/test/java/org/tron/core/capsule/BlockCapsuleTest.java +++ b/framework/src/test/java/org/tron/core/capsule/BlockCapsuleTest.java @@ -12,7 +12,6 @@ import org.junit.ClassRule; import org.junit.Test; import org.junit.rules.TemporaryFolder; -import org.mockito.Mockito; import org.tron.common.TestConstants; import org.tron.common.utils.ByteArray; import org.tron.common.utils.LocalWitnesses; @@ -22,7 +21,6 @@ import org.tron.core.config.args.Args; import org.tron.core.exception.BadBlockException; import org.tron.core.exception.BadItemException; -import org.tron.core.store.DynamicPropertiesStore; import org.tron.protos.Protocol.Transaction.Contract.ContractType; import org.tron.protos.contract.BalanceContract.TransferContract; @@ -171,11 +169,10 @@ public void testHasWitnessSignature() { localWitnesses.initWitnessAccountAddress(null, true); Args.setLocalWitnesses(localWitnesses); - DynamicPropertiesStore dps = Mockito.mock(DynamicPropertiesStore.class); - Assert.assertFalse(blockCapsule0.hasWitnessSignature(dps)); + Assert.assertFalse(blockCapsule0.hasWitnessSignature()); blockCapsule0 .sign(ByteArray.fromHexString(Args.getLocalWitnesses().getPrivateKey())); - Assert.assertTrue(blockCapsule0.hasWitnessSignature(dps)); + Assert.assertTrue(blockCapsule0.hasWitnessSignature()); } @Test From b8f20c3796c34b61017bfd28d72e185161aa59ff Mon Sep 17 00:00:00 2001 From: GrapeS Date: Mon, 25 May 2026 17:36:38 +0800 Subject: [PATCH 37/47] feat(metrics): add tx_fetch_latency histogram MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors block_fetch_latency for transactions: measures the end-to-end round-trip from sending GET_DATA to receiving the full TXS message, using the timestamp already stored in PeerConnection.advInvRequest at fetch-dispatch time. Records nothing for transactions pushed via gossip (no prior GET_DATA), which is intentional — this metric only captures the active-fetch path. Overhead is ~500 ns per tx (Item allocation + ConcurrentHashMap.remove + currentTimeMillis + histogram observe), negligible even at >1k TPS. Useful for PQ migration baseline / stress-test comparison: shows how much extra time the bigger Falcon-512 payloads add to in-flight transaction propagation, independent of local processing cost. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../org/tron/common/prometheus/MetricKeys.java | 1 + .../common/prometheus/MetricsHistogram.java | 2 ++ .../messagehandler/TransactionsMsgHandler.java | 17 ++++++++++++++++- 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/common/src/main/java/org/tron/common/prometheus/MetricKeys.java b/common/src/main/java/org/tron/common/prometheus/MetricKeys.java index 95a38c4b479..47eca4dd903 100644 --- a/common/src/main/java/org/tron/common/prometheus/MetricKeys.java +++ b/common/src/main/java/org/tron/common/prometheus/MetricKeys.java @@ -67,6 +67,7 @@ public static class Histogram { public static final String BLOCK_FETCH_LATENCY = "tron:block_fetch_latency_seconds"; public static final String BLOCK_RECEIVE_DELAY = "tron:block_receive_delay_seconds"; public static final String BLOCK_TRANSACTION_COUNT = "tron:block_transaction_count"; + public static final String TX_FETCH_LATENCY = "tron:tx_fetch_latency_seconds"; private Histogram() { throw new IllegalStateException("Histogram"); diff --git a/common/src/main/java/org/tron/common/prometheus/MetricsHistogram.java b/common/src/main/java/org/tron/common/prometheus/MetricsHistogram.java index fa42a59aeaa..d8adf7e18c2 100644 --- a/common/src/main/java/org/tron/common/prometheus/MetricsHistogram.java +++ b/common/src/main/java/org/tron/common/prometheus/MetricsHistogram.java @@ -48,6 +48,8 @@ public class MetricsHistogram { init(MetricKeys.Histogram.BLOCK_FETCH_LATENCY, "fetch block latency."); init(MetricKeys.Histogram.BLOCK_RECEIVE_DELAY, "receive block delay time, receiveTime - blockTime."); + init(MetricKeys.Histogram.TX_FETCH_LATENCY, + "fetch transaction latency: GET_DATA send to full TXS received round-trip."); init(MetricKeys.Histogram.BLOCK_TRANSACTION_COUNT, "Distribution of transaction counts per block.", diff --git a/framework/src/main/java/org/tron/core/net/messagehandler/TransactionsMsgHandler.java b/framework/src/main/java/org/tron/core/net/messagehandler/TransactionsMsgHandler.java index e153e21f331..bd1d591e34c 100644 --- a/framework/src/main/java/org/tron/core/net/messagehandler/TransactionsMsgHandler.java +++ b/framework/src/main/java/org/tron/core/net/messagehandler/TransactionsMsgHandler.java @@ -14,6 +14,8 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.tron.common.es.ExecutorServiceManager; +import org.tron.common.prometheus.MetricKeys; +import org.tron.common.prometheus.Metrics; import org.tron.common.utils.Sha256Hash; import org.tron.core.ChainBaseManager; import org.tron.core.config.args.Args; @@ -169,10 +171,23 @@ private void handleTransaction(PeerConnection peer, TransactionMessage trx) { return; } - if (advService.getMessage(new Item(trx.getMessageId(), InventoryType.TRX)) != null) { + Item item = new Item(trx.getMessageId(), InventoryType.TRX); + + if (advService.getMessage(item) != null) { return; } + // Measure end-to-end fetch latency: from GET_DATA send (recorded in + // advInvRequest when consumerInvToFetch picks this peer) to full TXS + // received here. Returns null if this tx wasn't actively fetched (e.g. + // pushed via gossip without a prior GET_DATA), in which case no sample + // is observed. + Long requestTime = peer.getAdvInvRequest().remove(item); + if (requestTime != null) { + Metrics.histogramObserve(MetricKeys.Histogram.TX_FETCH_LATENCY, + (System.currentTimeMillis() - requestTime) / Metrics.MILLISECONDS_PER_SECOND); + } + try { trx.getTransactionCapsule().checkExpiration(chainBaseManager.getNextBlockSlotTime()); tronNetDelegate.pushTransaction(trx.getTransactionCapsule()); From 773c9128ad3e7a086bbd9a29a939f24a199bfe9a Mon Sep 17 00:00:00 2001 From: GrapeS Date: Mon, 25 May 2026 17:37:17 +0800 Subject: [PATCH 38/47] fix merge --- .../src/main/java/org/tron/core/actuator/VMActuator.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/actuator/src/main/java/org/tron/core/actuator/VMActuator.java b/actuator/src/main/java/org/tron/core/actuator/VMActuator.java index 0a9045a1586..3c92d1c0c33 100644 --- a/actuator/src/main/java/org/tron/core/actuator/VMActuator.java +++ b/actuator/src/main/java/org/tron/core/actuator/VMActuator.java @@ -178,8 +178,7 @@ public void execute(Object object) throws ContractExeException { ProgramResult result = context.getProgramResult(); try { if (program != null) { - if (null != blockCap && blockCap.generatedByMyself && blockCap.hasWitnessSignature(context.getStoreFactory().getChainBaseManager() - .getDynamicPropertiesStore()) + if (null != blockCap && blockCap.generatedByMyself && blockCap.hasWitnessSignature() && null != TransactionUtil.getContractRet(trx) && contractResult.OUT_OF_TIME == TransactionUtil.getContractRet(trx)) { result = program.getResult(); @@ -675,7 +674,7 @@ private double getCpuLimitInUsRatio(DynamicPropertiesStore dynamicPropertiesStor if (ExecutorType.ET_NORMAL_TYPE == executorType) { // self witness generates block if (blockCap != null && blockCap.generatedByMyself - && !blockCap.hasWitnessSignature(dynamicPropertiesStore)) { + && !blockCap.hasWitnessSignature()) { cpuLimitRatio = 1.0; } else { // self witness or other witness or fullnode verifies block From e12b47f22fe4abaaca5e524d1e6fe4443917b19b Mon Sep 17 00:00:00 2001 From: GrapeS Date: Mon, 25 May 2026 17:41:59 +0800 Subject: [PATCH 39/47] fix merge --- .../src/main/java/org/tron/core/actuator/VMActuator.java | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/actuator/src/main/java/org/tron/core/actuator/VMActuator.java b/actuator/src/main/java/org/tron/core/actuator/VMActuator.java index 3c92d1c0c33..1b0e8a6637f 100644 --- a/actuator/src/main/java/org/tron/core/actuator/VMActuator.java +++ b/actuator/src/main/java/org/tron/core/actuator/VMActuator.java @@ -37,7 +37,6 @@ import org.tron.core.db.TransactionContext; import org.tron.core.exception.ContractExeException; import org.tron.core.exception.ContractValidateException; -import org.tron.core.store.DynamicPropertiesStore; import org.tron.core.utils.TransactionUtil; import org.tron.core.vm.EnergyCost; import org.tron.core.vm.LogInfoTriggerParser; @@ -401,7 +400,7 @@ private void create() long thisTxCPULimitInUs = calculateCpuLimitInUs(isConstantCall, rootRepository.getDynamicPropertiesStore().getMaxCpuTimeOfOneTx(), - getCpuLimitInUsRatio(rootRepository.getDynamicPropertiesStore()), CommonParameter.getInstance().getConstantCallTimeoutMs()); + getCpuLimitInUsRatio(), CommonParameter.getInstance().getConstantCallTimeoutMs()); long vmStartInUs = System.nanoTime() / VMConstant.ONE_THOUSAND; long vmShouldEndInUs = vmStartInUs + thisTxCPULimitInUs; ProgramInvoke programInvoke = ProgramInvokeFactory @@ -515,7 +514,7 @@ private void call() long thisTxCPULimitInUs = calculateCpuLimitInUs(isConstantCall, rootRepository.getDynamicPropertiesStore().getMaxCpuTimeOfOneTx(), - getCpuLimitInUsRatio(rootRepository.getDynamicPropertiesStore()), CommonParameter.getInstance().getConstantCallTimeoutMs()); + getCpuLimitInUsRatio(), CommonParameter.getInstance().getConstantCallTimeoutMs()); long vmStartInUs = System.nanoTime() / VMConstant.ONE_THOUSAND; long vmShouldEndInUs = vmStartInUs + thisTxCPULimitInUs; ProgramInvoke programInvoke = ProgramInvokeFactory @@ -667,7 +666,7 @@ public void checkTokenValueAndId(long tokenValue, long tokenId) throws ContractV } - private double getCpuLimitInUsRatio(DynamicPropertiesStore dynamicPropertiesStore) { + private double getCpuLimitInUsRatio() { double cpuLimitRatio; From 650d3d8abce62d89b41e4e2c29b27da62f0d6af4 Mon Sep 17 00:00:00 2001 From: GrapeS Date: Mon, 25 May 2026 17:45:29 +0800 Subject: [PATCH 40/47] fix merge --- actuator/src/main/java/org/tron/core/actuator/VMActuator.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/actuator/src/main/java/org/tron/core/actuator/VMActuator.java b/actuator/src/main/java/org/tron/core/actuator/VMActuator.java index 1b0e8a6637f..f2eb59850d1 100644 --- a/actuator/src/main/java/org/tron/core/actuator/VMActuator.java +++ b/actuator/src/main/java/org/tron/core/actuator/VMActuator.java @@ -400,7 +400,8 @@ private void create() long thisTxCPULimitInUs = calculateCpuLimitInUs(isConstantCall, rootRepository.getDynamicPropertiesStore().getMaxCpuTimeOfOneTx(), - getCpuLimitInUsRatio(), CommonParameter.getInstance().getConstantCallTimeoutMs()); + getCpuLimitInUsRatio(), + CommonParameter.getInstance().getConstantCallTimeoutMs()); long vmStartInUs = System.nanoTime() / VMConstant.ONE_THOUSAND; long vmShouldEndInUs = vmStartInUs + thisTxCPULimitInUs; ProgramInvoke programInvoke = ProgramInvokeFactory From 72ef8ca40d42a5367cb34033d066aec685b121ce Mon Sep 17 00:00:00 2001 From: GrapeS Date: Tue, 26 May 2026 10:23:53 +0800 Subject: [PATCH 41/47] fix(metrics): observe tx_fetch_latency in processMessage, not handleTransaction The original placement in handleTransaction() was dead code: processMessage() drains advInvRequest at L92-95 (before enqueueing tx work onto the smartContract/queue handlers), so by the time the worker thread reaches handleTransaction() the timestamp is already gone and remove(item) always returns null. Move the histogram observe into the same draining loop in processMessage(), using a single currentTimeMillis() reference captured just before the loop. This is both correct (we observe at the only remove() call that ever sees a non-null value) and slightly cheaper (one currentTimeMillis() per TRXS message instead of per tx). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../TransactionsMsgHandler.java | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/framework/src/main/java/org/tron/core/net/messagehandler/TransactionsMsgHandler.java b/framework/src/main/java/org/tron/core/net/messagehandler/TransactionsMsgHandler.java index bd1d591e34c..198d12690d4 100644 --- a/framework/src/main/java/org/tron/core/net/messagehandler/TransactionsMsgHandler.java +++ b/framework/src/main/java/org/tron/core/net/messagehandler/TransactionsMsgHandler.java @@ -89,9 +89,17 @@ public void processMessage(PeerConnection peer, TronMessage msg) throws P2pExcep } TransactionsMessage transactionsMessage = (TransactionsMessage) msg; check(peer, transactionsMessage); + long now = System.currentTimeMillis(); for (Transaction trx : transactionsMessage.getTransactions().getTransactionsList()) { Item item = new Item(new TransactionMessage(trx).getMessageId(), InventoryType.TRX); - peer.getAdvInvRequest().remove(item); + // Observe end-to-end fetch latency (GET_DATA send → full TXS received) + // before consuming the timestamp. Null means this tx wasn't actively + // fetched (e.g. pushed via gossip), in which case no sample is recorded. + Long requestTime = peer.getAdvInvRequest().remove(item); + if (requestTime != null) { + Metrics.histogramObserve(MetricKeys.Histogram.TX_FETCH_LATENCY, + (now - requestTime) / Metrics.MILLISECONDS_PER_SECOND); + } } int smartContractQueueSize = 0; int trxHandlePoolQueueSize = 0; @@ -177,17 +185,6 @@ private void handleTransaction(PeerConnection peer, TransactionMessage trx) { return; } - // Measure end-to-end fetch latency: from GET_DATA send (recorded in - // advInvRequest when consumerInvToFetch picks this peer) to full TXS - // received here. Returns null if this tx wasn't actively fetched (e.g. - // pushed via gossip without a prior GET_DATA), in which case no sample - // is observed. - Long requestTime = peer.getAdvInvRequest().remove(item); - if (requestTime != null) { - Metrics.histogramObserve(MetricKeys.Histogram.TX_FETCH_LATENCY, - (System.currentTimeMillis() - requestTime) / Metrics.MILLISECONDS_PER_SECOND); - } - try { trx.getTransactionCapsule().checkExpiration(chainBaseManager.getNextBlockSlotTime()); tronNetDelegate.pushTransaction(trx.getTransactionCapsule()); From c546dd536f00d704248364f26146e8b2411a1cfd Mon Sep 17 00:00:00 2001 From: GrapeS Date: Tue, 26 May 2026 10:29:16 +0800 Subject: [PATCH 42/47] revert some --- .../tron/core/net/messagehandler/TransactionsMsgHandler.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/framework/src/main/java/org/tron/core/net/messagehandler/TransactionsMsgHandler.java b/framework/src/main/java/org/tron/core/net/messagehandler/TransactionsMsgHandler.java index 198d12690d4..7189150e9ee 100644 --- a/framework/src/main/java/org/tron/core/net/messagehandler/TransactionsMsgHandler.java +++ b/framework/src/main/java/org/tron/core/net/messagehandler/TransactionsMsgHandler.java @@ -179,9 +179,7 @@ private void handleTransaction(PeerConnection peer, TransactionMessage trx) { return; } - Item item = new Item(trx.getMessageId(), InventoryType.TRX); - - if (advService.getMessage(item) != null) { + if (advService.getMessage(new Item(trx.getMessageId(), InventoryType.TRX)) != null) { return; } From 9cb41b946783f863a7d3bbe48f5410e448492c80 Mon Sep 17 00:00:00 2001 From: GrapeS Date: Tue, 26 May 2026 10:33:10 +0800 Subject: [PATCH 43/47] add comment --- .../main/java/org/tron/common/prometheus/MetricKeys.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/common/src/main/java/org/tron/common/prometheus/MetricKeys.java b/common/src/main/java/org/tron/common/prometheus/MetricKeys.java index 47eca4dd903..d473dac2ccb 100644 --- a/common/src/main/java/org/tron/common/prometheus/MetricKeys.java +++ b/common/src/main/java/org/tron/common/prometheus/MetricKeys.java @@ -67,6 +67,14 @@ public static class Histogram { public static final String BLOCK_FETCH_LATENCY = "tron:block_fetch_latency_seconds"; public static final String BLOCK_RECEIVE_DELAY = "tron:block_receive_delay_seconds"; public static final String BLOCK_TRANSACTION_COUNT = "tron:block_transaction_count"; + /** + * Transaction fetch round-trip latency in seconds: from sending + * {@code GET_DATA (FETCH_INV_DATA)} to receiving the full {@code TXS} + * message. + *

Transactions pushed via gossip without a prior {@code GET_DATA} + * (i.e. not actively fetched by this node) are not sampled; + *

Companion to {@link #BLOCK_FETCH_LATENCY} for the TX path. + */ public static final String TX_FETCH_LATENCY = "tron:tx_fetch_latency_seconds"; private Histogram() { From ea3dc859971b39ae2cdc7601c17f2a4badba4b78 Mon Sep 17 00:00:00 2001 From: GrapeS Date: Tue, 26 May 2026 12:32:18 +0800 Subject: [PATCH 44/47] feature: add fastforward hello message latency metric --- .../org/tron/common/prometheus/MetricKeys.java | 10 ++++++++++ .../tron/common/prometheus/MetricsHistogram.java | 2 ++ .../net/service/handshake/HandshakeService.java | 16 ++++++++++++++-- .../core/net/service/relay/RelayService.java | 16 ++++++++++++++++ 4 files changed, 42 insertions(+), 2 deletions(-) diff --git a/common/src/main/java/org/tron/common/prometheus/MetricKeys.java b/common/src/main/java/org/tron/common/prometheus/MetricKeys.java index d473dac2ccb..7e9dfa566b9 100644 --- a/common/src/main/java/org/tron/common/prometheus/MetricKeys.java +++ b/common/src/main/java/org/tron/common/prometheus/MetricKeys.java @@ -77,6 +77,16 @@ public static class Histogram { */ public static final String TX_FETCH_LATENCY = "tron:tx_fetch_latency_seconds"; + /** + * Handshake round-trip latency in seconds: from TCP connection + * establishment to {@code HelloMessage} fully processed. + *

Sampled only on the SR{@literal <->}FF handshake path — either + * the received {@code HelloMessage} carries a witness signature, or + * the remote peer is in {@code node.fastForward.nodes}. Regular + * FullNode handshakes are not sampled. + */ + public static final String HANDSHAKE_LATENCY = "tron:handshake_latency_seconds"; + private Histogram() { throw new IllegalStateException("Histogram"); } diff --git a/common/src/main/java/org/tron/common/prometheus/MetricsHistogram.java b/common/src/main/java/org/tron/common/prometheus/MetricsHistogram.java index d8adf7e18c2..d792372e177 100644 --- a/common/src/main/java/org/tron/common/prometheus/MetricsHistogram.java +++ b/common/src/main/java/org/tron/common/prometheus/MetricsHistogram.java @@ -50,6 +50,8 @@ public class MetricsHistogram { "receive block delay time, receiveTime - blockTime."); init(MetricKeys.Histogram.TX_FETCH_LATENCY, "fetch transaction latency: GET_DATA send to full TXS received round-trip."); + init(MetricKeys.Histogram.HANDSHAKE_LATENCY, + "handshake round-trip latency on the SR<->FF path."); init(MetricKeys.Histogram.BLOCK_TRANSACTION_COUNT, "Distribution of transaction counts per block.", diff --git a/framework/src/main/java/org/tron/core/net/service/handshake/HandshakeService.java b/framework/src/main/java/org/tron/core/net/service/handshake/HandshakeService.java index 070a9f56406..2c61a557d63 100644 --- a/framework/src/main/java/org/tron/core/net/service/handshake/HandshakeService.java +++ b/framework/src/main/java/org/tron/core/net/service/handshake/HandshakeService.java @@ -4,6 +4,8 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; +import org.tron.common.prometheus.MetricKeys; +import org.tron.common.prometheus.Metrics; import org.tron.common.utils.ByteArray; import org.tron.core.ChainBaseManager; import org.tron.core.ChainBaseManager.NodeType; @@ -15,6 +17,7 @@ import org.tron.core.net.service.effective.EffectiveCheckService; import org.tron.core.net.service.relay.RelayService; import org.tron.p2p.discover.Node; +import org.tron.protos.Protocol; import org.tron.protos.Protocol.ReasonCode; @Slf4j(topic = "net") @@ -122,8 +125,17 @@ public void processHelloMessage(PeerConnection peer, HelloMessage msg) { peer.setHelloMessageReceive(msg); - peer.getChannel().updateAvgLatency( - System.currentTimeMillis() - peer.getChannel().getStartTime()); + long latencyMs = System.currentTimeMillis() - peer.getChannel().getStartTime(); + peer.getChannel().updateAvgLatency(latencyMs); + // Sample only the SR<->FF handshake path: + // - inbound: received hello carries a witness signature. + // - outbound: peer is in node.fastForward.nodes. + Protocol.HelloMessage hello = msg.getInstance(); + boolean signed = !hello.getSignature().isEmpty() || hello.hasPqAuthSig(); + if (signed || relayService.isFastForwardPeer(peer.getChannel())) { + Metrics.histogramObserve(MetricKeys.Histogram.HANDSHAKE_LATENCY, + latencyMs / Metrics.MILLISECONDS_PER_SECOND); + } PeerManager.sortPeers(); peer.onConnect(); } diff --git a/framework/src/main/java/org/tron/core/net/service/relay/RelayService.java b/framework/src/main/java/org/tron/core/net/service/relay/RelayService.java index f898cafdb49..a52f9f12470 100644 --- a/framework/src/main/java/org/tron/core/net/service/relay/RelayService.java +++ b/framework/src/main/java/org/tron/core/net/service/relay/RelayService.java @@ -121,6 +121,22 @@ public void close() { ExecutorServiceManager.shutdownAndAwaitTermination(executorService, esName); } + /** + * Whether the channel's remote peer is in {@code node.fastForward.nodes}. + */ + public boolean isFastForwardPeer(Channel channel) { + if (fastForwardNodes.isEmpty() || channel == null + || channel.getInetAddress() == null) { + return false; + } + for (InetSocketAddress ff : fastForwardNodes) { + if (channel.getInetAddress().equals(ff.getAddress())) { + return true; + } + } + return false; + } + public void fillHelloMessage(HelloMessage message, Channel channel) { if (!isActiveWitness()) { return; From c1d77228c4b91cea4a43e8595900ffecb74ae345 Mon Sep 17 00:00:00 2001 From: federico Date: Wed, 27 May 2026 18:07:30 +0800 Subject: [PATCH 45/47] test(crypto): add ml-dsa-44 support to pqc demo programs --- .../common/crypto/pqc/program/PQFullNode.java | 18 +- .../common/crypto/pqc/program/PQTxSender.java | 208 +++++++++++------- .../crypto/pqc/program/PQWitnessNode.java | 114 +++++++--- 3 files changed, 226 insertions(+), 114 deletions(-) diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/program/PQFullNode.java b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQFullNode.java index d11d6028c83..64766c1c94c 100644 --- a/framework/src/test/java/org/tron/common/crypto/pqc/program/PQFullNode.java +++ b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQFullNode.java @@ -5,6 +5,8 @@ import java.nio.file.Files; import java.util.ArrayList; import java.util.Collections; +import java.util.EnumMap; +import java.util.Map; import org.tron.common.application.Application; import org.tron.common.application.ApplicationFactory; import org.tron.common.application.TronApplicationContext; @@ -15,6 +17,7 @@ import org.tron.core.config.DefaultConfig; import org.tron.core.config.args.Args; import org.tron.core.db.Manager; +import org.tron.protos.Protocol.PQScheme; /** * Demo fullnode that dials {@link PQWitnessNode} via P2P and syncs PQ-signed blocks. @@ -61,14 +64,17 @@ public static void main(String[] args) throws Exception { // ── 1. Derive the same deterministic keys used by PQWitnessNode ────── PQSignature witnessKp = PQSchemeRegistry.fromSeed( PQWitnessNode.PQ_SCHEME, PQWitnessNode.WITNESS_SEED); - PQSignature userKp = PQSchemeRegistry.fromSeed( - PQWitnessNode.PQ_SCHEME, PQWitnessNode.USER_SEED); + Map userPubs = new EnumMap<>(PQScheme.class); + for (PQScheme scheme : PQSchemeRegistry.registeredSchemes()) { + userPubs.put(scheme, + PQSchemeRegistry.fromSeed(scheme, PQWitnessNode.USER_SEEDS.get(scheme)) + .getPublicKey()); + } byte[] witnessPub = witnessKp.getPublicKey(); - byte[] userPub = userKp.getPublicKey(); System.out.println("=== PQC Full Node ==="); - System.out.println("Scheme: " + PQWitnessNode.PQ_SCHEME); + System.out.println("Block-producing scheme: " + PQWitnessNode.PQ_SCHEME); System.out.println("Peer (witness): " + WITNESS_HOST + ":" + WITNESS_P2P_PORT); System.out.println("gRPC port: " + GRPC_PORT); System.out.println("HTTP port: " + HTTP_PORT); @@ -105,8 +111,8 @@ public static void main(String[] args) throws Exception { // ── 4. Install matching PQ genesis pre-state ────────────────────────── // Without this the incoming pq_auth_sig would fail to validate because - // this node wouldn't know the witness's FN-DSA-512 public key. - PQWitnessNode.installPQGenesisState(db, chain, witnessPub, userPub); + // this node wouldn't know the witness's PQ public key. + PQWitnessNode.installPQGenesisState(db, chain, witnessPub, userPubs); // ── 5. Start P2P + gRPC (no ConsensusService.start — we don't produce) ─ app.startup(); diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/program/PQTxSender.java b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQTxSender.java index f6127e9f3c6..fe06f9c5126 100644 --- a/framework/src/test/java/org/tron/common/crypto/pqc/program/PQTxSender.java +++ b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQTxSender.java @@ -6,7 +6,12 @@ import io.grpc.ManagedChannelBuilder; import java.nio.ByteBuffer; import java.security.MessageDigest; +import java.util.ArrayList; import java.util.Arrays; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; import org.tron.api.GrpcAPI.EmptyMessage; import org.tron.api.GrpcAPI.Return; @@ -33,35 +38,40 @@ import org.tron.protos.contract.SmartContractOuterClass.TriggerSmartContract; /** - * Demo client that connects to {@link PQWitnessNode} and continuously broadcasts transfer and - * TRC20 transactions signed by a configurable PQ scheme ({@code -Dpqc.scheme}, default - * FN_DSA_512; must match the witness node) and ECDSA. + * Demo client that connects to {@link PQWitnessNode} and continuously broadcasts transfer + * and TRC20 transactions signed by every registered PQ scheme (FN-DSA-512 and ML-DSA-44) + * in parallel, plus a parallel ECDSA stream. The witness node activates both PQ schemes + * and gives the demo user account an owner permission with one signer key per scheme, so + * either signature satisfies the threshold-1 owner permission. *

- * The FN-DSA-512 keypair is derived from the same fixed seed used by PQWitnessNode, so no + * PQ keypairs are derived from the same fixed seeds used by PQWitnessNode, so no * out-of-band key exchange is needed. ECDSA transactions use -Decdsa.private.key. *

* Run from the repository root: * ./gradlew :framework:buildFullNodeJar :framework:compileTestJava * CP="framework/build/classes/java/test:framework/build/resources/test" * CP="$CP:framework/build/libs/FullNode.jar" - * java -Dpqc.host=127.0.0.1 -Dpqc.port=50051 -Dpqc.transfer.tps=10 -Dpqc.trc20.tps=10 \ - * -Decdsa.private.key=HEX_PRIVATE_KEY -Decdsa.transfer.tps=10 -Decdsa.trc20.tps=10 \ + * java -Dpqc.host=127.0.0.1 -Dpqc.port=50051 \ + * -Dpqc.fn-dsa-512.transfer.tps=5 -Dpqc.fn-dsa-512.trc20.tps=0 \ + * -Dpqc.ml-dsa-44.transfer.tps=5 -Dpqc.ml-dsa-44.trc20.tps=0 \ + * -Decdsa.private.key=HEX_PRIVATE_KEY \ + * -Decdsa.transfer.tps=5 -Decdsa.trc20.tps=0 \ * -cp "$CP" \ * org.tron.common.crypto.pqc.program.PQTxSender * * Optional JVM args: * -Dpqc.host=localhost * -Dpqc.port=50051 - * -Dpqc.transfer.tps=10 - * -Dpqc.trc20.tps=10 + * -Dpqc.fn-dsa-512.transfer.tps=5 (per-scheme transfer rate; 0 disables that stream) + * -Dpqc.fn-dsa-512.trc20.tps=0 + * -Dpqc.ml-dsa-44.transfer.tps=5 + * -Dpqc.ml-dsa-44.trc20.tps=0 * -Decdsa.private.key=1234567890123456789012345678901234567890123456789012345678901234 - * -Decdsa.transfer.tps=10 - * -Decdsa.trc20.tps=10 + * -Decdsa.transfer.tps=5 + * -Decdsa.trc20.tps=0 */ public class PQTxSender { - private static final PQScheme PQ_SCHEME = PQScheme.valueOf( - System.getProperty("pqc.scheme", PQScheme.FN_DSA_512.name())); private static final String HOST = System.getProperty("pqc.host", "localhost"); private static final int PORT = @@ -96,21 +106,29 @@ public class PQTxSender { "1234567890123456789012345678901234567890123456789012345678901234"; /** - * Default send rate for FN-DSA-512 transfer transactions. + * Per-scheme default send rates. Split so each PQ algorithm can be tuned + * independently from the others (Falcon-512 signing is ~2× slower than + * ML-DSA-44, so operators often run Falcon at a lower default rate). */ - private static final double DEFAULT_TRANSFER_TPS = 10.0d; - /** - * Default send rate for FN-DSA-512 TRC20 transactions. - */ - private static final double DEFAULT_TRC20_TPS = 10.0d; - /** - * Default send rate for ECDSA transfer transactions. - */ - private static final double DEFAULT_ECDSA_TRANSFER_TPS = 10.0d; - /** - * Default send rate for ECDSA TRC20 transactions. - */ - private static final double DEFAULT_ECDSA_TRC20_TPS = 10.0d; + private static final Map DEFAULT_PQ_TRANSFER_TPS; + private static final Map DEFAULT_PQ_TRC20_TPS; + + static { + Map transfer = new EnumMap<>(PQScheme.class); + transfer.put(PQScheme.FN_DSA_512, 5.0d); + transfer.put(PQScheme.ML_DSA_44, 5.0d); + DEFAULT_PQ_TRANSFER_TPS = transfer; + + Map trc20 = new EnumMap<>(PQScheme.class); + trc20.put(PQScheme.FN_DSA_512, 0d); + trc20.put(PQScheme.ML_DSA_44, 0d); + DEFAULT_PQ_TRC20_TPS = trc20; + } + + /** Default send rate for ECDSA transfer transactions. */ + private static final double DEFAULT_ECDSA_TRANSFER_TPS = 5.0d; + /** Default send rate for ECDSA TRC20 transactions. */ + private static final double DEFAULT_ECDSA_TRC20_TPS = 0d; public static void main(String[] args) throws Exception { // Force INFO level: logback-test.xml (on the test classpath) sets root=DEBUG @@ -119,30 +137,43 @@ public static void main(String[] args) throws Exception { .getLogger(ch.qos.logback.classic.Logger.ROOT_LOGGER_NAME)) .setLevel(ch.qos.logback.classic.Level.INFO); - // ── 1. Derive user keypair from same fixed seed as PQWitnessNode ───── - byte[] userSeed = new byte[PQSchemeRegistry.getSeedLength(PQ_SCHEME)]; - Arrays.fill(userSeed, (byte) 0x02); - PQSignature userKp = PQSchemeRegistry.fromSeed(PQ_SCHEME, userSeed); + // byte[] ownerAddr = Commons.decodeFromBase58Check("TJUfbazhixG4YtqJxUDmv5XisZvvy1wP91"); + byte[] ownerAddr = PQWitnessNode.USER_ADDR; + + // ── 1. Derive a user keypair per registered PQ scheme (same seed as + // PQWitnessNode), and parse per-scheme TPS knobs. ───────────────── + Map pqKeypairs = new EnumMap<>(PQScheme.class); + Map pqTransferTps = new EnumMap<>(PQScheme.class); + Map pqTrc20Tps = new EnumMap<>(PQScheme.class); + for (PQScheme scheme : PQSchemeRegistry.registeredSchemes()) { + byte[] userSeed = new byte[PQSchemeRegistry.getSeedLength(scheme)]; + Arrays.fill(userSeed, (byte) 0x02); + pqKeypairs.put(scheme, PQSchemeRegistry.fromSeed(scheme, userSeed)); + pqTransferTps.put(scheme, + readTps("pqc." + tpsKey(scheme) + ".transfer.tps", + DEFAULT_PQ_TRANSFER_TPS.get(scheme))); + pqTrc20Tps.put(scheme, + readTps("pqc." + tpsKey(scheme) + ".trc20.tps", + DEFAULT_PQ_TRC20_TPS.get(scheme))); + } - byte[] userPub = userKp.getPublicKey(); - byte[] signerAddr = userKp.getAddress(); - byte[] ownerAddr = Commons.decodeFromBase58Check("TJUfbazhixG4YtqJxUDmv5XisZvvy1wP91"); ECKey ecdsaKey = ECKey.fromPrivate( ByteArray.fromHexString(System.getProperty("ecdsa.private.key", DEFAULT_ECDSA_PRIVATE_KEY))); byte[] ecdsaOwnerAddr = ecdsaKey.getAddress(); - double transferTps = readTps("pqc.transfer.tps", DEFAULT_TRANSFER_TPS); - double trc20Tps = readTps("pqc.trc20.tps", DEFAULT_TRC20_TPS); double ecdsaTransferTps = readTps("ecdsa.transfer.tps", DEFAULT_ECDSA_TRANSFER_TPS); double ecdsaTrc20Tps = readTps("ecdsa.trc20.tps", DEFAULT_ECDSA_TRC20_TPS); System.out.println("=== PQC/ECDSA Tx Sender ==="); System.out.println("Connecting to " + HOST + ":" + PORT); - System.out.println("PQC scheme: " + PQ_SCHEME); System.out.println("PQC owner address: " + ByteArray.toHexString(ownerAddr)); - System.out.println("PQC signer address: " + ByteArray.toHexString(signerAddr)); - System.out.println("PQC transfer TPS: " + transferTps); - System.out.println("PQC TRC20 TPS: " + trc20Tps); + for (Map.Entry entry : pqKeypairs.entrySet()) { + PQScheme scheme = entry.getKey(); + System.out.println("PQC signer (" + scheme + "): " + + ByteArray.toHexString(entry.getValue().getAddress()) + + " transfer TPS=" + pqTransferTps.get(scheme) + + " trc20 TPS=" + pqTrc20Tps.get(scheme)); + } System.out.println("ECDSA owner address: " + ByteArray.toHexString(ecdsaOwnerAddr)); System.out.println("ECDSA transfer TPS: " + ecdsaTransferTps); System.out.println("ECDSA TRC20 TPS: " + ecdsaTrc20Tps); @@ -155,33 +186,43 @@ public static void main(String[] args) throws Exception { WalletBlockingStub stub = WalletGrpc.newBlockingStub(channel); try { - Thread transferThread = new Thread( - () -> runTransferLoop(stub, ownerAddr, userKp, transferTps), - "pqc-transfer-sender-grpc"); - Thread trc20Thread = new Thread( - () -> runTrc20Loop(stub, ownerAddr, userKp, trc20Tps), - "pqc-trc20-sender-grpc"); - Thread ecdsaTransferThread = new Thread( + List threads = new ArrayList<>(); + for (Map.Entry entry : pqKeypairs.entrySet()) { + PQScheme scheme = entry.getKey(); + PQSignature kp = entry.getValue(); + double transferTps = pqTransferTps.get(scheme); + double trc20Tps = pqTrc20Tps.get(scheme); + threads.add(new Thread( + () -> runTransferLoop(stub, ownerAddr, kp, scheme, transferTps), + "pqc-" + tpsKey(scheme) + "-transfer-sender-grpc")); + threads.add(new Thread( + () -> runTrc20Loop(stub, ownerAddr, kp, scheme, trc20Tps), + "pqc-" + tpsKey(scheme) + "-trc20-sender-grpc")); + } + threads.add(new Thread( () -> runEcdsaTransferLoop(stub, ecdsaOwnerAddr, ecdsaKey, ecdsaTransferTps), - "ecdsa-transfer-sender-grpc"); - Thread ecdsaTrc20Thread = new Thread( + "ecdsa-transfer-sender-grpc")); + threads.add(new Thread( () -> runEcdsaTrc20Loop(stub, ecdsaOwnerAddr, ecdsaKey, ecdsaTrc20Tps), - "ecdsa-trc20-sender-grpc"); - - transferThread.start(); - trc20Thread.start(); - ecdsaTransferThread.start(); - ecdsaTrc20Thread.start(); - transferThread.join(); - trc20Thread.join(); - ecdsaTransferThread.join(); - ecdsaTrc20Thread.join(); + "ecdsa-trc20-sender-grpc")); + + for (Thread t : threads) { + t.start(); + } + for (Thread t : threads) { + t.join(); + } } finally { channel.shutdown(); channel.awaitTermination(5, TimeUnit.SECONDS); } } + /** Lowercase, hyphenated form of the scheme name for tag/property keys. */ + private static String tpsKey(PQScheme scheme) { + return scheme.name().toLowerCase().replace('_', '-'); + } + private static byte[] sha256(byte[] data) throws Exception { return MessageDigest.getInstance("SHA-256").digest(data); } @@ -196,31 +237,31 @@ private static byte[] longToBytes(long value) { } private static void runTransferLoop(WalletBlockingStub stub, byte[] ownerAddr, - PQSignature userKp, double tps) { + PQSignature userKp, PQScheme scheme, double tps) { if (tps <= 0) { - System.out.println("pqc transfer sender disabled"); + System.out.println("pqc transfer sender disabled for " + scheme); return; } long intervalMs = tpsToIntervalMs(tps); long counter = 1L; while (!Thread.currentThread().isInterrupted()) { long loopStart = System.currentTimeMillis(); - sendTransferTransaction(stub, ownerAddr, userKp, counter++); + sendTransferTransaction(stub, ownerAddr, userKp, scheme, counter++); sleepRemaining(intervalMs, loopStart); } } private static void runTrc20Loop(WalletBlockingStub stub, byte[] ownerAddr, - PQSignature userKp, double tps) { + PQSignature userKp, PQScheme scheme, double tps) { if (tps <= 0) { - System.out.println("pqc trc20 sender disabled"); + System.out.println("pqc trc20 sender disabled for " + scheme); return; } long intervalMs = tpsToIntervalMs(tps); long counter = 1L; while (!Thread.currentThread().isInterrupted()) { long loopStart = System.currentTimeMillis(); - sendTrc20Transaction(stub, ownerAddr, userKp, counter++); + sendTrc20Transaction(stub, ownerAddr, userKp, scheme, counter++); sleepRemaining(intervalMs, loopStart); } } @@ -256,7 +297,8 @@ private static void runEcdsaTrc20Loop(WalletBlockingStub stub, byte[] ownerAddr, } private static void sendTransferTransaction(WalletBlockingStub stub, byte[] ownerAddr, - PQSignature userKp, long seq) { + PQSignature userKp, PQScheme scheme, long seq) { + String tag = "pqc-" + tpsKey(scheme) + "-transfer-" + seq; try { WalletBlockingStub timedStub = stub.withDeadlineAfter(10, TimeUnit.SECONDS); @@ -272,23 +314,24 @@ private static void sendTransferTransaction(WalletBlockingStub stub, byte[] owne byte[] sig = userKp.sign(txId); Transaction signedTx = tx.toBuilder() .addPqAuthSig(PQAuthSig.newBuilder() - .setScheme(PQ_SCHEME) + .setScheme(scheme) .setPublicKey(ByteString.copyFrom(userKp.getPublicKey())) .setSignature(ByteString.copyFrom(sig))) .build(); Return result = timedStub.broadcastTransaction(signedTx); - System.out.println("[pqc-transfer-" + seq + "] ref=#" + refNum + System.out.println("[" + tag + "] ref=#" + refNum + " tx=" + ByteArray.toHexString(txId) + " result=" + result.getCode()); } catch (Exception e) { - System.err.println("[pqc-transfer-" + seq + "] send failed: " + e.getMessage()); + System.err.println("[" + tag + "] send failed: " + e.getMessage()); e.printStackTrace(System.err); } } private static void sendTrc20Transaction(WalletBlockingStub stub, byte[] ownerAddr, - PQSignature userKp, long seq) { + PQSignature userKp, PQScheme scheme, long seq) { + String tag = "pqc-" + tpsKey(scheme) + "-trc20-" + seq; try { WalletBlockingStub timedStub = stub.withDeadlineAfter(10, TimeUnit.SECONDS); @@ -308,17 +351,17 @@ private static void sendTrc20Transaction(WalletBlockingStub stub, byte[] ownerAd byte[] sig = userKp.sign(txId); Transaction signedTx = tx.toBuilder() .addPqAuthSig(PQAuthSig.newBuilder() - .setScheme(PQ_SCHEME) + .setScheme(scheme) .setPublicKey(ByteString.copyFrom(userKp.getPublicKey())) .setSignature(ByteString.copyFrom(sig))) .build(); Return result = timedStub.broadcastTransaction(signedTx); - System.out.println("[pqc-trc20-" + seq + "] ref=#" + refNum + System.out.println("[" + tag + "] ref=#" + refNum + " tx=" + ByteArray.toHexString(txId) + " result=" + result.getCode()); } catch (Exception e) { - System.err.println("[pqc-trc20-" + seq + "] send failed: " + e.getMessage()); + System.err.println("[" + tag + "] send failed: " + e.getMessage()); e.printStackTrace(System.err); } } @@ -390,12 +433,12 @@ private static Transaction buildTransferTransaction(byte[] ownerAddr, byte[] blo .setParameter(Any.pack(TransferContract.newBuilder() .setOwnerAddress(ByteString.copyFrom(ownerAddr)) .setToAddress(ByteString.copyFrom(TO_ADDR)) - .setAmount(1000L) + .setAmount(1L) .build())) .setPermissionId(0)) .setRefBlockHash(ByteString.copyFrom(Arrays.copyOfRange(blockHash, 8, 16))) .setRefBlockBytes(ByteString.copyFrom(longToBytes(refNum), 6, 2)) - .setExpiration(System.currentTimeMillis() + 60_000L) + .setExpiration(randomExpiration()) .build(); return Transaction.newBuilder().setRawData(rawData).build(); } @@ -415,10 +458,25 @@ private static Transaction buildTrc20Transaction(byte[] ownerAddr, byte[] blockH Transaction.raw.Builder rawBuilder = tx.getRawData().toBuilder(); rawBuilder.setRefBlockHash(ByteString.copyFrom(Arrays.copyOfRange(blockHash, 8, 16))); rawBuilder.setRefBlockBytes(ByteString.copyFrom(longToBytes(refNum), 6, 2)); - rawBuilder.setExpiration(System.currentTimeMillis() + 60_000L); + rawBuilder.setExpiration(randomExpiration()); return tx.toBuilder().setRawData(rawBuilder).build(); } + /** + * Random expiration in [now + 60_000ms, now + 80_000_000ms]. tx_id = + * sha256(rawData) and the signature is not part of the digest, so two threads + * that share an owner address and emit byte-identical rawData would collide and + * trip DUP_TRANSACTION_ERROR. Spreading expiration across an ~80M ms window + * gives ~8e7 entropy per send — at 30 TPS, the per-3s-refBlock-window collision + * chance is ~5.6e-6, more than enough for a long-running demo. The upper bound + * stays well below the 24h server-side cap (Manager.validateCommon → + * MAXIMUM_TIME_UNTIL_EXPIRATION = 86_400_000ms). + */ + private static long randomExpiration() { + long now = System.currentTimeMillis(); + return now + ThreadLocalRandom.current().nextLong(60_000L, 80_000_001L); + } + private static double readTps(String key, double defaultValue) { return Double.parseDouble(System.getProperty(key, Double.toString(defaultValue))); } diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/program/PQWitnessNode.java b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQWitnessNode.java index 459d26ad9fd..98f461f8503 100644 --- a/framework/src/test/java/org/tron/common/crypto/pqc/program/PQWitnessNode.java +++ b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQWitnessNode.java @@ -8,10 +8,13 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.EnumMap; +import java.util.Map; import org.bouncycastle.util.encoders.Hex; import org.tron.common.application.Application; import org.tron.common.application.ApplicationFactory; import org.tron.common.application.TronApplicationContext; +import org.tron.common.crypto.ECKey; import org.tron.common.crypto.pqc.PQSchemeRegistry; import org.tron.common.crypto.pqc.PQSignature; import org.tron.common.utils.ByteArray; @@ -49,14 +52,25 @@ */ public class PQWitnessNode { - /** Active PQ scheme, selectable via {@code -Dpqc.scheme}. */ + /** + * Active PQ scheme used for block production (witness signs blocks with this + * scheme). Selectable via {@code -Dpqc.scheme}. The on-chain user account + * carries owner-permission keys for ALL registered PQ schemes, so PQTxSender + * can broadcast transactions signed by either scheme regardless of which one + * the witness uses to sign blocks. + */ static final PQScheme PQ_SCHEME = PQScheme.valueOf( System.getProperty("pqc.scheme", PQScheme.ML_DSA_44.name())); - /** Fixed seed for the PQ witness keypair (shared with PQClient for derivation). */ - static final byte[] WITNESS_SEED = filledSeed(0x01); - /** Fixed seed for the PQ user keypair (shared with PQClient for derivation). */ - static final byte[] USER_SEED = filledSeed(0x02); + /** Per-scheme fixed seed for the PQ witness keypair (shared with PQClient). */ + static final Map WITNESS_SEEDS = filledSeeds((byte) 0x01); + /** Per-scheme fixed seed for the PQ user keypair (shared with PQClient). */ + static final Map USER_SEEDS = filledSeeds((byte) 0x02); + + /** Active-scheme witness seed (kept for callers that don't iterate schemes). */ + static final byte[] WITNESS_SEED = WITNESS_SEEDS.get(PQ_SCHEME); + /** Active-scheme user seed (kept for callers that don't iterate schemes). */ + static final byte[] USER_SEED = USER_SEEDS.get(PQ_SCHEME); /** gRPC port the node listens on. */ static final int GRPC_PORT = 50051; @@ -67,9 +81,12 @@ public class PQWitnessNode { /** P2P listen port (shared with PQFullNode so it can dial in as a seed peer). */ static final int P2P_PORT = 18888; + private static final String DEFAULT_ECDSA_PRIVATE_KEY = + "1234567890123456789012345678901234567890123456789012345678901234"; + /** Fixed on-chain address for the demo user account. */ - static final byte[] USER_ADDR = - ByteArray.fromHexString("41abd4b9367799eaa3197fecb144eb71de1e049abc"); + static final byte[] USER_ADDR = ECKey.fromPrivate( + ByteArray.fromHexString(DEFAULT_ECDSA_PRIVATE_KEY)).getAddress(); public static void main(String[] args) throws Exception { // Force INFO level: logback-test.xml (on the test classpath) sets root=DEBUG @@ -79,8 +96,15 @@ public static void main(String[] args) throws Exception { .setLevel(ch.qos.logback.classic.Level.INFO); // ── 1. Derive deterministic keypairs ────────────────────────────────── + // Active-scheme keypair drives block production; per-scheme user keypairs + // populate the multi-key owner permission so transactions signed under any + // registered PQ scheme verify against the same on-chain account. PQSignature witnessKp = PQSchemeRegistry.fromSeed(PQ_SCHEME, WITNESS_SEED); - PQSignature userKp = PQSchemeRegistry.fromSeed(PQ_SCHEME, USER_SEED); + Map userKps = new EnumMap<>(PQScheme.class); + for (PQScheme scheme : PQSchemeRegistry.registeredSchemes()) { + userKps.put(scheme, PQSchemeRegistry.fromSeed(scheme, USER_SEEDS.get(scheme))); + } + PQSignature userKp = userKps.get(PQ_SCHEME); byte[] witnessPub = witnessKp.getPublicKey(); byte[] witnessAddr = witnessKp.getAddress(); @@ -88,10 +112,14 @@ public static void main(String[] args) throws Exception { byte[] signerAddr = userKp.getAddress(); System.out.println("=== PQC Witness Node ==="); - System.out.println("Scheme: " + PQ_SCHEME); + System.out.println("Block-producing scheme: " + PQ_SCHEME); System.out.println("Witness address: " + ByteArray.toHexString(witnessAddr)); System.out.println("User address: " + ByteArray.toHexString(USER_ADDR)); - System.out.println("User signer address: " + ByteArray.toHexString(signerAddr)); + System.out.println("User signer (ECDSA): " + ByteArray.toHexString(USER_ADDR)); + for (Map.Entry entry : userKps.entrySet()) { + System.out.println("User signer (" + entry.getKey() + "): " + + ByteArray.toHexString(entry.getValue().getAddress())); + } System.out.println("gRPC port: " + GRPC_PORT); System.out.println("HTTP port: " + HTTP_PORT); System.out.println("P2P port: " + P2P_PORT); @@ -123,7 +151,11 @@ public static void main(String[] args) throws Exception { ChainBaseManager chain = context.getBean(ChainBaseManager.class); // ── 4. Install PQ genesis pre-state (shared with PQFullNode) ───────── - installPQGenesisState(db, chain, witnessPub, userPub); + Map userPubs = new EnumMap<>(PQScheme.class); + for (Map.Entry entry : userKps.entrySet()) { + userPubs.put(entry.getKey(), entry.getValue().getPublicKey()); + } + installPQGenesisState(db, chain, witnessPub, userPubs); // ── 5. Start consensus (DposTask auto-produces blocks) ─────────────── context.getBean(ConsensusService.class).start(); @@ -147,24 +179,32 @@ public static void main(String[] args) throws Exception { * Apply the PQ-specific pre-state that must exist on every node participating * in the demo network. Both PQWitnessNode and PQFullNode call this so their * genesis state matches before the first PQ block is produced / received. + * + *

{@code userPubs} carries one public key per registered PQ scheme; the + * owner permission is built as a multi-key permission with threshold 1, so + * a single signature under any included scheme satisfies it. This lets + * PQTxSender send transactions signed by either FN-DSA-512 or ML-DSA-44 + * against the same on-chain account. */ static void installPQGenesisState(Manager db, ChainBaseManager chain, - byte[] witnessPub, byte[] userPub) { + byte[] witnessPub, Map userPubs) { byte[] witnessAddr = PQSchemeRegistry.computeAddress(PQ_SCHEME, witnessPub); ByteString witnessAddrBs = ByteString.copyFrom(witnessAddr); - byte[] signerAddr = PQSchemeRegistry.computeAddress(PQ_SCHEME, userPub); - ByteString signerAddrBs = ByteString.copyFrom(signerAddr); - // Activate the active scheme on the local chain params. - if (PQ_SCHEME == PQScheme.ML_DSA_44) { - db.getDynamicPropertiesStore().saveAllowMlDsa44(1L); - } else { - db.getDynamicPropertiesStore().saveAllowFnDsa512(1L); + // Activate every registered PQ scheme so transactions signed under any of + // them are accepted by the verifier. + for (PQScheme scheme : PQSchemeRegistry.registeredSchemes()) { + if (scheme == PQScheme.ML_DSA_44) { + db.getDynamicPropertiesStore().saveAllowMlDsa44(1L); + } else if (scheme == PQScheme.FN_DSA_512) { + db.getDynamicPropertiesStore().saveAllowFnDsa512(1L); + } } db.getDynamicPropertiesStore().saveAllowMultiSign(1L); - // Witness account with FN-DSA-512 witness permission. Address-as-fingerprint - // binds the public key in-band; no separate pq_key field is stored. + // Witness account with PQ witness permission for the block-producing scheme. + // Address-as-fingerprint binds the public key in-band; no separate pq_key + // field is stored. Permission witnessPerm = Permission.newBuilder() .setType(PermissionType.Witness) .setId(1).setPermissionName("witness").setThreshold(1) @@ -182,23 +222,31 @@ static void installPQGenesisState(Manager db, ChainBaseManager chain, chain.getWitnessScheduleStore().saveActiveWitnesses(new ArrayList<>()); chain.addWitness(witnessAddrBs); - // User account with FN-DSA-512 owner permission. - Permission userOwnerPerm = Permission.newBuilder() - .setType(PermissionType.Owner).setPermissionName("owner").setThreshold(1) - .addKeys(Key.newBuilder() - .setAddress(signerAddrBs).setWeight(1)) - .build(); + // User account with one owner-permission key per registered PQ scheme. + // Threshold 1 ⇒ a single signature under any included scheme passes. + Permission.Builder userOwnerPerm = Permission.newBuilder() + .setType(PermissionType.Owner).setPermissionName("owner").setThreshold(1); + for (Map.Entry entry : userPubs.entrySet()) { + byte[] signerAddr = PQSchemeRegistry.computeAddress(entry.getKey(), entry.getValue()); + userOwnerPerm.addKeys(Key.newBuilder() + .setAddress(ByteString.copyFrom(signerAddr)).setWeight(1)); + } + userOwnerPerm.addKeys(Key.newBuilder().setAddress(ByteString.copyFrom(USER_ADDR)).setWeight(1)); AccountCapsule userCapsule = new AccountCapsule( ByteString.copyFrom(USER_ADDR), ByteString.copyFromUtf8("pquser"), AccountType.Normal); - userCapsule.setBalance(100_000_000L); // 100 TRX - userCapsule.updatePermissions(userOwnerPerm, null, Collections.emptyList()); + userCapsule.setBalance(100_000_000_000_000L); // 100000000 TRX + userCapsule.updatePermissions(userOwnerPerm.build(), null, Collections.emptyList()); db.getAccountStore().put(USER_ADDR, userCapsule); } - private static byte[] filledSeed(int value) { - byte[] seed = new byte[PQSchemeRegistry.getSeedLength(PQ_SCHEME)]; - Arrays.fill(seed, (byte) value); - return seed; + private static Map filledSeeds(byte value) { + Map seeds = new EnumMap<>(PQScheme.class); + for (PQScheme scheme : PQSchemeRegistry.registeredSchemes()) { + byte[] seed = new byte[PQSchemeRegistry.getSeedLength(scheme)]; + Arrays.fill(seed, value); + seeds.put(scheme, seed); + } + return Collections.unmodifiableMap(seeds); } private static Path writeWitnessConfig(PQSignature witnessKp) throws java.io.IOException { From c437db730a78e888b73b5c0060b304ea43954e51 Mon Sep 17 00:00:00 2001 From: federico Date: Fri, 29 May 2026 16:25:21 +0800 Subject: [PATCH 46/47] fix(crypto): correct fn-dsa signature bounds --- .../tron/core/vm/PrecompiledContracts.java | 94 ++++++++++++------- .../org/tron/common/crypto/pqc/FNDSA512.java | 60 +++++++----- .../common/crypto/pqc/PQSchemeRegistry.java | 2 +- .../common/crypto/pqc/FNDSA512KatTest.java | 2 +- .../tron/common/crypto/pqc/FNDSA512Test.java | 49 ++++++++-- .../runtime/vm/BatchValidateFnDsa512Test.java | 16 ++-- .../runtime/vm/FnDsaPrecompileTest.java | 30 +++--- .../runtime/vm/ValidateMultiPQSigTest.java | 23 +++-- .../org/tron/core/BandwidthProcessorTest.java | 4 +- .../core/capsule/TransactionCapsuleTest.java | 2 +- 10 files changed, 181 insertions(+), 101 deletions(-) diff --git a/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java b/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java index 75a14298654..e9080fd2a92 100644 --- a/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java +++ b/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java @@ -228,8 +228,9 @@ public class PrecompiledContracts { "0000000000000000000000000000000000000000000000000000000000000100"); // EIP-8052 0x16: FN-DSA / Falcon-512 verify (FIPS-206 draft). Input layout: - // [msg 32B | sig 666B (zero-padded slot, logical sig ends at last non-zero byte) | pk 896B]. - // Total 1594 B. Logical sig length is recovered by trimming trailing zeros. + // [msg 32B | sig 666B (headerless salt‖s2 slot, zero-padded; body ends at last + // non-zero byte) | pk 896B]. Total 1594 B. The slot holds the EIP-8052 headerless + // signature (no 0x39 byte); the precompile re-inserts the header before verifying. private static final DataWord verifyFnDsa512Addr = new DataWord( "0000000000000000000000000000000000000000000000000000000000000016"); @@ -602,6 +603,27 @@ static int recoverFalconSigLen(byte[] data, int from, int to) { return 0; } + /** + * Reconstructs the BC-native Falcon-512 signature from an EIP-8052 headerless + * slot. The slot {@code data[from..to)} holds {@code salt ‖ s2_compressed} + * (no leading {@code 0x39}) zero-padded to {@code SIGNATURE_MAX_LENGTH - 1}; + * the logical body ends at the last non-zero byte. Returns + * {@code 0x39 ‖ body} so BC's {@code FalconSigner} (which requires the header) + * can verify it, or {@code null} if the recovered body length is out of range. + * Shared by 0x16, 0x18, and 0x1a. + */ + static byte[] falconSlotToHeaderedSig(byte[] data, int from, int to) { + int bodyLen = recoverFalconSigLen(data, from, to); + if (bodyLen < FNDSA512.SIGNATURE_MIN_LENGTH - 1 + || bodyLen > FNDSA512.SIGNATURE_MAX_LENGTH - 1) { + return null; + } + byte[] sig = new byte[bodyLen + 1]; + sig[0] = FNDSA512.SIGNATURE_HEADER; + System.arraycopy(data, from, sig, 1, bodyLen); + return sig; + } + /** * Base class for precompiled contracts. Subclasses follow one of two * return-semantics conventions; mixing them within a single precompile @@ -2575,14 +2597,16 @@ public Pair execute(byte[] data) { *

    *   [msg 32B | sig 666B (zero-padded) | pk 896B]  total = 1594B
    * 
- * Falcon-512 signatures are logically variable in - * [{@code FNDSA512.SIGNATURE_MIN_LENGTH}, {@code FNDSA512.SIGNATURE_LENGTH}] = - * [41, 666]; the precompile slot fixes a 666-byte window. Encoders write the - * canonical signature into the prefix of the slot and zero-pad the tail to - * length 666. The canonical Falcon encoding always ends in a non-zero byte - * (the {@code compressed_s2} unary terminator bit), so the logical length is - * recovered by scanning the slot backwards for the first non-zero byte. Total - * input length must equal exactly 1594 (no trailing bytes; matches 0x100 + * The 666-byte sig slot holds the EIP-8052 headerless encoding + * {@code salt(40B) ‖ s2_compressed}: unlike BouncyCastle's native form there is + * no leading {@code 0x39} header byte. The headerless body is logically + * variable (≤ 665B after the salt); encoders write it into the prefix of the slot + * and zero-pad the tail to length 666. The {@code compressed_s2} encoding always + * ends in a non-zero byte (its unary terminator bit), so the logical body length + * is recovered by scanning the slot backwards for the first non-zero byte. Before + * verifying, the precompile re-inserts the {@code 0x39} header that BC's + * {@code FalconSigner} requires (it rejects any first byte ≠ {@code 0x30 + logn}). + * Total input length must equal exactly 1594 (no trailing bytes; matches 0x100 * P256Verify / EIP-7951 strictness). * *

Returns a 32-byte word: 1 on valid signature, 0 otherwise. Malformed @@ -2592,8 +2616,7 @@ public Pair execute(byte[] data) { public static class VerifyFnDsa512 extends PrecompiledContract { private static final int MSG_LEN = 32; - private static final int SIG_SLOT_LEN = FNDSA512.SIGNATURE_LENGTH; - private static final int SIG_MIN_LEN = FNDSA512.SIGNATURE_MIN_LENGTH; + private static final int SIG_SLOT_LEN = FNDSA512.SIGNATURE_MAX_LENGTH - 1; private static final int PK_LEN = FNDSA512.PUBLIC_KEY_LENGTH; private static final int INPUT_LEN = MSG_LEN + SIG_SLOT_LEN + PK_LEN; @@ -2611,11 +2634,12 @@ public Pair execute(byte[] data) { byte[] msg = copyOfRange(data, 0, MSG_LEN); int sigStart = MSG_LEN; int sigEnd = MSG_LEN + SIG_SLOT_LEN; - int sigLen = recoverFalconSigLen(data, sigStart, sigEnd); - if (sigLen < SIG_MIN_LEN || sigLen > SIG_SLOT_LEN) { + // The slot carries the EIP-8052 headerless body (salt ‖ s2); reconstruct + // the BC-headered form (re-inserts 0x39) BC's FalconSigner requires. + byte[] sig = falconSlotToHeaderedSig(data, sigStart, sigEnd); + if (sig == null) { return Pair.of(true, DataWord.ZERO().getData()); } - byte[] sig = copyOfRange(data, sigStart, sigStart + sigLen); byte[] pk = copyOfRange(data, sigEnd, INPUT_LEN); boolean ok = FNDSA512.verify(pk, msg, sig); return Pair.of(true, ok ? DataWord.ONE().getData() : DataWord.ZERO().getData()); @@ -2636,16 +2660,17 @@ public Pair execute(byte[] data) { *

    *   batchValidateFnDsa512(
    *       bytes32   hash,                  // word[0]
-   *       bytes[]   signatures,            // word[1] = offset; each 666 B (zero-padded slot,
-   *                                        //          logical sig ends at last non-zero byte)
+   *       bytes[]   signatures,            // word[1] = offset; each 666 B EIP-8052 headerless
+   *                                        //          slot (salt‖s2, no 0x39), zero-padded;
+   *                                        //          body ends at last non-zero byte
    *       bytes[]   publicKeys,            // word[2] = offset; each 896 B
    *       bytes32[] expectedAddresses      // word[3] = offset; 21-byte addr in low 21 bytes
    *   ) returns (bytes32)
    * 
* *

Falcon sigs are pinned to the 666-byte slot from {@code VerifyFnDsa512} (0x16) - * for cross-precompile consistency; {@link #recoverFalconSigLen} trims the slot to - * the canonical {@code [41, 666]} length before BC verification. + * for cross-precompile consistency; {@link #falconSlotToHeaderedSig} recovers the + * headerless body and re-inserts the {@code 0x39} header before BC verification. * *

Reuses the {@code BatchValidateSign.workers} pool when not in a constant * call and enforces {@code getCPUTimeLeftInNanoSecond()} timeout. {@code MAX_SIZE = 16}. @@ -2656,8 +2681,7 @@ public static class BatchValidateFnDsa512 extends PrecompiledContract { private static final int ENERGY_PER_SIGN = 2000; private static final int MAX_SIZE = 16; private static final int PK_LEN = FNDSA512.PUBLIC_KEY_LENGTH; - private static final int SIG_SLOT_LEN = FNDSA512.SIGNATURE_LENGTH; - private static final int SIG_MIN_LEN = FNDSA512.SIGNATURE_MIN_LENGTH; + private static final int SIG_SLOT_LEN = FNDSA512.SIGNATURE_MAX_LENGTH - 1; // hash, sigArrayOffset, pkArrayOffset, addrArrayOffset. private static final int ABI_HEAD_WORDS = 4; @@ -2766,11 +2790,11 @@ private static boolean verifyOne(byte[] sig, byte[] pk, byte[] hash, || sig == null || sig.length != SIG_SLOT_LEN) { return false; } - int logical = recoverFalconSigLen(sig, 0, sig.length); - if (logical < SIG_MIN_LEN) { + // The slot is the EIP-8052 headerless body; rebuild the BC-headered sig. + byte[] canonicalSig = falconSlotToHeaderedSig(sig, 0, sig.length); + if (canonicalSig == null) { return false; } - byte[] canonicalSig = Arrays.copyOf(sig, logical); try { byte[] derived = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pk); if (!DataWord.equalAddressByteArray(derived, expectedAddr)) { @@ -2908,11 +2932,12 @@ public Pair execute(byte[] data) { * ) returns (bytes32) // 1 on (totalWeight >= threshold), 0 otherwise * * - *

Falcon sigs follow the EIP-8052 666-byte fixed slot convention (matches - * 0x16/0x18): the slot is zero-padded and the logical sig ends at the last - * non-zero byte (Falcon's canonical encoding always ends with a non-zero - * {@code compressed_s2} terminator). Dilithium sigs are exactly 2420 B and - * Dilithium pks 1312 B. + *

Falcon sigs follow the EIP-8052 666-byte headerless slot convention + * (matches 0x16/0x18): the slot holds {@code salt ‖ s2_compressed} with no + * leading {@code 0x39}, zero-padded, the body ending at the last non-zero byte + * (Falcon's {@code compressed_s2} always ends with a non-zero terminator); + * {@link #falconSlotToHeaderedSig} re-inserts the header before verification. + * Dilithium sigs are exactly 2420 B and Dilithium pks 1312 B. * *

{@code MAX_SIZE = 5} across ECDSA + PQ entries combined. Energy is * {@code ecdsaCnt × 1500 + sum_i pqEnergy(scheme_i)} with FN-DSA-512 = 2000 @@ -3061,7 +3086,9 @@ public Pair execute(byte[] rawData) { byte[] sig = pqSigs[i]; byte[] pk = pqPks[i]; int expectedPkLen = PQSchemeRegistry.getPublicKeyLength(scheme); - int expectedSigSlot = PQSchemeRegistry.getSignatureLength(scheme); + int expectedSigSlot = scheme == PQScheme.FN_DSA_512 + ? FNDSA512.SIGNATURE_MAX_LENGTH - 1 + : PQSchemeRegistry.getSignatureLength(scheme); if (pk == null || pk.length != expectedPkLen || sig == null || sig.length != expectedSigSlot) { // Slot lengths are exact here (Falcon = 666, Dilithium = 2420) — @@ -3069,11 +3096,12 @@ public Pair execute(byte[] rawData) { return Pair.of(true, DATA_FALSE); } if (scheme == PQScheme.FN_DSA_512) { - int logical = recoverFalconSigLen(sig, 0, sig.length); - if (logical < FNDSA512.SIGNATURE_MIN_LENGTH) { + // The Falcon slot is the EIP-8052 headerless body; rebuild the + // BC-headered sig (re-inserts 0x39) before verification. + sig = falconSlotToHeaderedSig(sig, 0, sig.length); + if (sig == null) { return Pair.of(true, DATA_FALSE); } - sig = Arrays.copyOf(sig, logical); } byte[] derivedAddr; try { diff --git a/crypto/src/main/java/org/tron/common/crypto/pqc/FNDSA512.java b/crypto/src/main/java/org/tron/common/crypto/pqc/FNDSA512.java index 267405ded29..6e61d8ec135 100644 --- a/crypto/src/main/java/org/tron/common/crypto/pqc/FNDSA512.java +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/FNDSA512.java @@ -21,10 +21,11 @@ * *

Falcon signatures are variable-length: every accepted * signature must fall within {@code [}{@link #SIGNATURE_MIN_LENGTH}{@code ,} - * {@link #SIGNATURE_LENGTH}{@code ]}. {@link #SIGNATURE_LENGTH} (666) matches - * the Falcon Round-3 / FIPS-206 draft canonical maximum ({@code sbytelen}) for - * Falcon-512; {@link #SIGNATURE_MIN_LENGTH} (41) is the smallest syntactically - * well-formed encoding (header byte + 40-byte nonce, before {@code compressed_s2}). + * {@link #SIGNATURE_MAX_LENGTH}{@code ]}. {@link #SIGNATURE_MAX_LENGTH} (667) is + * the TRON/EIP-8052 upper bound after re-inserting Falcon's stripped header byte + * into a 666-byte headerless slot; {@link #SIGNATURE_MIN_LENGTH} (617) is the + * smallest syntactically well-formed compressed encoding (header byte + 40-byte + * nonce + 512 minimal {@code compressed_s2} coefficients). * BouncyCastle does not implement Falcon's spec-mandated rejection sampling * (its internal buffer permits up to 689 B); {@link #sign(byte[], byte[])} adds * that loop so produced signatures always respect the canonical cap. @@ -54,23 +55,29 @@ public final class FNDSA512 implements PQSignature { public static final int PRIVATE_KEY_WITH_PUBLIC_KEY_LENGTH = PRIVATE_KEY_LENGTH + PUBLIC_KEY_LENGTH; /** - * Canonical maximum Falcon-512 signature length per Falcon Round-3 / FIPS-206 - * draft ({@code sbytelen}). Also the protocol-level upper bound: anything - * longer is rejected by the verifier and never produced by - * {@link #sign(byte[], byte[])} (which loops with fresh randomness if BC - * exceeds it). + * TRON/EIP-8052 maximum Falcon-512 signature length after re-inserting the + * stripped header byte into a 666-byte headerless signature slot. */ - public static final int SIGNATURE_LENGTH = 666; + public static final int SIGNATURE_MAX_LENGTH = 667; /** - * Smallest syntactically well-formed Falcon-512 encoding: 1-byte header + - * 40-byte nonce, with {@code compressed_s2} potentially empty. Real valid - * signatures sit well above this — the bound exists to reject obviously - * malformed inputs without invoking BC. + * Smallest syntactically well-formed Falcon-512 compressed encoding: 1-byte header + * + 40-byte nonce + 576-byte {@code compressed_s2}. The compressed form encodes + * N=512 coefficients and each coefficient takes at least 9 bits. */ - public static final int SIGNATURE_MIN_LENGTH = 41; + public static final int SIGNATURE_MIN_LENGTH = 617; + /** + * Canonical Falcon-512 header byte ({@code 0x30 + logn}, logn=9): identifies the + * compressed encoding. BC's {@code FalconSigner} only ever produces this byte and + * rejects any other first byte; {@link #verify} enforces it explicitly so the + * "compressed-only" rule is pinned in our own code rather than relying on BC + * internals. The padded ({@code 0x49}) and constant-time ({@code 0x59}) encodings + * are deliberately not accepted — admitting them would make the same (key, message) + * verifiable under multiple distinct byte strings (signature malleability). + */ + public static final byte SIGNATURE_HEADER = 0x39; /** * Maximum signing retries before {@link #sign(byte[], byte[])} gives up. - * Empirically BC produces signatures above {@link #SIGNATURE_LENGTH} with + * Empirically BC produces signatures above {@link #SIGNATURE_MAX_LENGTH} with * probability ≪ 1/5000, so 16 attempts is comfortably above the * spec-targeted rejection rate (~2^-40) — failure probability after 16 * retries on honest input is astronomically small. @@ -153,12 +160,12 @@ public int getPublicKeyLength() { /** Returns the canonical signature length upper bound (signatures are variable-length). */ @Override public int getSignatureLength() { - return SIGNATURE_LENGTH; + return SIGNATURE_MAX_LENGTH; } /** * FN-DSA signatures are variable-length; the lower bound is the smallest - * syntactically well-formed encoding (1-byte header + 40-byte nonce). + * syntactically well-formed compressed encoding. */ @Override public int getSignatureMinLength() { @@ -211,14 +218,21 @@ public static boolean verify(byte[] publicKey, byte[] message, byte[] signature) } if (signature == null || signature.length < SIGNATURE_MIN_LENGTH - || signature.length > SIGNATURE_LENGTH) { + || signature.length > SIGNATURE_MAX_LENGTH) { throw new IllegalArgumentException( "FN-DSA signature length must be " - + SIGNATURE_MIN_LENGTH + ".." + SIGNATURE_LENGTH); + + SIGNATURE_MIN_LENGTH + ".." + SIGNATURE_MAX_LENGTH); } if (message == null) { throw new IllegalArgumentException("message must not be null"); } + // Reject non-canonical encodings (padded 0x49 / constant-time 0x59) so only the + // compressed form is verifiable — see SIGNATURE_HEADER. Ordered after the argument + // checks above: malformed arguments throw, a non-canonical-but-well-formed + // signature is simply an invalid signature (return false). + if (signature[0] != SIGNATURE_HEADER) { + return false; + } FalconPublicKeyParameters pk = new FalconPublicKeyParameters(PARAMS, publicKey); FalconSigner verifier = new FalconSigner(); verifier.init(false, pk); @@ -241,7 +255,7 @@ public static boolean verify(byte[] publicKey, byte[] message, byte[] signature) * address instead (see the PQ multisig dedup in {@code PrecompiledContracts}). * *

Per Falcon Round-3 / FIPS-206 draft the signature MUST be ≤ - * {@link #SIGNATURE_LENGTH} bytes; if it exceeds, the signer must resample + * {@link #SIGNATURE_MAX_LENGTH} bytes; if it exceeds, the signer must resample * with a fresh nonce. BouncyCastle does not implement this * rejection step — its internal buffer permits up to 689 B and would return * those longer signatures. This wrapper enforces the spec cap by discarding @@ -268,7 +282,7 @@ public static byte[] sign(byte[] privateKey, byte[] message) { for (int attempt = 0; attempt < SIGN_RETRY_BUDGET; attempt++) { try { byte[] sig = signer.generateSignature(message); - if (sig.length <= SIGNATURE_LENGTH) { + if (sig.length <= SIGNATURE_MAX_LENGTH) { return sig; } // BC produced a spec-overlong signature; retry with fresh randomness. @@ -282,7 +296,7 @@ public static byte[] sign(byte[] privateKey, byte[] message) { } throw new IllegalStateException( "FN-DSA signing failed: could not produce a signature ≤ " - + SIGNATURE_LENGTH + " bytes after " + SIGN_RETRY_BUDGET + " attempts", + + SIGNATURE_MAX_LENGTH + " bytes after " + SIGN_RETRY_BUDGET + " attempts", lastFailure); } diff --git a/crypto/src/main/java/org/tron/common/crypto/pqc/PQSchemeRegistry.java b/crypto/src/main/java/org/tron/common/crypto/pqc/PQSchemeRegistry.java index 87be8ac1095..f2b3f6e2d24 100644 --- a/crypto/src/main/java/org/tron/common/crypto/pqc/PQSchemeRegistry.java +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/PQSchemeRegistry.java @@ -108,7 +108,7 @@ private static final class SchemeInfo { EnumMap m = new EnumMap<>(PQScheme.class); m.put(PQScheme.FN_DSA_512, new SchemeInfo( FNDSA512.PRIVATE_KEY_LENGTH, FNDSA512.PUBLIC_KEY_LENGTH, - FNDSA512.SIGNATURE_LENGTH, FNDSA512.SIGNATURE_MIN_LENGTH, + FNDSA512.SIGNATURE_MAX_LENGTH, FNDSA512.SIGNATURE_MIN_LENGTH, FNDSA512.SEED_LENGTH, false, // Falcon keygen is FFT-based, not bit-stable across platforms. false, // BC has no public path from (f,g) to h (bcgit/bc-java#2297). diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/FNDSA512KatTest.java b/framework/src/test/java/org/tron/common/crypto/pqc/FNDSA512KatTest.java index 14b5f6eb60a..e5fc7d13377 100644 --- a/framework/src/test/java/org/tron/common/crypto/pqc/FNDSA512KatTest.java +++ b/framework/src/test/java/org/tron/common/crypto/pqc/FNDSA512KatTest.java @@ -189,7 +189,7 @@ public void signaturesFromKatKeysVerifyUnderTheirOwnPublicKey() { assertTrue(v.label + ": signature must be non-empty", sig.length > 0); assertTrue(v.label + ": signature must respect 752-byte upper bound", - sig.length <= FNDSA512.SIGNATURE_LENGTH); + sig.length <= FNDSA512.SIGNATURE_MAX_LENGTH); assertTrue(v.label + ": signature must verify under its own pk", FNDSA512.verify(k.getPublicKey(), msg, sig)); assertTrue(v.label + ": registry verify must accept own signature", diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/FNDSA512Test.java b/framework/src/test/java/org/tron/common/crypto/pqc/FNDSA512Test.java index 3deda94e918..3047a4d5ae4 100644 --- a/framework/src/test/java/org/tron/common/crypto/pqc/FNDSA512Test.java +++ b/framework/src/test/java/org/tron/common/crypto/pqc/FNDSA512Test.java @@ -55,7 +55,8 @@ private byte[] rawSign(byte[] message) { public void schemeAndLengthsMatchFips206Draft() { assertEquals(PQScheme.FN_DSA_512, keypair.getScheme()); assertEquals(FNDSA512.PUBLIC_KEY_LENGTH, keypair.getPublicKeyLength()); - assertEquals(FNDSA512.SIGNATURE_LENGTH, keypair.getSignatureLength()); + assertEquals(FNDSA512.SIGNATURE_MAX_LENGTH, keypair.getSignatureLength()); + assertEquals(617, keypair.getSignatureMinLength()); assertEquals(FNDSA512.PRIVATE_KEY_LENGTH, keypair.getPrivateKeyLength()); assertEquals(FNDSA512.PUBLIC_KEY_LENGTH, pk.getH().length); } @@ -85,19 +86,22 @@ public void signProducesVerifiableSignatureWithinBound() { assertTrue("signature must be non-empty", sig.length > 0); assertTrue( "signature must respect protocol-level upper bound", - sig.length <= FNDSA512.SIGNATURE_LENGTH); + sig.length <= FNDSA512.SIGNATURE_MAX_LENGTH); + assertTrue( + "signature must respect protocol-level lower bound", + sig.length >= FNDSA512.SIGNATURE_MIN_LENGTH); assertTrue(FNDSA512.verify(pk.getH(), msg, sig)); } @Test public void signatureBoundaryAtMaxAcceptedByLengthCheck() { - byte[] sig = new byte[FNDSA512.SIGNATURE_LENGTH]; + byte[] sig = new byte[FNDSA512.SIGNATURE_MAX_LENGTH]; keypair.validateSignature(sig); } @Test public void signatureBoundaryAboveMaxRejected() { - byte[] sig = new byte[FNDSA512.SIGNATURE_LENGTH + 1]; + byte[] sig = new byte[FNDSA512.SIGNATURE_MAX_LENGTH + 1]; try { keypair.validateSignature(sig); fail("signature longer than upper bound should be rejected"); @@ -137,7 +141,7 @@ public void emptySignatureRejectedByLengthCheck() { @Test public void verifyRejectsSignatureLongerThanUpperBound() { byte[] msg = new byte[] {1, 2, 3}; - byte[] tooLong = new byte[FNDSA512.SIGNATURE_LENGTH + 1]; + byte[] tooLong = new byte[FNDSA512.SIGNATURE_MAX_LENGTH + 1]; try { FNDSA512.verify(pk.getH(), msg, tooLong); fail("signature exceeding upper bound should be rejected at static verify"); @@ -198,6 +202,31 @@ public void tamperedSignatureFailsVerification() { assertFalse(keypair.verify(msg, sig)); } + @Test + public void validSignatureCarriesCanonicalHeader() { + byte[] msg = "header check".getBytes(); + byte[] sig = rawSign(msg); + assertEquals( + "BC must produce the canonical compressed header", + FNDSA512.SIGNATURE_HEADER, sig[0]); + } + + @Test + public void nonCanonicalHeaderRejected() { + byte[] msg = "header check".getBytes(); + byte[] sig = rawSign(msg); + assertTrue(FNDSA512.verify(pk.getH(), msg, sig)); + // Padded (0x49) and constant-time (0x59) encodings must be rejected even though + // their length is in range — only the compressed 0x39 header is accepted. + for (byte header : new byte[] {0x49, 0x59, 0x00, (byte) 0xFF}) { + byte[] tampered = sig.clone(); + tampered[0] = header; + assertFalse( + "non-canonical header 0x" + Integer.toHexString(header & 0xFF) + " must be rejected", + FNDSA512.verify(pk.getH(), msg, tampered)); + } + } + @Test public void wrongPublicKeyFailsVerification() { byte[] msg = "payload".getBytes(); @@ -236,7 +265,7 @@ public void keypairBoundInstanceSignsAndVerifies() { FNDSA512 signer = new FNDSA512(); byte[] msg = "keypair-bound".getBytes(); byte[] sig = signer.sign(msg); - assertTrue(sig.length > 0 && sig.length <= FNDSA512.SIGNATURE_LENGTH); + assertTrue(sig.length > 0 && sig.length <= FNDSA512.SIGNATURE_MAX_LENGTH); assertTrue(signer.verify(msg, sig)); } @@ -278,7 +307,7 @@ public void registryDispatchMatchesDirectCalls() { assertTrue(FNDSA512.verify(pk.getH(), msg, sigViaRegistry)); assertEquals(FNDSA512.PUBLIC_KEY_LENGTH, PQSchemeRegistry.getPublicKeyLength(PQScheme.FN_DSA_512)); - assertEquals(FNDSA512.SIGNATURE_LENGTH, + assertEquals(FNDSA512.SIGNATURE_MAX_LENGTH, PQSchemeRegistry.getSignatureLength(PQScheme.FN_DSA_512)); } @@ -287,12 +316,12 @@ public void registryIsValidSignatureLengthRespectsBounds() { assertTrue(PQSchemeRegistry.isValidSignatureLength( PQScheme.FN_DSA_512, FNDSA512.SIGNATURE_MIN_LENGTH)); assertTrue(PQSchemeRegistry.isValidSignatureLength( - PQScheme.FN_DSA_512, FNDSA512.SIGNATURE_LENGTH)); + PQScheme.FN_DSA_512, FNDSA512.SIGNATURE_MAX_LENGTH)); assertFalse(PQSchemeRegistry.isValidSignatureLength(PQScheme.FN_DSA_512, 0)); assertFalse(PQSchemeRegistry.isValidSignatureLength( PQScheme.FN_DSA_512, FNDSA512.SIGNATURE_MIN_LENGTH - 1)); assertFalse(PQSchemeRegistry.isValidSignatureLength( - PQScheme.FN_DSA_512, FNDSA512.SIGNATURE_LENGTH + 1)); + PQScheme.FN_DSA_512, FNDSA512.SIGNATURE_MAX_LENGTH + 1)); } @Test @@ -400,7 +429,7 @@ public void staticSignAcceptsExtendedPrivateKey() { byte[] extended = keypair.getPrivateKeyWithPublicKey(); byte[] msg = "static-sign-extended".getBytes(); byte[] sig = FNDSA512.sign(extended, msg); - assertTrue(sig.length > 0 && sig.length <= FNDSA512.SIGNATURE_LENGTH); + assertTrue(sig.length > 0 && sig.length <= FNDSA512.SIGNATURE_MAX_LENGTH); assertTrue(FNDSA512.verify(pk.getH(), msg, sig)); } diff --git a/framework/src/test/java/org/tron/common/runtime/vm/BatchValidateFnDsa512Test.java b/framework/src/test/java/org/tron/common/runtime/vm/BatchValidateFnDsa512Test.java index a052f2bd99b..ae39f8b48aa 100644 --- a/framework/src/test/java/org/tron/common/runtime/vm/BatchValidateFnDsa512Test.java +++ b/framework/src/test/java/org/tron/common/runtime/vm/BatchValidateFnDsa512Test.java @@ -303,7 +303,7 @@ public void slotShorterThan666_clearsBit() { contract.setConstantCall(true); FNDSA512 k = new FNDSA512(); byte[] sig = k.sign(HASH); - byte[] shortSlot = Arrays.copyOf(sig, FNDSA512.SIGNATURE_LENGTH - 1); + byte[] shortSlot = Arrays.copyOf(sig, FNDSA512.SIGNATURE_MAX_LENGTH - 2); List sigs = Collections1(Hex.toHexString(shortSlot)); List pks = Collections1(Hex.toHexString(k.getPublicKey())); List addrs = Collections1(addrAsBytes32Hex(k.getPublicKey())); @@ -316,7 +316,7 @@ public void slotShorterThan666_clearsBit() { public void allZeroSlot_clearsBit() { contract.setConstantCall(true); FNDSA512 k = new FNDSA512(); - byte[] zeroSlot = new byte[FNDSA512.SIGNATURE_LENGTH]; + byte[] zeroSlot = new byte[FNDSA512.SIGNATURE_MAX_LENGTH - 1]; List sigs = Collections1(Hex.toHexString(zeroSlot)); List pks = Collections1(Hex.toHexString(k.getPublicKey())); List addrs = Collections1(addrAsBytes32Hex(k.getPublicKey())); @@ -328,15 +328,17 @@ public void allZeroSlot_clearsBit() { // -------- helpers -------- /** - * Pin a canonical Falcon-512 signature into the precompile's fixed 666-byte slot. - * BC's variable-length encoding is preserved at the head; the tail is zero-padded. - * Mirrors the EIP-8052 slot convention enforced by 0x16 / 0x1a / 0x18. + * Pin a Falcon-512 signature into the precompile's fixed 666-byte slot using the + * EIP-8052 headerless convention enforced by 0x16 / 0x1a / 0x18: strip BC's leading + * 0x39 header so the slot holds {@code salt ‖ s2}; the tail is zero-padded. */ private static byte[] padSlot(byte[] sig) { - if (sig.length > FNDSA512.SIGNATURE_LENGTH) { + if (sig.length > FNDSA512.SIGNATURE_MAX_LENGTH) { throw new IllegalStateException("Falcon sig longer than slot: " + sig.length); } - return Arrays.copyOf(sig, FNDSA512.SIGNATURE_LENGTH); + byte[] slot = new byte[FNDSA512.SIGNATURE_MAX_LENGTH - 1]; + System.arraycopy(sig, 1, slot, 0, sig.length - 1); + return slot; } diff --git a/framework/src/test/java/org/tron/common/runtime/vm/FnDsaPrecompileTest.java b/framework/src/test/java/org/tron/common/runtime/vm/FnDsaPrecompileTest.java index 7e6cc5a4d99..e200b0726b4 100644 --- a/framework/src/test/java/org/tron/common/runtime/vm/FnDsaPrecompileTest.java +++ b/framework/src/test/java/org/tron/common/runtime/vm/FnDsaPrecompileTest.java @@ -13,6 +13,8 @@ /** * Unit tests for the FN-DSA / Falcon-512 (0x16) verify precompile (EIP-8052 / TRON extension). * Input layout (fixed-length): [msg 32B | sig 666B (zero-padded) | pk 896B] = 1594B total. + * The 666-byte sig slot holds the EIP-8052 headerless body (salt ‖ s2): BC's + * leading 0x39 header is stripped on the way in and re-inserted by the precompile. * Stateless — no chain DB. */ public class FnDsaPrecompileTest { @@ -21,7 +23,7 @@ public class FnDsaPrecompileTest { "0000000000000000000000000000000000000000000000000000000000000016"); private static final int INPUT_LEN = - 32 + FNDSA512.SIGNATURE_LENGTH + FNDSA512.PUBLIC_KEY_LENGTH; + 32 + FNDSA512.SIGNATURE_MAX_LENGTH - 1 + FNDSA512.PUBLIC_KEY_LENGTH; private static final byte[] MESSAGE_HASH = new byte[32]; @@ -85,7 +87,8 @@ public void tamperedMessage_returnsZero() { public void tamperedSignature_returnsZero() { FNDSA512 key = new FNDSA512(); byte[] sig = key.sign(MESSAGE_HASH); - sig[0] ^= 0x01; + // sig[0] is the 0x39 header, stripped by buildInput; flip a salt byte instead. + sig[1] ^= 0x01; byte[] input = buildInput(MESSAGE_HASH, sig, key.getPublicKey()); Pair result = @@ -146,12 +149,12 @@ public void trailingBytes_returnsZero() { @Test public void emptySigSlot_returnsZero() { - // All-zero sig slot → recovered length 0 → below SIGNATURE_MIN_LENGTH (41). + // All-zero sig slot -> recovered length 0, below the headerless minimum. FNDSA512 key = new FNDSA512(); byte[] input = new byte[INPUT_LEN]; System.arraycopy(MESSAGE_HASH, 0, input, 0, 32); System.arraycopy(key.getPublicKey(), 0, input, - 32 + FNDSA512.SIGNATURE_LENGTH, FNDSA512.PUBLIC_KEY_LENGTH); + 32 + FNDSA512.SIGNATURE_MAX_LENGTH - 1, FNDSA512.PUBLIC_KEY_LENGTH); Pair result = PrecompiledContracts.getContractForAddress(FNDSA_ADDR).execute(input); @@ -162,14 +165,15 @@ public void emptySigSlot_returnsZero() { @Test public void sigSlotShorterThanMin_returnsZero() { - // Recovered logical length 32 (last non-zero at offset 31 of sig slot) is below - // SIGNATURE_MIN_LENGTH (41) — too short to contain header + nonce. + // Recovered headerless body length 32 (last non-zero at offset 31 of sig slot) is + // below the headerless minimum (SIGNATURE_MIN_LENGTH - 1 = 616) — too short to + // contain a syntactically well-formed compressed_s2 body. FNDSA512 key = new FNDSA512(); byte[] input = new byte[INPUT_LEN]; System.arraycopy(MESSAGE_HASH, 0, input, 0, 32); input[32 + 31] = (byte) 0xFF; System.arraycopy(key.getPublicKey(), 0, input, - 32 + FNDSA512.SIGNATURE_LENGTH, FNDSA512.PUBLIC_KEY_LENGTH); + 32 + FNDSA512.SIGNATURE_MAX_LENGTH - 1, FNDSA512.PUBLIC_KEY_LENGTH); Pair result = PrecompiledContracts.getContractForAddress(FNDSA_ADDR).execute(input); @@ -179,16 +183,16 @@ public void sigSlotShorterThanMin_returnsZero() { } /** - * Encodes input as [msg 32B | sig 666B (zero-padded) | pk 896B]. The caller's - * {@code sig} must satisfy {@code FNDSA512.SIGNATURE_MIN_LENGTH <= sig.length - * <= FNDSA512.SIGNATURE_LENGTH}; bytes beyond {@code sig.length} are zero-padded - * to fill the 666-byte slot. + * Encodes input as [msg 32B | sig 666B (zero-padded) | pk 896B]. The caller passes + * a BC-native headered signature ({@code 0x39 ‖ salt ‖ s2}); this strips the leading + * 0x39 header to produce the EIP-8052 headerless body the precompile expects, then + * zero-pads the tail to fill the 666-byte slot. */ private static byte[] buildInput(byte[] msg, byte[] sig, byte[] pk) { byte[] out = new byte[INPUT_LEN]; System.arraycopy(msg, 0, out, 0, 32); - System.arraycopy(sig, 0, out, 32, sig.length); - System.arraycopy(pk, 0, out, 32 + FNDSA512.SIGNATURE_LENGTH, pk.length); + System.arraycopy(sig, 1, out, 32, sig.length - 1); + System.arraycopy(pk, 0, out, 32 + FNDSA512.SIGNATURE_MAX_LENGTH - 1, pk.length); return out; } } diff --git a/framework/src/test/java/org/tron/common/runtime/vm/ValidateMultiPQSigTest.java b/framework/src/test/java/org/tron/common/runtime/vm/ValidateMultiPQSigTest.java index 401c2e75bde..7c7f0545479 100644 --- a/framework/src/test/java/org/tron/common/runtime/vm/ValidateMultiPQSigTest.java +++ b/framework/src/test/java/org/tron/common/runtime/vm/ValidateMultiPQSigTest.java @@ -461,7 +461,7 @@ public void falconSigSlotExact666_returnsOne() { byte[] toSign = computeHash(owner.getAddress(), 2, data); byte[] padded = padFalconSig(falcon.sign(toSign)); - Assert.assertEquals(FNDSA512.SIGNATURE_LENGTH, padded.length); + Assert.assertEquals(FNDSA512.SIGNATURE_MAX_LENGTH - 1, padded.length); List schemes = Collections.singletonList(TAG_FN_DSA_512); List pqSigs = Collections.singletonList(Hex.toHexString(padded)); @@ -485,7 +485,7 @@ public void falconSigSlotNot666_returnsZero() { // Trim the slot one byte short of 666 — must be rejected (slot length exact). byte[] shortSlot = Arrays.copyOf(padFalconSig(falcon.sign(toSign)), - FNDSA512.SIGNATURE_LENGTH - 1); + FNDSA512.SIGNATURE_MAX_LENGTH - 2); List schemes = Collections.singletonList(TAG_FN_DSA_512); List pqSigs = Collections.singletonList(Hex.toHexString(shortSlot)); @@ -498,7 +498,8 @@ public void falconSigSlotNot666_returnsZero() { @Test public void falconSigAllZero_returnsZero() { - // All-zero 666-byte slot — recoverFalconSigLen returns 0, below SIGNATURE_MIN_LENGTH. + // All-zero 666-byte slot: recoverFalconSigLen returns 0, below the headerless + // minimum. FNDSA512 falcon = new FNDSA512(); ECKey owner = new ECKey(); byte[] addr = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, falcon.getPublicKey()); @@ -507,7 +508,7 @@ public void falconSigAllZero_returnsZero() { byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); - byte[] zeros = new byte[FNDSA512.SIGNATURE_LENGTH]; + byte[] zeros = new byte[FNDSA512.SIGNATURE_MAX_LENGTH - 1]; List schemes = Collections.singletonList(TAG_FN_DSA_512); List pqSigs = Collections.singletonList(Hex.toHexString(zeros)); List pqPks = Collections.singletonList(Hex.toHexString(falcon.getPublicKey())); @@ -749,16 +750,18 @@ public void thresholdNotReached_returnsZero() { // -------- helpers -------- /** - * Zero-pad a logical Falcon signature out to the precompile's 666-byte slot. - * Canonical Falcon encodings always end in a non-zero {@code compressed_s2} - * terminator, so {@code recoverFalconSigLen} can recover the logical length - * inside the precompile. + * Pin a Falcon signature into the precompile's 666-byte slot using the EIP-8052 + * headerless convention: strip BC's leading 0x39 header so the slot holds + * {@code salt ‖ s2}, then zero-pad. The body ends in a non-zero + * {@code compressed_s2} terminator, so the precompile recovers its length. */ private static byte[] padFalconSig(byte[] sig) { - if (sig.length > FNDSA512.SIGNATURE_LENGTH) { + if (sig.length > FNDSA512.SIGNATURE_MAX_LENGTH) { throw new IllegalStateException("Falcon sig longer than slot: " + sig.length); } - return Arrays.copyOf(sig, FNDSA512.SIGNATURE_LENGTH); + byte[] slot = new byte[FNDSA512.SIGNATURE_MAX_LENGTH - 1]; + System.arraycopy(sig, 1, slot, 0, sig.length - 1); + return slot; } private void setupPermission(ECKey owner, diff --git a/framework/src/test/java/org/tron/core/BandwidthProcessorTest.java b/framework/src/test/java/org/tron/core/BandwidthProcessorTest.java index 5467777e538..6a8810fb93b 100755 --- a/framework/src/test/java/org/tron/core/BandwidthProcessorTest.java +++ b/framework/src/test/java/org/tron/core/BandwidthProcessorTest.java @@ -906,7 +906,7 @@ public void pqPQAuthWitnessBytesSubtractedInCreateAccountCap() throws Exception .setAmount(100L) .build(); - byte[] fakeSig = new byte[FNDSA512.SIGNATURE_LENGTH]; + byte[] fakeSig = new byte[FNDSA512.SIGNATURE_MAX_LENGTH]; byte[] fakePub = new byte[FNDSA512.PUBLIC_KEY_LENGTH]; Protocol.PQAuthSig pqAuthSig = Protocol.PQAuthSig.newBuilder() .setScheme(Protocol.PQScheme.FN_DSA_512) @@ -968,7 +968,7 @@ public void pqPQAuthWitnessCountedInBandwidthUsage() throws Exception { .setAmount(100L) .build(); - byte[] fakeSig = new byte[FNDSA512.SIGNATURE_LENGTH]; + byte[] fakeSig = new byte[FNDSA512.SIGNATURE_MAX_LENGTH]; byte[] fakePub = new byte[FNDSA512.PUBLIC_KEY_LENGTH]; Protocol.PQAuthSig pqAuthSig = Protocol.PQAuthSig.newBuilder() .setScheme(Protocol.PQScheme.FN_DSA_512) diff --git a/framework/src/test/java/org/tron/core/capsule/TransactionCapsuleTest.java b/framework/src/test/java/org/tron/core/capsule/TransactionCapsuleTest.java index ea019835181..32adea8e4cc 100644 --- a/framework/src/test/java/org/tron/core/capsule/TransactionCapsuleTest.java +++ b/framework/src/test/java/org/tron/core/capsule/TransactionCapsuleTest.java @@ -182,7 +182,7 @@ public void pqAuthSigBeforeActivationRejected() { .addPqAuthSig(PQAuthSig.newBuilder() .setScheme(PQScheme.FN_DSA_512) .setPublicKey(ByteString.copyFrom(new byte[FNDSA512.PUBLIC_KEY_LENGTH])) - .setSignature(ByteString.copyFrom(new byte[FNDSA512.SIGNATURE_LENGTH])) + .setSignature(ByteString.copyFrom(new byte[FNDSA512.SIGNATURE_MAX_LENGTH])) .build()) .build(); TransactionCapsule cap = new TransactionCapsule(tx); From 0c56c626f4f4b21684da574692dc429cae2b0c85 Mon Sep 17 00:00:00 2001 From: federico Date: Fri, 29 May 2026 17:19:54 +0800 Subject: [PATCH 47/47] fix(framework): use locale for pq tps key --- .../java/org/tron/common/crypto/pqc/program/PQTxSender.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/program/PQTxSender.java b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQTxSender.java index fe06f9c5126..fd3d956f34b 100644 --- a/framework/src/test/java/org/tron/common/crypto/pqc/program/PQTxSender.java +++ b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQTxSender.java @@ -10,6 +10,7 @@ import java.util.Arrays; import java.util.EnumMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; @@ -220,7 +221,7 @@ public static void main(String[] args) throws Exception { /** Lowercase, hyphenated form of the scheme name for tag/property keys. */ private static String tpsKey(PQScheme scheme) { - return scheme.name().toLowerCase().replace('_', '-'); + return scheme.name().toLowerCase(Locale.ROOT).replace('_', '-'); } private static byte[] sha256(byte[] data) throws Exception {