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/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 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..3975f8a631b 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,38 @@ public static void validator(DynamicPropertiesStore dynamicPropertiesStore, } break; } + case ALLOW_FN_DSA_512: { + if (!forkController.pass(ForkBlockVersionEnum.VERSION_4_8_2)) { + throw new ContractValidateException( + "Bad chain parameter id [ALLOW_FN_DSA_512]"); + } + if (value != 0 && value != 1) { + throw new ContractValidateException( + "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; + } + 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; } @@ -1029,7 +1061,10 @@ 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(99), // 0, 1 + ALLOW_ML_DSA_44(100); // 0, 1 + private long code; ProposalType(long code) { 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..3d89dd45c74 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,14 +222,28 @@ public TransactionSignWeight getTransactionSignWeight(Transaction trx) { } } tswBuilder.setPermission(permission); - if (trx.getSignatureCount() > 0) { - List approveList = new ArrayList<>(); - long currentWeight = TransactionCapsule.checkWeight(permission, trx.getSignatureList(), + 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); - tswBuilder.addAllApprovedList(approveList); - tswBuilder.setCurrentWeight(currentWeight); } + 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); + if (tswBuilder.getCurrentWeight() >= permission.getThreshold()) { resultBuilder.setCode(Result.response_code.ENOUGH_PERMISSION); } else { 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..e9080fd2a92 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; @@ -47,12 +49,16 @@ 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; import org.tron.common.crypto.Rsv; 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; import org.tron.common.crypto.zksnark.BN128G1; @@ -83,6 +89,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 +124,14 @@ public class PrecompiledContracts { private static final Blake2F blake2F = new Blake2F(); private static final P256Verify p256Verify = new P256Verify(); + 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(); + // FreezeV2 PrecompileContracts private static final GetChainParameter getChainParameter = new GetChainParameter(); private static final AvailableUnfreezeV2Size availableUnfreezeV2Size = new AvailableUnfreezeV2Size(); @@ -212,6 +227,46 @@ 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 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"); + + // 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 + // the bitmaps client-side. + private static final DataWord batchValidateFnDsa512Addr = new DataWord( + "0000000000000000000000000000000000000000000000000000000000000018"); + + // 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"); + + // 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. + private static final DataWord batchValidateMlDsa44Addr = new DataWord( + "000000000000000000000000000000000000000000000000000000000000001b"); + public static PrecompiledContract getOptimizedContractForConstant(PrecompiledContract contract) { try { Constructor constructor = contract.getClass().getDeclaredConstructor(); @@ -297,6 +352,41 @@ public static PrecompiledContract getContractForAddress(DataWord address) { return p256Verify; } + // 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(batchValidateFnDsa512Addr)) { + return batchValidateFnDsa512; + } + } + + // 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; + } + if (address.equals(batchValidateMlDsa44Addr)) { + return batchValidateMlDsa44; + } + } + if (VMConfig.allowTvmFreezeV2()) { if (address.equals(getChainParameterAddr)) { return getChainParameter; @@ -396,11 +486,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; @@ -426,7 +525,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 = StrictMathWrapper.min(len, data.length - offset); + return Arrays.copyOfRange(data, offset, offset + safe); } private static boolean isValidAbiEncoding(byte[] data, int headerWords, int itemWords) { @@ -437,8 +545,110 @@ 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; + } + + /** + * 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 + * (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; + } + + /** + * 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 + * breaks caller expectations and must be avoided. + * + *

Single-verify convention (e.g. {@code VerifyFnDsa512} 0x16, + * {@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; + * 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; @@ -2380,4 +2590,713 @@ public Pair execute(byte[] data) { } } + /** + * Verifies a FN-DSA / Falcon-512 signature (FIPS-206 draft). EIP-8052 / TRON extension. + * + *

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

+   *   [msg 32B | sig 666B (zero-padded) | pk 896B]  total = 1594B
+   * 
+ * 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 + * 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_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; + + @Override + public long getEnergyForData(byte[] data) { + return 4000; + } + + @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); + int sigStart = MSG_LEN; + int sigEnd = MSG_LEN + 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[] 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()); + } + } + + } + + + /** + * 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 FNDSA512.verify(pk_i, hash, sig_i)}. + * + *

ABI: + *

+   *   batchValidateFnDsa512(
+   *       bytes32   hash,                  // word[0]
+   *       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 #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}. + * Energy is {@code cnt × 2000}. + */ + 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_MAX_LENGTH - 1; + // 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) { + cancelAll(futures); + logger.info("BatchValidateFnDsa512 timeout"); + throw Program.Exception.notEnoughTime("call BatchValidateFnDsa512 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_SLOT_LEN) { + return false; + } + // 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; + } + try { + byte[] derived = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pk); + if (!DataWord.equalAddressByteArray(derived, expectedAddr)) { + return false; + } + return FNDSA512.verify(pk, hash, canonicalSig); + } 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; + } + } + + /** + * Verifies an ML-DSA-44 signature (FIPS 204 / CRYSTALS-Dilithium-2). + * + *

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. + * + *

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. The EIP-8051 expanded-pk + * variant is implemented separately at 0x12 — 0x19 stays as-is. + */ + 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()); + } + } + } + + /** + * 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, ...) + * 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 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 + * 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 ValidateMultiPQSig extends PrecompiledContract { + + private static final int ECDSA_ENERGY_PER_SIGN = 1500; + 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; + // 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 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 * WORST_PQ_ENERGY; + } + } + + @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) + || !isValidArrayOffset(words, 6, 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 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(); + + // 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); + } + + 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) { + 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 < 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]; + int expectedPkLen = PQSchemeRegistry.getPublicKeyLength(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) — + // a Falcon sig mislabelled as Dilithium fails this check. + return Pair.of(true, DATA_FALSE); + } + if (scheme == PQScheme.FN_DSA_512) { + // 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); + } + } + byte[] derivedAddr; + try { + derivedAddr = PQSchemeRegistry.computeAddress(scheme, pk); + } catch (Throwable t) { + return Pair.of(true, DATA_FALSE); + } + // 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; + } + long weight = TransactionCapsule.getWeight(permission, derivedAddr); + if (weight == 0) { + return Pair.of(true, DATA_FALSE); + } + if (!PQSchemeRegistry.verify(scheme, 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) { + cancelAll(futures); + 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 881eb861bea..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 @@ -47,6 +47,8 @@ public static void load(StoreFactory storeFactory) { VMConfig.initAllowTvmSelfdestructRestriction(ds.getAllowTvmSelfdestructRestriction()); VMConfig.initAllowTvmOsaka(ds.getAllowTvmOsaka()); VMConfig.initAllowHardenResourceCalculation(ds.getAllowHardenResourceCalculation()); + VMConfig.initAllowFnDsa512(ds.getAllowFnDsa512()); + VMConfig.initAllowMlDsa44(ds.getAllowMlDsa44()); } } } 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..cd7de299ee2 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,18 @@ 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.common.crypto.pqc.PqKeypair; 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,9 +37,35 @@ 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 + * 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 + * non-determinism in BC's Falcon keygen — operators generate the keypair + * off-line and ship both halves to the node. + */ + @Getter + private List pqKeypairs = Lists.newArrayList(); + + /** + * 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() { } @@ -47,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); @@ -59,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. */ @@ -95,6 +152,48 @@ public void addPrivateKeys(String privateKey) { this.privateKeys.add(privateKey); } + /** + * 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; + } + 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"); + } + this.pqKeypairs = pqKeypairs; + } + + 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..64923a9bf53 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,8 @@ import org.tron.core.store.DynamicPropertiesStore; import org.tron.protos.Protocol.Block; import org.tron.protos.Protocol.BlockHeader; +import org.tron.protos.Protocol.PQScheme; +import org.tron.protos.Protocol.PQAuthSig; import org.tron.protos.Protocol.Transaction; @Slf4j(topic = "capsule") @@ -170,13 +173,26 @@ 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(); } + public void setPqAuthSig(PQAuthSig pqAuthSig) { + BlockHeader blockHeader = this.block.getBlockHeader().toBuilder() + .clearWitnessSignature() + .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 +200,100 @@ private Sha256Hash getRawHash() { public boolean validateSignature(DynamicPropertiesStore dynamicPropertiesStore, AccountStore accountStore) throws ValidateSignatureException { + BlockHeader header = block.getBlockHeader(); + byte[] witnessAccountAddress = header.getRawData().getWitnessAddress() + .toByteArray(); + + byte[] witnessPermissionAddress; + if (dynamicPropertiesStore.getAllowMultiSign() != 1) { + witnessPermissionAddress = witnessAccountAddress; + } else { + 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(); + boolean hasPq = header.hasPqAuthSig(); + + if (hasLegacy == hasPq) { + throw new ValidateSignatureException( + 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()); + } + return validateLegacySignature(header, witnessPermissionAddress); + } + + private boolean validateLegacySignature(BlockHeader header, byte[] witnessPermissionAddress) + throws ValidateSignatureException { try { byte[] sigAddress = SignUtils.signatureToAddress(getRawHash().getBytes(), - TransactionCapsule.getBase64FromByteString( - block.getBlockHeader().getWitnessSignature()), + TransactionCapsule.getBase64FromByteString(header.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); - } + 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, + 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( + "pq_auth_sig scheme " + scheme + " is not registered"); + } + if (!dynamicPropertiesStore.isPqSchemeAllowed(scheme)) { + throw new ValidateSignatureException( + "pq_auth_sig scheme " + scheme + " is not activated"); + } + + 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[] derivedAddr = PQSchemeRegistry.computeAddress(scheme, publicKey); + if (!Arrays.equals(derivedAddr, witnessPermissionAddress)) { + throw new ValidateSignatureException( + "pq_auth_sig public key does not match witness permission address"); + } + + byte[] signature = pqAuthSig.getSignature().toByteArray(); + if (!PQSchemeRegistry.isValidSignatureLength(scheme, signature.length)) { + throw new ValidateSignatureException( + "pq_auth_sig signature length mismatch for scheme " + scheme); + } + + byte[] digest = getRawHash().getBytes(); + return PQSchemeRegistry.verify(scheme, publicKey, digest, signature); + } + public BlockId getBlockId() { if (blockId.equals(Sha256Hash.ZERO_HASH)) { blockId = @@ -325,7 +414,9 @@ public long getTimeStamp() { } public boolean hasWitnessSignature() { - return !getInstance().getBlockHeader().getWitnessSignature().isEmpty(); + BlockHeader header = getInstance().getBlockHeader(); + return !header.getWitnessSignature().isEmpty() + || !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 bb4b70cde1b..5987073cb7d 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; @@ -45,7 +47,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 +70,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 +493,23 @@ 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. + List approveList = new ArrayList<>(); + long weight = checkWeight(permission, transaction.getSignatureList(), hash, approveList); + + if (dynamicPropertiesStore.isAnyPqSchemeAllowed() && transaction.getPqAuthSigCount() > 0) { + try { + weight = StrictMathWrapper.addExact(weight, + validatePQSignatureGetWeight(transaction, permission, dynamicPropertiesStore, + approveList)); + } catch (ArithmeticException e) { + throw new PermissionException("weight overflow"); + } } - return false; + return weight >= permission.getThreshold(); } public void resetResult() { @@ -620,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) { @@ -640,12 +658,23 @@ 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 signatureCount = this.transaction.getSignatureCount(); + int pqCount = this.transaction.getPqAuthSigCount(); + + if (dynamicPropertiesStore.isAnyPqSchemeAllowed()) { + signatureCount += pqCount; + } else if (pqCount > 0) { + throw new ValidateSignatureException( + "pq_auth_sig not allowed: no post-quantum scheme is activated"); + } + + if (signatureCount == 0) { + throw new ValidateSignatureException("miss sig"); } - if (this.transaction.getSignatureCount() > dynamicPropertiesStore - .getTotalSignNum()) { + if (this.transaction.getRawData().getContractCount() <= 0) { + throw new ValidateSignatureException("miss contract"); + } + if (signatureCount > dynamicPropertiesStore.getTotalSignNum()) { throw new ValidateSignatureException("too many signatures"); } @@ -681,6 +710,84 @@ 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. + *
+ */ + 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(); + 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"); + } + if (approveList != null) { + approveList.add(addrBs); + } + } + return weight; + } + + private static Sha256Hash computeRawHash(Transaction transaction) { + return Sha256Hash.of(CommonParameter.getInstance().isECKeyCryptoEngine(), + transaction.getRawData().toByteArray()); + } + /** * validate signature */ @@ -696,14 +803,15 @@ 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"); } } } isVerified = true; - } + } return true; } 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..6b7e9795bf5 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; @@ -140,8 +141,18 @@ public void consume(TransactionCapsule trx, TransactionTrace trace) if (optimizeTxs) { long maxCreateAccountTxSize = dynamicPropertiesStore.getMaxCreateAccountTxSize(); int signatureCount = trx.getInstance().getSignatureCount(); + long sigOverhead = signatureCount * PER_SIGN_LENGTH; + + // 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(); + } + 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..217765c8909 100644 --- a/chainbase/src/main/java/org/tron/core/store/DynamicPropertiesStore.java +++ b/chainbase/src/main/java/org/tron/core/store/DynamicPropertiesStore.java @@ -16,11 +16,13 @@ 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; 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 +260,10 @@ 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(); + + private static final byte[] ALLOW_ML_DSA_44 = "ALLOW_ML_DSA_44".getBytes(); + @Autowired private DynamicPropertiesStore(@Value("properties") String dbName) { super(dbName); @@ -3083,6 +3089,70 @@ 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; + } + + 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 + * adding a new scheme to the registry (and its corresponding case in + * {@link #isPqSchemeAllowed}) automatically propagates here — no manual edit + * needed. + */ + public boolean isAnyPqSchemeAllowed() { + for (PQScheme scheme : PQSchemeRegistry.registeredSchemes()) { + if (isPqSchemeAllowed(scheme)) { + return true; + } + } + return false; + } + + /** + * Per-scheme governance check. Each registered scheme has its own flag so + * activation is independent. + */ + public boolean isPqSchemeAllowed(PQScheme scheme) { + if (scheme == null) { + return false; + } + switch (scheme) { + case FN_DSA_512: + return allowFnDsa512(); + case ML_DSA_44: + return allowMlDsa44(); + 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..b2704e55e24 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,15 @@ public class CommonParameter { @Setter public long allowTvmBlob; + @Getter + @Setter + public long allowFnDsa512; + + @Getter + @Setter + public long allowMlDsa44; + + private static double calcMaxTimeRatio() { return 5.0; } 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..7e9dfa566b9 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,25 @@ 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"; + + /** + * 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 fa42a59aeaa..d792372e177 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,10 @@ 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.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/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..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 @@ -2,22 +2,31 @@ 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, + * 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 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 String pqAccountAddress = null; private List keystores = new ArrayList<>(); + private List pqEntries = Collections.emptyList(); public static LocalWitnessConfig fromConfig(Config config) { LocalWitnessConfig lw = new LocalWitnessConfig(); @@ -27,9 +36,24 @@ 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"); } + 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/java/org/tron/core/vm/config/VMConfig.java b/common/src/main/java/org/tron/core/vm/config/VMConfig.java index 94c1e50284e..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 @@ -65,6 +65,10 @@ public class VMConfig { private static boolean ALLOW_HARDEN_RESOURCE_CALCULATION = false; + private static boolean ALLOW_FN_DSA_512 = false; + + private static boolean ALLOW_ML_DSA_44 = false; + private VMConfig() { } @@ -184,6 +188,14 @@ 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 void initAllowMlDsa44(long allow) { + ALLOW_ML_DSA_44 = allow == 1; + } + public static boolean getEnergyLimitHardFork() { return CommonParameter.ENERGY_LIMIT_HARD_FORK; } @@ -291,4 +303,12 @@ public static boolean allowTvmOsaka() { public static boolean allowHardenResourceCalculation() { return ALLOW_HARDEN_RESOURCE_CALCULATION; } + + public static boolean allowFnDsa512() { + return ALLOW_FN_DSA_512; + } + + public static boolean allowMlDsa44() { + return ALLOW_ML_DSA_44; + } } 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..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 @@ -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; @@ -24,7 +25,19 @@ 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 @@ -45,4 +58,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 f7b7de3d084..b08648c47ba 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,10 +68,98 @@ public class Miner { @Setter private ByteString witnessAddress; + /** + * 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 + 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); + } + + /** True iff this miner signs via the PQ path (i.e. has a {@link PQMiner}). */ + public boolean isPq() { + return pq != null; + } + + /** + * 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; + } + + /** + * 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; + } + + /** + * 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/FNDSA512.java b/crypto/src/main/java/org/tron/common/crypto/pqc/FNDSA512.java new file mode 100644 index 00000000000..6e61d8ec135 --- /dev/null +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/FNDSA512.java @@ -0,0 +1,373 @@ +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.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: every accepted + * signature must fall within {@code [}{@link #SIGNATURE_MIN_LENGTH}{@code ,} + * {@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. + */ +public final class FNDSA512 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; + /** + * 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_MAX_LENGTH = 667; + /** + * 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 = 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_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. + */ + 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; + + private static final FalconParameters PARAMS = FalconParameters.falcon_512; + private static final SecureRandom SIGNING_RNG = new SecureRandom(); + + private final byte[] privateKey; + private final byte[] publicKey; + + public FNDSA512() { + AsymmetricCipherKeyPair kp = generateKeyPair(new SecureRandom()); + this.privateKey = ((FalconPrivateKeyParameters) kp.getPrivate()).getEncoded(); + this.publicKey = ((FalconPublicKeyParameters) kp.getPublic()).getH(); + } + + public FNDSA512(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 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); + } + if (publicKey == null || publicKey.length != PUBLIC_KEY_LENGTH) { + throw new IllegalArgumentException( + "FN-DSA public key length must be " + PUBLIC_KEY_LENGTH); + } + requireConsistent(privateKey, publicKey); + 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 FNDSA512(byte[])} constructor because Java cannot + * overload {@link #FNDSA512(byte[]) the seed constructor} on length alone. + */ + public static FNDSA512 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 FNDSA512(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 canonical signature length upper bound (signatures are variable-length). */ + @Override + public int getSignatureLength() { + return SIGNATURE_MAX_LENGTH; + } + + /** + * FN-DSA signatures are variable-length; the lower bound is the smallest + * syntactically well-formed compressed encoding. + */ + @Override + public int getSignatureMinLength() { + return SIGNATURE_MIN_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 < SIGNATURE_MIN_LENGTH + || signature.length > SIGNATURE_MAX_LENGTH) { + throw new IllegalArgumentException( + "FN-DSA signature length must be " + + 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); + 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. + * + *

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_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 + * 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); + 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, SIGNING_RNG)); + Exception lastFailure = null; + for (int attempt = 0; attempt < SIGN_RETRY_BUDGET; attempt++) { + try { + byte[] sig = signer.generateSignature(message); + if (sig.length <= SIGNATURE_MAX_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_MAX_LENGTH + " bytes after " + SIGN_RETRY_BUDGET + " attempts", + lastFailure); + } + + /** + * 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) { + 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; + } + 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(); + } + + /** + * 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 + && 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/MLDSA44.java b/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA44.java new file mode 100644 index 00000000000..e21bdd9eb1e --- /dev/null +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA44.java @@ -0,0 +1,217 @@ +package org.tron.common.crypto.pqc; + +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.crypto.signers.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 standard + * 1312-byte public key verify path used by the 0x19 precompile. + */ +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); + } + + 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(); + } + + /** + * 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[] derived; + try { + derived = derivePublicKey(privateKey); + } catch (RuntimeException e) { + throw new IllegalArgumentException("ML-DSA private key is malformed", e); + } + 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 new file mode 100644 index 00000000000..f2b3f6e2d24 --- /dev/null +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/PQSchemeRegistry.java @@ -0,0 +1,335 @@ +package org.tron.common.crypto.pqc; + +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; + +/** + * 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} 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 + * 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 { + + /** 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); + + /** + * 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; + } + } + + /** + * 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; + // 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 + // 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; + this.signatureLength = signatureLength; + this.signatureMinLength = signatureMinLength; + this.seedLength = seedLength; + this.seedDeterministic = seedDeterministic; + this.publicKeyRecoverable = publicKeyRecoverable; + 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( + FNDSA512.PRIVATE_KEY_LENGTH, FNDSA512.PUBLIC_KEY_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). + KECCAK_256, + new SignatureOps() { + @Override + public byte[] sign(byte[] privateKey, byte[] message) { + return FNDSA512.sign(privateKey, message); + } + + @Override + public boolean verify(byte[] publicKey, byte[] message, byte[] signature) { + return FNDSA512.verify(publicKey, message, signature); + } + + @Override + public PQSignature fromSeed(byte[] seed) { + return new FNDSA512(seed); + } + + @Override + 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.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 + 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); + } + + @Override + public byte[] derivePublicKey(byte[] privateKey) { + return MLDSA44.derivePublicKey(privateKey); + } + })); + SCHEMES = Collections.unmodifiableMap(m); + } + + private PQSchemeRegistry() { + } + + /** + * 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) { + return scheme; + } + + public static boolean contains(PQScheme scheme) { + if (scheme == null || scheme == PQScheme.UNKNOWN_PQ_SCHEME) { + return false; + } + return SCHEMES.containsKey(scheme); + } + + /** + * 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; + } + + 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; + } + + /** + * 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. 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); + 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) { + 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); + } + + /** + * 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. + */ + 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"); + } + 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); + } + 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..48c29a8b6a4 --- /dev/null +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/PQSignature.java @@ -0,0 +1,87 @@ +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(); + + /** + * 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(); + + /** + * 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 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) { + 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 " + (min == max ? String.valueOf(max) : (min + ".." + max))); + } + } +} 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 new file mode 100644 index 00000000000..4d6472e8c5f --- /dev/null +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/PqKeypair.java @@ -0,0 +1,22 @@ +package org.tron.common.crypto.pqc; + +import lombok.ToString; +import lombok.Value; +import org.tron.protos.Protocol.PQScheme; + +/** + * 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 0482643d8d0..775875acdae 100755 --- a/framework/src/main/java/org/tron/core/Wallet.java +++ b/framework/src/main/java/org/tron/core/Wallet.java @@ -1495,6 +1495,16 @@ public Protocol.ChainParameters getChainParameters() { .setValue(dbManager.getDynamicPropertiesStore().getAllowHardenExchangeCalculation()) .build()); + builder.addChainParameter(Protocol.ChainParameters.ChainParameter.newBuilder() + .setKey("getAllowFnDsa512") + .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 de8b7dba1ad..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 @@ -36,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; @@ -43,6 +44,9 @@ 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.crypto.pqc.PQSignature; +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; @@ -63,6 +67,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 @@ -492,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(); } /** @@ -925,32 +932,187 @@ private static void initLocalWitnesses(Config config, CLIParameter cmd) { return; } - // path 1: CLI --private-key - if (StringUtils.isNotBlank(cmd.privateKey)) { - localWitnesses = WitnessInitializer.initFromCLIPrivateKey( + LocalWitnessConfig lwConfig = LocalWitnessConfig.fromConfig(config); + boolean hasCliPriv = StringUtils.isNotBlank(cmd.privateKey); + boolean hasCfgPriv = !lwConfig.getPrivateKeys().isEmpty(); + boolean hasKeystore = !lwConfig.getKeystores().isEmpty(); + boolean hasPqKeys = !lwConfig.getPqEntries().isEmpty(); + + // Load the ECDSA source. CLI > config localwitness > keystore — the three + // legacy sources stay mutually exclusive among themselves. + LocalWitnesses ecdsaWitnesses = null; + if (hasCliPriv) { + 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()); } - LocalWitnessConfig lwConfig = LocalWitnessConfig.fromConfig(config); - - // path 2: config localwitness (private key list) - if (!lwConfig.getPrivateKeys().isEmpty()) { - 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). 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) { + pqWitnesses = buildPqWitnesses( + lwConfig.getPqEntries(), lwConfig.getPqAccountAddress()); + } + + 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); + } + + if (ecdsaWitnesses != null && pqWitnesses != null) { + LocalWitnesses merged = new LocalWitnesses(); + merged.setPrivateKeys(ecdsaWitnesses.getPrivateKeys()); + merged.setPqKeypairs(pqWitnesses.getPqKeypairs()); + // 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; + } else { + localWitnesses = pqWitnesses; } + } - // path 3: config localwitnesskeystore + password - if (!lwConfig.getKeystores().isEmpty()) { - localWitnesses = WitnessInitializer.initFromKeystore( - lwConfig.getKeystores(), cmd.password, lwConfig.getAccountAddress()); - return; + 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. + String path = LocalWitnessConfig.PQ_KEYS_PATH; + List pqKeypairs = new ArrayList<>(pqEntries.size()); + for (PqEntryConfig entry : pqEntries) { + int i = entry.getIndex(); + if (entry.getScheme() == null) { + throw new TronError(String.format( + "%s[%d] must define `scheme`", path, i), + TronError.ErrCode.WITNESS_INIT); + } + if (entry.hasKey() == entry.hasSeed()) { + throw new TronError(String.format( + "%s[%d] must define exactly one of `key` or `seed`", path, i), + TronError.ErrCode.WITNESS_INIT); + } + PQScheme scheme; + try { + scheme = PQScheme.valueOf(entry.getScheme()); + } catch (IllegalArgumentException e) { + throw new TronError(String.format("invalid %s[%d].scheme: %s", + 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", + path, i, entry.getScheme(), PQSchemeRegistry.registeredSchemes()), + TronError.ErrCode.WITNESS_INIT); + } + String privHex; + String pubHex; + 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()); + 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 %s hex chars for %s, actual: %d", + path, i, expected, scheme, len), + TronError.ErrCode.WITNESS_INIT); + } + privHex = stripped.substring(0, 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 + // 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", + path, i, scheme), + TronError.ErrCode.WITNESS_INIT); + } + int seedHexLen = PQSchemeRegistry.getSeedLength(scheme) * 2; + 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", + path, 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", + path, 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/WitnessInitializer.java b/framework/src/main/java/org/tron/core/config/args/WitnessInitializer.java index c2ce2ba0046..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 @@ -7,6 +7,8 @@ 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.crypto.pqc.PqKeypair; import org.tron.common.utils.ByteArray; import org.tron.common.utils.Commons; import org.tron.common.utils.LocalWitnesses; @@ -14,6 +16,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 { @@ -112,6 +115,46 @@ public static LocalWitnesses initFromKeystore( return witnesses; } + /** + * Init for PQ-only witness nodes (no legacy ECDSA key). Each PqKeypair + * 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. 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 pqWitnessAccountAddress) { + if (pqKeypairs == null || pqKeypairs.isEmpty()) { + throw new TronError( + "PQ keypairs must be set for PQ-only witness nodes", + TronError.ErrCode.WITNESS_INIT); + } + LocalWitnesses witnesses = new LocalWitnesses(); + witnesses.setPqKeypairs(pqKeypairs); + + byte[] explicit = null; + if (StringUtils.isNotBlank(pqWitnessAccountAddress)) { + if (pqKeypairs.size() != 1) { + throw new TronError( + "localPqWitnessAccountAddress can only be set when there is only one PQ keypair", + TronError.ErrCode.WITNESS_INIT); + } + explicit = Commons.decodeFromBase58Check(pqWitnessAccountAddress); + if (explicit == null) { + throw new TronError( + "localPqWitnessAccountAddress format is incorrect", + TronError.ErrCode.WITNESS_INIT); + } + logger.debug("Got localPqWitnessAccountAddress from config.conf"); + } else { + logger.debug("Derived PQ-only witness address from public key"); + } + witnesses.initPqWitnessAccountAddress(explicit); + return witnesses; + } + 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..0356c6e3cbf 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,18 @@ 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.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; 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 +51,8 @@ public void start() { param.setAgreeNodeCount(parameter.getAgreeNodeCount()); List miners = new ArrayList<>(); List privateKeys = Args.getLocalWitnesses().getPrivateKeys(); + List pqKeypairs = Args.getLocalWitnesses().getPqKeypairs(); + if (privateKeys.size() > 1) { for (String key : privateKeys) { byte[] privateKey = fromHexString(key); @@ -67,6 +74,9 @@ public void start() { byte[] privateKeyAddress = SignUtils.fromPrivate(privateKey, Args.getInstance().isECKeyCryptoEngine()).getAddress(); byte[] witnessAddress = Args.getLocalWitnesses().getWitnessAccountAddress(); + 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)); @@ -78,6 +88,24 @@ public void start() { miners.add(miner); } + if (pqKeypairs.size() > 1) { + for (PqKeypair kp : pqKeypairs) { + Miner miner = buildPQMiner(param, kp, null); + miners.add(miner); + logger.info("Add {} witness (from configured keypair): {}, size: {}", + kp.getScheme(), + Hex.toHexString(miner.getPq().getWitnessAddress().toByteArray()), + miners.size()); + } + } else if (pqKeypairs.size() == 1) { + Miner miner = buildPQMiner(param, pqKeypairs.get(0), + Args.getLocalWitnesses().getPqWitnessAccountAddress()); + miners.add(miner); + logger.info("Add {} witness (from configured keypair): {}", + miner.getPq().getScheme(), + Hex.toHexString(miner.getPq().getWitnessAddress().toByteArray())); + } + param.setMiners(miners); param.setBlockHandle(blockHandle); param.setPbftInterface(pbftBaseImpl); @@ -85,6 +113,39 @@ public void start() { logger.info("consensus service start success"); } + /** + * 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); + PQSignature keypair = PQSchemeRegistry.fromKeypair(scheme, + fromHexString(pqKeypair.getPrivateKey()), fromHexString(pqKeypair.getPublicKey())); + byte[] pqAddress = keypair.getAddress(); + byte[] witnessAddress = + (witnessAddressOverride != null && witnessAddressOverride.length > 0) + ? witnessAddressOverride : pqAddress; + if (witnessStore.get(witnessAddress) == null) { + logger.warn("Witness {} is not in witnessStore.", Hex.toHexString(witnessAddress)); + } + return param.new Miner(scheme, + keypair.getPrivateKey(), keypair.getPublicKey(), + ByteString.copyFrom(pqAddress), ByteString.copyFrom(witnessAddress)); + } + + 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..74ab62194ca 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,14 @@ public static boolean process(Manager manager, ProposalCapsule proposalCapsule) .saveAllowHardenExchangeCalculation(entry.getValue()); break; } + case ALLOW_FN_DSA_512: { + 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/java/org/tron/core/db/Manager.java b/framework/src/main/java/org/tron/core/db/Manager.java index a534b9d1c5d..60f06aae3c3 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; @@ -85,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; @@ -171,6 +173,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; @@ -1549,7 +1553,8 @@ 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(), txId, + trace.getReceipt().getResult()); } if (blockCap.hasWitnessSignature()) { trace.check(); @@ -1610,7 +1615,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, @@ -1620,7 +1626,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()); @@ -1629,9 +1635,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; @@ -1740,7 +1746,25 @@ public BlockCapsule generateBlock(Miner miner, long blockTime, long timeout) { session.reset(); blockCapsule.setMerkleRoot(); - blockCapsule.sign(miner.getPrivateKey()); + 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 + // witness misses this slot, but the producer thread stays alive. + // 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). + Param.Miner.PQMiner pq = miner.getPq(); + if (!getDynamicPropertiesStore().isPqSchemeAllowed(pq.getScheme())) { + throw new IllegalStateException( + "PQ miner " + Hex.toHexString(pq.getWitnessAddress().toByteArray()) + + " has scheme " + pq.getScheme() + + " configured but that scheme is not allowed by dynamic properties"); + } + signBlockCapsuleWithPQ(blockCapsule, miner); + } else { + blockCapsule.sign(miner.getPrivateKey()); + } BlockCapsule capsule = new BlockCapsule(blockCapsule.getInstance()); capsule.generatedByMyself = true; @@ -1756,6 +1780,38 @@ public BlockCapsule generateBlock(Miner miner, long blockTime, long timeout) { return capsule; } + private void signBlockCapsuleWithPQ(BlockCapsule blockCapsule, Miner miner) { + Param.Miner.PQMiner pq = miner.getPq(); + PQScheme scheme = pq.getScheme(); + if (scheme == null || !PQSchemeRegistry.contains(scheme)) { + throw new IllegalStateException( + "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(pq.getWitnessAddress().toByteArray()) + + " has scheme " + scheme + + " but it is not allowed by dynamic properties"); + } + byte[] pqPrivateKey = pq.getPrivateKey(); + byte[] pqPublicKey = pq.getPublicKey(); + if (pqPrivateKey == null || pqPublicKey == null) { + throw new IllegalStateException( + "miner " + Hex.toHexString(pq.getWitnessAddress().toByteArray()) + + " has scheme " + scheme + + " set but local PQ key material is missing"); + } + 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)); + 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/java/org/tron/core/net/messagehandler/TransactionsMsgHandler.java b/framework/src/main/java/org/tron/core/net/messagehandler/TransactionsMsgHandler.java index e153e21f331..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 @@ -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; @@ -87,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; 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 61ae6326e9f..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 @@ -17,12 +17,16 @@ 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.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.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; @@ -35,6 +39,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,10 +74,19 @@ public class RelayService { private final int keySize = Args.getLocalWitnesses().getPrivateKeys().size(); - private final ByteString witnessAddress = + private final int pqKeySize = Args.getLocalWitnesses().getPqKeypairs().size(); + + // 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() { @@ -79,16 +94,18 @@ 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; } executorService.scheduleWithFixedDelay(() -> { try { - if (witnessScheduleStore.getActiveWitnesses().contains(witnessAddress) + if (scheduledHere() && backupManager.getStatus().equals(BackupStatusEnum.MASTER)) { connect(); } else { @@ -104,23 +121,72 @@ 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()) { - 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(); + // 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 (useEcdsa) { + 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 { + // 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"); + return; + } + 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); + 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 +216,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 +245,70 @@ 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()); + } + 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, + 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 { + 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 {}.", + 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,12 +317,19 @@ private long getPeerCountByAddress(ByteString address) { private boolean isActiveWitness() { return parameter.isWitness() - && keySize > 0 + && (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 b180ecd6d10..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,31 @@ 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. +# +# `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). +# +# Effective only after the scheme's activation proposal passes and the +# witness Permission is upgraded. ECDSA and PQ witnesses may coexist on one +# node. +# localwitness_pq = { +# keys = [ +# { scheme = "FN_DSA_512", key = "<4352 hex chars>" }, +# { scheme = "ML_DSA_44", seed = "<64 hex chars>" } +# ] +# } + block = { needSyncCheck = true maintenanceTimeInterval = 21600000 // 6 hours: 21600000(ms) @@ -800,6 +827,9 @@ committee = { # allowTvmBlob = 0 # consensusLogicOptimization = 0 # allowOptimizedReturnValueOfChainId = 0 + # allowTvmOsaka = 0 + # allowFnDsa512 = 0 + # allowMlDsa44 = 0 } event.subscribe = { 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 new file mode 100644 index 00000000000..e5fc7d13377 --- /dev/null +++ b/framework/src/test/java/org/tron/common/crypto/pqc/FNDSA512KatTest.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 FNDSA512KatTest { + + 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[FNDSA512.SEED_LENGTH]; + for (int i = 0; i < s.length; i++) { + s[i] = (byte) i; + } + return s; + } + + private static byte[] seedDescending() { + byte[] s = new byte[FNDSA512.SEED_LENGTH]; + for (int i = 0; i < s.length; i++) { + s[i] = (byte) (FNDSA512.SEED_LENGTH - 1 - i); + } + return s; + } + + private static byte[] seedFilled(int b) { + byte[] s = new byte[FNDSA512.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) { + FNDSA512 k = new FNDSA512(v.seed); + assertEquals(v.label + ": pk length", + FNDSA512.PUBLIC_KEY_LENGTH, k.getPublicKey().length); + assertEquals(v.label + ": sk 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", + v.skSha256, hex(sha256(k.getPrivateKey()))); + } + } + + @Test + public void allVectorsDeriveExpectedAddress() { + for (KatVector v : VECTORS) { + FNDSA512 k = new FNDSA512(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) { + FNDSA512 k = new FNDSA512(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) { + 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()); + } + } + + @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 FNDSA512(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) { + 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 <= 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", + PQSchemeRegistry.verify( + PQScheme.FN_DSA_512, k.getPublicKey(), msg, sig)); + } + } + } + + @Test + public void signatureFromVectorAFailsUnderVectorBPublicKey() { + byte[] msg = "tron-fn-dsa-kat-cross".getBytes(); + FNDSA512[] keys = new FNDSA512[VECTORS.length]; + byte[][] sigs = new byte[VECTORS.length][]; + for (int i = 0; i < VECTORS.length; i++) { + 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", + FNDSA512.verify(keys[i].getPublicKey(), msg, sigs[i])); + } else { + assertFalse("signature from " + VECTORS[i].label + + " must NOT verify under " + VECTORS[j].label, + FNDSA512.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 FNDSA512(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/FNDSA512Test.java b/framework/src/test/java/org/tron/common/crypto/pqc/FNDSA512Test.java new file mode 100644 index 00000000000..3047a4d5ae4 --- /dev/null +++ b/framework/src/test/java/org/tron/common/crypto/pqc/FNDSA512Test.java @@ -0,0 +1,489 @@ +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 FNDSA512Test { + + private static final FalconParameters PARAMS = FalconParameters.falcon_512; + + private FNDSA512 keypair; + private FalconPublicKeyParameters pk; + private FalconPrivateKeyParameters sk; + + @Before + public void setUp() { + AsymmetricCipherKeyPair kp = freshKeyPair(); + pk = (FalconPublicKeyParameters) kp.getPublic(); + sk = (FalconPrivateKeyParameters) kp.getPrivate(); + keypair = new FNDSA512(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(FNDSA512.PUBLIC_KEY_LENGTH, keypair.getPublicKeyLength()); + 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); + } + + @Test + public void publicKeyHasFixedLength() { + for (int i = 0; i < 4; i++) { + AsymmetricCipherKeyPair kp = freshKeyPair(); + byte[] pkBytes = ((FalconPublicKeyParameters) kp.getPublic()).getH(); + assertEquals(FNDSA512.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(FNDSA512.PRIVATE_KEY_LENGTH, skBytes.length); + } + } + + @Test + public void signProducesVerifiableSignatureWithinBound() { + byte[] msg = "hello, fn-dsa".getBytes(); + 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 <= 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_MAX_LENGTH]; + keypair.validateSignature(sig); + } + + @Test + public void signatureBoundaryAboveMaxRejected() { + byte[] sig = new byte[FNDSA512.SIGNATURE_MAX_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[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]; + 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[FNDSA512.SIGNATURE_MAX_LENGTH + 1]; + try { + 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")); + } + } + + @Test + public void verifyRejectsEmptySignature() { + byte[] msg = new byte[] {1, 2, 3}; + byte[] empty = new byte[0]; + try { + FNDSA512.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[FNDSA512.PUBLIC_KEY_LENGTH - 1]; + byte[] msg = new byte[] {1}; + byte[] sig = new byte[FNDSA512.SIGNATURE_MIN_LENGTH]; + try { + FNDSA512.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[FNDSA512.SIGNATURE_MIN_LENGTH]; + try { + FNDSA512.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 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(); + byte[] sig = rawSign(msg); + AsymmetricCipherKeyPair other = freshKeyPair(); + byte[] otherPk = ((FalconPublicKeyParameters) other.getPublic()).getH(); + assertFalse(FNDSA512.verify(otherPk, msg, sig)); + } + + @Test + public void crossAlgoSignatureRejected() { + // 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}; + for (int len : foreignLengths) { + byte[] foreign = new byte[len]; + try { + 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")); + } + } + } + + @Test + public void emptyMessageVerifiesConsistently() { + byte[] msg = new byte[0]; + byte[] sig = rawSign(msg); + assertTrue(keypair.verify(msg, sig)); + } + + @Test + 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_MAX_LENGTH); + assertTrue(signer.verify(msg, sig)); + } + + @Test + public void fromSeedIsDeterministic() { + byte[] seed = new byte[FNDSA512.SEED_LENGTH]; + for (int i = 0; i < seed.length; i++) { + seed[i] = (byte) i; + } + 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 FNDSA512(new byte[FNDSA512.SEED_LENGTH - 1]); + } + + @Test(expected = UnsupportedOperationException.class) + public void derivePublicKeyFromEncodedPrivateKeyUnsupported() { + FNDSA512.derivePublicKey(sk.getEncoded()); + } + + @Test + public void computeAddressIs21Bytes() { + assertEquals(21, FNDSA512.computeAddress(pk.getH()).length); + } + + @Test + public void registryDispatchMatchesDirectCalls() { + byte[] msg = "registry-dispatch".getBytes(); + 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(FNDSA512.verify(pk.getH(), msg, sigViaRegistry)); + assertEquals(FNDSA512.PUBLIC_KEY_LENGTH, + PQSchemeRegistry.getPublicKeyLength(PQScheme.FN_DSA_512)); + assertEquals(FNDSA512.SIGNATURE_MAX_LENGTH, + PQSchemeRegistry.getSignatureLength(PQScheme.FN_DSA_512)); + } + + @Test + public void registryIsValidSignatureLengthRespectsBounds() { + assertTrue(PQSchemeRegistry.isValidSignatureLength( + PQScheme.FN_DSA_512, FNDSA512.SIGNATURE_MIN_LENGTH)); + assertTrue(PQSchemeRegistry.isValidSignatureLength( + 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_MAX_LENGTH + 1)); + } + + @Test + public void registryComputeAddressMatchesDirect() { + assertArrayEquals( + FNDSA512.computeAddress(pk.getH()), + PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pk.getH())); + } + + @Test(expected = IllegalArgumentException.class) + public void seedConstructorRejectsNull() { + new FNDSA512((byte[]) null); + } + + @Test + public void keypairConstructorRejectsNullPrivateKey() { + try { + new FNDSA512(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 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")); + } + } + + @Test + public void keypairConstructorRejectsNullPublicKey() { + try { + new FNDSA512(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 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(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. + 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() { + FNDSA512.fromPrivateKeyWithPublicKey(null); + } + + @Test(expected = IllegalArgumentException.class) + public void fromExtendedPrivateKeyRejectsWrongLength() { + FNDSA512.fromPrivateKeyWithPublicKey(new byte[FNDSA512.PRIVATE_KEY_LENGTH]); + } + + @Test + public void derivePublicKeyFromExtendedFormReturnsAppendedPublicKey() { + byte[] extended = keypair.getPrivateKeyWithPublicKey(); + byte[] derived = FNDSA512.derivePublicKey(extended); + assertArrayEquals(keypair.getPublicKey(), derived); + } + + @Test(expected = IllegalArgumentException.class) + public void derivePublicKeyRejectsNull() { + FNDSA512.derivePublicKey(null); + } + + @Test + 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_MAX_LENGTH); + assertTrue(FNDSA512.verify(pk.getH(), msg, sig)); + } + + @Test + public void staticSignRejectsNullPrivateKey() { + try { + FNDSA512.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 { + 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")); + } + } + + @Test + public void staticSignRejectsNullMessage() { + try { + FNDSA512.sign(sk.getEncoded(), null); + fail("null message must be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("message")); + } + } + + @Test + public void staticVerifyRejectsNullPublicKey() { + try { + 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")); + } + } + + @Test + 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/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]))); + } + } + } +} 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 new file mode 100644 index 00000000000..288c1a47efb --- /dev/null +++ b/framework/src/test/java/org/tron/common/crypto/pqc/PQSchemeRegistryTest.java @@ -0,0 +1,175 @@ +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 containsRejectsUnknownPqScheme() { + assertFalse(PQSchemeRegistry.contains(PQScheme.UNKNOWN_PQ_SCHEME)); + } + + @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 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, + 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 + public void fromSeedDispatchesToFalcon() { + 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. + FNDSA512 direct = new FNDSA512(seed); + assertArrayEquals(direct.getPublicKey(), sig.getPublicKey()); + 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]; + Arrays.fill(seed, (byte) 0x09); + FNDSA512 src = new FNDSA512(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[FNDSA512.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 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)); + assertEquals(PQScheme.UNKNOWN_PQ_SCHEME, + PQSchemeRegistry.resolve(PQScheme.UNKNOWN_PQ_SCHEME)); + 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..03eb0b8a0fa --- /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 FNDSA512} + * 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..c271ec0afc1 --- /dev/null +++ b/framework/src/test/java/org/tron/common/crypto/pqc/SignatureSchemeBenchmarkTest.java @@ -0,0 +1,167 @@ +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), 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 { + + 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(); + Result mldsa = benchMlDsa(); + + 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); + printResult(mldsa); + } + + 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++) { + FNDSA512 k = new FNDSA512(); + byte[] sig = k.sign(MESSAGE); + k.verify(MESSAGE, sig); + } + + long keygenNs = 0; + FNDSA512[] keys = new FNDSA512[ITERATIONS]; + for (int i = 0; i < ITERATIONS; i++) { + long t0 = System.nanoTime(); + keys[i] = new FNDSA512(); + 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 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", + 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..4b7ce5b79e6 --- /dev/null +++ b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQClient.java @@ -0,0 +1,154 @@ +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.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.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; +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; + +/** + * 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. + * + * 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.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", PQWitnessNode.PQ_SCHEME.name())); + 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[PQSchemeRegistry.getSeedLength(PQ_SCHEME)]; + Arrays.fill(userSeed, (byte) 0x02); + PQSignature userKp = PQSchemeRegistry.fromSeed(PQ_SCHEME, userSeed); + + byte[] userPub = userKp.getPublicKey(); + 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)); + + // ── 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 = Sha256Hash.of( + CommonParameter.getInstance().isECKeyCryptoEngine(), headerRaw).getBytes(); + + 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 selected PQ scheme ───────────────────────────────── + byte[] txId = Sha256Hash.of( + CommonParameter.getInstance().isECKeyCryptoEngine(), + rawData.toByteArray()).getBytes(); + 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(PQ_SCHEME) + .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[] 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..64766c1c94c --- /dev/null +++ b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQFullNode.java @@ -0,0 +1,130 @@ +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 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; +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; +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. + * 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 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. + * + * 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 ────── + PQSignature witnessKp = PQSchemeRegistry.fromSeed( + PQWitnessNode.PQ_SCHEME, PQWitnessNode.WITNESS_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(); + + System.out.println("=== PQC Full Node ==="); + 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); + System.out.println("P2P port: " + P2P_PORT); + System.out.println("Witness address (expected): " + + ByteArray.toHexString(witnessKp.getAddress())); + + // ── 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 PQ public key. + PQWitnessNode.installPQGenesisState(db, chain, witnessPub, userPubs); + + // ── 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/PQTxSender.java b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQTxSender.java new file mode 100644 index 00000000000..fd3d956f34b --- /dev/null +++ b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQTxSender.java @@ -0,0 +1,499 @@ +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.ArrayList; +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; +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.ECKey; +import org.tron.common.crypto.ECKey.ECDSASignature; +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; +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; +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; +import org.tron.protos.contract.SmartContractOuterClass.TriggerSmartContract; + +/** + * 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. + *

+ * 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.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.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=5 + * -Decdsa.trc20.tps=0 + */ +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("TKmyxLsRR2FWMVEHaQA2pZh1xB7oXPXzG1"); + + /** + * 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 demo ECDSA private key. Override it with -Decdsa.private.key for a funded account. + */ + private static final String DEFAULT_ECDSA_PRIVATE_KEY = + "1234567890123456789012345678901234567890123456789012345678901234"; + + /** + * 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 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 + // 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); + + // 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))); + } + + ECKey ecdsaKey = ECKey.fromPrivate( + ByteArray.fromHexString(System.getProperty("ecdsa.private.key", + DEFAULT_ECDSA_PRIVATE_KEY))); + byte[] ecdsaOwnerAddr = ecdsaKey.getAddress(); + 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 owner address: " + ByteArray.toHexString(ownerAddr)); + 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); + + // ── 2. Connect via gRPC ─────────────────────────────────────────────── + ManagedChannel channel = ManagedChannelBuilder + .forAddress(HOST, PORT) + .usePlaintext() + .build(); + WalletBlockingStub stub = WalletGrpc.newBlockingStub(channel); + + try { + 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")); + threads.add(new Thread( + () -> runEcdsaTrc20Loop(stub, ecdsaOwnerAddr, ecdsaKey, ecdsaTrc20Tps), + "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(Locale.ROOT).replace('_', '-'); + } + + 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(); + } + + private static void runTransferLoop(WalletBlockingStub stub, byte[] ownerAddr, + PQSignature userKp, PQScheme scheme, double tps) { + if (tps <= 0) { + 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, scheme, counter++); + sleepRemaining(intervalMs, loopStart); + } + } + + private static void runTrc20Loop(WalletBlockingStub stub, byte[] ownerAddr, + PQSignature userKp, PQScheme scheme, double tps) { + if (tps <= 0) { + 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, scheme, counter++); + sleepRemaining(intervalMs, loopStart); + } + } + + 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, + PQSignature userKp, PQScheme scheme, long seq) { + String tag = "pqc-" + tpsKey(scheme) + "-transfer-" + 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 = userKp.sign(txId); + Transaction signedTx = tx.toBuilder() + .addPqAuthSig(PQAuthSig.newBuilder() + .setScheme(scheme) + .setPublicKey(ByteString.copyFrom(userKp.getPublicKey())) + .setSignature(ByteString.copyFrom(sig))) + .build(); + + Return result = timedStub.broadcastTransaction(signedTx); + System.out.println("[" + tag + "] ref=#" + refNum + + " tx=" + ByteArray.toHexString(txId) + + " result=" + result.getCode()); + } catch (Exception e) { + System.err.println("[" + tag + "] send failed: " + e.getMessage()); + e.printStackTrace(System.err); + } + } + + private static void sendTrc20Transaction(WalletBlockingStub stub, byte[] ownerAddr, + PQSignature userKp, PQScheme scheme, long seq) { + String tag = "pqc-" + tpsKey(scheme) + "-trc20-" + 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 = userKp.sign(txId); + Transaction signedTx = tx.toBuilder() + .addPqAuthSig(PQAuthSig.newBuilder() + .setScheme(scheme) + .setPublicKey(ByteString.copyFrom(userKp.getPublicKey())) + .setSignature(ByteString.copyFrom(sig))) + .build(); + + Return result = timedStub.broadcastTransaction(signedTx); + System.out.println("[" + tag + "] ref=#" + refNum + + " tx=" + ByteArray.toHexString(txId) + + " result=" + result.getCode()); + } catch (Exception e) { + System.err.println("[" + tag + "] 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()); + } catch (Exception e) { + 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() + .addContract(Transaction.Contract.newBuilder() + .setType(ContractType.TransferContract) + .setParameter(Any.pack(TransferContract.newBuilder() + .setOwnerAddress(ByteString.copyFrom(ownerAddr)) + .setToAddress(ByteString.copyFrom(TO_ADDR)) + .setAmount(1L) + .build())) + .setPermissionId(0)) + .setRefBlockHash(ByteString.copyFrom(Arrays.copyOfRange(blockHash, 8, 16))) + .setRefBlockBytes(ByteString.copyFrom(longToBytes(refNum), 6, 2)) + .setExpiration(randomExpiration()) + .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(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))); + } + + 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 new file mode 100644 index 00000000000..98f461f8503 --- /dev/null +++ b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQWitnessNode.java @@ -0,0 +1,281 @@ +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 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; +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 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 a PQ 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 { + + /** + * 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())); + + /** 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; + + /** 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; + + private static final String DEFAULT_ECDSA_PRIVATE_KEY = + "1234567890123456789012345678901234567890123456789012345678901234"; + + /** Fixed on-chain address for the demo user account. */ + 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 + // 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 ────────────────────────────────── + // 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); + 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(); + byte[] userPub = userKp.getPublicKey(); + byte[] signerAddr = userKp.getAddress(); + + System.out.println("=== PQC Witness Node ==="); + 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 (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); + + // ── 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) ───────── + 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(); + + // ── 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. + * + *

