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..3a780fd5222 100644 --- a/actuator/src/main/java/org/tron/core/actuator/AccountPermissionUpdateActuator.java +++ b/actuator/src/main/java/org/tron/core/actuator/AccountPermissionUpdateActuator.java @@ -16,9 +16,14 @@ import org.tron.core.exception.ContractValidateException; import org.tron.core.store.AccountStore; import org.tron.core.store.DynamicPropertiesStore; +import org.tron.common.crypto.pqc.FNDSA; +import org.tron.common.crypto.pqc.MLDSA44; +import org.tron.common.crypto.pqc.MLDSA65; +import org.tron.common.crypto.pqc.PQSignatureRegistry; import org.tron.protos.Protocol.Key; import org.tron.protos.Protocol.Permission; import org.tron.protos.Protocol.Permission.PermissionType; +import org.tron.protos.Protocol.SignatureScheme; import org.tron.protos.Protocol.Transaction.Contract.ContractType; import org.tron.protos.Protocol.Transaction.Result.code; import org.tron.protos.contract.AccountContract.AccountPermissionUpdateContract; @@ -95,15 +100,38 @@ private boolean checkPermission(Permission permission) throws ContractValidateEx long weightSum = 0; List addressList = permission.getKeysList() .stream() - .map(x -> x.getAddress()) + .map(Key::getAddress) + .filter(addr -> !addr.isEmpty()) .distinct() .collect(toList()); - if (addressList.size() != permission.getKeysList().size()) { + long nonEmptyAddrCount = permission.getKeysList().stream() + .map(Key::getAddress) + .filter(addr -> !addr.isEmpty()) + .count(); + if (addressList.size() != nonEmptyAddrCount) { throw new ContractValidateException( "address should be distinct in permission " + permission.getType()); } + validatePermissionScheme(permission); + + List publicKeyList = permission.getKeysList() + .stream() + .map(k -> k.hasPqKey() ? k.getPqKey().getPublicKey() : ByteString.EMPTY) + .filter(pk -> !pk.isEmpty()) + .distinct() + .collect(toList()); + long nonEmptyPublicKeyCount = permission.getKeysList().stream() + .map(k -> k.hasPqKey() ? k.getPqKey().getPublicKey() : ByteString.EMPTY) + .filter(pk -> !pk.isEmpty()) + .count(); + if (publicKeyList.size() != nonEmptyPublicKeyCount) { + throw new ContractValidateException( + "public_key should be distinct in permission " + permission.getType()); + } + for (Key key : permission.getKeysList()) { - if (!DecodeUtil.addressValid(key.getAddress().toByteArray())) { + if (!key.getAddress().isEmpty() + && !DecodeUtil.addressValid(key.getAddress().toByteArray())) { throw new ContractValidateException("key is not a validate address"); } if (key.getWeight() <= 0) { @@ -237,4 +265,75 @@ public ByteString getOwnerAddress() throws InvalidProtocolBufferException { public long calcFee() { return chainBaseManager.getDynamicPropertiesStore().getUpdateAccountPermissionFee(); } + + private void validatePermissionScheme(Permission permission) throws ContractValidateException { + DynamicPropertiesStore dynamicStore = chainBaseManager.getDynamicPropertiesStore(); + + SignatureScheme first = keyScheme(permission.getKeysList().get(0)); + for (Key key : permission.getKeysList()) { + SignatureScheme scheme = keyScheme(key); + if (scheme != first) { + throw new ContractValidateException( + "all keys in a permission must use the same scheme"); + } + if (scheme == SignatureScheme.UNKNOWN_SIG_SCHEME) { + if (key.hasPqKey() && !key.getPqKey().getPublicKey().isEmpty()) { + throw new ContractValidateException( + "public_key must be empty when scheme is UNKNOWN_SIG_SCHEME"); + } + } else { + if (!dynamicStore.isPqSchemeAllowed(scheme)) { + throw new ContractValidateException( + schemeNotActivatedMessage(scheme) + ", scheme " + scheme + " is not allowed"); + } + int expected = expectedPublicKeyLength(scheme); + if (expected < 0) { + throw new ContractValidateException( + "unsupported signature scheme: " + scheme); + } + int actual = key.hasPqKey() ? key.getPqKey().getPublicKey().size() : 0; + if (actual != expected) { + throw new ContractValidateException( + "public_key length for " + scheme + " must be " + expected + " bytes, got " + + actual); + } + } + } + + if (permission.getType() == PermissionType.Witness + && first != SignatureScheme.UNKNOWN_SIG_SCHEME + && !PQSignatureRegistry.contains(first)) { + throw new ContractValidateException( + "Witness permission only supports legacy or registered PQ schemes, got " + first); + } + } + + private static SignatureScheme keyScheme(Key key) { + return key.hasPqKey() ? key.getPqKey().getScheme() : SignatureScheme.UNKNOWN_SIG_SCHEME; + } + + private static int expectedPublicKeyLength(SignatureScheme scheme) { + switch (scheme) { + case ML_DSA_44: + return MLDSA44.PUBLIC_KEY_LENGTH; + case ML_DSA_65: + return MLDSA65.PUBLIC_KEY_LENGTH; + case FN_DSA: + return FNDSA.PUBLIC_KEY_LENGTH; + default: + return -1; + } + } + + private static String schemeNotActivatedMessage(SignatureScheme scheme) { + switch (scheme) { + case ML_DSA_44: + case ML_DSA_65: + return "ML-DSA is not activated"; + case FN_DSA: + return "FN-DSA is not activated"; + default: + return scheme + " is not activated"; + } + } } diff --git a/actuator/src/main/java/org/tron/core/actuator/CreateAccountActuator.java b/actuator/src/main/java/org/tron/core/actuator/CreateAccountActuator.java index 352f394d6cb..f34930e7b7f 100755 --- a/actuator/src/main/java/org/tron/core/actuator/CreateAccountActuator.java +++ b/actuator/src/main/java/org/tron/core/actuator/CreateAccountActuator.java @@ -4,8 +4,10 @@ import com.google.protobuf.ByteString; import com.google.protobuf.InvalidProtocolBufferException; +import java.util.Arrays; import java.util.Objects; import lombok.extern.slf4j.Slf4j; +import org.tron.common.crypto.pqc.PQSignatureRegistry; import org.tron.common.utils.DecodeUtil; import org.tron.common.utils.StringUtil; import org.tron.core.capsule.AccountCapsule; @@ -15,6 +17,8 @@ import org.tron.core.exception.ContractValidateException; import org.tron.core.store.AccountStore; import org.tron.core.store.DynamicPropertiesStore; +import org.tron.protos.Protocol.PQPublicKey; +import org.tron.protos.Protocol.SignatureScheme; import org.tron.protos.Protocol.Transaction.Contract.ContractType; import org.tron.protos.Protocol.Transaction.Result.code; import org.tron.protos.contract.AccountContract.AccountCreateContract; @@ -120,6 +124,25 @@ public boolean validate() throws ContractValidateException { throw new ContractValidateException("Account has existed"); } + if (contract.hasPqKey()) { + PQPublicKey pq = contract.getPqKey(); + SignatureScheme scheme = pq.getScheme(); + DynamicPropertiesStore dyn = chainBaseManager.getDynamicPropertiesStore(); + if (!dyn.isPqSchemeAllowed(scheme)) { + throw new ContractValidateException("PQ scheme not activated: " + scheme); + } + byte[] pubKey = pq.getPublicKey().toByteArray(); + if (pubKey.length != PQSignatureRegistry.getPublicKeyLength(scheme)) { + throw new ContractValidateException( + "Invalid PQ public key length for scheme " + scheme); + } + byte[] derived = PQSignatureRegistry.computeAddress(scheme, pubKey); + if (!Arrays.equals(derived, accountAddress)) { + throw new ContractValidateException( + "account_address does not match the address derived from pq_key"); + } + } + return true; } 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 cd42d7a9010..f092c89c14e 100644 --- a/actuator/src/main/java/org/tron/core/utils/ProposalUtil.java +++ b/actuator/src/main/java/org/tron/core/utils/ProposalUtil.java @@ -886,6 +886,28 @@ public static void validator(DynamicPropertiesStore dynamicPropertiesStore, } break; } + case ALLOW_ML_DSA: { + if (dynamicPropertiesStore.getAllowMlDsa() == 1) { + throw new ContractValidateException( + "[ALLOW_ML_DSA] has been valid, no need to propose again"); + } + if (value != 1) { + throw new ContractValidateException( + "This value[ALLOW_ML_DSA] is only allowed to be 1"); + } + break; + } + case ALLOW_FN_DSA: { + if (dynamicPropertiesStore.getAllowFnDsa() == 1) { + throw new ContractValidateException( + "[ALLOW_FN_DSA] has been valid, no need to propose again"); + } + if (value != 1) { + throw new ContractValidateException( + "This value[ALLOW_FN_DSA] is only allowed to be 1"); + } + break; + } default: break; } @@ -971,7 +993,9 @@ public enum ProposalType { // current value, value range ALLOW_TVM_BLOB(89), // 0, 1 PROPOSAL_EXPIRE_TIME(92), // (0, 31536003000) ALLOW_TVM_SELFDESTRUCT_RESTRICTION(94), // 0, 1 - ALLOW_TVM_OSAKA(96); // 0, 1 + ALLOW_TVM_OSAKA(96), // 0, 1 + ALLOW_ML_DSA(97), // 0, 1 + ALLOW_FN_DSA(100); // 0, 1 private long code; diff --git a/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java b/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java index 634f7f2d3d1..5f5769ef60c 100644 --- a/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java +++ b/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java @@ -45,6 +45,9 @@ import org.tron.common.crypto.Rsv; import org.tron.common.crypto.SignUtils; import org.tron.common.crypto.SignatureInterface; +import org.tron.common.crypto.pqc.FNDSA; +import org.tron.common.crypto.pqc.MLDSA44; +import org.tron.common.crypto.pqc.MLDSA65; import org.tron.common.crypto.zksnark.BN128; import org.tron.common.crypto.zksnark.BN128Fp; import org.tron.common.crypto.zksnark.BN128G1; @@ -107,6 +110,10 @@ public class PrecompiledContracts { private static final EthRipemd160 ethRipemd160 = new EthRipemd160(); private static final Blake2F blake2F = new Blake2F(); + private static final VerifyMlDsa44 verifyMlDsa44 = new VerifyMlDsa44(); + private static final VerifyMlDsa65 verifyMlDsa65 = new VerifyMlDsa65(); + private static final VerifyFnDsa verifyFnDsa = new VerifyFnDsa(); + // FreezeV2 PrecompileContracts private static final GetChainParameter getChainParameter = new GetChainParameter(); private static final AvailableUnfreezeV2Size availableUnfreezeV2Size = new AvailableUnfreezeV2Size(); @@ -200,6 +207,20 @@ public class PrecompiledContracts { private static final DataWord blake2FAddr = new DataWord( "0000000000000000000000000000000000000000000000000000000000020009"); + // EIP-8051 0x12: ML-DSA-44 verify (FIPS-204, SHAKE256). Uses raw 1312-byte public key + // rather than EIP-8051's 20512-byte expanded form; signatures produced for 0x12 on + // EIP-8051-compliant chains are not byte-compatible with this precompile's input. + private static final DataWord verifyMlDsa44Addr = new DataWord( + "0000000000000000000000000000000000000000000000000000000000000012"); + // 0x14: ML-DSA-65 verify (TRON extension, FIPS-204 / SHAKE256, raw 1952-byte public key). + private static final DataWord verifyMlDsa65Addr = new DataWord( + "0000000000000000000000000000000000000000000000000000000000000014"); + // EIP-8052 0x16: FN-DSA / Falcon-512 verify (FIPS-206 draft). Input layout: + // [msg 32B | sig_len 2B (big-endian) | sig sig_len B (1..752) | pk 896B]. + // Variable-length signature is prefixed with a 2-byte length field. + private static final DataWord verifyFnDsaAddr = new DataWord( + "0000000000000000000000000000000000000000000000000000000000000016"); + public static PrecompiledContract getOptimizedContractForConstant(PrecompiledContract contract) { try { Constructor constructor = contract.getClass().getDeclaredConstructor(); @@ -282,6 +303,16 @@ public static PrecompiledContract getContractForAddress(DataWord address) { return blake2F; } + if (VMConfig.allowMlDsa() && address.equals(verifyMlDsa44Addr)) { + return verifyMlDsa44; + } + if (VMConfig.allowMlDsa() && address.equals(verifyMlDsa65Addr)) { + return verifyMlDsa65; + } + if (VMConfig.allowFnDsa() && address.equals(verifyFnDsaAddr)) { + return verifyFnDsa; + } + if (VMConfig.allowTvmFreezeV2()) { if (address.equals(getChainParameterAddr)) { return getChainParameter; @@ -2221,4 +2252,122 @@ public Pair execute(byte[] data) { } } + /** + * Verifies an ML-DSA-44 signature (FIPS-204, SHAKE256). Input layout (right-padded with + * zeros if shorter): [msg 32B | signature 2420B | publicKey 1312B] = 3764B. Returns a + * 32-byte word: 1 on success, 0 on failure or malformed input. + */ + 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()); + } + } + } + + /** + * Verifies an ML-DSA-65 signature (FIPS-204, SHAKE256). Input layout: + * [msg 32B | signature 3309B | publicKey 1952B] = 5293B. TRON extension; not part of + * EIP-8051. Returns a 32-byte word: 1 on success, 0 otherwise. + */ + public static class VerifyMlDsa65 extends PrecompiledContract { + + private static final int MSG_LEN = 32; + private static final int SIG_LEN = MLDSA65.SIGNATURE_LENGTH; + private static final int PK_LEN = MLDSA65.PUBLIC_KEY_LENGTH; + private static final int INPUT_LEN = MSG_LEN + SIG_LEN + PK_LEN; + + @Override + public long getEnergyForData(byte[] data) { + return 7000; + } + + @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 = MLDSA65.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()); + } + } + } + + /** + * Verifies a FN-DSA / Falcon-512 signature (FIPS-206 draft). EIP-8052 / TRON extension. + * + *

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

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

Returns a 32-byte word: 1 on valid signature, 0 otherwise. + * Malformed input (wrong lengths, out-of-range sig_len) returns 0 without error. + */ + public static class VerifyFnDsa extends PrecompiledContract { + + private static final int MSG_LEN = 32; + private static final int SIG_LEN_FIELD = 2; + private static final int PK_LEN = FNDSA.PUBLIC_KEY_LENGTH; + private static final int MAX_SIG_LEN = FNDSA.SIGNATURE_LENGTH; + private static final int MIN_INPUT_LEN = MSG_LEN + SIG_LEN_FIELD + 1 + PK_LEN; + + @Override + public long getEnergyForData(byte[] data) { + return 2500; + } + + @Override + public Pair execute(byte[] data) { + if (data == null || data.length < MIN_INPUT_LEN) { + return Pair.of(true, DataWord.ZERO().getData()); + } + try { + byte[] msg = copyOfRange(data, 0, MSG_LEN); + int sigLen = ((data[MSG_LEN] & 0xFF) << 8) | (data[MSG_LEN + 1] & 0xFF); + if (sigLen < 1 || sigLen > MAX_SIG_LEN) { + return Pair.of(true, DataWord.ZERO().getData()); + } + int pkOffset = MSG_LEN + SIG_LEN_FIELD + sigLen; + if (data.length < pkOffset + PK_LEN) { + return Pair.of(true, DataWord.ZERO().getData()); + } + byte[] sig = copyOfRange(data, MSG_LEN + SIG_LEN_FIELD, pkOffset); + byte[] pk = copyOfRange(data, pkOffset, pkOffset + PK_LEN); + boolean ok = FNDSA.verify(pk, msg, sig); + return Pair.of(true, ok ? DataWord.ONE().getData() : DataWord.ZERO().getData()); + } catch (Throwable t) { + return Pair.of(true, DataWord.ZERO().getData()); + } + } + } + } 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 e099101912b..a5102462061 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 @@ -46,6 +46,8 @@ public static void load(StoreFactory storeFactory) { VMConfig.initAllowTvmBlob(ds.getAllowTvmBlob()); VMConfig.initAllowTvmSelfdestructRestriction(ds.getAllowTvmSelfdestructRestriction()); VMConfig.initAllowTvmOsaka(ds.getAllowTvmOsaka()); + VMConfig.initAllowMlDsa(ds.getAllowMlDsa()); + VMConfig.initAllowFnDsa(ds.getAllowFnDsa()); } } } 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..60be6440276 100644 --- a/chainbase/src/main/java/org/tron/common/utils/LocalWitnesses.java +++ b/chainbase/src/main/java/org/tron/common/utils/LocalWitnesses.java @@ -18,14 +18,17 @@ import com.google.common.collect.Lists; import java.util.List; import lombok.Getter; +import lombok.Setter; import lombok.extern.slf4j.Slf4j; import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.StringUtils; import org.tron.common.crypto.ECKey; import org.tron.common.crypto.SignInterface; import org.tron.common.crypto.SignUtils; +import org.tron.common.crypto.pqc.PQSignatureRegistry; import org.tron.core.config.Parameter.ChainConstant; import org.tron.core.exception.TronError; +import org.tron.protos.Protocol.SignatureScheme; @Slf4j(topic = "app") public class LocalWitnesses { @@ -33,6 +36,27 @@ public class LocalWitnesses { @Getter private List privateKeys = Lists.newArrayList(); + /** + * PQ seed values in hex format. The expected byte length depends on + * {@link #pqScheme}: 32 bytes (64 hex chars) for ML-DSA-44 / ML-DSA-65, + * 48 bytes (96 hex chars) for FN-DSA. + */ + @Getter + private List pqSeeds = Lists.newArrayList(); + + /** PQ signature scheme used to derive keys from {@link #pqSeeds}. */ + @Getter + private SignatureScheme pqScheme = SignatureScheme.ML_DSA_65; + + public void setPqScheme(SignatureScheme pqScheme) { + if (pqScheme == null || !PQSignatureRegistry.contains(pqScheme)) { + throw new TronError("unsupported PQ signature scheme: " + pqScheme, + TronError.ErrCode.WITNESS_INIT); + } + this.pqScheme = pqScheme; + } + + @Setter @Getter private byte[] witnessAccountAddress; @@ -95,6 +119,41 @@ public void addPrivateKeys(String privateKey) { this.privateKeys.add(privateKey); } + /** + * PQ seed values used to derive signing keys under {@link #pqScheme}. Each seed must + * be a hex string whose byte length matches the scheme's required seed size; callers + * must therefore set the scheme via {@link #setPqScheme(SignatureScheme)} before + * calling this method when targeting a non-default scheme. + */ + public void setPqSeeds(final List pqSeeds) { + if (CollectionUtils.isEmpty(pqSeeds)) { + return; + } + int expectedSeedLen = PQSignatureRegistry.getSeedLength(pqScheme); + for (String seed : pqSeeds) { + validatePqSeed(seed, expectedSeedLen); + } + this.pqSeeds = pqSeeds; + } + + private static void validatePqSeed(String seed, int expectedSeedLen) { + String hex = seed; + // Match downstream ByteArray.fromHexString, which only strips lowercase "0x". + if (StringUtils.startsWith(hex, "0x")) { + hex = hex.substring(2); + } + int expectedHexLen = expectedSeedLen * 2; + if (StringUtils.isBlank(hex) || hex.length() != expectedHexLen) { + throw new TronError(String.format("PQ seed must be %d hex chars, actual: %d", + expectedHexLen, StringUtils.isBlank(hex) ? 0 : hex.length()), + TronError.ErrCode.WITNESS_INIT); + } + if (!StringUtil.isHexadecimal(hex)) { + throw new TronError("PQ seed 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/AccountCapsule.java b/chainbase/src/main/java/org/tron/core/capsule/AccountCapsule.java index 1af7b55c8b2..d35ac2abb90 100644 --- a/chainbase/src/main/java/org/tron/core/capsule/AccountCapsule.java +++ b/chainbase/src/main/java/org/tron/core/capsule/AccountCapsule.java @@ -46,6 +46,7 @@ import org.tron.protos.Protocol.Account.UnFreezeV2; import org.tron.protos.Protocol.AccountType; import org.tron.protos.Protocol.Key; +import org.tron.protos.Protocol.PQPublicKey; import org.tron.protos.Protocol.Permission; import org.tron.protos.Protocol.Permission.PermissionType; import org.tron.protos.Protocol.Vote; @@ -98,6 +99,22 @@ public AccountCapsule(final AccountCreateContract contract) { */ public AccountCapsule(final AccountCreateContract contract, long createTime, boolean withDefaultPermission, DynamicPropertiesStore dynamicPropertiesStore) { + if (contract.hasPqKey()) { + Permission owner = createDefaultPqOwnerPermission(contract.getPqKey()); + Permission active = createDefaultPqActivePermission(contract.getPqKey(), + dynamicPropertiesStore); + + this.account = Account.newBuilder() + .setType(contract.getType()) + .setAddress(contract.getAccountAddress()) + .setTypeValue(contract.getTypeValue()) + .setCreateTime(createTime) + .setOwnerPermission(owner) + .addActivePermission(active) + .build(); + return; + } + if (withDefaultPermission) { Permission owner = createDefaultOwnerPermission(contract.getAccountAddress()); Permission active = createDefaultActivePermission(contract.getAccountAddress(), @@ -225,6 +242,50 @@ public static Permission createDefaultActivePermission(ByteString address, return active.build(); } + /** + * Default Owner permission bound to a PQ public key. The {@code address} + * field is left empty — the PQ key is authoritative and resolved by + * {@code key_id} during witness verification. + */ + public static Permission createDefaultPqOwnerPermission(PQPublicKey pqKey) { + Key key = Key.newBuilder() + .setAddress(ByteString.EMPTY) + .setWeight(1) + .setPqKey(pqKey) + .build(); + + return Permission.newBuilder() + .setType(PermissionType.Owner) + .setId(0) + .setPermissionName("owner") + .setThreshold(1) + .setParentId(0) + .addKeys(key) + .build(); + } + + /** + * Default Active permission bound to a PQ public key. + */ + public static Permission createDefaultPqActivePermission(PQPublicKey pqKey, + DynamicPropertiesStore dynamicPropertiesStore) { + Key key = Key.newBuilder() + .setAddress(ByteString.EMPTY) + .setWeight(1) + .setPqKey(pqKey) + .build(); + + return Permission.newBuilder() + .setType(PermissionType.Active) + .setId(2) + .setPermissionName("active") + .setThreshold(1) + .setParentId(0) + .setOperations(getActiveDefaultOperations(dynamicPropertiesStore)) + .addKeys(key) + .build(); + } + public static Permission createDefaultWitnessPermission(ByteString address) { Key.Builder key = Key.newBuilder(); key.setAddress(address); 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 01ff7fb5365..75cfe7cfad7 100755 --- a/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java +++ b/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java @@ -31,6 +31,8 @@ import org.tron.common.bloom.Bloom; import org.tron.common.crypto.SignInterface; import org.tron.common.crypto.SignUtils; +import org.tron.common.crypto.pqc.PQAuthDigest; +import org.tron.common.crypto.pqc.PQSignatureRegistry; import org.tron.common.parameter.CommonParameter; import org.tron.common.utils.ByteArray; import org.tron.common.utils.Sha256Hash; @@ -41,8 +43,12 @@ import org.tron.core.exception.ValidateSignatureException; import org.tron.core.store.AccountStore; import org.tron.core.store.DynamicPropertiesStore; +import org.tron.protos.Protocol.PQAuthWitness; import org.tron.protos.Protocol.Block; import org.tron.protos.Protocol.BlockHeader; +import org.tron.protos.Protocol.Key; +import org.tron.protos.Protocol.Permission; +import org.tron.protos.Protocol.SignatureScheme; import org.tron.protos.Protocol.Transaction; @Slf4j(topic = "capsule") @@ -173,6 +179,16 @@ public void sign(byte[] privateKey) { } + public void setPqWitness(PQAuthWitness pqWitness) { + BlockHeader blockHeader = this.block.getBlockHeader().toBuilder() + .setPqWitness(pqWitness).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()); @@ -180,27 +196,106 @@ private Sha256Hash getRawHash() { public boolean validateSignature(DynamicPropertiesStore dynamicPropertiesStore, AccountStore accountStore) throws ValidateSignatureException { + BlockHeader header = block.getBlockHeader(); + boolean hasLegacy = !header.getWitnessSignature().isEmpty(); + PQAuthWitness witnessAuth = header.getPqWitness(); + boolean hasAuth = witnessAuth != null + && witnessAuth.getSignature() != null + && !witnessAuth.getSignature().isEmpty(); + + if (hasLegacy && hasAuth) { + throw new ValidateSignatureException( + "witness_signature and pq_witness are mutually exclusive"); + } + if (!hasLegacy && !hasAuth) { + throw new ValidateSignatureException("missing witness signature"); + } + + byte[] witnessAccountAddress = header.getRawData().getWitnessAddress().toByteArray(); + if (hasAuth) { + return validateWitnessAuth(dynamicPropertiesStore, accountStore, + witnessAccountAddress, witnessAuth); + } + return validateLegacySignature(dynamicPropertiesStore, accountStore, witnessAccountAddress); + } + + private boolean validateLegacySignature(DynamicPropertiesStore dynamicPropertiesStore, + AccountStore accountStore, byte[] witnessAccountAddress) + throws ValidateSignatureException { + if (dynamicPropertiesStore.allowMlDsa()) { + AccountCapsule accountCapsule = accountStore.get(witnessAccountAddress); + if (accountCapsule != null && accountCapsule.getInstance().hasWitnessPermission()) { + Permission witnessPermission = accountCapsule.getInstance().getWitnessPermission(); + if (witnessPermission.getKeysCount() > 0) { + Key k = witnessPermission.getKeys(0); + SignatureScheme ks = k.hasPqKey() + ? k.getPqKey().getScheme() : SignatureScheme.UNKNOWN_SIG_SCHEME; + if (PQSignatureRegistry.contains(ks)) { + throw new ValidateSignatureException( + "witness permission requires PQ scheme " + ks + + " but witness_signature is legacy"); + } + } + } + } try { byte[] sigAddress = SignUtils.signatureToAddress(getRawHash().getBytes(), TransactionCapsule.getBase64FromByteString( block.getBlockHeader().getWitnessSignature()), CommonParameter.getInstance().isECKeyCryptoEngine()); - byte[] witnessAccountAddress = block.getBlockHeader().getRawData().getWitnessAddress() - .toByteArray(); - if (dynamicPropertiesStore.getAllowMultiSign() != 1) { return Arrays.equals(sigAddress, witnessAccountAddress); - } else { - byte[] witnessPermissionAddress = accountStore.get(witnessAccountAddress) - .getWitnessPermissionAddress(); - return Arrays.equals(sigAddress, witnessPermissionAddress); } - + byte[] witnessPermissionAddress = accountStore.get(witnessAccountAddress) + .getWitnessPermissionAddress(); + return Arrays.equals(sigAddress, witnessPermissionAddress); } catch (SignatureException e) { throw new ValidateSignatureException(e.getMessage()); } } + private boolean validateWitnessAuth(DynamicPropertiesStore dynamicPropertiesStore, + AccountStore accountStore, byte[] witnessAccountAddress, PQAuthWitness witnessAuth) + throws ValidateSignatureException { + if (!dynamicPropertiesStore.isAnyPqSchemeAllowed()) { + throw new ValidateSignatureException( + "pq_witness present but no post-quantum scheme is activated"); + } + AccountCapsule accountCapsule = accountStore.get(witnessAccountAddress); + Permission witnessPermission = null; + if (accountCapsule != null && accountCapsule.getInstance().hasWitnessPermission()) { + witnessPermission = accountCapsule.getInstance().getWitnessPermission(); + } + if (witnessPermission == null || witnessPermission.getKeysCount() == 0) { + throw new ValidateSignatureException( + "pq_witness present but witness permission is not configured"); + } + int keyId = witnessAuth.getKeyId(); + if (keyId < 0 || keyId >= witnessPermission.getKeysCount()) { + throw new ValidateSignatureException("pq_witness key_id out of range: " + keyId); + } + Key matched = witnessPermission.getKeys(keyId); + if (!matched.hasPqKey()) { + throw new ValidateSignatureException( + "witness permission key at index " + keyId + " is not a PQ key"); + } + SignatureScheme scheme = matched.getPqKey().getScheme(); + if (!PQSignatureRegistry.contains(scheme)) { + throw new ValidateSignatureException( + "witness permission scheme " + scheme + " is not allowed for block signing"); + } + if (!dynamicPropertiesStore.isPqSchemeAllowed(scheme)) { + throw new ValidateSignatureException( + "witness permission scheme " + scheme + " is not activated"); + } + + byte[] publicKey = matched.getPqKey().getPublicKey().toByteArray(); + byte[] signature = witnessAuth.getSignature().toByteArray(); + byte[] rawHdrHash = getRawHash().getBytes(); + byte[] digest = PQAuthDigest.block(rawHdrHash, keyId); + return PQSignatureRegistry.verify(scheme, publicKey, digest, signature); + } + public BlockId getBlockId() { if (blockId.equals(Sha256Hash.ZERO_HASH)) { blockId = @@ -308,7 +403,12 @@ public long getTimeStamp() { } public boolean hasWitnessSignature() { - return !getInstance().getBlockHeader().getWitnessSignature().isEmpty(); + BlockHeader header = getInstance().getBlockHeader(); + if (!header.getWitnessSignature().isEmpty()) { + return true; + } + PQAuthWitness auth = header.getPqWitness(); + return auth != null && !auth.getSignature().isEmpty(); } @Override diff --git a/chainbase/src/main/java/org/tron/core/capsule/TransactionCapsule.java b/chainbase/src/main/java/org/tron/core/capsule/TransactionCapsule.java index b11c6b1e0a4..904777ceeed 100755 --- a/chainbase/src/main/java/org/tron/core/capsule/TransactionCapsule.java +++ b/chainbase/src/main/java/org/tron/core/capsule/TransactionCapsule.java @@ -44,7 +44,10 @@ import org.tron.common.crypto.Rsv; import org.tron.common.crypto.SignInterface; import org.tron.common.crypto.SignUtils; +import org.tron.common.crypto.pqc.PQAuthDigest; +import org.tron.common.crypto.pqc.PQSignatureRegistry; 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; @@ -64,9 +67,11 @@ import org.tron.core.exception.ValidateSignatureException; import org.tron.core.store.AccountStore; import org.tron.core.store.DynamicPropertiesStore; +import org.tron.protos.Protocol.PQAuthWitness; import org.tron.protos.Protocol.Key; import org.tron.protos.Protocol.Permission; import org.tron.protos.Protocol.Permission.PermissionType; +import org.tron.protos.Protocol.SignatureScheme; import org.tron.protos.Protocol.Transaction; import org.tron.protos.Protocol.Transaction.Contract.ContractType; import org.tron.protos.Protocol.Transaction.Result; @@ -484,6 +489,12 @@ public static boolean validateSignature(Transaction transaction, throw new PermissionException("permission isn't exit"); } checkPermission(permissionId, permission, contract); + Key firstKey = permission.getKeysCount() > 0 ? permission.getKeysList().get(0) : null; + if (firstKey != null && firstKey.hasPqKey() + && firstKey.getPqKey().getScheme() != SignatureScheme.UNKNOWN_SIG_SCHEME) { + throw new PermissionException( + "permission uses PQ scheme, pq_witness is required"); + } long weight = checkWeight(permission, transaction.getSignatureList(), hash, null); if (weight >= permission.getThreshold()) { return true; @@ -637,12 +648,42 @@ public boolean validatePubSignature(AccountStore accountStore, DynamicPropertiesStore dynamicPropertiesStore) throws ValidateSignatureException { if (!isVerified) { - if (this.transaction.getSignatureCount() <= 0 - || this.transaction.getRawData().getContractCount() <= 0) { + int legacyCount = this.transaction.getSignatureCount(); + int pqCount = this.transaction.getPqWitnessCount(); + + if (pqCount > 0 && !dynamicPropertiesStore.isAnyPqSchemeAllowed()) { + throw new ValidateSignatureException( + "pq_witness not allowed: no post-quantum scheme is activated"); + } + if (legacyCount > 0 && pqCount > 0) { + throw new ValidateSignatureException( + "signature and pq_witness are mutually exclusive"); + } + if (legacyCount == 0 && pqCount == 0) { + throw new ValidateSignatureException("miss sig or contract"); + } + if (this.transaction.getRawData().getContractCount() <= 0) { throw new ValidateSignatureException("miss sig or contract"); } - if (this.transaction.getSignatureCount() > dynamicPropertiesStore - .getTotalSignNum()) { + if (pqCount > 0) { + if (pqCount > dynamicPropertiesStore.getTotalSignNum()) { + throw new ValidateSignatureException("too many signatures"); + } + try { + if (!validateStructuredSignature( + this.transaction, accountStore, dynamicPropertiesStore)) { + isVerified = false; + throw new ValidateSignatureException("sig error"); + } + } catch (PermissionException e) { + isVerified = false; + throw new ValidateSignatureException(e.getMessage()); + } + isVerified = true; + return true; + } + + if (legacyCount > dynamicPropertiesStore.getTotalSignNum()) { throw new ValidateSignatureException("too many signatures"); } @@ -662,6 +703,85 @@ public boolean validatePubSignature(AccountStore accountStore, return true; } + static boolean validateStructuredSignature(Transaction transaction, + AccountStore accountStore, DynamicPropertiesStore dynamicPropertiesStore) + throws PermissionException { + Transaction.Contract contract = transaction.getRawData().getContractList().get(0); + int permissionId = contract.getPermissionId(); + byte[] owner = getOwner(contract); + AccountCapsule account = accountStore.get(owner); + Permission permission = null; + if (account == null) { + if (permissionId == 0) { + permission = AccountCapsule.getDefaultPermission(ByteString.copyFrom(owner)); + } + if (permissionId == 2) { + permission = AccountCapsule + .createDefaultActivePermission(ByteString.copyFrom(owner), dynamicPropertiesStore); + } + } else { + permission = account.getPermissionById(permissionId); + } + if (permission == null) { + throw new PermissionException("permission isn't exit"); + } + checkPermission(permissionId, permission, contract); + + if (permission.getKeysCount() == 0 + || !permission.getKeysList().get(0).hasPqKey() + || permission.getKeysList().get(0).getPqKey().getScheme() + == SignatureScheme.UNKNOWN_SIG_SCHEME) { + throw new PermissionException( + "permission uses legacy scheme, pq_witness is not allowed"); + } + + byte[] txid = computeRawHash(transaction).getBytes(); + List witnesses = transaction.getPqWitnessList(); + java.util.Set seen = new java.util.HashSet<>(); + long weight = 0L; + for (PQAuthWitness aw : witnesses) { + int keyId = aw.getKeyId(); + if (!seen.add(keyId)) { + throw new PermissionException("duplicate key_id in pq_witness"); + } + if (keyId < 0 || keyId >= permission.getKeysCount()) { + throw new PermissionException("key_id out of range: " + keyId); + } + Key key = permission.getKeys(keyId); + if (!key.hasPqKey()) { + throw new PermissionException("key at index " + keyId + " is not a PQ key"); + } + SignatureScheme scheme = key.getPqKey().getScheme(); + if (!PQSignatureRegistry.contains(scheme)) { + throw new PermissionException("unsupported scheme: " + scheme); + } + if (!dynamicPropertiesStore.isPqSchemeAllowed(scheme)) { + throw new PermissionException(scheme + " is not activated"); + } + byte[] digest = PQAuthDigest.tx(txid, permissionId, keyId); + byte[] pk = key.getPqKey().getPublicKey().toByteArray(); + byte[] sig = aw.getSignature().toByteArray(); + if (pk.length != PQSignatureRegistry.getPublicKeyLength(scheme) + || !PQSignatureRegistry.isValidSignatureLength(scheme, sig.length)) { + throw new PermissionException("public key or signature length mismatch"); + } + if (!PQSignatureRegistry.verify(scheme, pk, digest, sig)) { + throw new PermissionException("pq sig invalid"); + } + try { + weight = StrictMathWrapper.addExact(weight, key.getWeight()); + } catch (ArithmeticException e) { + throw new PermissionException("weight overflow"); + } + } + return weight >= permission.getThreshold(); + } + + private static Sha256Hash computeRawHash(Transaction transaction) { + return Sha256Hash.of(CommonParameter.getInstance().isECKeyCryptoEngine(), + transaction.getRawData().toByteArray()); + } + /** * validate signature */ diff --git a/chainbase/src/main/java/org/tron/core/db/BandwidthProcessor.java b/chainbase/src/main/java/org/tron/core/db/BandwidthProcessor.java index 2488686bfb0..2072f4d2975 100644 --- a/chainbase/src/main/java/org/tron/core/db/BandwidthProcessor.java +++ b/chainbase/src/main/java/org/tron/core/db/BandwidthProcessor.java @@ -140,8 +140,17 @@ public void consume(TransactionCapsule trx, TransactionTrace trace) if (optimizeTxs) { long maxCreateAccountTxSize = dynamicPropertiesStore.getMaxCreateAccountTxSize(); int signatureCount = trx.getInstance().getSignatureCount(); + long sigOverhead = signatureCount * PER_SIGN_LENGTH; + if (trx.getInstance().getPqWitnessCount() > 0) { + long pqWitnessBytes = 0L; + for (org.tron.protos.Protocol.PQAuthWitness aw + : trx.getInstance().getPqWitnessList()) { + pqWitnessBytes += aw.getSerializedSize(); + } + sigOverhead = pqWitnessBytes; + } 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 e0adb0d444a..38aa7f81d1f 100644 --- a/chainbase/src/main/java/org/tron/core/store/DynamicPropertiesStore.java +++ b/chainbase/src/main/java/org/tron/core/store/DynamicPropertiesStore.java @@ -21,6 +21,7 @@ import org.tron.common.utils.Sha256Hash; import org.tron.core.capsule.BytesCapsule; import org.tron.core.config.Parameter.ChainConstant; +import org.tron.protos.Protocol.SignatureScheme; import org.tron.core.db.TronStoreWithRevoking; import org.tron.core.exception.BadItemException; import org.tron.core.exception.ItemNotFoundException; @@ -240,6 +241,10 @@ public class DynamicPropertiesStore extends TronStoreWithRevoking private static final byte[] ALLOW_TVM_OSAKA = "ALLOW_TVM_OSAKA".getBytes(); + private static final byte[] ALLOW_ML_DSA = "ALLOW_ML_DSA".getBytes(); + + private static final byte[] ALLOW_FN_DSA = "ALLOW_FN_DSA".getBytes(); + @Autowired private DynamicPropertiesStore(@Value("properties") String dbName) { super(dbName); @@ -2993,6 +2998,60 @@ public void saveAllowTvmOsaka(long value) { this.put(ALLOW_TVM_OSAKA, new BytesCapsule(ByteArray.fromLong(value))); } + public long getAllowMlDsa() { + return Optional.ofNullable(getUnchecked(ALLOW_ML_DSA)) + .map(BytesCapsule::getData) + .map(ByteArray::toLong) + .orElse(CommonParameter.getInstance().getAllowMlDsa()); + } + + public void saveAllowMlDsa(long value) { + this.put(ALLOW_ML_DSA, new BytesCapsule(ByteArray.fromLong(value))); + } + + public boolean allowMlDsa() { + return getAllowMlDsa() == 1L; + } + + public long getAllowFnDsa() { + return Optional.ofNullable(getUnchecked(ALLOW_FN_DSA)) + .map(BytesCapsule::getData) + .map(ByteArray::toLong) + .orElse(CommonParameter.getInstance().getAllowFnDsa()); + } + + public void saveAllowFnDsa(long value) { + this.put(ALLOW_FN_DSA, new BytesCapsule(ByteArray.fromLong(value))); + } + + public boolean allowFnDsa() { + return getAllowFnDsa() == 1L; + } + + /** Returns true iff at least one post-quantum signature scheme is currently activated. */ + public boolean isAnyPqSchemeAllowed() { + return allowMlDsa() || allowFnDsa(); + } + + /** + * Per-scheme governance check. ML-DSA-44 and ML-DSA-65 are gated by a single + * {@code ALLOW_ML_DSA} flag; FN-DSA has its own flag. Non-PQ schemes return false. + */ + public boolean isPqSchemeAllowed(SignatureScheme scheme) { + if (scheme == null) { + return false; + } + switch (scheme) { + case ML_DSA_44: + case ML_DSA_65: + return allowMlDsa(); + case FN_DSA: + return allowFnDsa(); + 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 a73158a718a..ee52ff7f6a2 100644 --- a/common/src/main/java/org/tron/common/parameter/CommonParameter.java +++ b/common/src/main/java/org/tron/common/parameter/CommonParameter.java @@ -637,6 +637,14 @@ public class CommonParameter { @Setter public long allowTvmOsaka; + @Getter + @Setter + public long allowMlDsa; + + @Getter + @Setter + public long allowFnDsa; + private static double calcMaxTimeRatio() { return 5.0; } diff --git a/common/src/main/java/org/tron/core/Constant.java b/common/src/main/java/org/tron/core/Constant.java index 1437d319346..e8ee8f28dc4 100644 --- a/common/src/main/java/org/tron/core/Constant.java +++ b/common/src/main/java/org/tron/core/Constant.java @@ -60,6 +60,19 @@ public class Constant { // Crypto engine public static final String ECKey_ENGINE = "ECKey"; + // Post-quantum (ML-DSA / FIPS 204) signature constants + public static final int ML_DSA_44_PUBLIC_KEY_LENGTH = 1312; + public static final int ML_DSA_44_SIGNATURE_LENGTH = 2420; + public static final int ML_DSA_65_PUBLIC_KEY_LENGTH = 1952; + public static final int ML_DSA_65_SIGNATURE_LENGTH = 3309; + // Post-quantum (FIPS 206 draft) FN-DSA / Falcon-512 signature constants. + // Falcon signatures are variable-length; SIGNATURE_MAX_LENGTH is the protocol-level + // upper bound, not an exact length. + public static final int FN_DSA_PUBLIC_KEY_LENGTH = 896; + public static final int FN_DSA_SIGNATURE_MAX_LENGTH = 752; + public static final String PQ_TX_AUTH_DOMAIN = "TRON_TX_AUTH_V1"; + public static final String PQ_BLOCK_AUTH_DOMAIN = "TRON_BLOCK_AUTH_V1"; + // Network public static final String LOCAL_HOST = "127.0.0.1"; diff --git a/common/src/main/java/org/tron/core/vm/config/VMConfig.java b/common/src/main/java/org/tron/core/vm/config/VMConfig.java index 1a7f0c058a4..7b6fd492a6b 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 @@ -63,6 +63,10 @@ public class VMConfig { private static boolean ALLOW_TVM_OSAKA = false; + private static boolean ALLOW_ML_DSA = false; + + private static boolean ALLOW_FN_DSA = false; + private VMConfig() { } @@ -178,6 +182,14 @@ public static void initAllowTvmOsaka(long allow) { ALLOW_TVM_OSAKA = allow == 1; } + public static void initAllowMlDsa(long allow) { + ALLOW_ML_DSA = allow == 1; + } + + public static void initAllowFnDsa(long allow) { + ALLOW_FN_DSA = allow == 1; + } + public static boolean getEnergyLimitHardFork() { return CommonParameter.ENERGY_LIMIT_HARD_FORK; } @@ -281,4 +293,12 @@ public static boolean allowTvmSelfdestructRestriction() { public static boolean allowTvmOsaka() { return ALLOW_TVM_OSAKA; } + + public static boolean allowMlDsa() { + return ALLOW_ML_DSA; + } + + public static boolean allowFnDsa() { + return ALLOW_FN_DSA; + } } 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..22cae16b6a4 100644 --- a/consensus/src/main/java/org/tron/consensus/base/Param.java +++ b/consensus/src/main/java/org/tron/consensus/base/Param.java @@ -67,11 +67,31 @@ public class Miner { @Setter private ByteString witnessAddress; + private byte[] pqPrivateKey; + + private byte[] pqPublicKey; + public Miner(byte[] privateKey, ByteString privateKeyAddress, ByteString witnessAddress) { this.privateKey = privateKey; this.privateKeyAddress = privateKeyAddress; this.witnessAddress = witnessAddress; } + + public byte[] getPQPrivateKey() { + return pqPrivateKey == null ? null : pqPrivateKey.clone(); + } + + public void setPQPrivateKey(byte[] pqPrivateKey) { + this.pqPrivateKey = pqPrivateKey == null ? null : pqPrivateKey.clone(); + } + + public byte[] getPQPublicKey() { + return pqPublicKey == null ? null : pqPublicKey.clone(); + } + + public void setPQPublicKey(byte[] pqPublicKey) { + this.pqPublicKey = pqPublicKey == null ? null : pqPublicKey.clone(); + } } public Miner getMiner() { diff --git a/crypto/src/main/java/org/tron/common/crypto/pqc/FNDSA.java b/crypto/src/main/java/org/tron/common/crypto/pqc/FNDSA.java new file mode 100644 index 00000000000..fbbc9dbf48b --- /dev/null +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/FNDSA.java @@ -0,0 +1,194 @@ +package org.tron.common.crypto.pqc; + +import java.security.SecureRandom; +import org.bouncycastle.crypto.AsymmetricCipherKeyPair; +import org.bouncycastle.crypto.params.ParametersWithRandom; +import org.bouncycastle.crypto.prng.FixedSecureRandom; +import org.bouncycastle.pqc.crypto.falcon.FalconKeyGenerationParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconKeyPairGenerator; +import org.bouncycastle.pqc.crypto.falcon.FalconParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconPrivateKeyParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconPublicKeyParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconSigner; +import org.tron.common.crypto.Hash; +import org.tron.protos.Protocol.SignatureScheme; + +/** + * FIPS 206 (draft) FN-DSA / Falcon-512 keypair-bound signer/verifier. Mirrors the + * {@link MLDSA44} / {@link MLDSA65} / {@link SLHDSA} shape: instance methods sign/verify + * with the bound keypair, static {@link #sign(byte[], byte[])} / {@link #verify} provide + * stateless entry points used by {@link PQSignatureRegistry}. + * + *

Falcon signatures are variable-length: {@link #SIGNATURE_LENGTH} is + * the protocol-level upper bound, not an exact length. The {@link PQSignature#validateSignature} + * default treats this as {@code <= SIGNATURE_LENGTH}; ML-DSA / SLH-DSA override back to + * strict equality. BouncyCastle 1.79's {@code FalconNIST.CRYPTO_BYTES} for Falcon-512 is + * 690 bytes, well below the 752-byte protocol cap. + */ +public final class FNDSA implements PQSignature { + + /** + * Falcon-512 encoded private key from BC: f || g || F, where f and g are each + * {@link #F_G_ENCODED_LENGTH} bytes (6 bits per coefficient × N=512 / 8) and F is + * {@link #BIG_F_ENCODED_LENGTH} bytes (8 bits per coefficient × N=512 / 8). + */ + public static final int F_G_ENCODED_LENGTH = 384; + public static final int BIG_F_ENCODED_LENGTH = 512; + public static final int PRIVATE_KEY_LENGTH = + F_G_ENCODED_LENGTH + F_G_ENCODED_LENGTH + BIG_F_ENCODED_LENGTH; + /** + * Falcon-512 public key from BC: 14 * N / 8 = 896 bytes (the modq-encoded h polynomial). + * The 1-byte serialization header is stripped from {@code getH()}. + */ + public static final int PUBLIC_KEY_LENGTH = 896; + /** Protocol-level upper bound on Falcon-512 signature length (variable). */ + public static final int SIGNATURE_LENGTH = 752; + /** Falcon keygen seeds an internal SHAKE256 from 48 bytes of randomness. */ + public static final int SEED_LENGTH = 48; + + private static final FalconParameters PARAMS = FalconParameters.falcon_512; + + private final byte[] privateKey; + private final byte[] publicKey; + + public FNDSA() { + AsymmetricCipherKeyPair kp = generateKeyPair(new SecureRandom()); + this.privateKey = ((FalconPrivateKeyParameters) kp.getPrivate()).getEncoded(); + this.publicKey = ((FalconPublicKeyParameters) kp.getPublic()).getH(); + } + + public FNDSA(byte[] seed) { + if (seed == null || seed.length != SEED_LENGTH) { + throw new IllegalArgumentException("FN-DSA seed length must be " + SEED_LENGTH); + } + AsymmetricCipherKeyPair kp = generateKeyPair(new FixedSecureRandom(seed)); + this.privateKey = ((FalconPrivateKeyParameters) kp.getPrivate()).getEncoded(); + this.publicKey = ((FalconPublicKeyParameters) kp.getPublic()).getH(); + } + + public FNDSA(byte[] privateKey, byte[] publicKey) { + validatePrivateKeyBytes(privateKey); + if (publicKey == null || publicKey.length != PUBLIC_KEY_LENGTH) { + throw new IllegalArgumentException( + "FN-DSA public key length must be " + PUBLIC_KEY_LENGTH); + } + this.privateKey = privateKey.clone(); + this.publicKey = publicKey.clone(); + } + + @Override + public SignatureScheme getScheme() { + return SignatureScheme.FN_DSA; + } + + @Override + public int getPrivateKeyLength() { + return PRIVATE_KEY_LENGTH; + } + + @Override + public int getPublicKeyLength() { + return PUBLIC_KEY_LENGTH; + } + + /** Returns the protocol-level signature length upper bound (signatures are variable-length). */ + @Override + public int getSignatureLength() { + return SIGNATURE_LENGTH; + } + + @Override + public byte[] getPrivateKey() { + return privateKey.clone(); + } + + @Override + public byte[] getPublicKey() { + return publicKey.clone(); + } + + @Override + public byte[] getAddress() { + return Hash.sha3omit12(publicKey); + } + + @Override + public byte[] sign(byte[] message) { + return sign(privateKey, message); + } + + @Override + public boolean verify(byte[] message, byte[] signature) { + return verify(publicKey, message, signature); + } + + public static boolean verify(byte[] publicKey, byte[] message, byte[] signature) { + if (publicKey == null || publicKey.length != PUBLIC_KEY_LENGTH) { + throw new IllegalArgumentException( + "FN-DSA public key length must be " + PUBLIC_KEY_LENGTH); + } + if (signature == null || signature.length == 0 || signature.length > SIGNATURE_LENGTH) { + throw new IllegalArgumentException( + "FN-DSA signature length must be 1.." + SIGNATURE_LENGTH); + } + if (message == null) { + throw new IllegalArgumentException("message must not be null"); + } + FalconPublicKeyParameters pk = new FalconPublicKeyParameters(PARAMS, publicKey); + FalconSigner verifier = new FalconSigner(); + verifier.init(false, pk); + try { + return verifier.verifySignature(message, signature); + } catch (RuntimeException e) { + return false; + } + } + + public static byte[] sign(byte[] privateKey, byte[] message) { + validatePrivateKeyBytes(privateKey); + if (message == null) { + throw new IllegalArgumentException("message must not be null"); + } + byte[] f = new byte[F_G_ENCODED_LENGTH]; + byte[] g = new byte[F_G_ENCODED_LENGTH]; + byte[] bigF = new byte[BIG_F_ENCODED_LENGTH]; + System.arraycopy(privateKey, 0, f, 0, f.length); + System.arraycopy(privateKey, f.length, g, 0, g.length); + System.arraycopy(privateKey, f.length + g.length, bigF, 0, bigF.length); + FalconPrivateKeyParameters sk = new FalconPrivateKeyParameters(PARAMS, f, g, bigF, new byte[0]); + FalconSigner signer = new FalconSigner(); + signer.init(true, new ParametersWithRandom(sk, new SecureRandom())); + try { + return signer.generateSignature(message); + } catch (Exception e) { + throw new IllegalStateException("FN-DSA signing failed", e); + } + } + + public static byte[] derivePublicKey(byte[] privateKey) { + throw new UnsupportedOperationException( + "FN-DSA public key cannot be derived from the encoded private key alone; " + + "supply both halves to the (privateKey, publicKey) constructor"); + } + + public static byte[] computeAddress(byte[] publicKey) { + if (publicKey == null || publicKey.length != PUBLIC_KEY_LENGTH) { + throw new IllegalArgumentException( + "FN-DSA public key length must be " + PUBLIC_KEY_LENGTH); + } + return Hash.sha3omit12(publicKey); + } + + private static AsymmetricCipherKeyPair generateKeyPair(SecureRandom random) { + FalconKeyPairGenerator generator = new FalconKeyPairGenerator(); + generator.init(new FalconKeyGenerationParameters(random, PARAMS)); + return generator.generateKeyPair(); + } + + private static void validatePrivateKeyBytes(byte[] privateKey) { + if (privateKey == null || privateKey.length != PRIVATE_KEY_LENGTH) { + throw new IllegalArgumentException( + "FN-DSA private key length must be " + PRIVATE_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..3d14e81c499 --- /dev/null +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA44.java @@ -0,0 +1,208 @@ +package org.tron.common.crypto.pqc; + +import java.security.SecureRandom; +import org.bouncycastle.crypto.AsymmetricCipherKeyPair; +import org.bouncycastle.crypto.prng.FixedSecureRandom; +import org.bouncycastle.pqc.crypto.mldsa.MLDSAKeyGenerationParameters; +import org.bouncycastle.pqc.crypto.mldsa.MLDSAKeyPairGenerator; +import org.bouncycastle.pqc.crypto.mldsa.MLDSAParameters; +import org.bouncycastle.pqc.crypto.mldsa.MLDSAPrivateKeyParameters; +import org.bouncycastle.pqc.crypto.mldsa.MLDSAPublicKeyParameters; +import org.bouncycastle.pqc.crypto.mldsa.MLDSASigner; +import org.tron.common.crypto.Hash; +import org.tron.protos.Protocol.SignatureScheme; + +/** + * FIPS 204 ML-DSA-44 keypair-bound signer/verifier. Instance verify and sign + * use the bound keypair; stateless dispatch is available via the static + * {@link #verify} / {@link #sign(byte[], byte[])} entry points and the + * {@link PQSignatureRegistry}. Consumes raw public key / signature bytes — + * no SubjectPublicKeyInfo, PEM, or Base64 wrapping. + */ +public final class MLDSA44 implements PQSignature { + + public static final int PRIVATE_KEY_LENGTH = 2560; + public static final int PUBLIC_KEY_LENGTH = 1312; + public static final int SIGNATURE_LENGTH = 2420; + /** FIPS 204 ML-DSA seed (ξ) is 32 bytes. */ + public static final int SEED_LENGTH = 32; + + private final byte[] privateKey; + private final byte[] publicKey; + + /** Generate a fresh keypair using a cryptographically secure random source. */ + public MLDSA44() { + this.privateKey = generatePrivateKey(); + this.publicKey = derivePublicKey(this.privateKey); + } + + /** Deterministically generate a keypair from a 32-byte seed (FIPS 204 ξ). */ + public MLDSA44(byte[] seed) { + if (seed == null || seed.length != SEED_LENGTH) { + throw new IllegalArgumentException("ML-DSA-44 seed length must be " + SEED_LENGTH); + } + this.privateKey = generatePrivateKeyFromSeed(seed); + this.publicKey = derivePublicKey(this.privateKey); + } + + /** + * Build a keypair-bound instance from an existing keypair without re-deriving the public key. + * The caller is responsible for ensuring that {@code publicKey} is the correct public key + * corresponding to {@code privateKey}; no consistency check is performed. + */ + public MLDSA44(byte[] privateKey, byte[] publicKey) { + validatePrivateKeyBytes(privateKey); + if (publicKey == null || publicKey.length != PUBLIC_KEY_LENGTH) { + throw new IllegalArgumentException( + "ML-DSA-44 public key length must be " + PUBLIC_KEY_LENGTH); + } + this.privateKey = privateKey.clone(); + this.publicKey = publicKey.clone(); + } + + /** Load an existing 2560-byte ML-DSA-44 private key; the public key is derived. */ + public static MLDSA44 fromPrivate(byte[] privateKey) { + validatePrivateKeyBytes(privateKey); + byte[] sk = privateKey.clone(); + return new MLDSA44(sk, derivePublicKey(sk)); + } + + @Override + public SignatureScheme getScheme() { + return SignatureScheme.ML_DSA_44; + } + + @Override + public int getPrivateKeyLength() { + return PRIVATE_KEY_LENGTH; + } + + @Override + public int getPublicKeyLength() { + return PUBLIC_KEY_LENGTH; + } + + @Override + public int getSignatureLength() { + return SIGNATURE_LENGTH; + } + + @Override + public byte[] getPrivateKey() { + return privateKey.clone(); + } + + @Override + public byte[] getPublicKey() { + return publicKey.clone(); + } + + /** 21-byte TRON address derived from the public key. */ + @Override + public byte[] getAddress() { + return Hash.sha3omit12(publicKey); + } + + @Override + public byte[] sign(byte[] message) { + return sign(privateKey, message); + } + + @Override + public boolean verify(byte[] message, byte[] signature) { + return verify(publicKey, message, signature); + } + + /** ML-DSA-44 produces fixed-length signatures; override the default upper-bound check. */ + @Override + public void validateSignature(byte[] signature) { + if (signature == null || signature.length != SIGNATURE_LENGTH) { + throw new IllegalArgumentException( + "invalid " + getScheme() + " signature length: " + + (signature == null ? "null" : signature.length) + + ", expected " + SIGNATURE_LENGTH); + } + } + + public static boolean verify(byte[] publicKey, byte[] message, byte[] signature) { + if (publicKey == null || publicKey.length != PUBLIC_KEY_LENGTH) { + throw new IllegalArgumentException( + "ML-DSA-44 public key length must be " + PUBLIC_KEY_LENGTH); + } + if (signature == null || signature.length != SIGNATURE_LENGTH) { + throw new IllegalArgumentException( + "ML-DSA-44 signature length must be " + SIGNATURE_LENGTH); + } + if (message == null) { + throw new IllegalArgumentException("message must not be null"); + } + MLDSAPublicKeyParameters pk = new MLDSAPublicKeyParameters( + MLDSAParameters.ml_dsa_44, publicKey); + MLDSASigner verifier = new MLDSASigner(); + verifier.init(false, pk); + verifier.update(message, 0, message.length); + return verifier.verifySignature(signature); + } + + public static byte[] sign(byte[] privateKey, byte[] message) { + validatePrivateKeyBytes(privateKey); + if (message == null) { + throw new IllegalArgumentException("message must not be null"); + } + MLDSAPrivateKeyParameters sk = new MLDSAPrivateKeyParameters( + MLDSAParameters.ml_dsa_44, privateKey); + MLDSASigner signer = new MLDSASigner(); + signer.init(true, sk); + signer.update(message, 0, message.length); + try { + return signer.generateSignature(); + } catch (Exception e) { + throw new IllegalStateException("ML-DSA-44 signing failed", e); + } + } + + /** Generate a fresh ML-DSA-44 private key; returns the NIST-encoded 2560-byte key. */ + public static byte[] generatePrivateKey() { + return generatePrivateKey(new SecureRandom()); + } + + /** Deterministically generate an ML-DSA-44 private key from a 32-byte seed. */ + public static byte[] generatePrivateKeyFromSeed(byte[] seed) { + if (seed == null || seed.length != SEED_LENGTH) { + throw new IllegalArgumentException( + "ML-DSA-44 seed length must be " + SEED_LENGTH); + } + return generatePrivateKey(new FixedSecureRandom(seed)); + } + + private static byte[] generatePrivateKey(SecureRandom random) { + MLDSAKeyPairGenerator generator = new MLDSAKeyPairGenerator(); + generator.init(new MLDSAKeyGenerationParameters(random, MLDSAParameters.ml_dsa_44)); + AsymmetricCipherKeyPair keyPair = generator.generateKeyPair(); + return ((MLDSAPrivateKeyParameters) keyPair.getPrivate()).getEncoded(); + } + + public static byte[] derivePublicKey(byte[] privateKey) { + validatePrivateKeyBytes(privateKey); + MLDSAPrivateKeyParameters sk = new MLDSAPrivateKeyParameters( + MLDSAParameters.ml_dsa_44, privateKey); + MLDSAPublicKeyParameters pk = sk.getPublicKeyParameters(); + return pk.getEncoded(); + } + + /** Derive a TRON 21-byte address from an ML-DSA-44 public key via {@code sha3omit12(pubKey)}. */ + public static byte[] computeAddress(byte[] publicKey) { + if (publicKey == null || publicKey.length != PUBLIC_KEY_LENGTH) { + throw new IllegalArgumentException( + "ML-DSA-44 public key length must be " + PUBLIC_KEY_LENGTH); + } + return Hash.sha3omit12(publicKey); + } + + private static void validatePrivateKeyBytes(byte[] privateKey) { + if (privateKey == null || privateKey.length != PRIVATE_KEY_LENGTH) { + throw new IllegalArgumentException( + "ML-DSA-44 private key length must be " + PRIVATE_KEY_LENGTH); + } + } +} diff --git a/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA65.java b/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA65.java new file mode 100644 index 00000000000..2dca36ca52d --- /dev/null +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA65.java @@ -0,0 +1,208 @@ +package org.tron.common.crypto.pqc; + +import java.security.SecureRandom; +import org.bouncycastle.crypto.AsymmetricCipherKeyPair; +import org.bouncycastle.crypto.prng.FixedSecureRandom; +import org.bouncycastle.pqc.crypto.mldsa.MLDSAKeyGenerationParameters; +import org.bouncycastle.pqc.crypto.mldsa.MLDSAKeyPairGenerator; +import org.bouncycastle.pqc.crypto.mldsa.MLDSAParameters; +import org.bouncycastle.pqc.crypto.mldsa.MLDSAPrivateKeyParameters; +import org.bouncycastle.pqc.crypto.mldsa.MLDSAPublicKeyParameters; +import org.bouncycastle.pqc.crypto.mldsa.MLDSASigner; +import org.tron.common.crypto.Hash; +import org.tron.protos.Protocol.SignatureScheme; + +/** + * FIPS 204 ML-DSA-65 keypair-bound signer/verifier. Instance verify and sign + * use the bound keypair; stateless dispatch is available via the static + * {@link #verify} / {@link #sign(byte[], byte[])} entry points and the + * {@link PQSignatureRegistry}. Consumes raw public key / signature bytes — + * no SubjectPublicKeyInfo, PEM, or Base64 wrapping. + */ +public final class MLDSA65 implements PQSignature { + + public static final int PRIVATE_KEY_LENGTH = 4032; + public static final int PUBLIC_KEY_LENGTH = 1952; + public static final int SIGNATURE_LENGTH = 3309; + /** FIPS 204 ML-DSA seed (ξ) is 32 bytes. */ + public static final int SEED_LENGTH = 32; + + private final byte[] privateKey; + private final byte[] publicKey; + + /** Generate a fresh keypair using a cryptographically secure random source. */ + public MLDSA65() { + this.privateKey = generatePrivateKey(); + this.publicKey = derivePublicKey(this.privateKey); + } + + /** Deterministically generate a keypair from a 32-byte seed (FIPS 204 ξ). */ + public MLDSA65(byte[] seed) { + if (seed == null || seed.length != SEED_LENGTH) { + throw new IllegalArgumentException("ML-DSA-65 seed length must be " + SEED_LENGTH); + } + this.privateKey = generatePrivateKeyFromSeed(seed); + this.publicKey = derivePublicKey(this.privateKey); + } + + /** + * Build a keypair-bound instance from an existing keypair without re-deriving the public key. + * The caller is responsible for ensuring that {@code publicKey} is the correct public key + * corresponding to {@code privateKey}; no consistency check is performed. + */ + public MLDSA65(byte[] privateKey, byte[] publicKey) { + validatePrivateKeyBytes(privateKey); + if (publicKey == null || publicKey.length != PUBLIC_KEY_LENGTH) { + throw new IllegalArgumentException( + "ML-DSA-65 public key length must be " + PUBLIC_KEY_LENGTH); + } + this.privateKey = privateKey.clone(); + this.publicKey = publicKey.clone(); + } + + /** Load an existing 4032-byte ML-DSA-65 private key; the public key is derived. */ + public static MLDSA65 fromPrivate(byte[] privateKey) { + validatePrivateKeyBytes(privateKey); + byte[] sk = privateKey.clone(); + return new MLDSA65(sk, derivePublicKey(sk)); + } + + @Override + public SignatureScheme getScheme() { + return SignatureScheme.ML_DSA_65; + } + + @Override + public int getPrivateKeyLength() { + return PRIVATE_KEY_LENGTH; + } + + @Override + public int getPublicKeyLength() { + return PUBLIC_KEY_LENGTH; + } + + @Override + public int getSignatureLength() { + return SIGNATURE_LENGTH; + } + + @Override + public byte[] getPrivateKey() { + return privateKey.clone(); + } + + @Override + public byte[] getPublicKey() { + return publicKey.clone(); + } + + /** 21-byte TRON address derived from the public key. */ + @Override + public byte[] getAddress() { + return Hash.sha3omit12(publicKey); + } + + @Override + public byte[] sign(byte[] message) { + return sign(privateKey, message); + } + + @Override + public boolean verify(byte[] message, byte[] signature) { + return verify(publicKey, message, signature); + } + + /** ML-DSA-65 produces fixed-length signatures; override the default upper-bound check. */ + @Override + public void validateSignature(byte[] signature) { + if (signature == null || signature.length != SIGNATURE_LENGTH) { + throw new IllegalArgumentException( + "invalid " + getScheme() + " signature length: " + + (signature == null ? "null" : signature.length) + + ", expected " + SIGNATURE_LENGTH); + } + } + + public static boolean verify(byte[] publicKey, byte[] message, byte[] signature) { + if (publicKey == null || publicKey.length != PUBLIC_KEY_LENGTH) { + throw new IllegalArgumentException( + "ML-DSA-65 public key length must be " + PUBLIC_KEY_LENGTH); + } + if (signature == null || signature.length != SIGNATURE_LENGTH) { + throw new IllegalArgumentException( + "ML-DSA-65 signature length must be " + SIGNATURE_LENGTH); + } + if (message == null) { + throw new IllegalArgumentException("message must not be null"); + } + MLDSAPublicKeyParameters pk = new MLDSAPublicKeyParameters( + MLDSAParameters.ml_dsa_65, publicKey); + MLDSASigner verifier = new MLDSASigner(); + verifier.init(false, pk); + verifier.update(message, 0, message.length); + return verifier.verifySignature(signature); + } + + public static byte[] sign(byte[] privateKey, byte[] message) { + validatePrivateKeyBytes(privateKey); + if (message == null) { + throw new IllegalArgumentException("message must not be null"); + } + MLDSAPrivateKeyParameters sk = new MLDSAPrivateKeyParameters( + MLDSAParameters.ml_dsa_65, privateKey); + MLDSASigner signer = new MLDSASigner(); + signer.init(true, sk); + signer.update(message, 0, message.length); + try { + return signer.generateSignature(); + } catch (Exception e) { + throw new IllegalStateException("ML-DSA-65 signing failed", e); + } + } + + /** Generate a fresh ML-DSA-65 private key; returns the NIST-encoded 4032-byte key. */ + public static byte[] generatePrivateKey() { + return generatePrivateKey(new SecureRandom()); + } + + /** Deterministically generate an ML-DSA-65 private key from a 32-byte seed. */ + public static byte[] generatePrivateKeyFromSeed(byte[] seed) { + if (seed == null || seed.length != SEED_LENGTH) { + throw new IllegalArgumentException( + "ML-DSA-65 seed length must be " + SEED_LENGTH); + } + return generatePrivateKey(new FixedSecureRandom(seed)); + } + + private static byte[] generatePrivateKey(SecureRandom random) { + MLDSAKeyPairGenerator generator = new MLDSAKeyPairGenerator(); + generator.init(new MLDSAKeyGenerationParameters(random, MLDSAParameters.ml_dsa_65)); + AsymmetricCipherKeyPair keyPair = generator.generateKeyPair(); + return ((MLDSAPrivateKeyParameters) keyPair.getPrivate()).getEncoded(); + } + + public static byte[] derivePublicKey(byte[] privateKey) { + validatePrivateKeyBytes(privateKey); + MLDSAPrivateKeyParameters sk = new MLDSAPrivateKeyParameters( + MLDSAParameters.ml_dsa_65, privateKey); + MLDSAPublicKeyParameters pk = sk.getPublicKeyParameters(); + return pk.getEncoded(); + } + + /** Derive a TRON 21-byte address from an ML-DSA-65 public key via {@code sha3omit12(pubKey)}. */ + public static byte[] computeAddress(byte[] publicKey) { + if (publicKey == null || publicKey.length != PUBLIC_KEY_LENGTH) { + throw new IllegalArgumentException( + "ML-DSA-65 public key length must be " + PUBLIC_KEY_LENGTH); + } + return Hash.sha3omit12(publicKey); + } + + private static void validatePrivateKeyBytes(byte[] privateKey) { + if (privateKey == null || privateKey.length != PRIVATE_KEY_LENGTH) { + throw new IllegalArgumentException( + "ML-DSA-65 private key length must be " + PRIVATE_KEY_LENGTH); + } + } +} diff --git a/crypto/src/main/java/org/tron/common/crypto/pqc/PQAuthDigest.java b/crypto/src/main/java/org/tron/common/crypto/pqc/PQAuthDigest.java new file mode 100644 index 00000000000..caa122eb4ec --- /dev/null +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/PQAuthDigest.java @@ -0,0 +1,75 @@ +package org.tron.common.crypto.pqc; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import org.tron.common.utils.Sha256Hash; + +/** + * Domain-separated SHA-256 digests for post-quantum authentication. + * + *

The domain prefixes are UTF-8 string bytes concatenated before the + * context fields. They differ between transaction and block flows so a + * transaction signature can never be replayed as a block signature and vice + * versa. + */ +public final class PQAuthDigest { + + public static final String TX_DOMAIN = "TRON_TX_AUTH_V1"; + public static final String BLOCK_DOMAIN = "TRON_BLOCK_AUTH_V1"; + + static final byte[] TX_DOMAIN_BYTES = TX_DOMAIN.getBytes(StandardCharsets.UTF_8); + static final byte[] BLOCK_DOMAIN_BYTES = BLOCK_DOMAIN.getBytes(StandardCharsets.UTF_8); + + private PQAuthDigest() { + } + + /** + * Transaction-level PQ authentication digest. + * + *

digest = SHA-256("TRON_TX_AUTH_V1" || txid || permission_id_be4 || key_id_be4)
+ * + *

{@code keyId} is the 0-based index of the signing key in the permission's key list. + * For single-key permissions the caller passes 0. + */ + public static byte[] tx(byte[] txid, int permissionId, int keyId) { + requireNonNull(txid, "txid"); + MessageDigest md = Sha256Hash.newDigest(); + md.update(TX_DOMAIN_BYTES); + md.update(txid); + md.update(intToBe4(permissionId)); + md.update(intToBe4(keyId)); + return md.digest(); + } + + /** + * Block-level PQ authentication digest. + * + *

digest = SHA-256("TRON_BLOCK_AUTH_V1" || block_header_raw_hash || key_id_be4)
+ * + *

{@code keyId} is the 0-based index of the signing key in the witness permission's key list. + * For the typical single-key witness permission the caller passes 0. + */ + public static byte[] block(byte[] blockHeaderRawHash, int keyId) { + requireNonNull(blockHeaderRawHash, "blockHeaderRawHash"); + MessageDigest md = Sha256Hash.newDigest(); + md.update(BLOCK_DOMAIN_BYTES); + md.update(blockHeaderRawHash); + md.update(intToBe4(keyId)); + return md.digest(); + } + + private static byte[] intToBe4(int v) { + return new byte[] { + (byte) ((v >>> 24) & 0xff), + (byte) ((v >>> 16) & 0xff), + (byte) ((v >>> 8) & 0xff), + (byte) (v & 0xff) + }; + } + + static void requireNonNull(byte[] b, String name) { + if (b == null) { + throw new IllegalArgumentException(name + " must not be null"); + } + } +} 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..0cb30030757 --- /dev/null +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/PQSignature.java @@ -0,0 +1,68 @@ +package org.tron.common.crypto.pqc; + +import org.tron.protos.Protocol.SignatureScheme; + +/** + * 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 SignatureScheme} is provided by + * {@link PQSignatureRegistry}. + */ +public interface PQSignature { + + SignatureScheme getScheme(); + + int getPrivateKeyLength(); + + int getPublicKeyLength(); + + int getSignatureLength(); + + byte[] getPrivateKey(); + + byte[] getPublicKey(); + + /** 21-byte TRON address derived from the held public key. */ + byte[] getAddress(); + + /** Sign {@code message} with the held private key; returns the raw signature. */ + byte[] sign(byte[] message); + + /** + * Verify {@code signature} over {@code message} against the held public key. + * + * @return true iff the signature is cryptographically valid for the bound keypair + */ + boolean verify(byte[] message, byte[] signature); + + default void validatePrivateKey(byte[] privateKey) { + if (privateKey == null || privateKey.length != getPrivateKeyLength()) { + throw new IllegalArgumentException( + "invalid " + getScheme() + " private key length: " + + (privateKey == null ? "null" : privateKey.length) + + ", expected " + getPrivateKeyLength()); + } + } + + default void validatePublicKey(byte[] publicKey) { + if (publicKey == null || publicKey.length != getPublicKeyLength()) { + throw new IllegalArgumentException( + "invalid " + getScheme() + " public key length: " + + (publicKey == null ? "null" : publicKey.length) + + ", expected " + getPublicKeyLength()); + } + } + + /** + * Default upper-bound check, sufficient for variable-length schemes (FN-DSA). + * Fixed-length schemes (ML-DSA-44 / ML-DSA-65) override this with strict equality. + */ + default void validateSignature(byte[] signature) { + if (signature == null || signature.length == 0 || signature.length > getSignatureLength()) { + throw new IllegalArgumentException( + "invalid " + getScheme() + " signature length: " + + (signature == null ? "null" : signature.length) + + ", expected 1.." + getSignatureLength()); + } + } +} diff --git a/crypto/src/main/java/org/tron/common/crypto/pqc/PQSignatureRegistry.java b/crypto/src/main/java/org/tron/common/crypto/pqc/PQSignatureRegistry.java new file mode 100644 index 00000000000..d9913fdc701 --- /dev/null +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/PQSignatureRegistry.java @@ -0,0 +1,171 @@ +package org.tron.common.crypto.pqc; + +import java.util.Collections; +import java.util.EnumMap; +import java.util.Map; +import org.tron.common.crypto.Hash; +import org.tron.protos.Protocol.SignatureScheme; + +/** + * Static dispatch table for post-quantum signature schemes keyed by + * {@link SignatureScheme}. Each entry binds a scheme to its public-key length, + * signature length, and stateless sign/verify/keygen operations. Legacy + * schemes (ECDSA secp256k1, SM2/SM3) are NOT registered — they flow through + * the existing {@code SignInterface} path. + */ +public final class PQSignatureRegistry { + + /** 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); + } + + private static final class SchemeInfo { + final int publicKeyLength; + final int signatureLength; + final int seedLength; + final SignatureOps ops; + + SchemeInfo(int publicKeyLength, int signatureLength, int seedLength, SignatureOps ops) { + this.publicKeyLength = publicKeyLength; + this.signatureLength = signatureLength; + this.seedLength = seedLength; + this.ops = ops; + } + } + + private static final Map SCHEMES; + + static { + EnumMap m = new EnumMap<>(SignatureScheme.class); + m.put(SignatureScheme.ML_DSA_44, new SchemeInfo( + MLDSA44.PUBLIC_KEY_LENGTH, MLDSA44.SIGNATURE_LENGTH, MLDSA44.SEED_LENGTH, + 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); + } + })); + m.put(SignatureScheme.ML_DSA_65, new SchemeInfo( + MLDSA65.PUBLIC_KEY_LENGTH, MLDSA65.SIGNATURE_LENGTH, MLDSA65.SEED_LENGTH, + new SignatureOps() { + @Override + public byte[] sign(byte[] privateKey, byte[] message) { + return MLDSA65.sign(privateKey, message); + } + + @Override + public boolean verify(byte[] publicKey, byte[] message, byte[] signature) { + return MLDSA65.verify(publicKey, message, signature); + } + + @Override + public PQSignature fromSeed(byte[] seed) { + return new MLDSA65(seed); + } + })); + m.put(SignatureScheme.FN_DSA, new SchemeInfo( + FNDSA.PUBLIC_KEY_LENGTH, FNDSA.SIGNATURE_LENGTH, FNDSA.SEED_LENGTH, + new SignatureOps() { + @Override + public byte[] sign(byte[] privateKey, byte[] message) { + return FNDSA.sign(privateKey, message); + } + + @Override + public boolean verify(byte[] publicKey, byte[] message, byte[] signature) { + return FNDSA.verify(publicKey, message, signature); + } + + @Override + public PQSignature fromSeed(byte[] seed) { + return new FNDSA(seed); + } + })); + SCHEMES = Collections.unmodifiableMap(m); + } + + private PQSignatureRegistry() { + } + + public static boolean contains(SignatureScheme scheme) { + return SCHEMES.containsKey(scheme); + } + + public static int getPublicKeyLength(SignatureScheme scheme) { + return require(scheme).publicKeyLength; + } + + public static int getSignatureLength(SignatureScheme scheme) { + return require(scheme).signatureLength; + } + + public static int getSeedLength(SignatureScheme scheme) { + return require(scheme).seedLength; + } + + /** + * Per-scheme signature-length predicate. Fixed-length schemes (ML-DSA-44 / ML-DSA-65) + * require exact equality with {@link #getSignatureLength(SignatureScheme)}; + * variable-length schemes (FN-DSA) treat that value as an upper bound and accept any + * {@code 1..max}. + */ + public static boolean isValidSignatureLength(SignatureScheme scheme, int length) { + SchemeInfo info = require(scheme); + if (scheme == SignatureScheme.FN_DSA) { + return length > 0 && length <= info.signatureLength; + } + return length == info.signatureLength; + } + + public static byte[] sign(SignatureScheme scheme, byte[] privateKey, byte[] message) { + return require(scheme).ops.sign(privateKey, message); + } + + public static boolean verify( + SignatureScheme scheme, byte[] publicKey, byte[] message, byte[] signature) { + return require(scheme).ops.verify(publicKey, message, signature); + } + + public static PQSignature fromSeed(SignatureScheme scheme, byte[] seed) { + return require(scheme).ops.fromSeed(seed); + } + + /** + * Derive the 21-byte TRON address from a PQ public key. Uses + * {@code Hash.sha3omit12(publicKey)} so the mapping matches the existing + * {@link PQSignature#getAddress()} contract. + */ + public static byte[] computeAddress(SignatureScheme 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 Hash.sha3omit12(publicKey); + } + + private static SchemeInfo require(SignatureScheme scheme) { + SchemeInfo info = SCHEMES.get(scheme); + if (info == null) { + throw new IllegalArgumentException( + "no PQSignature registered for scheme: " + scheme); + } + return info; + } +} \ No newline at end of file diff --git a/framework/src/main/java/org/tron/core/Wallet.java b/framework/src/main/java/org/tron/core/Wallet.java index 39e8f06c281..4d2457d9cfb 100755 --- a/framework/src/main/java/org/tron/core/Wallet.java +++ b/framework/src/main/java/org/tron/core/Wallet.java @@ -1514,6 +1514,16 @@ public Protocol.ChainParameters getChainParameters() { .setValue(dbManager.getDynamicPropertiesStore().getAllowTvmOsaka()) .build()); + builder.addChainParameter(Protocol.ChainParameters.ChainParameter.newBuilder() + .setKey("getAllowMlDsa") + .setValue(dbManager.getDynamicPropertiesStore().getAllowMlDsa()) + .build()); + + builder.addChainParameter(Protocol.ChainParameters.ChainParameter.newBuilder() + .setKey("getAllowFnDsa") + .setValue(dbManager.getDynamicPropertiesStore().getAllowFnDsa()) + .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 83d7fd2c63d..346bc281e11 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 @@ -31,6 +31,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.EnumSet; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; @@ -78,6 +79,7 @@ import org.tron.p2p.dns.update.PublishConfig; import org.tron.p2p.utils.NetUtil; import org.tron.program.Version; +import org.tron.protos.Protocol.SignatureScheme; @Slf4j(topic = "app") @NoArgsConstructor @@ -1041,6 +1043,14 @@ public static void applyConfigParams( config.hasPath(ConfigKey.COMMITTEE_ALLOW_TVM_OSAKA) ? config .getInt(ConfigKey.COMMITTEE_ALLOW_TVM_OSAKA) : 0; + PARAMETER.allowMlDsa = + config.hasPath(ConfigKey.COMMITTEE_ALLOW_ML_DSA) ? config + .getInt(ConfigKey.COMMITTEE_ALLOW_ML_DSA) : 0; + + PARAMETER.allowFnDsa = + config.hasPath(ConfigKey.COMMITTEE_ALLOW_FN_DSA) ? config + .getInt(ConfigKey.COMMITTEE_ALLOW_FN_DSA) : 0; + logConfig(); } @@ -1184,6 +1194,10 @@ private static void applyCLIParams(CLIParameter cmd, JCommander jc) { } } + private static final EnumSet WITNESS_PQ_SEED_SCHEMES = EnumSet.of( + SignatureScheme.ML_DSA_44, SignatureScheme.ML_DSA_65, + SignatureScheme.FN_DSA); + private static void initLocalWitnesses(Config config, CLIParameter cmd) { // not a witness node, skip if (!PARAMETER.isWitness()) { @@ -1220,6 +1234,36 @@ private static void initLocalWitnesses(Config config, CLIParameter cmd) { } } + // path 4: PQ seed configuration + if (config.hasPath(ConfigKey.LOCAL_WITNESS_SEED_PQ)) { + List pqSeeds = config.getStringList(ConfigKey.LOCAL_WITNESS_SEED_PQ); + if (!pqSeeds.isEmpty()) { + localWitnesses = new LocalWitnesses(); + // Scheme must be applied before seeds — seed-length validation depends on it. + if (config.hasPath(ConfigKey.LOCAL_WITNESS_SEED_PQ_SCHEME)) { + String schemeName = config.getString(ConfigKey.LOCAL_WITNESS_SEED_PQ_SCHEME); + try { + SignatureScheme scheme = SignatureScheme.valueOf(schemeName); + if (!WITNESS_PQ_SEED_SCHEMES.contains(scheme)) { + throw new TronError("invalid " + ConfigKey.LOCAL_WITNESS_SEED_PQ_SCHEME + + ": " + schemeName + "; valid values: " + WITNESS_PQ_SEED_SCHEMES, + TronError.ErrCode.WITNESS_INIT); + } + localWitnesses.setPqScheme(scheme); + } catch (IllegalArgumentException e) { + throw new TronError("invalid " + ConfigKey.LOCAL_WITNESS_SEED_PQ_SCHEME + + ": " + schemeName, TronError.ErrCode.WITNESS_INIT); + } + } + localWitnesses.setPqSeeds(pqSeeds); + byte[] address = WitnessInitializer.resolvePqWitnessAddress(witnessAddr); + if (address != null) { + localWitnesses.setWitnessAccountAddress(address); + } + return; + } + } + // no private key source configured throw new TronError("This is a witness node, but localWitnesses is null", TronError.ErrCode.WITNESS_INIT); diff --git a/framework/src/main/java/org/tron/core/config/args/ConfigKey.java b/framework/src/main/java/org/tron/core/config/args/ConfigKey.java index b21c9c440a4..11ce71c3cae 100644 --- a/framework/src/main/java/org/tron/core/config/args/ConfigKey.java +++ b/framework/src/main/java/org/tron/core/config/args/ConfigKey.java @@ -13,6 +13,8 @@ private ConfigKey() { public static final String LOCAL_WITNESS = "localwitness"; // private key public static final String LOCAL_WITNESS_ACCOUNT_ADDRESS = "localWitnessAccountAddress"; public static final String LOCAL_WITNESS_KEYSTORE = "localwitnesskeystore"; + public static final String LOCAL_WITNESS_SEED_PQ = "localwitness_seed_pq"; + public static final String LOCAL_WITNESS_SEED_PQ_SCHEME = "localwitness_seed_pq_scheme"; // crypto public static final String CRYPTO_ENGINE = "crypto.engine"; @@ -248,6 +250,8 @@ private ConfigKey() { public static final String COMMITTEE_ALLOW_TVM_BLOB = "committee.allowTvmBlob"; public static final String COMMITTEE_PROPOSAL_EXPIRE_TIME = "committee.proposalExpireTime"; public static final String COMMITTEE_ALLOW_TVM_OSAKA = "committee.allowTvmOsaka"; + public static final String COMMITTEE_ALLOW_ML_DSA = "committee.allowMlDsa"; + public static final String COMMITTEE_ALLOW_FN_DSA = "committee.allowFnDsa"; public static final String ALLOW_ACCOUNT_ASSET_OPTIMIZATION = "committee.allowAccountAssetOptimization"; public static final String ALLOW_ASSET_OPTIMIZATION = "committee.allowAssetOptimization"; 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 30711eb6190..40d8ca420b1 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 @@ -97,6 +97,45 @@ public static LocalWitnesses initFromKeystore( return witnesses; } + /** + * Init for PQ-only witness nodes (no legacy ECDSA key). The witness account + * address must be supplied explicitly because there is no ECDSA key to derive it from. + */ + public static LocalWitnesses initFromPQOnly(String witnessAccountAddress) { + if (StringUtils.isBlank(witnessAccountAddress)) { + throw new TronError( + "localWitnessAccountAddress must be set for PQ-only witness nodes", + TronError.ErrCode.WITNESS_INIT); + } + byte[] address = Commons.decodeFromBase58Check(witnessAccountAddress); + if (address == null) { + throw new TronError( + "LocalWitnessAccountAddress format is incorrect", + TronError.ErrCode.WITNESS_INIT); + } + LocalWitnesses witnesses = new LocalWitnesses(); + witnesses.initWitnessAccountAddress(address, false); + logger.debug("Initialised PQ-only witness with address {}", witnessAccountAddress); + return witnesses; + } + + /** + * Resolve witness address for PQ seed configuration. + */ + public static byte[] resolvePqWitnessAddress(String witnessAccountAddress) { + if (StringUtils.isEmpty(witnessAccountAddress)) { + return null; + } + byte[] address = Commons.decodeFromBase58Check(witnessAccountAddress); + if (address != null) { + logger.debug("Got localWitnessAccountAddress from config.conf"); + } else { + throw new TronError("LocalWitnessAccountAddress format from config is incorrect", + TronError.ErrCode.WITNESS_INIT); + } + return address; + } + static byte[] resolveWitnessAddress( LocalWitnesses witnesses, String witnessAccountAddress) { if (StringUtils.isEmpty(witnessAccountAddress)) { diff --git a/framework/src/main/java/org/tron/core/consensus/ConsensusService.java b/framework/src/main/java/org/tron/core/consensus/ConsensusService.java index ef8f30ef498..d241ae2672b 100644 --- a/framework/src/main/java/org/tron/core/consensus/ConsensusService.java +++ b/framework/src/main/java/org/tron/core/consensus/ConsensusService.java @@ -10,13 +10,17 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.tron.common.crypto.SignUtils; +import org.tron.common.crypto.pqc.PQSignature; +import org.tron.common.crypto.pqc.PQSignatureRegistry; 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.SignatureScheme; @Slf4j(topic = "consensus") @Component @@ -46,6 +50,7 @@ public void start() { param.setAgreeNodeCount(parameter.getAgreeNodeCount()); List miners = new ArrayList<>(); List privateKeys = Args.getLocalWitnesses().getPrivateKeys(); + List pqSeeds = Args.getLocalWitnesses().getPqSeeds(); if (privateKeys.size() > 1) { for (String key : privateKeys) { byte[] privateKey = fromHexString(key); @@ -76,6 +81,29 @@ public void start() { Miner miner = param.new Miner(privateKey, ByteString.copyFrom(privateKeyAddress), ByteString.copyFrom(witnessAddress)); miners.add(miner); + } else if (pqSeeds.size() > 1) { + SignatureScheme scheme = Args.getLocalWitnesses().getPqScheme(); + requireSupportedPqScheme(scheme); + for (String seed : pqSeeds) { + byte[] seedBytes = fromHexString(seed); + PQSignature keypair = PQSignatureRegistry.fromSeed(scheme, seedBytes); + byte[] sk = keypair.getPrivateKey(); + byte[] pk = keypair.getPublicKey(); + byte[] pqAddress = keypair.getAddress(); + WitnessCapsule witnessCapsule = witnessStore.get(pqAddress); + if (null == witnessCapsule) { + logger.warn("Witness {} is not in witnessStore.", Hex.toHexString(pqAddress)); + } + ByteString pqAddressBs = ByteString.copyFrom(pqAddress); + Miner miner = param.new Miner(null, pqAddressBs, pqAddressBs); + miner.setPQPrivateKey(sk); + miner.setPQPublicKey(pk); + miners.add(miner); + logger.info("Add {} witness (from seed): {}, size: {}", + scheme, Hex.toHexString(pqAddress), miners.size()); + } + } else if (pqSeeds.size() == 1) { + miners.add(buildPQOnlyMinerFromSeed(param, pqSeeds.get(0))); } param.setMiners(miners); @@ -85,6 +113,38 @@ public void start() { logger.info("consensus service start success"); } + private Miner buildPQOnlyMinerFromSeed(Param param, String pqSeed) { + SignatureScheme scheme = Args.getLocalWitnesses().getPqScheme(); + requireSupportedPqScheme(scheme); + byte[] seedBytes = fromHexString(pqSeed); + PQSignature keypair = PQSignatureRegistry.fromSeed(scheme, seedBytes); + byte[] sk = keypair.getPrivateKey(); + byte[] pk = keypair.getPublicKey(); + byte[] pqAddress = keypair.getAddress(); + byte[] witnessAddress = Args.getLocalWitnesses().getWitnessAccountAddress(); + if (witnessAddress == null || witnessAddress.length == 0) { + witnessAddress = pqAddress; + } + WitnessCapsule witnessCapsule = witnessStore.get(witnessAddress); + if (null == witnessCapsule) { + logger.warn("Witness {} is not in witnessStore.", Hex.toHexString(witnessAddress)); + } + // In multi-signature mode, the address derived from the PQ key may differ from witnessAddress. + Miner miner = param.new Miner(null, ByteString.copyFrom(pqAddress), + ByteString.copyFrom(witnessAddress)); + miner.setPQPrivateKey(sk); + miner.setPQPublicKey(pk); + logger.info("Add {} witness (from seed): {}", scheme, Hex.toHexString(witnessAddress)); + return miner; + } + + private static void requireSupportedPqScheme(SignatureScheme scheme) { + if (!PQSignatureRegistry.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 1bec0c2bda3..506f11abcc8 100644 --- a/framework/src/main/java/org/tron/core/consensus/ProposalService.java +++ b/framework/src/main/java/org/tron/core/consensus/ProposalService.java @@ -396,6 +396,14 @@ public static boolean process(Manager manager, ProposalCapsule proposalCapsule) manager.getDynamicPropertiesStore().saveAllowTvmOsaka(entry.getValue()); break; } + case ALLOW_ML_DSA: { + manager.getDynamicPropertiesStore().saveAllowMlDsa(entry.getValue()); + break; + } + case ALLOW_FN_DSA: { + manager.getDynamicPropertiesStore().saveAllowFnDsa(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 cd1a61c01fe..5d0a69ea3d3 100644 --- a/framework/src/main/java/org/tron/core/db/Manager.java +++ b/framework/src/main/java/org/tron/core/db/Manager.java @@ -54,6 +54,8 @@ import org.tron.common.args.GenesisBlock; import org.tron.common.bloom.Bloom; import org.tron.common.cron.CronExpression; +import org.tron.common.crypto.pqc.PQAuthDigest; +import org.tron.common.crypto.pqc.PQSignatureRegistry; import org.tron.common.es.ExecutorServiceManager; import org.tron.common.exit.ExitManager; import org.tron.common.logsfilter.EventPluginLoader; @@ -168,7 +170,10 @@ import org.tron.core.utils.TransactionRegister; import org.tron.protos.Protocol; import org.tron.protos.Protocol.AccountType; +import org.tron.protos.Protocol.Key; +import org.tron.protos.Protocol.PQAuthWitness; import org.tron.protos.Protocol.Permission; +import org.tron.protos.Protocol.SignatureScheme; import org.tron.protos.Protocol.Transaction; import org.tron.protos.Protocol.Transaction.Contract; import org.tron.protos.Protocol.TransactionInfo; @@ -1738,7 +1743,7 @@ public BlockCapsule generateBlock(Miner miner, long blockTime, long timeout) { session.reset(); blockCapsule.setMerkleRoot(); - blockCapsule.sign(miner.getPrivateKey()); + signBlockCapsule(blockCapsule, miner); BlockCapsule capsule = new BlockCapsule(blockCapsule.getInstance()); capsule.generatedByMyself = true; @@ -1754,6 +1759,50 @@ public BlockCapsule generateBlock(Miner miner, long blockTime, long timeout) { return capsule; } + private void signBlockCapsule(BlockCapsule blockCapsule, Miner miner) { + SignatureScheme scheme = resolveWitnessScheme(miner); + if (PQSignatureRegistry.contains(scheme)) { + signWitnessAuth(blockCapsule, miner, scheme); + } else { + blockCapsule.sign(miner.getPrivateKey()); + } + } + + private SignatureScheme resolveWitnessScheme(Miner miner) { + if (!chainBaseManager.getDynamicPropertiesStore().isAnyPqSchemeAllowed()) { + return SignatureScheme.UNKNOWN_SIG_SCHEME; + } + byte[] witnessAddress = miner.getWitnessAddress().toByteArray(); + AccountCapsule accountCapsule = chainBaseManager.getAccountStore().get(witnessAddress); + if (accountCapsule == null || !accountCapsule.getInstance().hasWitnessPermission()) { + return SignatureScheme.UNKNOWN_SIG_SCHEME; + } + Permission witnessPermission = accountCapsule.getInstance().getWitnessPermission(); + if (witnessPermission.getKeysCount() == 0) { + return SignatureScheme.UNKNOWN_SIG_SCHEME; + } + Key k = witnessPermission.getKeys(0); + return k.hasPqKey() ? k.getPqKey().getScheme() : SignatureScheme.UNKNOWN_SIG_SCHEME; + } + + private void signWitnessAuth(BlockCapsule blockCapsule, Miner miner, SignatureScheme scheme) { + byte[] witnessAddress = miner.getWitnessAddress().toByteArray(); + Permission witnessPermission = chainBaseManager.getAccountStore().get(witnessAddress) + .getInstance().getWitnessPermission(); + byte[] pqPrivateKey = miner.getPQPrivateKey(); + if (pqPrivateKey == null) { + throw new IllegalStateException( + "witness permission requires " + scheme + + " but local PQ private key is not configured"); + } + byte[] digest = PQAuthDigest.block(blockCapsule.getRawHashBytes(), 0); + byte[] signature = PQSignatureRegistry.sign(scheme, pqPrivateKey, digest); + PQAuthWitness witnessAuth = PQAuthWitness.newBuilder() + .setSignature(ByteString.copyFrom(signature)) + .build(); + blockCapsule.setPqWitness(witnessAuth); + } + private void filterOwnerAddress(TransactionCapsule transactionCapsule, Set result) { byte[] owner = transactionCapsule.getOwnerAddress(); String ownerAddress = ByteArray.toHexString(owner); diff --git a/framework/src/main/resources/config.conf b/framework/src/main/resources/config.conf index 369924074bc..b9404f7ad3d 100644 --- a/framework/src/main/resources/config.conf +++ b/framework/src/main/resources/config.conf @@ -673,6 +673,20 @@ localwitness = [ # "localwitnesskeystore.json" # ] +# Scheme used to derive keys from localwitness_seed_pq. Defaults to ML_DSA_65. +# Allowed values: ML_DSA_44, ML_DSA_65, FN_DSA. +# localwitness_seed_pq_scheme = "ML_DSA_65" + +# Post-quantum witness signing seed, hex-encoded. Length depends on +# localwitness_seed_pq_scheme: 32 bytes (64 hex chars) for ML_DSA_44 / ML_DSA_65 +# (FIPS 204), 48 bytes (96 hex chars) for FN_DSA (Falcon-512). Used only after +# the matching ALLOW_ML_DSA / ALLOW_FN_DSA proposal is active and the witness +# Permission is upgraded to the corresponding PQ scheme. +# MUST be produced by a CSPRNG; the value below is an example, never use in prod. +# localwitness_seed_pq = [ +# "0101010101010101010101010101010101010101010101010101010101010101" +# ] + block = { needSyncCheck = true maintenanceTimeInterval = 21600000 // 6 hours: 21600000(ms) @@ -760,6 +774,7 @@ committee = { # consensusLogicOptimization = 0 # allowOptimizedReturnValueOfChainId = 0 # allowTvmOsaka = 0 + # allowMlDsa = 0 } event.subscribe = { diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/FNDSATest.java b/framework/src/test/java/org/tron/common/crypto/pqc/FNDSATest.java new file mode 100644 index 00000000000..816a5643c31 --- /dev/null +++ b/framework/src/test/java/org/tron/common/crypto/pqc/FNDSATest.java @@ -0,0 +1,344 @@ +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.common.crypto.pqc.MLDSA44; +import org.tron.common.crypto.pqc.MLDSA65; +import org.tron.common.crypto.pqc.PQSignatureRegistry; +import org.tron.protos.Protocol.SignatureScheme; + +public class FNDSATest { + + private static final FalconParameters PARAMS = FalconParameters.falcon_512; + + private FNDSA keypair; + private FalconPublicKeyParameters pk; + private FalconPrivateKeyParameters sk; + + @Before + public void setUp() { + AsymmetricCipherKeyPair kp = freshKeyPair(); + pk = (FalconPublicKeyParameters) kp.getPublic(); + sk = (FalconPrivateKeyParameters) kp.getPrivate(); + keypair = new FNDSA(sk.getEncoded(), pk.getH()); + } + + private static AsymmetricCipherKeyPair freshKeyPair() { + FalconKeyPairGenerator gen = new FalconKeyPairGenerator(); + gen.init(new FalconKeyGenerationParameters(new SecureRandom(), PARAMS)); + return gen.generateKeyPair(); + } + + private byte[] rawSign(byte[] message) { + FalconSigner signer = new FalconSigner(); + signer.init(true, new ParametersWithRandom(sk, new SecureRandom())); + try { + return signer.generateSignature(message); + } catch (Exception e) { + throw new AssertionError("failed to sign in test setup", e); + } + } + + @Test + public void schemeAndLengthsMatchFips206Draft() { + assertEquals(SignatureScheme.FN_DSA, keypair.getScheme()); + assertEquals(FNDSA.PUBLIC_KEY_LENGTH, keypair.getPublicKeyLength()); + assertEquals(FNDSA.SIGNATURE_LENGTH, keypair.getSignatureLength()); + assertEquals(FNDSA.PRIVATE_KEY_LENGTH, keypair.getPrivateKeyLength()); + assertEquals(FNDSA.PUBLIC_KEY_LENGTH, pk.getH().length); + } + + @Test + public void publicKeyHasFixedLength() { + for (int i = 0; i < 4; i++) { + AsymmetricCipherKeyPair kp = freshKeyPair(); + byte[] pkBytes = ((FalconPublicKeyParameters) kp.getPublic()).getH(); + assertEquals(FNDSA.PUBLIC_KEY_LENGTH, pkBytes.length); + } + } + + @Test + public void privateKeyEncodingHasFixedLength() { + for (int i = 0; i < 4; i++) { + AsymmetricCipherKeyPair kp = freshKeyPair(); + byte[] skBytes = ((FalconPrivateKeyParameters) kp.getPrivate()).getEncoded(); + assertEquals(FNDSA.PRIVATE_KEY_LENGTH, skBytes.length); + } + } + + @Test + public void signProducesVerifiableSignatureWithinBound() { + byte[] msg = "hello, fn-dsa".getBytes(); + byte[] sig = FNDSA.sign(sk.getEncoded(), msg); + assertTrue("signature must be non-empty", sig.length > 0); + assertTrue( + "signature must respect protocol-level upper bound", + sig.length <= FNDSA.SIGNATURE_LENGTH); + assertTrue(FNDSA.verify(pk.getH(), msg, sig)); + } + + @Test + public void signatureBoundaryAtMaxAcceptedByLengthCheck() { + byte[] sig = new byte[FNDSA.SIGNATURE_LENGTH]; + keypair.validateSignature(sig); + } + + @Test + public void signatureBoundaryAboveMaxRejected() { + byte[] sig = new byte[FNDSA.SIGNATURE_LENGTH + 1]; + try { + keypair.validateSignature(sig); + fail("signature longer than upper bound should be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("signature length")); + } + } + + @Test + public void minimalValidLengthAcceptedByLengthCheck() { + byte[] sig = new byte[1]; + keypair.validateSignature(sig); + } + + @Test + public void emptySignatureRejectedByLengthCheck() { + byte[] sig = new byte[0]; + try { + keypair.validateSignature(sig); + fail("empty signature should be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("signature length")); + } + } + + @Test + public void verifyRejectsSignatureLongerThanUpperBound() { + byte[] msg = new byte[] {1, 2, 3}; + byte[] tooLong = new byte[FNDSA.SIGNATURE_LENGTH + 1]; + try { + FNDSA.verify(pk.getH(), msg, tooLong); + fail("signature exceeding upper bound should be rejected at static verify"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("signature length")); + } + } + + @Test + public void verifyRejectsEmptySignature() { + byte[] msg = new byte[] {1, 2, 3}; + byte[] empty = new byte[0]; + try { + FNDSA.verify(pk.getH(), msg, empty); + fail("empty signature should be rejected at static verify"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("signature length")); + } + } + + @Test + public void invalidPublicKeyLengthRejected() { + byte[] badPk = new byte[FNDSA.PUBLIC_KEY_LENGTH - 1]; + byte[] msg = new byte[] {1}; + byte[] sig = new byte[16]; + try { + FNDSA.verify(badPk, msg, sig); + fail("short public key should be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("public key length")); + } + } + + @Test + public void nullMessageRejected() { + byte[] sig = new byte[16]; + try { + FNDSA.verify(pk.getH(), null, sig); + fail("null message should be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("message")); + } + } + + @Test + public void signatureBoundToMessage() { + byte[] msg = "hello".getBytes(); + byte[] sig = rawSign(msg); + byte[] tamperedMsg = "hellp".getBytes(); + assertFalse(keypair.verify(tamperedMsg, sig)); + } + + @Test + public void tamperedSignatureFailsVerification() { + byte[] msg = "payload".getBytes(); + byte[] sig = rawSign(msg); + sig[0] ^= 0x01; + assertFalse(keypair.verify(msg, sig)); + } + + @Test + public void wrongPublicKeyFailsVerification() { + byte[] msg = "payload".getBytes(); + byte[] sig = rawSign(msg); + AsymmetricCipherKeyPair other = freshKeyPair(); + byte[] otherPk = ((FalconPublicKeyParameters) other.getPublic()).getH(); + assertFalse(FNDSA.verify(otherPk, msg, sig)); + } + + @Test + public void crossAlgoSignatureRejected() { + // FN-DSA upper bound is 752 bytes; ML-DSA-44 (2420), ML-DSA-65 (3309), + // SLH-DSA (7856) all exceed it and must be rejected at the length check. + byte[] msg = "cross-algo".getBytes(); + int[] foreignLengths = {2420, 3309, 7856}; + for (int len : foreignLengths) { + byte[] foreign = new byte[len]; + try { + FNDSA.verify(pk.getH(), msg, foreign); + fail("foreign-scheme signature length " + len + " should be rejected for FN-DSA"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("signature length")); + } + } + } + + @Test + public void emptyMessageVerifiesConsistently() { + byte[] msg = new byte[0]; + byte[] sig = rawSign(msg); + assertTrue(keypair.verify(msg, sig)); + } + + @Test + public void keypairBoundInstanceSignsAndVerifies() { + FNDSA signer = new FNDSA(); + byte[] msg = "keypair-bound".getBytes(); + byte[] sig = signer.sign(msg); + assertTrue(sig.length > 0 && sig.length <= FNDSA.SIGNATURE_LENGTH); + assertTrue(signer.verify(msg, sig)); + } + + @Test + public void fromSeedIsDeterministic() { + byte[] seed = new byte[FNDSA.SEED_LENGTH]; + for (int i = 0; i < seed.length; i++) { + seed[i] = (byte) i; + } + FNDSA a = new FNDSA(seed); + FNDSA b = new FNDSA(seed); + assertArrayEquals(a.getPublicKey(), b.getPublicKey()); + assertArrayEquals(a.getPrivateKey(), b.getPrivateKey()); + } + + @Test(expected = IllegalArgumentException.class) + public void invalidSeedLengthRejected() { + new FNDSA(new byte[FNDSA.SEED_LENGTH - 1]); + } + + @Test(expected = UnsupportedOperationException.class) + public void derivePublicKeyFromEncodedPrivateKeyUnsupported() { + FNDSA.derivePublicKey(sk.getEncoded()); + } + + @Test + public void computeAddressIs21Bytes() { + assertEquals(21, FNDSA.computeAddress(pk.getH()).length); + } + + @Test + public void registryDispatchMatchesDirectCalls() { + byte[] msg = "registry-dispatch".getBytes(); + byte[] sigDirect = FNDSA.sign(sk.getEncoded(), msg); + assertTrue(PQSignatureRegistry.verify( + SignatureScheme.FN_DSA, pk.getH(), msg, sigDirect)); + byte[] sigViaRegistry = PQSignatureRegistry.sign( + SignatureScheme.FN_DSA, sk.getEncoded(), msg); + assertTrue(FNDSA.verify(pk.getH(), msg, sigViaRegistry)); + assertEquals(FNDSA.PUBLIC_KEY_LENGTH, + PQSignatureRegistry.getPublicKeyLength(SignatureScheme.FN_DSA)); + assertEquals(FNDSA.SIGNATURE_LENGTH, + PQSignatureRegistry.getSignatureLength(SignatureScheme.FN_DSA)); + } + + @Test + public void registryIsValidSignatureLengthRespectsUpperBound() { + assertTrue(PQSignatureRegistry.isValidSignatureLength(SignatureScheme.FN_DSA, 1)); + assertTrue(PQSignatureRegistry.isValidSignatureLength( + SignatureScheme.FN_DSA, FNDSA.SIGNATURE_LENGTH)); + assertFalse(PQSignatureRegistry.isValidSignatureLength(SignatureScheme.FN_DSA, 0)); + assertFalse(PQSignatureRegistry.isValidSignatureLength( + SignatureScheme.FN_DSA, FNDSA.SIGNATURE_LENGTH + 1)); + } + + // ----- B.8 regression: fixed-length schemes still enforce strict equality ----- + + @Test + public void mlDsa44ValidateSignatureRemainsStrictEquality() { + MLDSA44 mlDsa44 = new MLDSA44(); + // exact length passes + mlDsa44.validateSignature(new byte[MLDSA44.SIGNATURE_LENGTH]); + // shorter rejected + try { + mlDsa44.validateSignature(new byte[MLDSA44.SIGNATURE_LENGTH - 1]); + fail("ML-DSA-44 must reject undersized signature"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("signature length")); + } + // longer rejected + try { + mlDsa44.validateSignature(new byte[MLDSA44.SIGNATURE_LENGTH + 1]); + fail("ML-DSA-44 must reject oversized signature"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("signature length")); + } + } + + @Test + public void mlDsa65ValidateSignatureRemainsStrictEquality() { + MLDSA65 mlDsa65 = new MLDSA65(); + mlDsa65.validateSignature(new byte[MLDSA65.SIGNATURE_LENGTH]); + try { + mlDsa65.validateSignature(new byte[MLDSA65.SIGNATURE_LENGTH - 1]); + fail("ML-DSA-65 must reject undersized signature"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("signature length")); + } + try { + mlDsa65.validateSignature(new byte[MLDSA65.SIGNATURE_LENGTH + 1]); + fail("ML-DSA-65 must reject oversized signature"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("signature length")); + } + } + + @Test + public void registryIsValidSignatureLengthForFixedSchemesIsStrictEquality() { + assertTrue(PQSignatureRegistry.isValidSignatureLength( + SignatureScheme.ML_DSA_44, MLDSA44.SIGNATURE_LENGTH)); + assertFalse(PQSignatureRegistry.isValidSignatureLength( + SignatureScheme.ML_DSA_44, MLDSA44.SIGNATURE_LENGTH - 1)); + assertFalse(PQSignatureRegistry.isValidSignatureLength( + SignatureScheme.ML_DSA_44, MLDSA44.SIGNATURE_LENGTH + 1)); + + assertTrue(PQSignatureRegistry.isValidSignatureLength( + SignatureScheme.ML_DSA_65, MLDSA65.SIGNATURE_LENGTH)); + assertFalse(PQSignatureRegistry.isValidSignatureLength( + SignatureScheme.ML_DSA_65, MLDSA65.SIGNATURE_LENGTH - 1)); + assertFalse(PQSignatureRegistry.isValidSignatureLength( + SignatureScheme.ML_DSA_65, MLDSA65.SIGNATURE_LENGTH + 1)); + } +} 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..5b3a6bfd168 --- /dev/null +++ b/framework/src/test/java/org/tron/common/crypto/pqc/MLDSA44Test.java @@ -0,0 +1,228 @@ +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.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.SignatureScheme; + +public class MLDSA44Test { + + private MLDSA44 keypair; + private MLDSAPublicKeyParameters pk; + private MLDSAPrivateKeyParameters sk; + + @Before + public void setUp() { + MLDSAKeyPairGenerator gen = new MLDSAKeyPairGenerator(); + gen.init(new MLDSAKeyGenerationParameters(new SecureRandom(), MLDSAParameters.ml_dsa_44)); + AsymmetricCipherKeyPair kp = gen.generateKeyPair(); + pk = (MLDSAPublicKeyParameters) kp.getPublic(); + sk = (MLDSAPrivateKeyParameters) kp.getPrivate(); + keypair = new MLDSA44(sk.getEncoded(), pk.getEncoded()); + } + + private static byte[] freshPrivateKey() { + MLDSAKeyPairGenerator gen = new MLDSAKeyPairGenerator(); + gen.init(new MLDSAKeyGenerationParameters(new SecureRandom(), MLDSAParameters.ml_dsa_44)); + AsymmetricCipherKeyPair kp = gen.generateKeyPair(); + return ((MLDSAPrivateKeyParameters) kp.getPrivate()).getEncoded(); + } + + private byte[] rawSign(byte[] message) { + MLDSASigner signer = new MLDSASigner(); + signer.init(true, sk); + 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(SignatureScheme.ML_DSA_44, keypair.getScheme()); + assertEquals(1312, keypair.getPublicKeyLength()); + assertEquals(2420, keypair.getSignatureLength()); + assertEquals(1312, pk.getEncoded().length); + } + + @Test + public void privateKeyLengthMatchesFips204() { + byte[] skBytes = freshPrivateKey(); + assertEquals(MLDSA44.PRIVATE_KEY_LENGTH, skBytes.length); + } + + @Test + public void derivedPublicKeyLengthMatchesFips204() { + byte[] skBytes = freshPrivateKey(); + byte[] pkBytes = MLDSA44.derivePublicKey(skBytes); + assertEquals(MLDSA44.PUBLIC_KEY_LENGTH, pkBytes.length); + } + + @Test + public void signProducesVerifiableSignature() { + byte[] skBytes = freshPrivateKey(); + byte[] pkBytes = MLDSA44.derivePublicKey(skBytes); + byte[] message = "hello, ml-dsa-44".getBytes(); + + byte[] sig = MLDSA44.sign(skBytes, message); + assertEquals(MLDSA44.SIGNATURE_LENGTH, sig.length); + + assertTrue(MLDSA44.verify(pkBytes, message, sig)); + } + + @Test + public void roundTripSignVerifyWithTamperRejected() { + byte[] skBytes = freshPrivateKey(); + byte[] pkBytes = MLDSA44.derivePublicKey(skBytes); + byte[] message = "roundtrip".getBytes(); + byte[] sig = MLDSA44.sign(skBytes, message); + + assertTrue(MLDSA44.verify(pkBytes, message, sig)); + + byte[] tampered = sig.clone(); + tampered[0] ^= 0x01; + if (MLDSA44.verify(pkBytes, message, tampered)) { + fail("tampered signature should not verify"); + } + } + + @Test + public void deterministicPublicKeyDerivation() { + byte[] skBytes = freshPrivateKey(); + byte[] pk1 = MLDSA44.derivePublicKey(skBytes); + byte[] pk2 = MLDSA44.derivePublicKey(skBytes); + assertArrayEquals(pk1, pk2); + } + + @Test(expected = IllegalArgumentException.class) + public void rejectsShortPrivateKey() { + MLDSA44.sign(new byte[10], new byte[4]); + } + + @Test(expected = IllegalArgumentException.class) + public void rejectsNullMessage() { + byte[] skBytes = freshPrivateKey(); + MLDSA44.sign(skBytes, null); + } + + @Test + public void validSignatureVerifiesViaInstance() { + byte[] msg = "tron-pq-mldsa44".getBytes(); + byte[] sig = rawSign(msg); + assertEquals(2420, sig.length); + assertTrue(keypair.verify(msg, sig)); + } + + @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); + MLDSAKeyPairGenerator gen = new MLDSAKeyPairGenerator(); + gen.init(new MLDSAKeyGenerationParameters(new SecureRandom(), MLDSAParameters.ml_dsa_44)); + MLDSAPublicKeyParameters otherPk = + (MLDSAPublicKeyParameters) gen.generateKeyPair().getPublic(); + assertFalse(MLDSA44.verify(otherPk.getEncoded(), msg, sig)); + } + + @Test + public void invalidPublicKeyLengthRejected() { + byte[] badPk = new byte[1311]; + byte[] msg = new byte[] {1}; + byte[] sig = new byte[2420]; + 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 invalidSignatureLengthRejected() { + byte[] badSig = new byte[2419]; + byte[] msg = new byte[] {1}; + try { + MLDSA44.verify(pk.getEncoded(), msg, badSig); + fail("short signature should be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("signature length")); + } + } + + @Test + public void nullMessageRejected() { + byte[] sig = new byte[2420]; + try { + MLDSA44.verify(pk.getEncoded(), null, sig); + fail("null message should be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("message")); + } + } + + @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[32]; + 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 + public void computeAddressIs21Bytes() { + byte[] skBytes = freshPrivateKey(); + byte[] pkBytes = MLDSA44.derivePublicKey(skBytes); + assertEquals(21, MLDSA44.computeAddress(pkBytes).length); + } +} diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/MLDSA65Test.java b/framework/src/test/java/org/tron/common/crypto/pqc/MLDSA65Test.java new file mode 100644 index 00000000000..370fb6772e1 --- /dev/null +++ b/framework/src/test/java/org/tron/common/crypto/pqc/MLDSA65Test.java @@ -0,0 +1,178 @@ +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.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.SignatureScheme; + +public class MLDSA65Test { + + private MLDSA65 keypair; + private MLDSAPublicKeyParameters pk; + private MLDSAPrivateKeyParameters sk; + + @Before + public void setUp() { + MLDSAKeyPairGenerator gen = new MLDSAKeyPairGenerator(); + gen.init(new MLDSAKeyGenerationParameters(new SecureRandom(), MLDSAParameters.ml_dsa_65)); + AsymmetricCipherKeyPair kp = gen.generateKeyPair(); + pk = (MLDSAPublicKeyParameters) kp.getPublic(); + sk = (MLDSAPrivateKeyParameters) kp.getPrivate(); + keypair = new MLDSA65(sk.getEncoded(), pk.getEncoded()); + } + + private byte[] rawSign(byte[] message) { + MLDSASigner signer = new MLDSASigner(); + signer.init(true, sk); + 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(SignatureScheme.ML_DSA_65, keypair.getScheme()); + assertEquals(1952, keypair.getPublicKeyLength()); + assertEquals(3309, keypair.getSignatureLength()); + assertEquals(1952, pk.getEncoded().length); + } + + @Test + public void validSignatureVerifies() { + byte[] msg = "tron-pq-mldsa65".getBytes(); + byte[] sig = rawSign(msg); + assertEquals(3309, sig.length); + assertTrue(keypair.verify(msg, sig)); + } + + @Test + public void signatureBoundToMessage() { + byte[] msg = "block-header".getBytes(); + byte[] sig = rawSign(msg); + byte[] tamperedMsg = "block-footer".getBytes(); + assertFalse(keypair.verify(tamperedMsg, sig)); + } + + @Test + public void tamperedSignatureFailsVerification() { + byte[] msg = "payload".getBytes(); + byte[] sig = rawSign(msg); + sig[sig.length - 1] ^= 0x01; + assertFalse(keypair.verify(msg, sig)); + } + + @Test + public void wrongPublicKeyFailsVerification() { + byte[] msg = "payload".getBytes(); + byte[] sig = rawSign(msg); + MLDSAKeyPairGenerator gen = new MLDSAKeyPairGenerator(); + gen.init(new MLDSAKeyGenerationParameters(new SecureRandom(), MLDSAParameters.ml_dsa_65)); + MLDSAPublicKeyParameters otherPk = + (MLDSAPublicKeyParameters) gen.generateKeyPair().getPublic(); + assertFalse(MLDSA65.verify(otherPk.getEncoded(), msg, sig)); + } + + @Test + public void invalidPublicKeyLengthRejected() { + byte[] badPk = new byte[1951]; + byte[] msg = new byte[] {1}; + byte[] sig = new byte[3309]; + try { + MLDSA65.verify(badPk, msg, sig); + fail("short public key should be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("public key length")); + } + } + + @Test + public void invalidSignatureLengthRejected() { + byte[] badSig = new byte[3310]; + byte[] msg = new byte[] {1}; + try { + MLDSA65.verify(pk.getEncoded(), msg, badSig); + fail("wrong-length signature should be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("signature length")); + } + } + + @Test + public void nullMessageRejected() { + byte[] sig = new byte[3309]; + try { + MLDSA65.verify(pk.getEncoded(), null, sig); + fail("null message should be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("message")); + } + } + + @Test + public void emptyMessageVerifiesConsistently() { + byte[] msg = new byte[0]; + byte[] sig = rawSign(msg); + assertTrue(keypair.verify(msg, sig)); + } + + @Test + public void crossSchemeKeyFailsVerification() { + MLDSAKeyPairGenerator gen44 = new MLDSAKeyPairGenerator(); + gen44.init(new MLDSAKeyGenerationParameters(new SecureRandom(), MLDSAParameters.ml_dsa_44)); + MLDSAPublicKeyParameters pk44 = + (MLDSAPublicKeyParameters) gen44.generateKeyPair().getPublic(); + byte[] pk44Bytes = pk44.getEncoded(); + byte[] msg = new byte[] {1}; + byte[] sig = new byte[3309]; + try { + MLDSA65.verify(pk44Bytes, msg, sig); + fail("ML-DSA-44 key for ML-DSA-65 verifier should be rejected on length"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("public key length")); + } + } + + @Test + public void keypairBoundInstanceSignsAndVerifies() { + MLDSA65 signer = new MLDSA65(); + byte[] msg = "keypair-bound".getBytes(); + byte[] sig = signer.sign(msg); + assertEquals(MLDSA65.SIGNATURE_LENGTH, sig.length); + assertTrue(signer.verify(msg, sig)); + } + + @Test + public void fromSeedIsDeterministic() { + byte[] seed = new byte[32]; + for (int i = 0; i < seed.length; i++) { + seed[i] = (byte) i; + } + MLDSA65 a = new MLDSA65(seed); + MLDSA65 b = new MLDSA65(seed); + assertArrayEquals(a.getPublicKey(), b.getPublicKey()); + assertArrayEquals(a.getPrivateKey(), b.getPrivateKey()); + } + + @Test + public void computeAddressIs21Bytes() { + byte[] skBytes = MLDSA65.generatePrivateKey(); + byte[] pkBytes = MLDSA65.derivePublicKey(skBytes); + assertEquals(21, MLDSA65.computeAddress(pkBytes).length); + } +} diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/PQAuthDigestTest.java b/framework/src/test/java/org/tron/common/crypto/pqc/PQAuthDigestTest.java new file mode 100644 index 00000000000..1ebd20bebef --- /dev/null +++ b/framework/src/test/java/org/tron/common/crypto/pqc/PQAuthDigestTest.java @@ -0,0 +1,120 @@ +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 static org.junit.Assert.fail; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import org.junit.Test; + +public class PQAuthDigestTest { + + private static byte[] be4(int v) { + return new byte[] { + (byte) ((v >>> 24) & 0xff), + (byte) ((v >>> 16) & 0xff), + (byte) ((v >>> 8) & 0xff), + (byte) (v & 0xff) + }; + } + + @Test + public void txDigestEqualsExpectedSha256() throws Exception { + byte[] txid = new byte[] {0x11, 0x22, 0x33, 0x44}; + int permissionId = 2; + int keyId = 3; + + MessageDigest md = MessageDigest.getInstance("SHA-256"); + md.update("TRON_TX_AUTH_V1".getBytes(StandardCharsets.UTF_8)); + md.update(txid); + md.update(be4(permissionId)); + md.update(be4(keyId)); + byte[] expected = md.digest(); + + byte[] actual = PQAuthDigest.tx(txid, permissionId, keyId); + assertArrayEquals(expected, actual); + assertEquals(32, actual.length); + } + + @Test + public void blockDigestEqualsExpectedSha256() throws Exception { + byte[] hdrHash = new byte[32]; + for (int i = 0; i < hdrHash.length; i++) { + hdrHash[i] = (byte) i; + } + int keyId = 0; + + MessageDigest md = MessageDigest.getInstance("SHA-256"); + md.update("TRON_BLOCK_AUTH_V1".getBytes(StandardCharsets.UTF_8)); + md.update(hdrHash); + md.update(be4(keyId)); + byte[] expected = md.digest(); + + byte[] actual = PQAuthDigest.block(hdrHash, keyId); + assertArrayEquals(expected, actual); + assertEquals(32, actual.length); + } + + @Test + public void txAndBlockDigestsDifferForSameContext() { + byte[] shared = new byte[32]; + byte[] txDigest = PQAuthDigest.tx(shared, 0, 0); + byte[] blockDigest = PQAuthDigest.block(shared, 0); + assertFalse("tx and block digests must not collide for shared inputs", + java.util.Arrays.equals(txDigest, blockDigest)); + } + + @Test + public void differentKeyIdsProduceDifferentTxDigest() { + byte[] txid = new byte[32]; + assertNotEquals( + new String(PQAuthDigest.tx(txid, 0, 0)), + new String(PQAuthDigest.tx(txid, 0, 1))); + } + + @Test + public void differentPermissionIdsProduceDifferentDigest() { + byte[] txid = new byte[32]; + byte[] d0 = PQAuthDigest.tx(txid, 0, 0); + byte[] d1 = PQAuthDigest.tx(txid, 1, 0); + assertFalse(java.util.Arrays.equals(d0, d1)); + } + + @Test + public void differentKeyIdsProduceDifferentBlockDigest() { + byte[] hdr = new byte[32]; + byte[] d0 = PQAuthDigest.block(hdr, 0); + byte[] d1 = PQAuthDigest.block(hdr, 1); + assertFalse(java.util.Arrays.equals(d0, d1)); + } + + @Test + public void domainPrefixesAreExact() { + assertEquals("TRON_TX_AUTH_V1", PQAuthDigest.TX_DOMAIN); + assertEquals("TRON_BLOCK_AUTH_V1", PQAuthDigest.BLOCK_DOMAIN); + } + + @Test + public void nullTxidRejected() { + try { + PQAuthDigest.tx(null, 0, 0); + fail("null txid should be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("txid")); + } + } + + @Test + public void nullBlockHeaderHashRejected() { + try { + PQAuthDigest.block(null, 0); + fail("null blockHeaderRawHash should be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("blockHeaderRawHash")); + } + } +} diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/PQSignatureRegistryTest.java b/framework/src/test/java/org/tron/common/crypto/pqc/PQSignatureRegistryTest.java new file mode 100644 index 00000000000..2f0f24b9a49 --- /dev/null +++ b/framework/src/test/java/org/tron/common/crypto/pqc/PQSignatureRegistryTest.java @@ -0,0 +1,82 @@ +package org.tron.common.crypto.pqc; + +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 org.junit.Test; +import org.tron.protos.Protocol.SignatureScheme; + +public class PQSignatureRegistryTest { + + @Test + public void mlDsa44Registered() { + assertTrue(PQSignatureRegistry.contains(SignatureScheme.ML_DSA_44)); + assertEquals(MLDSA44.PUBLIC_KEY_LENGTH, + PQSignatureRegistry.getPublicKeyLength(SignatureScheme.ML_DSA_44)); + assertEquals(MLDSA44.SIGNATURE_LENGTH, + PQSignatureRegistry.getSignatureLength(SignatureScheme.ML_DSA_44)); + } + + @Test + public void mlDsa65Registered() { + assertTrue(PQSignatureRegistry.contains(SignatureScheme.ML_DSA_65)); + assertEquals(MLDSA65.PUBLIC_KEY_LENGTH, + PQSignatureRegistry.getPublicKeyLength(SignatureScheme.ML_DSA_65)); + assertEquals(MLDSA65.SIGNATURE_LENGTH, + PQSignatureRegistry.getSignatureLength(SignatureScheme.ML_DSA_65)); + } + + @Test + public void mlDsa44VerifyRoundTrip() { + MLDSA44 keypair = new MLDSA44(); + byte[] msg = "registry-44".getBytes(); + byte[] sig = keypair.sign(msg); + assertTrue(PQSignatureRegistry.verify( + SignatureScheme.ML_DSA_44, keypair.getPublicKey(), msg, sig)); + } + + @Test + public void mlDsa65VerifyRoundTrip() { + MLDSA65 keypair = new MLDSA65(); + byte[] msg = "registry-65".getBytes(); + byte[] sig = keypair.sign(msg); + assertTrue(PQSignatureRegistry.verify( + SignatureScheme.ML_DSA_65, keypair.getPublicKey(), msg, sig)); + } + + @Test + public void ecdsaNotRegistered() { + assertFalse(PQSignatureRegistry.contains(SignatureScheme.ECDSA_SECP256K1)); + try { + PQSignatureRegistry.getPublicKeyLength(SignatureScheme.ECDSA_SECP256K1); + fail("expected IllegalArgumentException for ECDSA_SECP256K1"); + } catch (IllegalArgumentException e) { + assertTrue(e.getMessage().contains("ECDSA_SECP256K1")); + } + } + + @Test + public void sm2NotRegistered() { + assertFalse(PQSignatureRegistry.contains(SignatureScheme.SM2_SM3)); + try { + PQSignatureRegistry.verify( + SignatureScheme.SM2_SM3, new byte[0], new byte[0], new byte[0]); + fail("expected IllegalArgumentException for SM2_SM3"); + } catch (IllegalArgumentException e) { + assertTrue(e.getMessage().contains("SM2_SM3")); + } + } + + @Test + public void unknownSchemeRejected() { + assertFalse(PQSignatureRegistry.contains(SignatureScheme.UNKNOWN_SIG_SCHEME)); + try { + PQSignatureRegistry.getSignatureLength(SignatureScheme.UNKNOWN_SIG_SCHEME); + fail("expected IllegalArgumentException for UNKNOWN_SIG_SCHEME"); + } catch (IllegalArgumentException e) { + assertTrue(e.getMessage().contains("UNKNOWN_SIG_SCHEME")); + } + } +} 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..b8210017fbf --- /dev/null +++ b/framework/src/test/java/org/tron/common/crypto/pqc/SignatureSchemeBenchmarkTest.java @@ -0,0 +1,166 @@ +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), ML-DSA-44 and ML-DSA-65. 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 mldsa44 = benchMlDsa44(); + Result mldsa65 = benchMlDsa65(); + + 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(mldsa44); + printResult(mldsa65); + } + + 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 benchMlDsa44() { + 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 Result benchMlDsa65() { + for (int i = 0; i < WARMUP; i++) { + MLDSA65 k = new MLDSA65(); + byte[] sig = k.sign(MESSAGE); + k.verify(MESSAGE, sig); + } + + long keygenNs = 0; + MLDSA65[] keys = new MLDSA65[ITERATIONS]; + for (int i = 0; i < ITERATIONS; i++) { + long t0 = System.nanoTime(); + keys[i] = new MLDSA65(); + 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-65", 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..49646913bd6 --- /dev/null +++ b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQClient.java @@ -0,0 +1,146 @@ +package org.tron.common.crypto.pqc.program; + +import com.google.protobuf.Any; +import com.google.protobuf.ByteString; +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; +import java.nio.ByteBuffer; +import java.security.MessageDigest; +import java.util.Arrays; +import java.util.concurrent.TimeUnit; +import org.tron.api.GrpcAPI.EmptyMessage; +import org.tron.api.GrpcAPI.Return; +import org.tron.api.WalletGrpc; +import org.tron.api.WalletGrpc.WalletBlockingStub; +import org.tron.common.crypto.pqc.MLDSA44; +import org.tron.common.crypto.pqc.PQAuthDigest; +import org.tron.common.utils.ByteArray; +import org.tron.protos.Protocol.Block; +import org.tron.protos.Protocol.PQAuthWitness; +import org.tron.protos.Protocol.Transaction; +import org.tron.protos.Protocol.Transaction.Contract.ContractType; +import org.tron.protos.contract.BalanceContract.TransferContract; + +/** + * Demo client that connects to {@link PQWitnessNode} and broadcasts an ML-DSA-44 + * signed transfer transaction. + * + * The keypair is derived from the same fixed seed used by PQWitnessNode, so no + * out-of-band key exchange is needed. + * + * Usage: + * Terminal 1 — start the witness node first: + * ./gradlew :framework:run -PmainClass=org.tron.common.crypto.pqc.program.PQWitnessNode + * Terminal 2 — broadcast a PQC transaction: + * ./gradlew :framework:run -PmainClass=org.tron.common.crypto.pqc.program.PQClient + * + * Optional JVM args: + * -Dpqc.host=localhost (default: localhost) + * -Dpqc.port=50051 (default: 50051) + */ +public class PQClient { + + private static final String HOST = + System.getProperty("pqc.host", "localhost"); + private static final int PORT = + Integer.parseInt(System.getProperty("pqc.port", "50051")); + + /** Recipient of the demo transfer. */ + private static final byte[] TO_ADDR = + ByteArray.fromHexString("41f522cc20ca18b636bdd93b4fb15ea84cc2b4e001"); + + public static void main(String[] args) throws Exception { + // Force INFO level: logback-test.xml (on the test classpath) sets root=DEBUG + // which is far too noisy for a demo run. + ((ch.qos.logback.classic.Logger) org.slf4j.LoggerFactory + .getLogger(ch.qos.logback.classic.Logger.ROOT_LOGGER_NAME)) + .setLevel(ch.qos.logback.classic.Level.INFO); + + // ── 1. Derive user keypair from same fixed seed as PQWitnessNode ───── + byte[] userSeed = new byte[32]; + Arrays.fill(userSeed, (byte) 0x02); + MLDSA44 userKp = new MLDSA44(userSeed); + + byte[] userPub = userKp.getPublicKey(); + byte[] userPriv = userKp.getPrivateKey(); + byte[] signerAddr = MLDSA44.computeAddress(userPub); + byte[] ownerAddr = PQWitnessNode.USER_ADDR; + + System.out.println("=== PQC Client ==="); + System.out.println("Connecting to " + HOST + ":" + PORT); + System.out.println("Owner address: " + ByteArray.toHexString(ownerAddr)); + System.out.println("Signer address: " + ByteArray.toHexString(signerAddr)); + + // ── 2. Connect via gRPC ─────────────────────────────────────────────── + ManagedChannel channel = ManagedChannelBuilder + .forAddress(HOST, PORT) + .usePlaintext() + .build(); + WalletBlockingStub stub = WalletGrpc.newBlockingStub(channel) + .withDeadlineAfter(10, TimeUnit.SECONDS); + + try { + // ── 3. Fetch reference block for TaPoS ─────────────────────────── + Block head = stub.getNowBlock(EmptyMessage.getDefaultInstance()); + byte[] headerRaw = head.getBlockHeader().getRawData().toByteArray(); + long refNum = head.getBlockHeader().getRawData().getNumber(); + byte[] blockHash = sha256(headerRaw); + + System.out.println("Reference block: #" + refNum + + " hash=" + ByteArray.toHexString(Arrays.copyOfRange(blockHash, 0, 8)) + "..."); + + // ── 4. Build the transfer transaction ───────────────────────────── + Transaction.raw rawData = Transaction.raw.newBuilder() + .addContract(Transaction.Contract.newBuilder() + .setType(ContractType.TransferContract) + .setParameter(Any.pack(TransferContract.newBuilder() + .setOwnerAddress(ByteString.copyFrom(ownerAddr)) + .setToAddress(ByteString.copyFrom(TO_ADDR)) + .setAmount(1_000_000L) // 1 TRX + .build())) + .setPermissionId(0)) + // TaPoS fields + .setRefBlockHash(ByteString.copyFrom(Arrays.copyOfRange(blockHash, 8, 16))) + .setRefBlockBytes(ByteString.copyFrom(longToBytes(refNum), 6, 2)) + .setExpiration(System.currentTimeMillis() + 60_000L) + .build(); + + Transaction tx = Transaction.newBuilder().setRawData(rawData).build(); + + // ── 5. Sign with ML-DSA-44 pq_witness ────────────────────────── + byte[] txId = sha256(rawData.toByteArray()); + byte[] digest = PQAuthDigest.tx(txId, 0, 0); + byte[] sig = MLDSA44.sign(userPriv, digest); + + Transaction signedTx = tx.toBuilder() + .addPqWitness(PQAuthWitness.newBuilder() + .setSignature(ByteString.copyFrom(sig))) + .build(); + + System.out.println("TX id: " + ByteArray.toHexString(txId)); + + // ── 6. Broadcast ────────────────────────────────────────────────── + Return result = stub.broadcastTransaction(signedTx); + System.out.println("Broadcast result: " + result.getCode() + + " — " + result.getMessage().toStringUtf8()); + + if (result.getResult()) { + System.out.println("SUCCESS: PQC-signed transaction accepted by the node."); + } else { + System.out.println("REJECTED: " + result.getCode()); + } + + } finally { + channel.shutdown(); + channel.awaitTermination(5, TimeUnit.SECONDS); + } + } + + private static byte[] sha256(byte[] data) throws Exception { + return MessageDigest.getInstance("SHA-256").digest(data); + } + + private static byte[] longToBytes(long value) { + return ByteBuffer.allocate(8).putLong(value).array(); + } +} diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/program/PQFullNode.java b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQFullNode.java new file mode 100644 index 00000000000..55f31205987 --- /dev/null +++ b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQFullNode.java @@ -0,0 +1,118 @@ +package org.tron.common.crypto.pqc.program; + +import java.io.File; +import java.net.InetSocketAddress; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Collections; +import org.tron.common.application.Application; +import org.tron.common.application.ApplicationFactory; +import org.tron.common.application.TronApplicationContext; +import org.tron.common.crypto.pqc.MLDSA44; +import org.tron.common.utils.ByteArray; +import org.tron.core.ChainBaseManager; +import org.tron.core.config.DefaultConfig; +import org.tron.core.config.args.Args; +import org.tron.core.db.Manager; + +/** + * Demo fullnode that dials {@link PQWitnessNode} via P2P and syncs PQ-signed blocks. + * + * Both nodes share the same deterministic PQ genesis pre-state (witness account with an + * ML-DSA-44 witness permission + demo user account with an ML-DSA-44 owner permission), + * installed via {@link PQWitnessNode#installPQGenesisState}. Once the witness produces + * a block it is broadcast over P2P; this node validates {@code BlockHeader.pq_witness} + * 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 ────── + MLDSA44 witnessKp = new MLDSA44(PQWitnessNode.WITNESS_SEED); + MLDSA44 userKp = new MLDSA44(PQWitnessNode.USER_SEED); + + byte[] witnessPub = witnessKp.getPublicKey(); + byte[] userPub = userKp.getPublicKey(); + + System.out.println("=== PQC Full Node ==="); + System.out.println("Peer (witness): " + WITNESS_HOST + ":" + WITNESS_P2P_PORT); + System.out.println("gRPC port: " + GRPC_PORT); + System.out.println("HTTP port: " + HTTP_PORT); + System.out.println("P2P port: " + P2P_PORT); + System.out.println("Witness address (expected): " + + ByteArray.toHexString(MLDSA44.computeAddress(witnessPub))); + + // ── 2. Configure node (no -w: this is a pure fullnode) ──────────────── + File dbDir = Files.createTempDirectory("pqc-fullnode-").toFile(); + dbDir.deleteOnExit(); + + Args.setParam(new String[]{"--output-directory", dbDir.getAbsolutePath()}, + "config-test.conf"); + Args.getInstance().setRpcEnable(true); + Args.getInstance().setFullNodeHttpEnable(true); + Args.getInstance().setFullNodeHttpPort(HTTP_PORT); + Args.getInstance().setSolidityNodeHttpEnable(false); + Args.getInstance().setRpcPort(GRPC_PORT); + Args.getInstance().setNodeListenPort(P2P_PORT); + Args.getInstance().setNeedSyncCheck(false); + Args.getInstance().setMinEffectiveConnection(0); + Args.getInstance().genesisBlock.setWitnesses(new ArrayList<>()); + + // Point to the witness node as the only seed peer. + // Mutable list — startup appends persisted peers to it. + Args.getInstance().getSeedNode().setAddressList(new ArrayList<>( + Collections.singletonList(new InetSocketAddress(WITNESS_HOST, WITNESS_P2P_PORT)))); + + // ── 3. Start Spring context ─────────────────────────────────────────── + TronApplicationContext context = new TronApplicationContext(DefaultConfig.class); + Application app = ApplicationFactory.create(context); + Manager db = context.getBean(Manager.class); + ChainBaseManager chain = context.getBean(ChainBaseManager.class); + + // ── 4. Install matching PQ genesis pre-state ────────────────────────── + // Without this the incoming pq_witness would fail to validate because + // this node wouldn't know the witness's ML-DSA-44 public key. + PQWitnessNode.installPQGenesisState(db, chain, witnessPub, userPub); + + // ── 5. Start P2P + gRPC (no ConsensusService.start — we don't produce) ─ + app.startup(); + + System.out.println("\nFull node running, syncing from witness. Send Ctrl-C to stop.\n"); + + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + System.out.println("Shutting down..."); + context.close(); + Args.clearParam(); + })); + + Thread.currentThread().join(); + } +} diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/program/PQWitnessNode.java b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQWitnessNode.java new file mode 100644 index 00000000000..42af5bd94bc --- /dev/null +++ b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQWitnessNode.java @@ -0,0 +1,191 @@ +package org.tron.common.crypto.pqc.program; + +import com.google.protobuf.ByteString; +import java.io.File; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import org.tron.common.application.Application; +import org.tron.common.application.ApplicationFactory; +import org.tron.common.application.TronApplicationContext; +import org.tron.common.crypto.pqc.MLDSA44; +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.PQPublicKey; +import org.tron.protos.Protocol.Permission; +import org.tron.protos.Protocol.Permission.PermissionType; +import org.tron.protos.Protocol.SignatureScheme; + +/** + * Demo witness node with ML-DSA-44 block production. + * + * Starts an in-process TRON node configured with a PQC witness keypair and + * a user account that holds an ML-DSA-44 owner permission — ready to receive + * transactions from {@link PQClient}. + * + * Keypairs are derived from fixed seeds so PQClient can derive matching keys + * without any out-of-band coordination. + * + * Usage: + * Terminal 1 — start this node: + * ./gradlew :framework:run -PmainClass=org.tron.common.crypto.pqc.program.PQWitnessNode + * Terminal 2 — broadcast a PQC transaction: + * ./gradlew :framework:run -PmainClass=org.tron.common.crypto.pqc.program.PQClient + */ +public class PQWitnessNode { + + /** Fixed seed for the ML-DSA-44 witness keypair (shared with PQClient for derivation). */ + static final byte[] WITNESS_SEED = filledSeed(0x01); + /** Fixed seed for the ML-DSA-44 user keypair (shared with PQClient for derivation). */ + static final byte[] USER_SEED = filledSeed(0x02); + + /** gRPC port the node listens on. */ + static final int GRPC_PORT = 50051; + + /** Full-node HTTP port. */ + static final int HTTP_PORT = 8090; + + /** P2P listen port (shared with PQFullNode so it can dial in as a seed peer). */ + static final int P2P_PORT = 18888; + + /** Fixed on-chain address for the demo user account. */ + static final byte[] USER_ADDR = + ByteArray.fromHexString("41abd4b9367799eaa3197fecb144eb71de1e049abc"); + + public static void main(String[] args) throws Exception { + // Force INFO level: logback-test.xml (on the test classpath) sets root=DEBUG + // which is far too noisy for a demo run. + ((ch.qos.logback.classic.Logger) org.slf4j.LoggerFactory + .getLogger(ch.qos.logback.classic.Logger.ROOT_LOGGER_NAME)) + .setLevel(ch.qos.logback.classic.Level.INFO); + + // ── 1. Derive deterministic keypairs ────────────────────────────────── + MLDSA44 witnessKp = new MLDSA44(WITNESS_SEED); + MLDSA44 userKp = new MLDSA44(USER_SEED); + + byte[] witnessPub = witnessKp.getPublicKey(); + byte[] witnessAddr = MLDSA44.computeAddress(witnessPub); + byte[] userPub = userKp.getPublicKey(); + byte[] signerAddr = MLDSA44.computeAddress(userPub); + + System.out.println("=== PQC Witness Node ==="); + System.out.println("Witness address (ML-DSA-44): " + ByteArray.toHexString(witnessAddr)); + System.out.println("User address: " + ByteArray.toHexString(USER_ADDR)); + System.out.println("User signer address: " + ByteArray.toHexString(signerAddr)); + System.out.println("gRPC port: " + GRPC_PORT); + System.out.println("HTTP port: " + HTTP_PORT); + System.out.println("P2P port: " + P2P_PORT); + + // ── 2. Configure node ───────────────────────────────────────────────── + File dbDir = Files.createTempDirectory("pqc-node-").toFile(); + dbDir.deleteOnExit(); + + Args.setParam(new String[]{"--output-directory", dbDir.getAbsolutePath(), "-w"}, + "config-test.conf"); + Args.getInstance().setRpcEnable(true); + Args.getInstance().setFullNodeHttpEnable(true); + Args.getInstance().setFullNodeHttpPort(HTTP_PORT); + Args.getInstance().setRpcPort(GRPC_PORT); + Args.getInstance().setNodeListenPort(P2P_PORT); + Args.getInstance().setNeedSyncCheck(false); + Args.getInstance().setMinEffectiveConnection(0); + Args.getInstance().genesisBlock.setWitnesses(new ArrayList<>()); + + // ── 3. Start Spring context ─────────────────────────────────────────── + TronApplicationContext context = new TronApplicationContext(DefaultConfig.class); + Application app = ApplicationFactory.create(context); + Manager db = context.getBean(Manager.class); + ChainBaseManager chain = context.getBean(ChainBaseManager.class); + + // ── 4. Install PQ genesis pre-state (shared with PQFullNode) ───────── + installPQGenesisState(db, chain, witnessPub, userPub); + + // ── 5. Start consensus (DposTask auto-produces blocks) ─────────────── + context.getBean(ConsensusService.class).start(); + + // ── 6. Start gRPC / P2P server ─────────────────────────────────────── + app.startup(); + + System.out.println("\nNode is running. Send Ctrl-C to stop."); + System.out.println("Run PQClient or PQFullNode in another terminal.\n"); + + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + System.out.println("Shutting down..."); + context.close(); + Args.clearParam(); + })); + + Thread.currentThread().join(); // block until Ctrl-C + } + + /** + * Apply the PQ-specific pre-state that must exist on every node participating + * in the demo network. Both PQWitnessNode and PQFullNode call this so their + * genesis state matches before the first PQ block is produced / received. + */ + static void installPQGenesisState(Manager db, ChainBaseManager chain, + byte[] witnessPub, byte[] userPub) { + byte[] witnessAddr = MLDSA44.computeAddress(witnessPub); + ByteString witnessAddrBs = ByteString.copyFrom(witnessAddr); + byte[] signerAddr = MLDSA44.computeAddress(userPub); + ByteString signerAddrBs = ByteString.copyFrom(signerAddr); + + // Activate ML-DSA on the local chain params. + db.getDynamicPropertiesStore().saveAllowMlDsa(1L); + db.getDynamicPropertiesStore().saveAllowMultiSign(1L); + + // Witness account with ML-DSA-44 witness permission. + Permission witnessPerm = Permission.newBuilder() + .setType(PermissionType.Witness) + .setId(1).setPermissionName("witness").setThreshold(1) + .addKeys(Key.newBuilder() + .setAddress(witnessAddrBs).setWeight(1) + .setPqKey(PQPublicKey.newBuilder() + .setScheme(SignatureScheme.ML_DSA_44) + .setPublicKey(ByteString.copyFrom(witnessPub)) + .build())) + .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 ML-DSA-44 owner permission. + Permission userOwnerPerm = Permission.newBuilder() + .setType(PermissionType.Owner).setPermissionName("owner").setThreshold(1) + .addKeys(Key.newBuilder() + .setAddress(signerAddrBs).setWeight(1) + .setPqKey(PQPublicKey.newBuilder() + .setScheme(SignatureScheme.ML_DSA_44) + .setPublicKey(ByteString.copyFrom(userPub)) + .build())) + .build(); + AccountCapsule userCapsule = new AccountCapsule( + ByteString.copyFrom(USER_ADDR), ByteString.copyFromUtf8("pquser"), AccountType.Normal); + userCapsule.setBalance(100_000_000L); // 100 TRX + userCapsule.updatePermissions(userOwnerPerm, null, Collections.emptyList()); + db.getAccountStore().put(USER_ADDR, userCapsule); + } + + private static byte[] filledSeed(int value) { + byte[] seed = new byte[32]; + Arrays.fill(seed, (byte) value); + return seed; + } +} 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..edf349c812a --- /dev/null +++ b/framework/src/test/java/org/tron/common/runtime/vm/FnDsaPrecompileTest.java @@ -0,0 +1,185 @@ +package org.tron.common.runtime.vm; + +import org.apache.commons.lang3.tuple.Pair; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.tron.common.crypto.pqc.FNDSA; +import org.tron.core.vm.PrecompiledContracts; +import org.tron.core.vm.PrecompiledContracts.PrecompiledContract; +import org.tron.core.vm.config.VMConfig; + +/** + * Unit tests for the FN-DSA / Falcon-512 (0x16) verify precompile (EIP-8052 / TRON extension). + * Input layout: [msg 32B | sig_len 2B | sig sig_len B | pk 896B]. Stateless — no chain DB. + */ +public class FnDsaPrecompileTest { + + private static final DataWord FNDSA_ADDR = new DataWord( + "0000000000000000000000000000000000000000000000000000000000000016"); + + private static final byte[] MESSAGE_HASH = new byte[32]; + + static { + for (int i = 0; i < 32; i++) { + MESSAGE_HASH[i] = (byte) i; + } + } + + @Before + public void enableProposal() { + VMConfig.initAllowFnDsa(1L); + } + + @After + public void disableProposal() { + VMConfig.initAllowFnDsa(0L); + } + + @Test + public void switchOff_returnsNull() { + VMConfig.initAllowFnDsa(0L); + Assert.assertNull(PrecompiledContracts.getContractForAddress(FNDSA_ADDR)); + } + + @Test + public void switchOn_returnsContract() { + Assert.assertNotNull(PrecompiledContracts.getContractForAddress(FNDSA_ADDR)); + } + + @Test + public void validSignature_returnsOne() { + FNDSA key = new FNDSA(); + byte[] sig = key.sign(MESSAGE_HASH); + byte[] input = buildInput(MESSAGE_HASH, sig, key.getPublicKey()); + + PrecompiledContract pc = PrecompiledContracts.getContractForAddress(FNDSA_ADDR); + Pair result = pc.execute(input); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ONE().getData(), result.getRight()); + Assert.assertEquals(2500, pc.getEnergyForData(input)); + } + + @Test + public void tamperedMessage_returnsZero() { + FNDSA key = new FNDSA(); + byte[] sig = key.sign(MESSAGE_HASH); + byte[] tampered = MESSAGE_HASH.clone(); + tampered[0] ^= 0x01; + byte[] input = buildInput(tampered, sig, key.getPublicKey()); + + Pair result = + PrecompiledContracts.getContractForAddress(FNDSA_ADDR).execute(input); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + @Test + public void tamperedSignature_returnsZero() { + FNDSA key = new FNDSA(); + byte[] sig = key.sign(MESSAGE_HASH); + sig[0] ^= 0x01; + byte[] input = buildInput(MESSAGE_HASH, sig, key.getPublicKey()); + + Pair result = + PrecompiledContracts.getContractForAddress(FNDSA_ADDR).execute(input); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + @Test + public void wrongPublicKey_returnsZero() { + FNDSA signer = new FNDSA(); + FNDSA other = new FNDSA(); + byte[] sig = signer.sign(MESSAGE_HASH); + byte[] input = buildInput(MESSAGE_HASH, sig, other.getPublicKey()); + + Pair result = + PrecompiledContracts.getContractForAddress(FNDSA_ADDR).execute(input); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + @Test + public void nullInput_returnsZero() { + Pair result = + PrecompiledContracts.getContractForAddress(FNDSA_ADDR).execute(null); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + @Test + public void shortInput_returnsZero() { + Pair result = + PrecompiledContracts.getContractForAddress(FNDSA_ADDR).execute(new byte[100]); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + @Test + public void zeroSigLen_returnsZero() { + FNDSA key = new FNDSA(); + byte[] pk = key.getPublicKey(); + // sig_len = 0 is invalid (must be >= 1) + byte[] input = new byte[32 + 2 + pk.length]; + System.arraycopy(MESSAGE_HASH, 0, input, 0, 32); + // sig_len bytes = 0x00 0x00 → sigLen = 0 + System.arraycopy(pk, 0, input, 34, pk.length); + + Pair result = + PrecompiledContracts.getContractForAddress(FNDSA_ADDR).execute(input); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + @Test + public void oversizedSigLen_returnsZero() { + // sig_len = 753, which exceeds FNDSA.SIGNATURE_LENGTH (752) + byte[] input = new byte[32 + 2 + 753 + FNDSA.PUBLIC_KEY_LENGTH]; + input[32] = 0x02; // high byte + input[33] = (byte) 0xF1; // low byte → 0x02F1 = 753 + Pair result = + PrecompiledContracts.getContractForAddress(FNDSA_ADDR).execute(input); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + @Test + public void sigLenLargerThanActualData_returnsZero() { + FNDSA key = new FNDSA(); + byte[] sig = key.sign(MESSAGE_HASH); + // claim sig is 100 bytes longer than it is + byte[] input = buildInput(MESSAGE_HASH, sig, key.getPublicKey()); + // corrupt sig_len field to claim a larger sig + int claimedLen = sig.length + 100; + input[32] = (byte) ((claimedLen >> 8) & 0xFF); + input[33] = (byte) (claimedLen & 0xFF); + + Pair result = + PrecompiledContracts.getContractForAddress(FNDSA_ADDR).execute(input); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + /** Encodes input as [msg 32B | sig_len 2B | sig | pk]. */ + private static byte[] buildInput(byte[] msg, byte[] sig, byte[] pk) { + int sigLen = sig.length; + byte[] out = new byte[32 + 2 + sigLen + pk.length]; + System.arraycopy(msg, 0, out, 0, 32); + out[32] = (byte) ((sigLen >> 8) & 0xFF); + out[33] = (byte) (sigLen & 0xFF); + System.arraycopy(sig, 0, out, 34, sigLen); + System.arraycopy(pk, 0, out, 34 + sigLen, pk.length); + return out; + } +} diff --git a/framework/src/test/java/org/tron/common/runtime/vm/MlDsaPrecompileTest.java b/framework/src/test/java/org/tron/common/runtime/vm/MlDsaPrecompileTest.java new file mode 100644 index 00000000000..51a77e4d8b7 --- /dev/null +++ b/framework/src/test/java/org/tron/common/runtime/vm/MlDsaPrecompileTest.java @@ -0,0 +1,193 @@ +package org.tron.common.runtime.vm; + +import java.util.Arrays; +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.common.crypto.pqc.MLDSA65; +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 (0x12) and ML-DSA-65 (0x14) verify precompiles. + * These tests are stateless — no chain DB needed. + */ +public class MlDsaPrecompileTest { + + private static final DataWord MLDSA44_ADDR = new DataWord( + "0000000000000000000000000000000000000000000000000000000000000012"); + private static final DataWord MLDSA65_ADDR = new DataWord( + "0000000000000000000000000000000000000000000000000000000000000014"); + + 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.initAllowMlDsa(1L); + } + + @After + public void disableProposal() { + VMConfig.initAllowMlDsa(0L); + } + + @Test + public void switchOff_returnsNull() { + VMConfig.initAllowMlDsa(0L); + Assert.assertNull(PrecompiledContracts.getContractForAddress(MLDSA44_ADDR)); + Assert.assertNull(PrecompiledContracts.getContractForAddress(MLDSA65_ADDR)); + } + + @Test + public void switchOn_returnsContracts() { + Assert.assertNotNull(PrecompiledContracts.getContractForAddress(MLDSA44_ADDR)); + Assert.assertNotNull(PrecompiledContracts.getContractForAddress(MLDSA65_ADDR)); + } + + @Test + public void mldsa44_validSignature_returnsOne() { + MLDSA44 key = new MLDSA44(); + byte[] sig = key.sign(MESSAGE_HASH); + byte[] input = concat(MESSAGE_HASH, sig, key.getPublicKey()); + + PrecompiledContract pc = PrecompiledContracts.getContractForAddress(MLDSA44_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 mldsa44_tamperedMessage_returnsZero() { + MLDSA44 key = new MLDSA44(); + byte[] sig = key.sign(MESSAGE_HASH); + byte[] tampered = MESSAGE_HASH.clone(); + tampered[0] ^= 0x01; + byte[] input = concat(tampered, sig, key.getPublicKey()); + + Pair result = + PrecompiledContracts.getContractForAddress(MLDSA44_ADDR).execute(input); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + @Test + public void mldsa44_tamperedSignature_returnsZero() { + MLDSA44 key = new MLDSA44(); + byte[] sig = key.sign(MESSAGE_HASH); + sig[0] ^= 0x01; + byte[] input = concat(MESSAGE_HASH, sig, key.getPublicKey()); + + Pair result = + PrecompiledContracts.getContractForAddress(MLDSA44_ADDR).execute(input); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + @Test + public void mldsa44_wrongPublicKey_returnsZero() { + MLDSA44 signer = new MLDSA44(); + MLDSA44 other = new MLDSA44(); + byte[] sig = signer.sign(MESSAGE_HASH); + byte[] input = concat(MESSAGE_HASH, sig, other.getPublicKey()); + + Pair result = + PrecompiledContracts.getContractForAddress(MLDSA44_ADDR).execute(input); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + @Test + public void mldsa44_shortInput_returnsZero() { + byte[] tooShort = new byte[100]; + Pair result = + PrecompiledContracts.getContractForAddress(MLDSA44_ADDR).execute(tooShort); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + @Test + public void mldsa44_nullInput_returnsZero() { + Pair result = + PrecompiledContracts.getContractForAddress(MLDSA44_ADDR).execute(null); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + @Test + public void mldsa65_validSignature_returnsOne() { + MLDSA65 key = new MLDSA65(); + byte[] sig = key.sign(MESSAGE_HASH); + byte[] input = concat(MESSAGE_HASH, sig, key.getPublicKey()); + + PrecompiledContract pc = PrecompiledContracts.getContractForAddress(MLDSA65_ADDR); + Pair result = pc.execute(input); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ONE().getData(), result.getRight()); + Assert.assertEquals(7000, pc.getEnergyForData(input)); + } + + @Test + public void mldsa65_tamperedSignature_returnsZero() { + MLDSA65 key = new MLDSA65(); + byte[] sig = key.sign(MESSAGE_HASH); + sig[sig.length - 1] ^= 0x01; + byte[] input = concat(MESSAGE_HASH, sig, key.getPublicKey()); + + Pair result = + PrecompiledContracts.getContractForAddress(MLDSA65_ADDR).execute(input); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + @Test + public void mldsa65_shortInput_returnsZero() { + Pair result = + PrecompiledContracts.getContractForAddress(MLDSA65_ADDR).execute(new byte[3000]); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + private static byte[] concat(byte[]... parts) { + int total = 0; + for (byte[] p : parts) { + total += p.length; + } + byte[] out = new byte[total]; + int off = 0; + for (byte[] p : parts) { + System.arraycopy(p, 0, out, off, p.length); + off += p.length; + } + return out; + } + + // Sanity check: the helper above and Arrays.copyOfRange behave consistently. + @Test + public void concatHelper_works() { + byte[] a = {1, 2}; + byte[] b = {3, 4, 5}; + byte[] c = concat(a, b); + Assert.assertArrayEquals(new byte[] {1, 2, 3, 4, 5}, c); + Assert.assertArrayEquals(a, Arrays.copyOfRange(c, 0, 2)); + } +} 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..3ca748868f5 --- /dev/null +++ b/framework/src/test/java/org/tron/common/utils/LocalWitnessesTest.java @@ -0,0 +1,68 @@ +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.Collections; +import org.junit.Test; +import org.tron.core.exception.TronError; +import org.tron.protos.Protocol.SignatureScheme; + +public class LocalWitnessesTest { + + // 32-byte hex seed (ML-DSA-44 / ML-DSA-65). + private static final String SEED_32 = + "0101010101010101010101010101010101010101010101010101010101010101"; + // 48-byte hex seed (FN-DSA / Falcon-512). + private static final String SEED_48 = SEED_32 + "02020202020202020202020202020202"; + + @Test + public void mlDsa65DefaultAccepts32ByteSeed() { + LocalWitnesses lw = new LocalWitnesses(); + lw.setPqSeeds(Collections.singletonList(SEED_32)); + assertEquals(SignatureScheme.ML_DSA_65, lw.getPqScheme()); + assertEquals(1, lw.getPqSeeds().size()); + } + + @Test + public void fnDsaAccepts48ByteSeedWhenSchemeSetFirst() { + LocalWitnesses lw = new LocalWitnesses(); + lw.setPqScheme(SignatureScheme.FN_DSA); + lw.setPqSeeds(Collections.singletonList(SEED_48)); + assertEquals(SignatureScheme.FN_DSA, lw.getPqScheme()); + assertEquals(1, lw.getPqSeeds().size()); + } + + @Test + public void fnDsaRejects32ByteSeed() { + LocalWitnesses lw = new LocalWitnesses(); + lw.setPqScheme(SignatureScheme.FN_DSA); + TronError err = assertThrows(TronError.class, + () -> lw.setPqSeeds(Collections.singletonList(SEED_32))); + assertEquals(TronError.ErrCode.WITNESS_INIT, err.getErrCode()); + assertTrue(err.getMessage().contains("PQ seed")); + assertTrue(err.getMessage().contains("96")); + } + + @Test + public void mlDsa44Rejects48ByteSeed() { + LocalWitnesses lw = new LocalWitnesses(); + lw.setPqScheme(SignatureScheme.ML_DSA_44); + TronError err = assertThrows(TronError.class, + () -> lw.setPqSeeds(Collections.singletonList(SEED_48))); + assertEquals(TronError.ErrCode.WITNESS_INIT, err.getErrCode()); + assertTrue(err.getMessage().contains("PQ seed")); + assertTrue(err.getMessage().contains("64")); + } + + @Test + public void nonHexSeedRejected() { + LocalWitnesses lw = new LocalWitnesses(); + String badSeed = "zz" + SEED_32.substring(2); + TronError err = assertThrows(TronError.class, + () -> lw.setPqSeeds(Collections.singletonList(badSeed))); + assertEquals(TronError.ErrCode.WITNESS_INIT, err.getErrCode()); + assertTrue(err.getMessage().contains("hex")); + } +} diff --git a/framework/src/test/java/org/tron/core/BandwidthProcessorTest.java b/framework/src/test/java/org/tron/core/BandwidthProcessorTest.java index cf652af3650..32c00003e82 100755 --- a/framework/src/test/java/org/tron/core/BandwidthProcessorTest.java +++ b/framework/src/test/java/org/tron/core/BandwidthProcessorTest.java @@ -881,4 +881,118 @@ 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[3309]; + Protocol.PQAuthWitness pqWitness = Protocol.PQAuthWitness.newBuilder() + .setSignature(ByteString.copyFrom(fakeSig)) + .build(); + + TransactionCapsule baseTrx = new TransactionCapsule(contract, + chainBaseManager.getAccountStore()); + Transaction withAuth = baseTrx.getInstance().toBuilder() + .addPqWitness(pqWitness) + .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_witness", + rawSize > cap); + + BandwidthProcessor processor = new BandwidthProcessor(chainBaseManager); + try { + processor.consume(trx, trace); + } catch (TooBigTransactionException e) { + Assert.fail("PQ pq_witness bytes should be deducted from create-account cap check"); + } catch (AccountResourceInsufficientException + | ContractValidateException + | TooBigTransactionResultException e) { + // acceptable: other code paths — we only care about the 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[3309]; + Protocol.PQAuthWitness pqWitness = Protocol.PQAuthWitness.newBuilder() + .setSignature(ByteString.copyFrom(fakeSig)) + .build(); + + TransactionCapsule baseTrx = new TransactionCapsule(contract, + chainBaseManager.getAccountStore()); + Transaction withAuth = baseTrx.getInstance().toBuilder() + .addPqWitness(pqWitness) + .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..299df65bf00 100644 --- a/framework/src/test/java/org/tron/core/actuator/AccountPermissionUpdateActuatorTest.java +++ b/framework/src/test/java/org/tron/core/actuator/AccountPermissionUpdateActuatorTest.java @@ -22,8 +22,10 @@ import org.tron.core.exception.ContractValidateException; import org.tron.protos.Protocol.AccountType; import org.tron.protos.Protocol.Key; +import org.tron.protos.Protocol.PQPublicKey; import org.tron.protos.Protocol.Permission; import org.tron.protos.Protocol.Permission.PermissionType; +import org.tron.protos.Protocol.SignatureScheme; import org.tron.protos.Protocol.Transaction.Contract.ContractType; import org.tron.protos.Protocol.Transaction.Result.code; import org.tron.protos.contract.AccountContract.AccountCreateContract; @@ -1019,4 +1021,347 @@ public void checkActiveDefaultOperations() { } + // ------------------------- ML-DSA scheme validation ------------------------- + + private static byte[] fixedBytes(int len, int seed) { + byte[] b = new byte[len]; + for (int i = 0; i < len; i++) { + b[i] = (byte) ((seed + i) & 0xff); + } + return b; + } + + private Key legacyKey(String addr) { + return Key.newBuilder() + .setAddress(ByteString.copyFrom(ByteArray.fromHexString(addr))) + .setWeight(KEY_WEIGHT) + .build(); + } + + private Key mlDsaKey(String addr, SignatureScheme scheme, int pkLen, int seed) { + return Key.newBuilder() + .setAddress(ByteString.copyFrom(ByteArray.fromHexString(addr))) + .setWeight(KEY_WEIGHT) + .setPqKey(PQPublicKey.newBuilder() + .setScheme(scheme) + .setPublicKey(ByteString.copyFrom(fixedBytes(pkLen, seed))) + .build()) + .build(); + } + + private Permission ownerPermissionWithKeys(List keys, long threshold) { + return Permission.newBuilder() + .setType(PermissionType.Owner) + .setPermissionName("owner") + .setThreshold(threshold) + .addAllKeys(keys) + .build(); + } + + private Permission activePermissionWithKeys(List keys, long threshold) { + byte[] ops = new byte[32]; + java.util.Arrays.fill(ops, (byte) 0); + ops[0] = 0x01; + return Permission.newBuilder() + .setType(PermissionType.Active) + .setPermissionName("active") + .setThreshold(threshold) + .setOperations(ByteString.copyFrom(ops)) + .addAllKeys(keys) + .build(); + } + + private Permission witnessPermissionWithKey(Key key) { + return Permission.newBuilder() + .setType(PermissionType.Witness) + .setPermissionName("witness") + .setThreshold(1) + .addKeys(key) + .build(); + } + + private AccountPermissionUpdateActuator actuatorFor(Any any) { + return (AccountPermissionUpdateActuator) new AccountPermissionUpdateActuator() + .setChainBaseManager(dbManager.getChainBaseManager()).setAny(any); + } + + @Test + public void mlDsaPermissionRejectedWhenNotAllowed() { + dbManager.getDynamicPropertiesStore().saveAllowMlDsa(0L); + ByteString address = ByteString.copyFrom(ByteArray.fromHexString(OWNER_ADDRESS)); + Permission owner = ownerPermissionWithKeys( + java.util.Collections.singletonList( + mlDsaKey(KEY_ADDRESS, SignatureScheme.ML_DSA_44, 1312, 1)), 2); + Permission active = activePermissionWithKeys( + java.util.Collections.singletonList(legacyKey(KEY_ADDRESS1)), 2); + Any any = getContract(address, owner, null, + java.util.Collections.singletonList(active)); + + try { + actuatorFor(any).validate(); + fail("should reject ML-DSA key when ALLOW_ML_DSA = 0"); + } catch (ContractValidateException e) { + Assert.assertTrue(e.getMessage().contains("ML-DSA is not activated")); + } + } + + @Test + public void legacyKeyWithNonEmptyPublicKeyRejected() { + dbManager.getDynamicPropertiesStore().saveAllowMlDsa(1L); + ByteString address = ByteString.copyFrom(ByteArray.fromHexString(OWNER_ADDRESS)); + Key badLegacy = Key.newBuilder() + .setAddress(ByteString.copyFrom(ByteArray.fromHexString(KEY_ADDRESS))) + .setWeight(KEY_WEIGHT) + .setPqKey(PQPublicKey.newBuilder() + .setPublicKey(ByteString.copyFrom(new byte[] {1, 2, 3})) + .build()) + .build(); + Permission owner = ownerPermissionWithKeys( + java.util.Collections.singletonList(badLegacy), 2); + Permission active = activePermissionWithKeys( + java.util.Collections.singletonList(legacyKey(KEY_ADDRESS1)), 2); + Any any = getContract(address, owner, null, + java.util.Collections.singletonList(active)); + + try { + actuatorFor(any).validate(); + fail("legacy key with non-empty public_key should be rejected"); + } catch (ContractValidateException e) { + Assert.assertTrue(e.getMessage().contains("public_key must be empty")); + } + } + + @Test + public void mixedSchemeInSamePermissionRejected() { + dbManager.getDynamicPropertiesStore().saveAllowMlDsa(1L); + ByteString address = ByteString.copyFrom(ByteArray.fromHexString(OWNER_ADDRESS)); + Permission owner = ownerPermissionWithKeys( + java.util.Arrays.asList( + mlDsaKey(KEY_ADDRESS, SignatureScheme.ML_DSA_44, 1312, 1), + legacyKey(KEY_ADDRESS1)), + 2); + Permission active = activePermissionWithKeys( + java.util.Collections.singletonList(legacyKey(KEY_ADDRESS2)), 2); + Any any = getContract(address, owner, null, + java.util.Collections.singletonList(active)); + + try { + actuatorFor(any).validate(); + fail("mixed scheme in one permission should be rejected"); + } catch (ContractValidateException e) { + Assert.assertTrue(e.getMessage().contains("same scheme")); + } + } + + @Test + public void mixedMlDsaSchemesRejected() { + dbManager.getDynamicPropertiesStore().saveAllowMlDsa(1L); + ByteString address = ByteString.copyFrom(ByteArray.fromHexString(OWNER_ADDRESS)); + Permission owner = ownerPermissionWithKeys( + java.util.Arrays.asList( + mlDsaKey(KEY_ADDRESS, SignatureScheme.ML_DSA_44, 1312, 1), + mlDsaKey(KEY_ADDRESS1, SignatureScheme.ML_DSA_65, 1952, 2)), + 2); + Permission active = activePermissionWithKeys( + java.util.Collections.singletonList(legacyKey(KEY_ADDRESS2)), 2); + Any any = getContract(address, owner, null, + java.util.Collections.singletonList(active)); + + try { + actuatorFor(any).validate(); + fail("mixed ML-DSA schemes should be rejected"); + } catch (ContractValidateException e) { + Assert.assertTrue(e.getMessage().contains("same scheme")); + } + } + + @Test + public void mlDsa44WrongPublicKeyLengthRejected() { + dbManager.getDynamicPropertiesStore().saveAllowMlDsa(1L); + ByteString address = ByteString.copyFrom(ByteArray.fromHexString(OWNER_ADDRESS)); + Permission owner = ownerPermissionWithKeys( + java.util.Collections.singletonList( + mlDsaKey(KEY_ADDRESS, SignatureScheme.ML_DSA_44, 1311, 1)), + 2); + Permission active = activePermissionWithKeys( + java.util.Collections.singletonList(legacyKey(KEY_ADDRESS1)), 2); + Any any = getContract(address, owner, null, + java.util.Collections.singletonList(active)); + + try { + actuatorFor(any).validate(); + fail("ML-DSA-44 wrong public_key length should be rejected"); + } catch (ContractValidateException e) { + Assert.assertTrue(e.getMessage().contains("public_key length")); + } + } + + @Test + public void witnessMlDsa44Accepted() throws ContractValidateException { + dbManager.getDynamicPropertiesStore().saveAllowMlDsa(1L); + ByteString address = ByteString.copyFrom(ByteArray.fromHexString(WITNESS_ADDRESS)); + Permission owner = ownerPermissionWithKeys( + java.util.Collections.singletonList( + mlDsaKey(KEY_ADDRESS, SignatureScheme.ML_DSA_65, 1952, 1)), + 2); + Permission witness = witnessPermissionWithKey( + mlDsaKey(KEY_ADDRESS1, SignatureScheme.ML_DSA_44, 1312, 2)); + Permission active = activePermissionWithKeys( + java.util.Collections.singletonList(legacyKey(KEY_ADDRESS2)), 2); + Any any = getContract(address, owner, witness, + java.util.Collections.singletonList(active)); + + actuatorFor(any).validate(); + } + + @Test + public void duplicatePublicKeyRejected() { + dbManager.getDynamicPropertiesStore().saveAllowMlDsa(1L); + ByteString address = ByteString.copyFrom(ByteArray.fromHexString(OWNER_ADDRESS)); + byte[] sharedPk = fixedBytes(1312, 1); + Key k1 = Key.newBuilder() + .setAddress(ByteString.copyFrom(ByteArray.fromHexString(KEY_ADDRESS))) + .setWeight(KEY_WEIGHT) + .setPqKey(PQPublicKey.newBuilder() + .setScheme(SignatureScheme.ML_DSA_44) + .setPublicKey(ByteString.copyFrom(sharedPk)) + .build()) + .build(); + Key k2 = Key.newBuilder() + .setAddress(ByteString.copyFrom(ByteArray.fromHexString(KEY_ADDRESS1))) + .setWeight(KEY_WEIGHT) + .setPqKey(PQPublicKey.newBuilder() + .setScheme(SignatureScheme.ML_DSA_44) + .setPublicKey(ByteString.copyFrom(sharedPk)) + .build()) + .build(); + Permission owner = ownerPermissionWithKeys(java.util.Arrays.asList(k1, k2), 2); + Permission active = activePermissionWithKeys( + java.util.Collections.singletonList(legacyKey(KEY_ADDRESS2)), 2); + Any any = getContract(address, owner, null, + java.util.Collections.singletonList(active)); + + try { + actuatorFor(any).validate(); + fail("duplicate public_key in same permission should be rejected"); + } catch (ContractValidateException e) { + Assert.assertTrue(e.getMessage().contains("public_key should be distinct")); + } + } + + @Test + public void validMlDsa44PermissionAccepted() throws ContractValidateException { + dbManager.getDynamicPropertiesStore().saveAllowMlDsa(1L); + ByteString address = ByteString.copyFrom(ByteArray.fromHexString(OWNER_ADDRESS)); + Permission owner = ownerPermissionWithKeys( + java.util.Collections.singletonList( + mlDsaKey(KEY_ADDRESS, SignatureScheme.ML_DSA_44, 1312, 1)), + 2); + Permission active = activePermissionWithKeys( + java.util.Collections.singletonList( + mlDsaKey(KEY_ADDRESS1, SignatureScheme.ML_DSA_44, 1312, 2)), + 2); + Any any = getContract(address, owner, null, + java.util.Collections.singletonList(active)); + + Assert.assertTrue(actuatorFor(any).validate()); + } + + @Test + public void validMlDsa65WitnessPermissionAccepted() throws ContractValidateException { + dbManager.getDynamicPropertiesStore().saveAllowMlDsa(1L); + ByteString address = ByteString.copyFrom(ByteArray.fromHexString(WITNESS_ADDRESS)); + Permission owner = ownerPermissionWithKeys( + java.util.Collections.singletonList( + mlDsaKey(KEY_ADDRESS, SignatureScheme.ML_DSA_65, 1952, 1)), + 2); + Permission witness = witnessPermissionWithKey( + mlDsaKey(KEY_ADDRESS1, SignatureScheme.ML_DSA_65, 1952, 2)); + Permission active = activePermissionWithKeys( + java.util.Collections.singletonList( + mlDsaKey(KEY_ADDRESS2, SignatureScheme.ML_DSA_65, 1952, 3)), + 2); + Any any = getContract(address, owner, witness, + java.util.Collections.singletonList(active)); + + Assert.assertTrue(actuatorFor(any).validate()); + } + + @Test + public void fnDsaPermissionRejectedWhenNotAllowed() { + dbManager.getDynamicPropertiesStore().saveAllowFnDsa(0L); + ByteString address = ByteString.copyFrom(ByteArray.fromHexString(OWNER_ADDRESS)); + Permission owner = ownerPermissionWithKeys( + java.util.Collections.singletonList( + mlDsaKey(KEY_ADDRESS, SignatureScheme.FN_DSA, 896, 1)), 2); + Permission active = activePermissionWithKeys( + java.util.Collections.singletonList(legacyKey(KEY_ADDRESS1)), 2); + Any any = getContract(address, owner, null, + java.util.Collections.singletonList(active)); + + try { + actuatorFor(any).validate(); + Assert.fail("should reject FN-DSA key when ALLOW_FN_DSA = 0"); + } catch (ContractValidateException e) { + Assert.assertTrue(e.getMessage().contains("FN-DSA is not activated")); + } + } + + @Test + public void fnDsaWrongPublicKeyLengthRejected() { + dbManager.getDynamicPropertiesStore().saveAllowFnDsa(1L); + ByteString address = ByteString.copyFrom(ByteArray.fromHexString(OWNER_ADDRESS)); + Permission owner = ownerPermissionWithKeys( + java.util.Collections.singletonList( + mlDsaKey(KEY_ADDRESS, SignatureScheme.FN_DSA, 895, 1)), 2); + Permission active = activePermissionWithKeys( + java.util.Collections.singletonList(legacyKey(KEY_ADDRESS1)), 2); + Any any = getContract(address, owner, null, + java.util.Collections.singletonList(active)); + + try { + actuatorFor(any).validate(); + Assert.fail("FN-DSA wrong public_key length should be rejected"); + } catch (ContractValidateException e) { + Assert.assertTrue(e.getMessage().contains("public_key length")); + } + } + + @Test + public void validFnDsaPermissionAccepted() throws ContractValidateException { + dbManager.getDynamicPropertiesStore().saveAllowFnDsa(1L); + ByteString address = ByteString.copyFrom(ByteArray.fromHexString(OWNER_ADDRESS)); + Permission owner = ownerPermissionWithKeys( + java.util.Collections.singletonList( + mlDsaKey(KEY_ADDRESS, SignatureScheme.FN_DSA, 896, 1)), + 2); + Permission active = activePermissionWithKeys( + java.util.Collections.singletonList( + mlDsaKey(KEY_ADDRESS1, SignatureScheme.FN_DSA, 896, 2)), + 2); + Any any = getContract(address, owner, null, + java.util.Collections.singletonList(active)); + + Assert.assertTrue(actuatorFor(any).validate()); + } + + @Test + public void validFnDsaWitnessPermissionAccepted() throws ContractValidateException { + dbManager.getDynamicPropertiesStore().saveAllowFnDsa(1L); + ByteString address = ByteString.copyFrom(ByteArray.fromHexString(WITNESS_ADDRESS)); + Permission owner = ownerPermissionWithKeys( + java.util.Collections.singletonList( + mlDsaKey(KEY_ADDRESS, SignatureScheme.FN_DSA, 896, 1)), + 2); + Permission witness = witnessPermissionWithKey( + mlDsaKey(KEY_ADDRESS1, SignatureScheme.FN_DSA, 896, 2)); + Permission active = activePermissionWithKeys( + java.util.Collections.singletonList( + mlDsaKey(KEY_ADDRESS2, SignatureScheme.FN_DSA, 896, 3)), + 2); + Any any = getContract(address, owner, witness, + java.util.Collections.singletonList(active)); + + Assert.assertTrue(actuatorFor(any).validate()); + } } \ 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..2bda708d62b 100755 --- a/framework/src/test/java/org/tron/core/actuator/CreateAccountActuatorTest.java +++ b/framework/src/test/java/org/tron/core/actuator/CreateAccountActuatorTest.java @@ -4,12 +4,17 @@ import com.google.protobuf.Any; import com.google.protobuf.ByteString; +import java.util.Arrays; import lombok.extern.slf4j.Slf4j; 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.pqc.FNDSA; +import org.tron.common.crypto.pqc.MLDSA44; +import org.tron.common.crypto.pqc.MLDSA65; +import org.tron.common.crypto.pqc.PQSignatureRegistry; import org.tron.common.utils.ByteArray; import org.tron.common.utils.StringUtil; import org.tron.core.Wallet; @@ -19,6 +24,8 @@ import org.tron.core.exception.ContractExeException; import org.tron.core.exception.ContractValidateException; import org.tron.protos.Protocol.AccountType; +import org.tron.protos.Protocol.PQPublicKey; +import org.tron.protos.Protocol.SignatureScheme; import org.tron.protos.Protocol.Transaction.Result.code; import org.tron.protos.contract.AccountContract.AccountCreateContract; import org.tron.protos.contract.AssetIssueContractOuterClass; @@ -220,6 +227,133 @@ public void commonErrorCheck() { } + private static byte[] filledSeed(int value, int length) { + byte[] seed = new byte[length]; + Arrays.fill(seed, (byte) value); + return seed; + } + + private Any pqContract(String ownerAddress, byte[] accountAddress, + SignatureScheme scheme, byte[] pqPublicKey) { + return Any.pack( + AccountCreateContract.newBuilder() + .setOwnerAddress(ByteString.copyFrom(ByteArray.fromHexString(ownerAddress))) + .setAccountAddress(ByteString.copyFrom(accountAddress)) + .setPqKey(PQPublicKey.newBuilder() + .setScheme(scheme) + .setPublicKey(ByteString.copyFrom(pqPublicKey)) + .build()) + .build()); + } + + private void runPqHappyPath(SignatureScheme scheme, byte[] pqPublicKey) { + dbManager.getDynamicPropertiesStore().saveAllowMultiSign(1L); + if (scheme == SignatureScheme.FN_DSA) { + dbManager.getDynamicPropertiesStore().saveAllowFnDsa(1L); + } else { + dbManager.getDynamicPropertiesStore().saveAllowMlDsa(1L); + } + byte[] derivedAddress = PQSignatureRegistry.computeAddress(scheme, pqPublicKey); + dbManager.getAccountStore().delete(derivedAddress); + + CreateAccountActuator actuator = new CreateAccountActuator(); + actuator.setChainBaseManager(dbManager.getChainBaseManager()) + .setAny(pqContract(OWNER_ADDRESS_SECOND, derivedAddress, scheme, pqPublicKey)); + TransactionResultCapsule ret = new TransactionResultCapsule(); + try { + actuator.validate(); + actuator.execute(ret); + Assert.assertEquals(code.SUCESS, ret.getInstance().getRet()); + + AccountCapsule created = dbManager.getAccountStore().get(derivedAddress); + Assert.assertNotNull(created); + // Owner permission bound to PQ key, address field empty. + Assert.assertEquals(1, created.getInstance().getOwnerPermission().getKeysCount()); + Assert.assertEquals(ByteString.EMPTY, + created.getInstance().getOwnerPermission().getKeys(0).getAddress()); + Assert.assertEquals(scheme, + created.getInstance().getOwnerPermission().getKeys(0).getPqKey().getScheme()); + Assert.assertEquals(ByteString.copyFrom(pqPublicKey), + created.getInstance().getOwnerPermission().getKeys(0).getPqKey().getPublicKey()); + // Active permission bound to same PQ key. + Assert.assertEquals(1, created.getInstance().getActivePermissionCount()); + Assert.assertEquals(scheme, + created.getInstance().getActivePermission(0).getKeys(0).getPqKey().getScheme()); + } catch (ContractValidateException | ContractExeException e) { + logger.info(e.getMessage()); + Assert.fail(e.getMessage()); + } + } + + @Test + public void createPqAccount_mlDsa44_success() { + MLDSA44 kp = new MLDSA44(filledSeed(0x11, MLDSA44.SEED_LENGTH)); + runPqHappyPath(SignatureScheme.ML_DSA_44, kp.getPublicKey()); + } + + @Test + public void createPqAccount_mlDsa65_success() { + MLDSA65 kp = new MLDSA65(filledSeed(0x12, MLDSA65.SEED_LENGTH)); + runPqHappyPath(SignatureScheme.ML_DSA_65, kp.getPublicKey()); + } + + @Test + public void createPqAccount_fnDsa_success() { + FNDSA kp = new FNDSA(filledSeed(0x13, FNDSA.SEED_LENGTH)); + runPqHappyPath(SignatureScheme.FN_DSA, kp.getPublicKey()); + } + + @Test + public void createPqAccount_addressMismatch() { + dbManager.getDynamicPropertiesStore().saveAllowMlDsa(1L); + MLDSA44 kp = new MLDSA44(filledSeed(0x21, MLDSA44.SEED_LENGTH)); + byte[] wrongAddress = ByteArray.fromHexString(OWNER_ADDRESS_FIRST); + + CreateAccountActuator actuator = new CreateAccountActuator(); + actuator.setChainBaseManager(dbManager.getChainBaseManager()) + .setAny(pqContract(OWNER_ADDRESS_SECOND, wrongAddress, + SignatureScheme.ML_DSA_44, kp.getPublicKey())); + TransactionResultCapsule ret = new TransactionResultCapsule(); + processAndCheckInvalid(actuator, ret, + "account_address does not match the address derived from pq_key", + "account_address does not match the address derived from pq_key"); + } + + @Test + public void createPqAccount_wrongPubKeyLength() { + dbManager.getDynamicPropertiesStore().saveAllowMlDsa(1L); + byte[] truncated = new byte[MLDSA44.PUBLIC_KEY_LENGTH - 1]; + byte[] derivedAddress = ByteArray.fromHexString(OWNER_ADDRESS_FIRST); + + CreateAccountActuator actuator = new CreateAccountActuator(); + actuator.setChainBaseManager(dbManager.getChainBaseManager()) + .setAny(pqContract(OWNER_ADDRESS_SECOND, derivedAddress, + SignatureScheme.ML_DSA_44, truncated)); + TransactionResultCapsule ret = new TransactionResultCapsule(); + processAndCheckInvalid(actuator, ret, + "Invalid PQ public key length for scheme ML_DSA_44", + "Invalid PQ public key length for scheme ML_DSA_44"); + } + + @Test + public void createPqAccount_schemeNotActivated() { + dbManager.getDynamicPropertiesStore().saveAllowMlDsa(0L); + dbManager.getDynamicPropertiesStore().saveAllowFnDsa(0L); + MLDSA44 kp = new MLDSA44(filledSeed(0x31, MLDSA44.SEED_LENGTH)); + byte[] derivedAddress = PQSignatureRegistry.computeAddress( + SignatureScheme.ML_DSA_44, kp.getPublicKey()); + dbManager.getAccountStore().delete(derivedAddress); + + CreateAccountActuator actuator = new CreateAccountActuator(); + actuator.setChainBaseManager(dbManager.getChainBaseManager()) + .setAny(pqContract(OWNER_ADDRESS_SECOND, derivedAddress, + SignatureScheme.ML_DSA_44, kp.getPublicKey())); + TransactionResultCapsule ret = new TransactionResultCapsule(); + processAndCheckInvalid(actuator, ret, + "PQ scheme not activated: ML_DSA_44", + "PQ scheme not activated: ML_DSA_44"); + } + private void processAndCheckInvalid(CreateAccountActuator actuator, TransactionResultCapsule ret, String failMsg, String expectedMsg) { 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 f8d8e6bdd9d..50017aa1a4f 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 @@ -772,4 +772,29 @@ public void blockVersionCheck() { } } } + + @Test + public void validateAllowMlDsa() { + long code = ProposalType.ALLOW_ML_DSA.getCode(); + + ContractValidateException thrown = assertThrows(ContractValidateException.class, + () -> ProposalUtil.validator(dynamicPropertiesStore, forkUtils, code, 0)); + assertEquals("This value[ALLOW_ML_DSA] is only allowed to be 1", thrown.getMessage()); + + thrown = assertThrows(ContractValidateException.class, + () -> ProposalUtil.validator(dynamicPropertiesStore, forkUtils, code, 2)); + assertEquals("This value[ALLOW_ML_DSA] is only allowed to be 1", thrown.getMessage()); + + try { + ProposalUtil.validator(dynamicPropertiesStore, forkUtils, code, 1); + } catch (ContractValidateException e) { + Assert.fail("value=1 should be accepted: " + e.getMessage()); + } + + dynamicPropertiesStore.saveAllowMlDsa(1L); + thrown = assertThrows(ContractValidateException.class, + () -> ProposalUtil.validator(dynamicPropertiesStore, forkUtils, code, 1)); + assertEquals("[ALLOW_ML_DSA] has been valid, no need to propose again", thrown.getMessage()); + dynamicPropertiesStore.saveAllowMlDsa(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..39cd040063c --- /dev/null +++ b/framework/src/test/java/org/tron/core/capsule/BlockCapsulePQTest.java @@ -0,0 +1,237 @@ +package org.tron.core.capsule; + +import com.google.protobuf.ByteString; +import org.junit.Assert; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.tron.common.BaseTest; +import org.tron.common.TestConstants; +import org.tron.common.crypto.ECKey; +import org.tron.common.crypto.pqc.MLDSA65; +import org.tron.common.crypto.pqc.PQAuthDigest; +import org.tron.common.utils.ByteArray; +import org.tron.common.utils.Sha256Hash; +import org.tron.core.config.args.Args; +import org.tron.core.exception.ValidateSignatureException; +import org.tron.protos.Protocol.Account; +import org.tron.protos.Protocol.AccountType; +import org.tron.protos.Protocol.Key; +import org.tron.protos.Protocol.PQAuthWitness; +import org.tron.protos.Protocol.PQPublicKey; +import org.tron.protos.Protocol.Permission; +import org.tron.protos.Protocol.Permission.PermissionType; +import org.tron.protos.Protocol.SignatureScheme; + +public class BlockCapsulePQTest extends BaseTest { + + private ECKey witnessKey; + private byte[] witnessAddress; + private MLDSA65 pqKeypair; + + @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 MLDSA65(); + } + + private AccountCapsule buildWitnessAccount(SignatureScheme scheme) { + Key.Builder kb = Key.newBuilder() + .setAddress(ByteString.copyFrom(witnessAddress)) + .setWeight(1); + if (scheme == SignatureScheme.ML_DSA_65) { + kb.setPqKey(PQPublicKey.newBuilder() + .setScheme(scheme) + .setPublicKey(ByteString.copyFrom(pqKeypair.getPublicKey())) + .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 MLDSA65.sign(pqKeypair.getPrivateKey(), message); + } + + @Test + public void legacyValidateWithoutPQAuthWitnessAcceptedBeforeActivation() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowMultiSign(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa(0L); + AccountCapsule witness = buildWitnessAccount(SignatureScheme.UNKNOWN_SIG_SCHEME); + 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 pqWitnessBeforeActivationRejected() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowMultiSign(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa(0L); + AccountCapsule witness = buildWitnessAccount(SignatureScheme.UNKNOWN_SIG_SCHEME); + dbManager.getAccountStore().put(witnessAddress, witness); + + byte[] parentHash = new byte[32]; + BlockCapsule block = buildUnsignedBlock(parentHash); + byte[] digest = PQAuthDigest.block(block.getRawHashBytes(), 0); + block.setPqWitness(PQAuthWitness.newBuilder() + .setSignature(ByteString.copyFrom(signPQ(digest))) + .build()); + block.validateSignature( + dbManager.getDynamicPropertiesStore(), dbManager.getAccountStore()); + } + + @Test(expected = ValidateSignatureException.class) + public void bothLegacyAndPQAuthWitnessRejected() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowMultiSign(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa(1L); + AccountCapsule witness = buildWitnessAccount(SignatureScheme.ML_DSA_65); + dbManager.getAccountStore().put(witnessAddress, witness); + + byte[] parentHash = new byte[32]; + BlockCapsule block = buildSignedBlock(parentHash); + byte[] digest = PQAuthDigest.block(block.getRawHashBytes(), 0); + block.setPqWitness(PQAuthWitness.newBuilder() + .setSignature(ByteString.copyFrom(signPQ(digest))) + .build()); + block.validateSignature( + dbManager.getDynamicPropertiesStore(), dbManager.getAccountStore()); + } + + @Test(expected = ValidateSignatureException.class) + public void mlDsaSchemeWithLegacyOnlyRejected() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowMultiSign(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa(1L); + AccountCapsule witness = buildWitnessAccount(SignatureScheme.ML_DSA_65); + dbManager.getAccountStore().put(witnessAddress, witness); + + byte[] parentHash = new byte[32]; + BlockCapsule block = buildSignedBlock(parentHash); + block.validateSignature( + dbManager.getDynamicPropertiesStore(), dbManager.getAccountStore()); + } + + @Test(expected = ValidateSignatureException.class) + public void legacySchemeWithPQAuthWitnessOnlyRejected() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowMultiSign(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa(1L); + AccountCapsule witness = buildWitnessAccount(SignatureScheme.UNKNOWN_SIG_SCHEME); + dbManager.getAccountStore().put(witnessAddress, witness); + + byte[] parentHash = new byte[32]; + BlockCapsule block = buildUnsignedBlock(parentHash); + byte[] digest = PQAuthDigest.block(block.getRawHashBytes(), 0); + block.setPqWitness(PQAuthWitness.newBuilder() + .setSignature(ByteString.copyFrom(signPQ(digest))) + .build()); + block.validateSignature( + dbManager.getDynamicPropertiesStore(), dbManager.getAccountStore()); + } + + @Test(expected = ValidateSignatureException.class) + public void neitherLegacyNorAuthRejected() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowMultiSign(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa(1L); + AccountCapsule witness = buildWitnessAccount(SignatureScheme.ML_DSA_65); + dbManager.getAccountStore().put(witnessAddress, witness); + + byte[] parentHash = new byte[32]; + BlockCapsule block = buildUnsignedBlock(parentHash); + block.validateSignature( + dbManager.getDynamicPropertiesStore(), dbManager.getAccountStore()); + } + + @Test + public void pqOnlyAccepted() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowMultiSign(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa(1L); + AccountCapsule witness = buildWitnessAccount(SignatureScheme.ML_DSA_65); + dbManager.getAccountStore().put(witnessAddress, witness); + + byte[] parentHash = new byte[32]; + BlockCapsule block = buildUnsignedBlock(parentHash); + byte[] digest = PQAuthDigest.block(block.getRawHashBytes(), 0); + block.setPqWitness(PQAuthWitness.newBuilder() + .setSignature(ByteString.copyFrom(signPQ(digest))) + .build()); + Assert.assertTrue(block.validateSignature( + dbManager.getDynamicPropertiesStore(), dbManager.getAccountStore())); + } + + @Test + public void tamperedPQAuthWitnessFails() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowMultiSign(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa(1L); + AccountCapsule witness = buildWitnessAccount(SignatureScheme.ML_DSA_65); + dbManager.getAccountStore().put(witnessAddress, witness); + + byte[] parentHash = new byte[32]; + BlockCapsule block = buildUnsignedBlock(parentHash); + byte[] digest = PQAuthDigest.block(block.getRawHashBytes(), 0); + byte[] pqSig = signPQ(digest); + pqSig[pqSig.length - 1] ^= 0x01; + block.setPqWitness(PQAuthWitness.newBuilder() + .setSignature(ByteString.copyFrom(pqSig)) + .build()); + Assert.assertFalse(block.validateSignature( + dbManager.getDynamicPropertiesStore(), dbManager.getAccountStore())); + } + + @Test(expected = ValidateSignatureException.class) + public void signerNotInWitnessPermissionRejected() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowMultiSign(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa(1L); + AccountCapsule witness = buildWitnessAccount(SignatureScheme.ML_DSA_65); + dbManager.getAccountStore().put(witnessAddress, witness); + + byte[] parentHash = new byte[32]; + BlockCapsule block = buildUnsignedBlock(parentHash); + byte[] digest = PQAuthDigest.block(block.getRawHashBytes(), 1); + block.setPqWitness(PQAuthWitness.newBuilder() + .setKeyId(1) + .setSignature(ByteString.copyFrom(signPQ(digest))) + .build()); + 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 70434430262..ee15dc059d2 100644 --- a/framework/src/test/java/org/tron/core/capsule/TransactionCapsuleTest.java +++ b/framework/src/test/java/org/tron/core/capsule/TransactionCapsuleTest.java @@ -4,6 +4,7 @@ import static org.tron.protos.Protocol.Transaction.Result.contractResult.PRECOMPILED_CONTRACT; import static org.tron.protos.Protocol.Transaction.Result.contractResult.SUCCESS; +import com.google.protobuf.Any; import com.google.protobuf.ByteString; import lombok.extern.slf4j.Slf4j; import org.junit.Assert; @@ -12,15 +13,31 @@ import org.junit.Test; import org.tron.common.BaseTest; import org.tron.common.TestConstants; +import org.tron.common.crypto.ECKey; +import org.tron.common.crypto.pqc.FNDSA; +import org.tron.common.crypto.pqc.MLDSA44; +import org.tron.common.crypto.pqc.MLDSA65; +import org.tron.common.crypto.pqc.PQAuthDigest; +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.PQAuthWitness; +import org.tron.protos.Protocol.PQPublicKey; +import org.tron.protos.Protocol.Permission; +import org.tron.protos.Protocol.Permission.PermissionType; +import org.tron.protos.Protocol.SignatureScheme; 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 { @@ -69,4 +86,393 @@ public void testRemoveRedundantRet() { Assert.assertEquals(1, transactionCapsule.getInstance().getRetCount()); Assert.assertEquals(SUCCESS, transactionCapsule.getInstance().getRet(0).getContractRet()); } + + // --------------------- ML-DSA pq_witness verification --------------------- + + private static final String PQ_OWNER_HEX = + "41abd4b9367799eaa3197fecb144eb71de1e049abc"; + private static final String PQ_SIGNER_HEX = + "41548794500882809695a8a687866e76d4271a1abc"; + + private byte[] sign(MLDSA44 kp, byte[] msg) { + return MLDSA44.sign(kp.getPrivateKey(), msg); + } + + private Transaction buildTransferTx(String ownerHex, int permissionId) { + TransferContract transfer = TransferContract.newBuilder() + .setOwnerAddress(ByteString.copyFrom(ByteArray.fromHexString(ownerHex))) + .setToAddress(ByteString.copyFrom(ByteArray.fromHexString(PQ_SIGNER_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(); + } + + private void putAccountWithPQPermission( + String ownerHex, byte[] pqPublicKey, SignatureScheme scheme) { + byte[] addr = ByteArray.fromHexString(ownerHex); + byte[] signerAddr = ByteArray.fromHexString(PQ_SIGNER_HEX); + Key pqKey = Key.newBuilder() + .setAddress(ByteString.copyFrom(signerAddr)) + .setWeight(1L) + .setPqKey(PQPublicKey.newBuilder() + .setScheme(scheme) + .setPublicKey(ByteString.copyFrom(pqPublicKey)) + .build()) + .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 pqWitnessBeforeActivationRejected() { + dbManager.getDynamicPropertiesStore().saveAllowMlDsa(0L); + dbManager.getDynamicPropertiesStore().saveAllowFnDsa(0L); + Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0).toBuilder() + .addPqWitness(PQAuthWitness.newBuilder() + .setSignature(ByteString.copyFrom(new byte[2420])) + .build()) + .build(); + TransactionCapsule cap = new TransactionCapsule(tx); + try { + cap.validatePubSignature(dbManager.getAccountStore(), + dbManager.getDynamicPropertiesStore()); + Assert.fail("should reject pq_witness before activation"); + } catch (ValidateSignatureException e) { + Assert.assertTrue(e.getMessage().contains("no post-quantum scheme is activated")); + } + } + + @Test + public void signatureAndPQAuthWitnessAreMutuallyExclusive() { + dbManager.getDynamicPropertiesStore().saveAllowMlDsa(1L); + Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0).toBuilder() + .addSignature(ByteString.copyFrom(new byte[65])) + .addPqWitness(PQAuthWitness.newBuilder() + .setSignature(ByteString.copyFrom(new byte[2420])) + .build()) + .build(); + TransactionCapsule cap = new TransactionCapsule(tx); + try { + cap.validatePubSignature(dbManager.getAccountStore(), + dbManager.getDynamicPropertiesStore()); + Assert.fail("should reject when both signature and pq_witness present"); + } catch (ValidateSignatureException e) { + Assert.assertTrue(e.getMessage().contains("mutually exclusive")); + } + } + + @Test + public void validPQAuthWitnessAccepted() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowMlDsa(1L); + MLDSA44 kp = new MLDSA44(); + putAccountWithPQPermission(PQ_OWNER_HEX, kp.getPublicKey(), SignatureScheme.ML_DSA_44); + + Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0); + byte[] txid = Sha256Hash.of(true, tx.getRawData().toByteArray()).getBytes(); + byte[] digest = PQAuthDigest.tx(txid, 0, 0); + byte[] sig = sign(kp, digest); + + Transaction signed = tx.toBuilder() + .addPqWitness(PQAuthWitness.newBuilder() + .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().saveAllowMlDsa(1L); + MLDSA44 kp = new MLDSA44(); + putAccountWithPQPermission(PQ_OWNER_HEX, kp.getPublicKey(), SignatureScheme.ML_DSA_44); + + Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0); + byte[] txid = Sha256Hash.of(true, tx.getRawData().toByteArray()).getBytes(); + byte[] digest = PQAuthDigest.tx(txid, 0, 0); + byte[] sig = sign(kp, digest); + PQAuthWitness aw = PQAuthWitness.newBuilder() + .setSignature(ByteString.copyFrom(sig)) + .build(); + Transaction signed = tx.toBuilder().addPqWitness(aw).addPqWitness(aw).build(); + + TransactionCapsule cap = new TransactionCapsule(signed); + try { + cap.validatePubSignature(dbManager.getAccountStore(), + dbManager.getDynamicPropertiesStore()); + Assert.fail("duplicate key_id should be rejected"); + } catch (ValidateSignatureException e) { + Assert.assertTrue(e.getMessage().contains("duplicate key_id")); + } + } + + @Test + public void tamperedPQAuthWitnessRejected() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowMlDsa(1L); + MLDSA44 kp = new MLDSA44(); + putAccountWithPQPermission(PQ_OWNER_HEX, kp.getPublicKey(), SignatureScheme.ML_DSA_44); + + Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0); + byte[] txid = Sha256Hash.of(true, tx.getRawData().toByteArray()).getBytes(); + byte[] digest = PQAuthDigest.tx(txid, 0, 0); + byte[] sig = sign(kp, digest); + sig[0] ^= 0x01; + + Transaction signed = tx.toBuilder() + .addPqWitness(PQAuthWitness.newBuilder() + .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().saveAllowMlDsa(1L); + MLDSA44 kp = new MLDSA44(); + putAccountWithPQPermission(PQ_OWNER_HEX, kp.getPublicKey(), SignatureScheme.ML_DSA_44); + + Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0); + byte[] txid = Sha256Hash.of(true, tx.getRawData().toByteArray()).getBytes(); + byte[] digest = PQAuthDigest.tx(txid, 0, 1); + byte[] sig = sign(kp, digest); + + Transaction signed = tx.toBuilder() + .addPqWitness(PQAuthWitness.newBuilder() + .setKeyId(1) + .setSignature(ByteString.copyFrom(sig)) + .build()) + .build(); + TransactionCapsule cap = new TransactionCapsule(signed); + try { + cap.validatePubSignature(dbManager.getAccountStore(), + dbManager.getDynamicPropertiesStore()); + Assert.fail("out-of-range key_id should be rejected"); + } catch (ValidateSignatureException e) { + Assert.assertTrue(e.getMessage().contains("key_id out of range")); + } + } + + /** + * TRC20 transfer(address,uint256) call data: 4-byte selector + 32-byte address + 32-byte amount. + */ + private Transaction buildTrc20TransferTx(String ownerHex, int permissionId) { + // transfer(address,uint256) selector + byte[] selector = ByteArray.fromHexString("a9059cbb"); + byte[] toAddrPadded = new byte[32]; + byte[] toRaw = ByteArray.fromHexString(PQ_SIGNER_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] for ECKey, ML-DSA-44, ML-DSA-65. */ + 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(); + + // ML-DSA-44: 2420-byte signature in pq_witness + MLDSA44 kp44 = new MLDSA44(); + byte[] txid44 = Sha256Hash.of(true, baseTx.getRawData().toByteArray()).getBytes(); + byte[] sig44 = MLDSA44.sign(kp44.getPrivateKey(), PQAuthDigest.tx(txid44, 0, 0)); + Transaction tx44 = baseTx.toBuilder() + .addPqWitness(PQAuthWitness.newBuilder() + .setSignature(ByteString.copyFrom(sig44)) + .build()) + .build(); + TransactionCapsule cap44 = new TransactionCapsule(tx44); + long d44Serial = tx44.toByteArray().length; + long d44Pack = cap44.computeTrxSizeForBlockMessage(); + + // ML-DSA-65: 3309-byte signature in pq_witness + MLDSA65 kp65 = new MLDSA65(); + byte[] txid65 = Sha256Hash.of(true, baseTx.getRawData().toByteArray()).getBytes(); + byte[] sig65 = MLDSA65.sign(kp65.getPrivateKey(), PQAuthDigest.tx(txid65, 0, 0)); + Transaction tx65 = baseTx.toBuilder() + .addPqWitness(PQAuthWitness.newBuilder() + .setSignature(ByteString.copyFrom(sig65)) + .build()) + .build(); + TransactionCapsule cap65 = new TransactionCapsule(tx65); + long d65Serial = tx65.toByteArray().length; + long d65Pack = cap65.computeTrxSizeForBlockMessage(); + + return new long[][]{ + {ecSerial, ecPack, blockLimit / ecPack}, + {d44Serial, d44Pack, blockLimit / d44Pack}, + {d65Serial, d65Pack, blockLimit / d65Pack} + }; + } + + @Test + public void transactionSizeComparisonByScheme() { + long[][] trx = measureSizes(buildTransferTx(PQ_OWNER_HEX, 0)); + long[][] trc20 = measureSizes(buildTrc20TransferTx(PQ_OWNER_HEX, 0)); + + String[] labels = {"ECKey (ECDSA)", "ML-DSA-44", "ML-DSA-65"}; + System.out.println("=== TRX transfer ==="); + for (int i = 0; i < 3; 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 < 3; 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]); + } + + for (int i = 0; i < 2; i++) { + Assert.assertTrue(trx[i + 1][0] > trx[i][0]); + Assert.assertTrue(trc20[i + 1][0] > trc20[i][0]); + Assert.assertTrue(trx[i + 1][2] < trx[i][2]); + Assert.assertTrue(trc20[i + 1][2] < trc20[i][2]); + } + } + + @Test + public void mlDsa65PQAuthWitnessAlsoAccepted() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowMlDsa(1L); + MLDSA65 kp = new MLDSA65(); + putAccountWithPQPermission(PQ_OWNER_HEX, kp.getPublicKey(), SignatureScheme.ML_DSA_65); + + Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0); + byte[] txid = Sha256Hash.of(true, tx.getRawData().toByteArray()).getBytes(); + byte[] digest = PQAuthDigest.tx(txid, 0, 0); + byte[] sig = MLDSA65.sign(kp.getPrivateKey(), digest); + + Transaction signed = tx.toBuilder() + .addPqWitness(PQAuthWitness.newBuilder() + .setSignature(ByteString.copyFrom(sig)) + .build()) + .build(); + TransactionCapsule cap = new TransactionCapsule(signed); + Assert.assertTrue(cap.validatePubSignature(dbManager.getAccountStore(), + dbManager.getDynamicPropertiesStore())); + } + + @Test + public void fnDsaPQAuthWitnessAccepted() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowFnDsa(1L); + FNDSA kp = new FNDSA(); + putAccountWithPQPermission(PQ_OWNER_HEX, kp.getPublicKey(), SignatureScheme.FN_DSA); + + Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0); + byte[] txid = Sha256Hash.of(true, tx.getRawData().toByteArray()).getBytes(); + byte[] digest = PQAuthDigest.tx(txid, 0, 0); + byte[] sig = FNDSA.sign(kp.getPrivateKey(), digest); + Assert.assertTrue("FN-DSA signature must be within protocol bound", + sig.length > 0 && sig.length <= FNDSA.SIGNATURE_LENGTH); + + Transaction signed = tx.toBuilder() + .addPqWitness(PQAuthWitness.newBuilder() + .setSignature(ByteString.copyFrom(sig)) + .build()) + .build(); + TransactionCapsule cap = new TransactionCapsule(signed); + Assert.assertTrue(cap.validatePubSignature(dbManager.getAccountStore(), + dbManager.getDynamicPropertiesStore())); + } + + @Test + public void fnDsaTamperedPQAuthWitnessRejected() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowFnDsa(1L); + FNDSA kp = new FNDSA(); + putAccountWithPQPermission(PQ_OWNER_HEX, kp.getPublicKey(), SignatureScheme.FN_DSA); + + Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0); + byte[] txid = Sha256Hash.of(true, tx.getRawData().toByteArray()).getBytes(); + byte[] digest = PQAuthDigest.tx(txid, 0, 0); + byte[] sig = FNDSA.sign(kp.getPrivateKey(), digest); + sig[0] ^= 0x01; + + Transaction signed = tx.toBuilder() + .addPqWitness(PQAuthWitness.newBuilder() + .setSignature(ByteString.copyFrom(sig)) + .build()) + .build(); + TransactionCapsule cap = new TransactionCapsule(signed); + try { + cap.validatePubSignature(dbManager.getAccountStore(), + dbManager.getDynamicPropertiesStore()); + Assert.fail("tampered FN-DSA signature should be rejected"); + } catch (ValidateSignatureException e) { + Assert.assertTrue(e.getMessage().contains("pq sig invalid")); + } + } + + @Test + public void fnDsaPQAuthWitnessRejectedWhenNotActivated() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowMlDsa(0L); + dbManager.getDynamicPropertiesStore().saveAllowFnDsa(0L); + FNDSA kp = new FNDSA(); + putAccountWithPQPermission(PQ_OWNER_HEX, kp.getPublicKey(), SignatureScheme.FN_DSA); + + Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0); + byte[] txid = Sha256Hash.of(true, tx.getRawData().toByteArray()).getBytes(); + byte[] digest = PQAuthDigest.tx(txid, 0, 0); + byte[] sig = FNDSA.sign(kp.getPrivateKey(), digest); + + Transaction signed = tx.toBuilder() + .addPqWitness(PQAuthWitness.newBuilder() + .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 is 0"); + } catch (ValidateSignatureException expected) { + // accepted: rejection path triggered + } + } } \ No newline at end of file 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 e965ae3fd60..9ca052a54e4 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; @@ -109,9 +110,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_seed_pq = []\n"; + Files.write(conf, content.getBytes()); TronError thrown = assertThrows(TronError.class, () -> { - Args.setParam(new String[]{"--witness"}, TestConstants.TEST_CONF); + Args.setParam(new String[]{"--witness"}, conf.toString()); }); assertEquals(TronError.ErrCode.WITNESS_INIT, thrown.getErrCode()); } diff --git a/framework/src/test/java/org/tron/core/services/ProposalServiceTest.java b/framework/src/test/java/org/tron/core/services/ProposalServiceTest.java index 5732e6f1cde..b30016d3dba 100644 --- a/framework/src/test/java/org/tron/core/services/ProposalServiceTest.java +++ b/framework/src/test/java/org/tron/core/services/ProposalServiceTest.java @@ -1,6 +1,7 @@ package org.tron.core.services; import static org.tron.core.Constant.MAX_PROPOSAL_EXPIRE_TIME; +import static org.tron.core.utils.ProposalUtil.ProposalType.ALLOW_ML_DSA; import static org.tron.core.utils.ProposalUtil.ProposalType.CONSENSUS_LOGIC_OPTIMIZATION; import static org.tron.core.utils.ProposalUtil.ProposalType.ENERGY_FEE; import static org.tron.core.utils.ProposalUtil.ProposalType.PROPOSAL_EXPIRE_TIME; @@ -151,4 +152,20 @@ public void testProposalExpireTime() { Assert.assertEquals(MAX_PROPOSAL_EXPIRE_TIME - 3000, window); } + @Test + public void testProcessAllowMlDsa() { + dbManager.getDynamicPropertiesStore().saveAllowMlDsa(0L); + Assert.assertFalse(dbManager.getDynamicPropertiesStore().allowMlDsa()); + + Proposal proposal = Proposal.newBuilder() + .putParameters(ALLOW_ML_DSA.getCode(), 1L).build(); + ProposalCapsule proposalCapsule = new ProposalCapsule(proposal); + boolean result = ProposalService.process(dbManager, proposalCapsule); + Assert.assertTrue(result); + + Assert.assertEquals(1L, dbManager.getDynamicPropertiesStore().getAllowMlDsa()); + Assert.assertTrue(dbManager.getDynamicPropertiesStore().allowMlDsa()); + + dbManager.getDynamicPropertiesStore().saveAllowMlDsa(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 98c11fd4018..73c7b8923c2 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,7 @@ import org.tron.core.config.args.Args; import org.tron.core.utils.TransactionUtil; import org.tron.protos.Protocol; +import org.tron.protos.Protocol.PQAuthWitness; import org.tron.protos.Protocol.Transaction; public class UtilTest extends BaseTest { @@ -166,4 +167,34 @@ public void testPackTransaction() { TransactionSignWeight txSignWeight = transactionUtil.getTransactionSignWeight(transaction); Assert.assertNotNull(txSignWeight); } + + @Test + public void roundtripPQAuthWitnessJson() throws Exception { + byte[] sig = new byte[3309]; + for (int i = 0; i < sig.length; i++) { + sig[i] = (byte) (i & 0xff); + } + PQAuthWitness pqWitness = PQAuthWitness.newBuilder() + .setKeyId(1) + .setSignature(ByteString.copyFrom(sig)) + .build(); + Transaction original = Transaction.newBuilder() + .setRawData(Transaction.raw.newBuilder().setTimestamp(1L).build()) + .addPqWitness(pqWitness) + .build(); + + String json = Util.printTransactionToJSON(original, false).toJSONString(); + Assert.assertTrue("JSON output should contain pq_witness field", + json.contains("pq_witness")); + + Transaction.Builder rebuilt = Transaction.newBuilder(); + JsonFormat.merge(json, rebuilt, false); + Transaction decoded = rebuilt.build(); + + Assert.assertEquals(1, decoded.getPqWitnessCount()); + Assert.assertEquals(pqWitness.getKeyId(), + decoded.getPqWitness(0).getKeyId()); + Assert.assertEquals(pqWitness.getSignature(), + decoded.getPqWitness(0).getSignature()); + } } diff --git a/framework/src/test/resources/config-test.conf b/framework/src/test/resources/config-test.conf index 71e93f84db5..f920e10253f 100644 --- a/framework/src/test/resources/config-test.conf +++ b/framework/src/test/resources/config-test.conf @@ -348,12 +348,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_seed_pq_scheme = "ML_DSA_44" +localwitness_seed_pq = [ + "0101010101010101010101010101010101010101010101010101010101010101" + ] + block = { needSyncCheck = true # first node : false, other : true } @@ -386,4 +390,4 @@ node.dynamicConfig.enable = true event.subscribe = { enable = false } -node.dynamicConfig.checkInterval = 0 \ No newline at end of file +node.dynamicConfig.checkInterval = 0 diff --git a/protocol/src/main/protos/core/Tron.proto b/protocol/src/main/protos/core/Tron.proto index 2b104b86d34..4a49577f83a 100644 --- a/protocol/src/main/protos/core/Tron.proto +++ b/protocol/src/main/protos/core/Tron.proto @@ -17,6 +17,22 @@ enum AccountType { Contract = 2; } +// Signature scheme identifier used by Permission.Key and PQWitness. +// UNKNOWN_SIG_SCHEME (0) denotes legacy keys authenticated via the existing +// Transaction.signature / BlockHeader.witness_signature paths (ECDSA secp256k1 +// on mainnet, SM2/SM3 where applicable). Post-quantum schemes use dedicated +// pq_witness / pq_witness fields. +enum SignatureScheme { + UNKNOWN_SIG_SCHEME = 0; + ECDSA_SECP256K1 = 1; + SM2_SM3 = 2; + ML_DSA_44 = 3; + ML_DSA_65 = 4; + FN_DSA = 6; + reserved 5; + reserved 7 to 15; +} + // AccountId, (name, address) use name, (null, address) use address, (name, null) use name, message AccountId { bytes name = 1; @@ -240,9 +256,28 @@ message Account { int64 acquired_delegated_frozenV2_balance_for_bandwidth = 37; } +// Post-quantum public key: algorithm identifier paired with raw key bytes. +// Used inside Key.pq_key; absent on legacy (ECDSA/SM2) keys. +message PQPublicKey { + SignatureScheme scheme = 1; + // Raw key bytes. ML-DSA-44=1312B, ML-DSA-65=1952B, FN-DSA=896B. + bytes public_key = 2; +} + message Key { - bytes address = 1; - int64 weight = 2; + bytes address = 1; // empty for PQ-only keys + int64 weight = 2; + // Post-quantum key. Absent for legacy keys. + PQPublicKey pq_key = 3; +} + +// Per-signer post-quantum authentication witness for a transaction or block. +// key_id is the 0-based index of the signing key in the permission's key list. +// key_id = 0 is the proto3 default and is omitted on the wire — single-key +// accounts pay no overhead for this field. +message PQAuthWitness { + uint32 key_id = 1; + bytes signature = 2; } message DelegatedResource { @@ -449,6 +484,11 @@ message Transaction { // only support size = 1, repeated list here for muti-sig extension repeated bytes signature = 2; repeated Result ret = 5; + // Post-quantum authentication witnesses. Mutually exclusive with signature: + // a transaction against a PQ Permission MUST use pq_witness and empty + // signature; a transaction against a legacy Permission MUST use signature + // and empty pq_witness. + repeated PQAuthWitness pq_witness = 6; } message TransactionInfo { @@ -515,6 +555,11 @@ message BlockHeader { } raw raw_data = 1; bytes witness_signature = 2; + // Post-quantum witness authentication. When the producing SR has a + // Witness Permission with scheme = ML_DSA_65, pq_witness SHALL be + // present in addition to witness_signature (Dual-Sign). Otherwise this + // field SHALL be empty. + PQAuthWitness pq_witness = 3; } // block diff --git a/protocol/src/main/protos/core/contract/account_contract.proto b/protocol/src/main/protos/core/contract/account_contract.proto index d3180048f43..08ea06b8c4e 100644 --- a/protocol/src/main/protos/core/contract/account_contract.proto +++ b/protocol/src/main/protos/core/contract/account_contract.proto @@ -27,6 +27,10 @@ message AccountCreateContract { bytes owner_address = 1; bytes account_address = 2; AccountType type = 3; + // Optional PQ public key. If set, account_address must equal + // sha3omit12(pq_key.public_key) and the new account's default Owner/Active + // permissions are bound to this PQ key (no ECDSA participation). + PQPublicKey pq_key = 4; } // Update account name. Account name is not unique now.