{@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, Map userPubs) { + byte[] witnessAddr = PQSchemeRegistry.computeAddress(PQ_SCHEME, witnessPub); + ByteString witnessAddrBs = ByteString.copyFrom(witnessAddr); + + // 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 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) + .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 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_000_000_000L); // 100000000 TRX + userCapsule.updatePermissions(userOwnerPerm.build(), null, Collections.emptyList()); + db.getAccountStore().put(USER_ADDR, userCapsule); + } + + 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 { + 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. 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[] 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(keyBytes) + "\" }\n" + + " ]\n" + + "}\n"; + Files.write(conf, body.getBytes(StandardCharsets.UTF_8)); + return conf; + } +} 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 new file mode 100644 index 00000000000..ae39f8b48aa --- /dev/null +++ b/framework/src/test/java/org/tron/common/runtime/vm/BatchValidateFnDsa512Test.java @@ -0,0 +1,392 @@ +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.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.BatchValidateFnDsa512; +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 && FNDSA512.verify(pk_i, hash, sig_i)}. + * Stateless — no chain DB. + */ +@Slf4j +public class BatchValidateFnDsa512Test { + + private static final DataWord ADDR_0X18 = new DataWord( + "0000000000000000000000000000000000000000000000000000000000000018"); + + private static final String METHOD_SIGN = + "batchvalidatefndsa512(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 BatchValidateFnDsa512 contract = new BatchValidateFnDsa512(); + + @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 BatchValidateFnDsa512); + } + + @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++) { + FNDSA512 k = new FNDSA512(); + sigs.add(Hex.toHexString(padSlot(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); + FNDSA512 k1 = new FNDSA512(); + FNDSA512 k2 = new FNDSA512(); + List sigs = Arrays.asList( + Hex.toHexString(padSlot(k1.sign(HASH))), + Hex.toHexString(padSlot(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); + FNDSA512 k = new FNDSA512(); + byte[] sig = padSlot(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); + FNDSA512 k = new FNDSA512(); + byte[] truncatedPk = Arrays.copyOf(k.getPublicKey(), k.getPublicKey().length - 1); + List sigs = Collections1(Hex.toHexString(padSlot(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++) { + FNDSA512 k = new FNDSA512(); + sigs.add(Hex.toHexString(padSlot(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); + FNDSA512 k = new FNDSA512(); + 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())); + + 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++) { + FNDSA512 k = new FNDSA512(); + sigs.add(Hex.toHexString(padSlot(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++) { + FNDSA512 k = new FNDSA512(); + sigs.add(Hex.toHexString(padSlot(k.sign(HASH)))); + pks.add(Hex.toHexString(k.getPublicKey())); + addrs.add(addrAsBytes32Hex(k.getPublicKey())); + } + byte[] input = encode(HASH, sigs, pks, addrs); + Assert.assertEquals(3L * 2000L, 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++) { + FNDSA512 k = new FNDSA512(); + // Sign HASH... + sigs.add(Hex.toHexString(padSlot(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++) { + FNDSA512 k = new FNDSA512(); + sigs.add(Hex.toHexString(padSlot(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++) { + FNDSA512 k = new FNDSA512(); + byte[] sig = padSlot(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); + FNDSA512 k = new FNDSA512(); + 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]); + } + + @Test + public void slotShorterThan666_clearsBit() { + contract.setConstantCall(true); + FNDSA512 k = new FNDSA512(); + byte[] sig = k.sign(HASH); + 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())); + + 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_MAX_LENGTH - 1]; + 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 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_MAX_LENGTH) { + throw new IllegalStateException("Falcon sig longer than slot: " + sig.length); + } + byte[] slot = new byte[FNDSA512.SIGNATURE_MAX_LENGTH - 1]; + System.arraycopy(sig, 1, slot, 0, sig.length - 1); + return slot; + } + + + 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_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; + } + + 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/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/FnDsaPrecompileTest.java b/framework/src/test/java/org/tron/common/runtime/vm/FnDsaPrecompileTest.java new file mode 100644 index 00000000000..e200b0726b4 --- /dev/null +++ b/framework/src/test/java/org/tron/common/runtime/vm/FnDsaPrecompileTest.java @@ -0,0 +1,198 @@ +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.FNDSA512; +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 (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 { + + private static final DataWord FNDSA_ADDR = new DataWord( + "0000000000000000000000000000000000000000000000000000000000000016"); + + private static final int INPUT_LEN = + 32 + FNDSA512.SIGNATURE_MAX_LENGTH - 1 + FNDSA512.PUBLIC_KEY_LENGTH; + + 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() { + FNDSA512 key = new FNDSA512(); + 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(4000, pc.getEnergyForData(input)); + } + + @Test + public void tamperedMessage_returnsZero() { + FNDSA512 key = new FNDSA512(); + 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() { + FNDSA512 key = new FNDSA512(); + byte[] sig = key.sign(MESSAGE_HASH); + // 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 = + PrecompiledContracts.getContractForAddress(FNDSA_ADDR).execute(input); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + @Test + public void wrongPublicKey_returnsZero() { + FNDSA512 signer = new FNDSA512(); + FNDSA512 other = new FNDSA512(); + 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[INPUT_LEN - 1]); + + Assert.assertTrue(result.getLeft()); + 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()); + } + + @Test + public void emptySigSlot_returnsZero() { + // 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_MAX_LENGTH - 1, FNDSA512.PUBLIC_KEY_LENGTH); + + Pair result = + PrecompiledContracts.getContractForAddress(FNDSA_ADDR).execute(input); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + @Test + public void sigSlotShorterThanMin_returnsZero() { + // 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_MAX_LENGTH - 1, FNDSA512.PUBLIC_KEY_LENGTH); + + 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 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, 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/MlDsa44PrecompileTest.java b/framework/src/test/java/org/tron/common/runtime/vm/MlDsa44PrecompileTest.java new file mode 100644 index 00000000000..79bf982df68 --- /dev/null +++ b/framework/src/test/java/org/tron/common/runtime/vm/MlDsa44PrecompileTest.java @@ -0,0 +1,202 @@ +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 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_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 { + 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_EIP8051_ADDR)); + } + + @Test + public void switchOn_returnsEip8051Contract() { + Assert.assertNotNull(PrecompiledContracts.getContractForAddress(MLDSA_EIP8051_ADDR)); + } + + @Test + public void draftAddress19StillReturnsContract() { + Assert.assertNotNull(PrecompiledContracts.getContractForAddress(MLDSA_DRAFT_ADDR)); + } + + @Test + 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_DRAFT_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 draftAddress19TamperedMessage_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_DRAFT_ADDR).execute(input); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + @Test + 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_DRAFT_ADDR).execute(input); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + @Test + 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_DRAFT_ADDR).execute(input); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + @Test + public void draftAddress19NullInput_returnsZero() { + Pair result = + PrecompiledContracts.getContractForAddress(MLDSA_DRAFT_ADDR).execute(null); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + @Test + public void draftAddress19ShortInput_returnsZero() { + Pair result = + PrecompiledContracts.getContractForAddress(MLDSA_DRAFT_ADDR).execute(new byte[100]); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + @Test + 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_DRAFT_ADDR).execute(oneByteShort); + Assert.assertTrue(r1.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), r1.getRight()); + } + + @Test + public void draftAddress19TrailingBytes_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_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()); + } + + /** 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/ValidateMultiPQSigTest.java b/framework/src/test/java/org/tron/common/runtime/vm/ValidateMultiPQSigTest.java new file mode 100644 index 00000000000..7c7f0545479 --- /dev/null +++ b/framework/src/test/java/org/tron/common/runtime/vm/ValidateMultiPQSigTest.java @@ -0,0 +1,842 @@ +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_MAX_LENGTH - 1, 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_MAX_LENGTH - 2); + + 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 the headerless + // minimum. + 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_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())); + + 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 -------- + + /** + * 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_MAX_LENGTH) { + throw new IllegalStateException("Falcon sig longer than slot: " + sig.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, + 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/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..32f20453aee --- /dev/null +++ b/framework/src/test/java/org/tron/common/utils/LocalWitnessesTest.java @@ -0,0 +1,139 @@ +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.FNDSA512; +import org.tron.common.crypto.pqc.PqKeypair; +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() { + FNDSA512 k1 = new FNDSA512(); + FNDSA512 k2 = new FNDSA512(); + 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( + 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(PQScheme.FN_DSA_512, priv, pub), + new PqKeypair(PQScheme.FN_DSA_512, priv2, pub2))); + assertEquals(2, lw.getPqKeypairs().size()); + } + + @Test + public void wrongLengthPrivateKeyRejected() { + LocalWitnesses lw = new LocalWitnesses(); + String shortPriv = priv.substring(2); + TronError err = assertThrows(TronError.class, + () -> 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. + 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( + 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. + 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( + new PqKeypair(PQScheme.FN_DSA_512, badPriv, 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.setPqKeypairs(Collections.singletonList( + new PqKeypair(PQScheme.UNRECOGNIZED, priv, pub)))); + 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.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 emptyKeypairsAreNoop() { + LocalWitnesses lw = new LocalWitnesses(); + lw.setPqKeypairs(Collections.emptyList()); + lw.setPqKeypairs(null); + assertEquals(0, lw.getPqKeypairs().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( + new PqKeypair(PQScheme.FN_DSA_512, "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( + 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/BandwidthProcessorTest.java b/framework/src/test/java/org/tron/core/BandwidthProcessorTest.java index cf652af3650..6a8810fb93b 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; @@ -881,4 +882,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[FNDSA512.SIGNATURE_MAX_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)) + .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[FNDSA512.SIGNATURE_MAX_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)) + .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..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 @@ -797,4 +797,124 @@ 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); + + 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); + + // 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 { + 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, 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); + } + + @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 new file mode 100644 index 00000000000..30f9a5764f5 --- /dev/null +++ b/framework/src/test/java/org/tron/core/capsule/BlockCapsulePQTest.java @@ -0,0 +1,311 @@ +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; +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.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.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; +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 FNDSA512 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 FNDSA512(); + pqAddress = PQSchemeRegistry.computeAddress( + 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 + * 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 FNDSA512.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(); + } + + /** + * {@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); + 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); + // 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); + + 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 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()); + } + + @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(expected = ValidateSignatureException.class) + public void pqAuthSigWithDefaultSchemeRejected() 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. 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); + 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()); + } + + /** + * 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/framework/src/test/java/org/tron/core/capsule/TransactionCapsuleTest.java b/framework/src/test/java/org/tron/core/capsule/TransactionCapsuleTest.java index 9c2e004931e..32adea8e4cc 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,29 @@ 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.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; 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 +128,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[FNDSA512.PUBLIC_KEY_LENGTH])) + .setSignature(ByteString.copyFrom(new byte[FNDSA512.SIGNATURE_MAX_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 +216,425 @@ public void fastVerify() { capsuleLogger.setLevel(originalLevel); } } + + 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); + FNDSA512 kp = new FNDSA512(); + putAccountWithPQPermission(PQ_OWNER_HEX, kp.getPublicKey(), PQScheme.FN_DSA_512); + + Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0); + byte[] txid = txId(tx); + + byte[] sig = FNDSA512.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); + FNDSA512 kp = new FNDSA512(); + putAccountWithPQPermission(PQ_OWNER_HEX, kp.getPublicKey(), PQScheme.FN_DSA_512); + + Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0); + byte[] txid = txId(tx); + + byte[] sig = FNDSA512.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); + FNDSA512 kp = new FNDSA512(); + putAccountWithPQPermission(PQ_OWNER_HEX, kp.getPublicKey(), PQScheme.FN_DSA_512); + + Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0); + byte[] txid = txId(tx); + + byte[] sig = FNDSA512.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); + 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. + FNDSA512 stranger = new FNDSA512(); + Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0); + byte[] txid = txId(tx); + + byte[] sig = FNDSA512.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, ML-DSA-44. + */ + 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(); + + byte[] txid = txId(baseTx); + + // FN-DSA-512: variable-length signature (<= 752 bytes) + 897-byte public key + FNDSA512 kpFn = new FNDSA512(); + byte[] sigFn = FNDSA512.sign(kpFn.getPrivateKey(), txid); + 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(); + + // 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}, + }; + } + + @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", "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", + 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]); + } + + // 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 + public void pqAuthSigWrongPublicKeyLengthRejected() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(1L); + FNDSA512 kp = new FNDSA512(); + putAccountWithPQPermission(PQ_OWNER_HEX, kp.getPublicKey(), PQScheme.FN_DSA_512); + + Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0); + 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[FNDSA512.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); + FNDSA512 kp = new FNDSA512(); + 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); + FNDSA512 kp = new FNDSA512(); + putAccountWithPQPermission(PQ_OWNER_HEX, kp.getPublicKey(), PQScheme.FN_DSA_512); + + Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0); + 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. + 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); + 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. + 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); + 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 = txId(tx); + byte[] sigA = FNDSA512.sign(a.getPrivateKey(), txid); + byte[] sigB = FNDSA512.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); + FNDSA512 kp = new FNDSA512(); + putAccountWithPQPermission(PQ_OWNER_HEX, kp.getPublicKey(), PQScheme.FN_DSA_512); + + Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0); + byte[] txid = txId(tx); + + byte[] sig = FNDSA512.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/config/args/ArgsPqConfigTest.java b/framework/src/test/java/org/tron/core/config/args/ArgsPqConfigTest.java new file mode 100644 index 00000000000..209fda04273 --- /dev/null +++ b/framework/src/test/java/org/tron/core/config/args/ArgsPqConfigTest.java @@ -0,0 +1,210 @@ +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 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` + // 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/exception/TronErrorTest.java b/framework/src/test/java/org/tron/core/exception/TronErrorTest.java index 91559d86362..14e4cdb4d7a 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/net/services/RelayServiceTest.java b/framework/src/test/java/org/tron/core/net/services/RelayServiceTest.java index 8585244b941..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 @@ -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; @@ -26,6 +25,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 +49,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 { @@ -61,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 @@ -226,6 +225,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 { @@ -235,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); @@ -245,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 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..86418687a95 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,8 @@ 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.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; @@ -151,4 +153,37 @@ 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); + } + + @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 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..816b5d5b5b1 100644 --- a/framework/src/test/resources/config-test.conf +++ b/framework/src/test/resources/config-test.conf @@ -349,12 +349,16 @@ 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 = { + keys = [ + ] +} + block = { needSyncCheck = true # first node : false, other : true } @@ -387,4 +391,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..6d940a165d0 100644 --- a/protocol/src/main/protos/core/Tron.proto +++ b/protocol/src/main/protos/core/Tron.proto @@ -16,6 +16,16 @@ enum AccountType { Contract = 2; } +// Post-quantum signature scheme identifier used by PQAuthSig. +// 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; +} + // AccountId, (name, address) use name, (null, address) use address, (name, null) use name, message AccountId { bytes name = 1; @@ -241,7 +251,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)[12..32] +// and matches against Permission.keys[].address. +message PQAuthSig { + PQScheme scheme = 1; + bytes public_key = 2; + bytes signature = 3; } message DelegatedResource { @@ -448,6 +468,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 +540,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 @@ -623,10 +655,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 {