From 02af25ccf62e64527e986a3c06a5cdac9230df18 Mon Sep 17 00:00:00 2001 From: federico Date: Wed, 22 Apr 2026 18:46:31 +0800 Subject: [PATCH 01/17] feat: add ML-DSA post-quantum signature support --- .../AccountPermissionUpdateActuator.java | 73 +++++ .../org/tron/core/utils/ProposalUtil.java | 14 +- .../org/tron/core/capsule/BlockCapsule.java | 114 +++++++- .../tron/core/capsule/TransactionCapsule.java | 125 ++++++++- .../org/tron/core/db/BandwidthProcessor.java | 11 +- .../core/store/DynamicPropertiesStore.java | 17 ++ .../common/parameter/CommonParameter.java | 4 + .../src/main/java/org/tron/core/Constant.java | 8 + .../java/org/tron/consensus/base/Param.java | 8 + .../tron/common/crypto/pqc/MLDSA44Signer.java | 45 +++ .../common/crypto/pqc/MLDSA44Verifier.java | 46 +++ .../tron/common/crypto/pqc/MLDSA65Signer.java | 45 +++ .../common/crypto/pqc/MLDSA65Verifier.java | 46 +++ .../tron/common/crypto/pqc/PqAuthDigest.java | 71 +++++ .../common/crypto/pqc/SignatureVerifier.java | 45 +++ .../crypto/pqc/SignatureVerifierRegistry.java | 39 +++ .../crypto/pqc/MLDSA44VerifierTest.java | 134 +++++++++ .../crypto/pqc/MLDSA65VerifierTest.java | 149 ++++++++++ .../common/crypto/pqc/PqAuthDigestTest.java | 133 +++++++++ .../pqc/SignatureVerifierRegistryTest.java | 62 +++++ .../tron/core/consensus/ProposalService.java | 4 + .../main/java/org/tron/core/db/Manager.java | 50 +++- framework/src/main/resources/config.conf | 11 + .../org/tron/core/BandwidthProcessorTest.java | 118 ++++++++ .../AccountPermissionUpdateActuatorTest.java | 263 ++++++++++++++++++ .../core/actuator/utils/ProposalUtilTest.java | 25 ++ .../tron/core/capsule/BlockCapsulePqTest.java | 260 +++++++++++++++++ .../core/capsule/TransactionCapsuleTest.java | 230 +++++++++++++++ .../core/services/ProposalServiceTest.java | 17 ++ .../org/tron/core/services/http/UtilTest.java | 32 +++ protocol/src/main/protos/core/Tron.proto | 39 +++ 31 files changed, 2222 insertions(+), 16 deletions(-) create mode 100644 crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA44Signer.java create mode 100644 crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA44Verifier.java create mode 100644 crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA65Signer.java create mode 100644 crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA65Verifier.java create mode 100644 crypto/src/main/java/org/tron/common/crypto/pqc/PqAuthDigest.java create mode 100644 crypto/src/main/java/org/tron/common/crypto/pqc/SignatureVerifier.java create mode 100644 crypto/src/main/java/org/tron/common/crypto/pqc/SignatureVerifierRegistry.java create mode 100644 crypto/src/test/java/org/tron/common/crypto/pqc/MLDSA44VerifierTest.java create mode 100644 crypto/src/test/java/org/tron/common/crypto/pqc/MLDSA65VerifierTest.java create mode 100644 crypto/src/test/java/org/tron/common/crypto/pqc/PqAuthDigestTest.java create mode 100644 crypto/src/test/java/org/tron/common/crypto/pqc/SignatureVerifierRegistryTest.java create mode 100644 framework/src/test/java/org/tron/core/capsule/BlockCapsulePqTest.java diff --git a/actuator/src/main/java/org/tron/core/actuator/AccountPermissionUpdateActuator.java b/actuator/src/main/java/org/tron/core/actuator/AccountPermissionUpdateActuator.java index f2eafb20a5e..e319aaab8a6 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,12 @@ import org.tron.core.exception.ContractValidateException; import org.tron.core.store.AccountStore; import org.tron.core.store.DynamicPropertiesStore; +import org.tron.common.crypto.pqc.MLDSA44Verifier; +import org.tron.common.crypto.pqc.MLDSA65Verifier; 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; @@ -102,6 +105,23 @@ private boolean checkPermission(Permission permission) throws ContractValidateEx throw new ContractValidateException( "address should be distinct in permission " + permission.getType()); } + validatePermissionScheme(permission); + + List publicKeyList = permission.getKeysList() + .stream() + .map(Key::getPublicKey) + .filter(pk -> !pk.isEmpty()) + .distinct() + .collect(toList()); + long nonEmptyPublicKeyCount = permission.getKeysList().stream() + .map(Key::getPublicKey) + .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())) { throw new ContractValidateException("key is not a validate address"); @@ -237,4 +257,57 @@ public ByteString getOwnerAddress() throws InvalidProtocolBufferException { public long calcFee() { return chainBaseManager.getDynamicPropertiesStore().getUpdateAccountPermissionFee(); } + + private void validatePermissionScheme(Permission permission) throws ContractValidateException { + DynamicPropertiesStore dynamicStore = chainBaseManager.getDynamicPropertiesStore(); + boolean mlDsaAllowed = dynamicStore.allowMlDsa(); + + SignatureScheme first = permission.getKeysList().get(0).getScheme(); + for (Key key : permission.getKeysList()) { + SignatureScheme scheme = key.getScheme(); + if (scheme != first) { + throw new ContractValidateException( + "all keys in a permission must use the same scheme"); + } + if (scheme == SignatureScheme.UNKNOWN_SIG_SCHEME) { + if (!key.getPublicKey().isEmpty()) { + throw new ContractValidateException( + "public_key must be empty when scheme is UNKNOWN_SIG_SCHEME"); + } + } else { + if (!mlDsaAllowed) { + throw new ContractValidateException( + "ML-DSA is not activated, scheme " + scheme + " is not allowed"); + } + int expected = expectedPublicKeyLength(scheme); + if (expected < 0) { + throw new ContractValidateException( + "unsupported signature scheme: " + scheme); + } + if (key.getPublicKey().size() != expected) { + throw new ContractValidateException( + "public_key length for " + scheme + " must be " + expected + " bytes, got " + + key.getPublicKey().size()); + } + } + } + + if (permission.getType() == PermissionType.Witness + && first != SignatureScheme.UNKNOWN_SIG_SCHEME + && first != SignatureScheme.ML_DSA_65) { + throw new ContractValidateException( + "Witness permission only supports ML_DSA_65 or legacy scheme, got " + first); + } + } + + private static int expectedPublicKeyLength(SignatureScheme scheme) { + switch (scheme) { + case ML_DSA_44: + return MLDSA44Verifier.PUBLIC_KEY_LENGTH; + case ML_DSA_65: + return MLDSA65Verifier.PUBLIC_KEY_LENGTH; + default: + return -1; + } + } } 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..383bdd37879 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,17 @@ 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; + } default: break; } @@ -971,7 +982,8 @@ 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 private long code; 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..a783d3b01e9 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,9 @@ 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.SignatureVerifier; +import org.tron.common.crypto.pqc.SignatureVerifierRegistry; import org.tron.common.parameter.CommonParameter; import org.tron.common.utils.ByteArray; import org.tron.common.utils.Sha256Hash; @@ -41,8 +44,12 @@ import org.tron.core.exception.ValidateSignatureException; import org.tron.core.store.AccountStore; import org.tron.core.store.DynamicPropertiesStore; +import org.tron.protos.Protocol.AuthWitness; 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 +180,16 @@ public void sign(byte[] privateKey) { } + public void setWitnessAuth(AuthWitness authWitness) { + BlockHeader blockHeader = this.block.getBlockHeader().toBuilder() + .setWitnessAuth(authWitness).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 +197,101 @@ private Sha256Hash getRawHash() { public boolean validateSignature(DynamicPropertiesStore dynamicPropertiesStore, AccountStore accountStore) throws ValidateSignatureException { + BlockHeader header = block.getBlockHeader(); + boolean hasLegacy = !header.getWitnessSignature().isEmpty(); + AuthWitness witnessAuth = header.getWitnessAuth(); + boolean hasAuth = witnessAuth != null + && witnessAuth.getSignature() != null + && !witnessAuth.getSignature().isEmpty(); + + if (hasLegacy && hasAuth) { + throw new ValidateSignatureException( + "witness_signature and witness_auth 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 + && witnessPermission.getKeys(0).getScheme() == SignatureScheme.ML_DSA_65) { + throw new ValidateSignatureException( + "witness permission requires ML_DSA_65 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, AuthWitness witnessAuth) + throws ValidateSignatureException { + if (!dynamicPropertiesStore.allowMlDsa()) { + throw new ValidateSignatureException( + "witness_auth present but ML-DSA is not activated"); + } + AccountCapsule accountCapsule = accountStore.get(witnessAccountAddress); + Permission witnessPermission = null; + if (accountCapsule != null && accountCapsule.getInstance().hasWitnessPermission()) { + witnessPermission = accountCapsule.getInstance().getWitnessPermission(); + } + if (witnessPermission == null || witnessPermission.getKeysCount() == 0) { + throw new ValidateSignatureException( + "witness_auth present but witness permission is not configured"); + } + SignatureScheme scheme = witnessPermission.getKeys(0).getScheme(); + if (scheme != SignatureScheme.ML_DSA_65) { + throw new ValidateSignatureException( + "witness permission scheme " + scheme + " is not allowed for block signing"); + } + + byte[] signerAddr = witnessAuth.getSignerAddress().toByteArray(); + Key matched = null; + for (Key k : witnessPermission.getKeysList()) { + if (Arrays.equals(k.getAddress().toByteArray(), signerAddr)) { + matched = k; + break; + } + } + if (matched == null) { + throw new ValidateSignatureException( + "witness_auth signer not found in witness permission"); + } + SignatureVerifier verifier = SignatureVerifierRegistry.get(scheme); + byte[] publicKey = matched.getPublicKey().toByteArray(); + byte[] signature = witnessAuth.getSignature().toByteArray(); + byte[] rawHdrHash = getRawHash().getBytes(); + byte[] digest = PqAuthDigest.block(rawHdrHash, signerAddr); + return verifier.verify(publicKey, digest, signature); + } + public BlockId getBlockId() { if (blockId.equals(Sha256Hash.ZERO_HASH)) { blockId = @@ -308,7 +399,12 @@ public long getTimeStamp() { } public boolean hasWitnessSignature() { - return !getInstance().getBlockHeader().getWitnessSignature().isEmpty(); + BlockHeader header = getInstance().getBlockHeader(); + if (!header.getWitnessSignature().isEmpty()) { + return true; + } + AuthWitness auth = header.getWitnessAuth(); + 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..72b6f3b5245 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,11 @@ 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.SignatureVerifier; +import org.tron.common.crypto.pqc.SignatureVerifierRegistry; 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 +68,11 @@ import org.tron.core.exception.ValidateSignatureException; import org.tron.core.store.AccountStore; import org.tron.core.store.DynamicPropertiesStore; +import org.tron.protos.Protocol.AuthWitness; 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 +490,11 @@ public static boolean validateSignature(Transaction transaction, throw new PermissionException("permission isn't exit"); } checkPermission(permissionId, permission, contract); + if (permission.getKeysCount() > 0 + && permission.getKeysList().get(0).getScheme() != SignatureScheme.UNKNOWN_SIG_SCHEME) { + throw new PermissionException( + "permission uses PQ scheme, auth_witness is required"); + } long weight = checkWeight(permission, transaction.getSignatureList(), hash, null); if (weight >= permission.getThreshold()) { return true; @@ -637,12 +648,41 @@ 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.getAuthWitnessCount(); + + if (pqCount > 0 && !dynamicPropertiesStore.allowMlDsa()) { + throw new ValidateSignatureException("auth_witness not allowed: ML-DSA not activated"); + } + if (legacyCount > 0 && pqCount > 0) { + throw new ValidateSignatureException( + "signature and auth_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 +702,83 @@ 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).getScheme() == SignatureScheme.UNKNOWN_SIG_SCHEME) { + throw new PermissionException( + "permission uses legacy scheme, auth_witness is not allowed"); + } + + byte[] txid = computeRawHash(transaction).getBytes(); + List witnesses = transaction.getAuthWitnessList(); + java.util.Set seen = new java.util.HashSet<>(); + long weight = 0L; + for (AuthWitness aw : witnesses) { + ByteString signer = aw.getSignerAddress(); + if (!seen.add(signer)) { + throw new PermissionException("duplicate signer in auth_witness"); + } + Key key = findKeyByAddress(permission, signer); + if (key == null) { + throw new PermissionException("signer is not in permission"); + } + SignatureScheme scheme = key.getScheme(); + if (!SignatureVerifierRegistry.contains(scheme)) { + throw new PermissionException("unsupported scheme: " + scheme); + } + SignatureVerifier verifier = SignatureVerifierRegistry.get(scheme); + byte[] digest = PqAuthDigest.tx(txid, permissionId, signer.toByteArray()); + byte[] pk = key.getPublicKey().toByteArray(); + byte[] sig = aw.getSignature().toByteArray(); + if (pk.length != verifier.getPublicKeyLength() + || sig.length != verifier.getSignatureLength()) { + throw new PermissionException("public key or signature length mismatch"); + } + if (!verifier.verify(pk, digest, sig)) { + throw new PermissionException("pq sig invalid"); + } + weight = StrictMathWrapper.addExact(weight, key.getWeight()); + } + return weight >= permission.getThreshold(); + } + + private static Sha256Hash computeRawHash(Transaction transaction) { + return Sha256Hash.of(CommonParameter.getInstance().isECKeyCryptoEngine(), + transaction.getRawData().toByteArray()); + } + + private static Key findKeyByAddress(Permission permission, ByteString address) { + for (Key k : permission.getKeysList()) { + if (k.getAddress().equals(address)) { + return k; + } + } + return null; + } + /** * 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..a3529fb0c78 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().getAuthWitnessCount() > 0) { + long authWitnessBytes = 0L; + for (org.tron.protos.Protocol.AuthWitness aw + : trx.getInstance().getAuthWitnessList()) { + authWitnessBytes += aw.getSerializedSize(); + } + sigOverhead = authWitnessBytes; + } 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..e713cd51a4c 100644 --- a/chainbase/src/main/java/org/tron/core/store/DynamicPropertiesStore.java +++ b/chainbase/src/main/java/org/tron/core/store/DynamicPropertiesStore.java @@ -240,6 +240,8 @@ 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(); + @Autowired private DynamicPropertiesStore(@Value("properties") String dbName) { super(dbName); @@ -2993,6 +2995,21 @@ 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; + } + 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..047213a2738 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,10 @@ public class CommonParameter { @Setter public long allowTvmOsaka; + @Getter + @Setter + public long allowMlDsa; + 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..aacf31b4005 100644 --- a/common/src/main/java/org/tron/core/Constant.java +++ b/common/src/main/java/org/tron/core/Constant.java @@ -60,6 +60,14 @@ public class Constant { // Crypto engine public static final String ECKey_ENGINE = "ECKey"; + // Post-quantum (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; + public static final String PQ_TX_AUTH_DOMAIN = "TRON_TX_AUTH_V1"; + public static final String PQ_BLOCK_AUTH_DOMAIN = "TRON_BLOCK_AUTH_V1"; + // Network public static final String LOCAL_HOST = "127.0.0.1"; diff --git a/consensus/src/main/java/org/tron/consensus/base/Param.java b/consensus/src/main/java/org/tron/consensus/base/Param.java index f7b7de3d084..060d1627f53 100644 --- a/consensus/src/main/java/org/tron/consensus/base/Param.java +++ b/consensus/src/main/java/org/tron/consensus/base/Param.java @@ -67,6 +67,14 @@ public class Miner { @Setter private ByteString witnessAddress; + @Getter + @Setter + private byte[] pqPrivateKey; + + @Getter + @Setter + private byte[] pqPublicKey; + public Miner(byte[] privateKey, ByteString privateKeyAddress, ByteString witnessAddress) { this.privateKey = privateKey; this.privateKeyAddress = privateKeyAddress; diff --git a/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA44Signer.java b/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA44Signer.java new file mode 100644 index 00000000000..9ea743ed3a1 --- /dev/null +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA44Signer.java @@ -0,0 +1,45 @@ +package org.tron.common.crypto.pqc; + +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; + +public final class MLDSA44Signer { + + public static final int PRIVATE_KEY_LENGTH = 2560; + + private MLDSA44Signer() { + } + + public static byte[] sign(byte[] privateKey, byte[] message) { + if (privateKey == null || privateKey.length != PRIVATE_KEY_LENGTH) { + throw new IllegalArgumentException( + "ML-DSA-44 private key length must be " + PRIVATE_KEY_LENGTH); + } + 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); + } + } + + public static byte[] derivePublicKey(byte[] privateKey) { + if (privateKey == null || privateKey.length != PRIVATE_KEY_LENGTH) { + throw new IllegalArgumentException( + "ML-DSA-44 private key length must be " + PRIVATE_KEY_LENGTH); + } + MLDSAPrivateKeyParameters sk = new MLDSAPrivateKeyParameters( + MLDSAParameters.ml_dsa_44, privateKey); + MLDSAPublicKeyParameters pk = sk.getPublicKeyParameters(); + return pk.getEncoded(); + } +} diff --git a/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA44Verifier.java b/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA44Verifier.java new file mode 100644 index 00000000000..7e46fb1fb30 --- /dev/null +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA44Verifier.java @@ -0,0 +1,46 @@ +package org.tron.common.crypto.pqc; + +import org.bouncycastle.pqc.crypto.mldsa.MLDSAParameters; +import org.bouncycastle.pqc.crypto.mldsa.MLDSAPublicKeyParameters; +import org.bouncycastle.pqc.crypto.mldsa.MLDSASigner; +import org.tron.protos.Protocol.SignatureScheme; + +/** + * FIPS 204 ML-DSA-44 verifier. Consumes raw public key and signature bytes — + * no SubjectPublicKeyInfo, PEM, or Base64 wrapping. + */ +public class MLDSA44Verifier implements SignatureVerifier { + + public static final int PUBLIC_KEY_LENGTH = 1312; + public static final int SIGNATURE_LENGTH = 2420; + + @Override + public SignatureScheme getScheme() { + return SignatureScheme.ML_DSA_44; + } + + @Override + public int getPublicKeyLength() { + return PUBLIC_KEY_LENGTH; + } + + @Override + public int getSignatureLength() { + return SIGNATURE_LENGTH; + } + + @Override + public boolean verify(byte[] publicKey, byte[] message, byte[] signature) { + validatePublicKey(publicKey); + validateSignature(signature); + if (message == null) { + throw new IllegalArgumentException("message must not be null"); + } + MLDSAPublicKeyParameters pk = new MLDSAPublicKeyParameters( + MLDSAParameters.ml_dsa_44, publicKey); + MLDSASigner signer = new MLDSASigner(); + signer.init(false, pk); + signer.update(message, 0, message.length); + return signer.verifySignature(signature); + } +} diff --git a/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA65Signer.java b/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA65Signer.java new file mode 100644 index 00000000000..1365243ad8b --- /dev/null +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA65Signer.java @@ -0,0 +1,45 @@ +package org.tron.common.crypto.pqc; + +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; + +public final class MLDSA65Signer { + + public static final int PRIVATE_KEY_LENGTH = 4032; + + private MLDSA65Signer() { + } + + public static byte[] sign(byte[] privateKey, byte[] message) { + if (privateKey == null || privateKey.length != PRIVATE_KEY_LENGTH) { + throw new IllegalArgumentException( + "ML-DSA-65 private key length must be " + PRIVATE_KEY_LENGTH); + } + 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); + } + } + + public static byte[] derivePublicKey(byte[] privateKey) { + if (privateKey == null || privateKey.length != PRIVATE_KEY_LENGTH) { + throw new IllegalArgumentException( + "ML-DSA-65 private key length must be " + PRIVATE_KEY_LENGTH); + } + MLDSAPrivateKeyParameters sk = new MLDSAPrivateKeyParameters( + MLDSAParameters.ml_dsa_65, privateKey); + MLDSAPublicKeyParameters pk = sk.getPublicKeyParameters(); + return pk.getEncoded(); + } +} diff --git a/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA65Verifier.java b/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA65Verifier.java new file mode 100644 index 00000000000..816bfd367a8 --- /dev/null +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA65Verifier.java @@ -0,0 +1,46 @@ +package org.tron.common.crypto.pqc; + +import org.bouncycastle.pqc.crypto.mldsa.MLDSAParameters; +import org.bouncycastle.pqc.crypto.mldsa.MLDSAPublicKeyParameters; +import org.bouncycastle.pqc.crypto.mldsa.MLDSASigner; +import org.tron.protos.Protocol.SignatureScheme; + +/** + * FIPS 204 ML-DSA-65 verifier. Consumes raw public key and signature bytes — + * no SubjectPublicKeyInfo, PEM, or Base64 wrapping. + */ +public class MLDSA65Verifier implements SignatureVerifier { + + public static final int PUBLIC_KEY_LENGTH = 1952; + public static final int SIGNATURE_LENGTH = 3309; + + @Override + public SignatureScheme getScheme() { + return SignatureScheme.ML_DSA_65; + } + + @Override + public int getPublicKeyLength() { + return PUBLIC_KEY_LENGTH; + } + + @Override + public int getSignatureLength() { + return SIGNATURE_LENGTH; + } + + @Override + public boolean verify(byte[] publicKey, byte[] message, byte[] signature) { + validatePublicKey(publicKey); + validateSignature(signature); + if (message == null) { + throw new IllegalArgumentException("message must not be null"); + } + MLDSAPublicKeyParameters pk = new MLDSAPublicKeyParameters( + MLDSAParameters.ml_dsa_65, publicKey); + MLDSASigner signer = new MLDSASigner(); + signer.init(false, pk); + signer.update(message, 0, message.length); + return signer.verifySignature(signature); + } +} 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..32ab9a8659e --- /dev/null +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/PqAuthDigest.java @@ -0,0 +1,71 @@ +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 || signer_address)
+ */ + public static byte[] tx(byte[] txid, int permissionId, byte[] signerAddress) { + requireNonNull(txid, "txid"); + requireNonNull(signerAddress, "signerAddress"); + MessageDigest md = Sha256Hash.newDigest(); + md.update(TX_DOMAIN_BYTES); + md.update(txid); + md.update(intToBe4(permissionId)); + md.update(signerAddress); + return md.digest(); + } + + /** + * Block-level PQ authentication digest. + * + *
digest = SHA-256("TRON_BLOCK_AUTH_V1" || block_header_raw_hash || witness_address)
+ */ + public static byte[] block(byte[] blockHeaderRawHash, byte[] witnessAddress) { + requireNonNull(blockHeaderRawHash, "blockHeaderRawHash"); + requireNonNull(witnessAddress, "witnessAddress"); + MessageDigest md = Sha256Hash.newDigest(); + md.update(BLOCK_DOMAIN_BYTES); + md.update(blockHeaderRawHash); + md.update(witnessAddress); + 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) + }; + } + + private 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/SignatureVerifier.java b/crypto/src/main/java/org/tron/common/crypto/pqc/SignatureVerifier.java new file mode 100644 index 00000000000..a2f49b2f5b0 --- /dev/null +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/SignatureVerifier.java @@ -0,0 +1,45 @@ +package org.tron.common.crypto.pqc; + +import org.tron.protos.Protocol.SignatureScheme; + +/** + * Stateless verifier for a single post-quantum signature scheme. Independent of + * the legacy {@code SignInterface} because PQ flows do not expose public-key + * recovery and have no notion of node-id / private-key handling in verification + * paths. + */ +public interface SignatureVerifier { + + SignatureScheme getScheme(); + + int getPublicKeyLength(); + + int getSignatureLength(); + + /** + * Verify a raw-byte signature against a raw-byte public key over {@code message}. + * The caller SHALL pre-validate lengths via {@link #validatePublicKey(byte[])} and + * {@link #validateSignature(byte[])}; implementations still defensively re-check. + * + * @return true iff the signature is cryptographically valid for the given inputs + */ + boolean verify(byte[] publicKey, byte[] message, byte[] signature); + + 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 void validateSignature(byte[] signature) { + if (signature == null || signature.length != getSignatureLength()) { + throw new IllegalArgumentException( + "invalid " + getScheme() + " signature length: " + + (signature == null ? "null" : signature.length) + + ", expected " + getSignatureLength()); + } + } +} diff --git a/crypto/src/main/java/org/tron/common/crypto/pqc/SignatureVerifierRegistry.java b/crypto/src/main/java/org/tron/common/crypto/pqc/SignatureVerifierRegistry.java new file mode 100644 index 00000000000..3b64cd637cd --- /dev/null +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/SignatureVerifierRegistry.java @@ -0,0 +1,39 @@ +package org.tron.common.crypto.pqc; + +import java.util.Collections; +import java.util.EnumMap; +import java.util.Map; +import org.tron.protos.Protocol.SignatureScheme; + +/** + * Static registry of post-quantum {@link SignatureVerifier} instances keyed by + * {@link SignatureScheme}. Legacy schemes (ECDSA secp256k1, SM2/SM3) are NOT + * registered — they flow through the existing {@code SignInterface} path. + */ +public final class SignatureVerifierRegistry { + + private static final Map VERIFIERS; + + static { + EnumMap m = new EnumMap<>(SignatureScheme.class); + m.put(SignatureScheme.ML_DSA_44, new MLDSA44Verifier()); + m.put(SignatureScheme.ML_DSA_65, new MLDSA65Verifier()); + VERIFIERS = Collections.unmodifiableMap(m); + } + + private SignatureVerifierRegistry() { + } + + public static SignatureVerifier get(SignatureScheme scheme) { + SignatureVerifier v = VERIFIERS.get(scheme); + if (v == null) { + throw new IllegalArgumentException( + "no SignatureVerifier registered for scheme: " + scheme); + } + return v; + } + + public static boolean contains(SignatureScheme scheme) { + return VERIFIERS.containsKey(scheme); + } +} diff --git a/crypto/src/test/java/org/tron/common/crypto/pqc/MLDSA44VerifierTest.java b/crypto/src/test/java/org/tron/common/crypto/pqc/MLDSA44VerifierTest.java new file mode 100644 index 00000000000..2f59ed8855d --- /dev/null +++ b/crypto/src/test/java/org/tron/common/crypto/pqc/MLDSA44VerifierTest.java @@ -0,0 +1,134 @@ +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 MLDSA44VerifierTest { + + private MLDSA44Verifier verifier; + private MLDSAPublicKeyParameters pk; + private MLDSAPrivateKeyParameters sk; + + @Before + public void setUp() { + verifier = new MLDSA44Verifier(); + 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(); + } + + private byte[] sign(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, verifier.getScheme()); + assertEquals(1312, verifier.getPublicKeyLength()); + assertEquals(2420, verifier.getSignatureLength()); + assertArrayEquals(pk.getEncoded(), pk.getEncoded()); + assertEquals(1312, pk.getEncoded().length); + } + + @Test + public void validSignatureVerifies() { + byte[] msg = "tron-pq-mldsa44".getBytes(); + byte[] sig = sign(msg); + assertEquals(2420, sig.length); + assertTrue(verifier.verify(pk.getEncoded(), msg, sig)); + } + + @Test + public void signatureBoundToMessage() { + byte[] msg = "hello".getBytes(); + byte[] sig = sign(msg); + byte[] tamperedMsg = "hellp".getBytes(); + assertFalse(verifier.verify(pk.getEncoded(), tamperedMsg, sig)); + } + + @Test + public void tamperedSignatureFailsVerification() { + byte[] msg = "payload".getBytes(); + byte[] sig = sign(msg); + sig[0] ^= 0x01; + assertFalse(verifier.verify(pk.getEncoded(), msg, sig)); + } + + @Test + public void wrongPublicKeyFailsVerification() { + byte[] msg = "payload".getBytes(); + byte[] sig = sign(msg); + MLDSAKeyPairGenerator gen = new MLDSAKeyPairGenerator(); + gen.init(new MLDSAKeyGenerationParameters(new SecureRandom(), MLDSAParameters.ml_dsa_44)); + MLDSAPublicKeyParameters otherPk = + (MLDSAPublicKeyParameters) gen.generateKeyPair().getPublic(); + assertFalse(verifier.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 { + verifier.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 { + verifier.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 { + verifier.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 = sign(msg); + assertTrue(verifier.verify(pk.getEncoded(), msg, sig)); + } +} diff --git a/crypto/src/test/java/org/tron/common/crypto/pqc/MLDSA65VerifierTest.java b/crypto/src/test/java/org/tron/common/crypto/pqc/MLDSA65VerifierTest.java new file mode 100644 index 00000000000..c6ee8077df5 --- /dev/null +++ b/crypto/src/test/java/org/tron/common/crypto/pqc/MLDSA65VerifierTest.java @@ -0,0 +1,149 @@ +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 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 MLDSA65VerifierTest { + + private MLDSA65Verifier verifier; + private MLDSAPublicKeyParameters pk; + private MLDSAPrivateKeyParameters sk; + + @Before + public void setUp() { + verifier = new MLDSA65Verifier(); + 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(); + } + + private byte[] sign(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, verifier.getScheme()); + assertEquals(1952, verifier.getPublicKeyLength()); + assertEquals(3309, verifier.getSignatureLength()); + assertEquals(1952, pk.getEncoded().length); + } + + @Test + public void validSignatureVerifies() { + byte[] msg = "tron-pq-mldsa65".getBytes(); + byte[] sig = sign(msg); + assertEquals(3309, sig.length); + assertTrue(verifier.verify(pk.getEncoded(), msg, sig)); + } + + @Test + public void signatureBoundToMessage() { + byte[] msg = "block-header".getBytes(); + byte[] sig = sign(msg); + byte[] tamperedMsg = "block-footer".getBytes(); + assertFalse(verifier.verify(pk.getEncoded(), tamperedMsg, sig)); + } + + @Test + public void tamperedSignatureFailsVerification() { + byte[] msg = "payload".getBytes(); + byte[] sig = sign(msg); + sig[sig.length - 1] ^= 0x01; + assertFalse(verifier.verify(pk.getEncoded(), msg, sig)); + } + + @Test + public void wrongPublicKeyFailsVerification() { + byte[] msg = "payload".getBytes(); + byte[] sig = sign(msg); + MLDSAKeyPairGenerator gen = new MLDSAKeyPairGenerator(); + gen.init(new MLDSAKeyGenerationParameters(new SecureRandom(), MLDSAParameters.ml_dsa_65)); + MLDSAPublicKeyParameters otherPk = + (MLDSAPublicKeyParameters) gen.generateKeyPair().getPublic(); + assertFalse(verifier.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 { + verifier.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 { + verifier.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 { + verifier.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 = sign(msg); + assertTrue(verifier.verify(pk.getEncoded(), 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 { + verifier.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")); + } + } +} diff --git a/crypto/src/test/java/org/tron/common/crypto/pqc/PqAuthDigestTest.java b/crypto/src/test/java/org/tron/common/crypto/pqc/PqAuthDigestTest.java new file mode 100644 index 00000000000..c8ad1cd8c36 --- /dev/null +++ b/crypto/src/test/java/org/tron/common/crypto/pqc/PqAuthDigestTest.java @@ -0,0 +1,133 @@ +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[] bytes(int... values) { + byte[] out = new byte[values.length]; + for (int i = 0; i < values.length; i++) { + out[i] = (byte) values[i]; + } + return out; + } + + @Test + public void txDigestEqualsExpectedSha256() throws Exception { + byte[] txid = bytes(0x11, 0x22, 0x33, 0x44); + int permissionId = 2; + byte[] signer = bytes(0xaa, 0xbb, 0xcc); + + MessageDigest md = MessageDigest.getInstance("SHA-256"); + md.update("TRON_TX_AUTH_V1".getBytes(StandardCharsets.UTF_8)); + md.update(txid); + md.update(bytes(0, 0, 0, 2)); + md.update(signer); + byte[] expected = md.digest(); + + byte[] actual = PqAuthDigest.tx(txid, permissionId, signer); + 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; + } + byte[] witness = bytes(0x41, 0x42, 0x43); + + MessageDigest md = MessageDigest.getInstance("SHA-256"); + md.update("TRON_BLOCK_AUTH_V1".getBytes(StandardCharsets.UTF_8)); + md.update(hdrHash); + md.update(witness); + byte[] expected = md.digest(); + + byte[] actual = PqAuthDigest.block(hdrHash, witness); + assertArrayEquals(expected, actual); + assertEquals(32, actual.length); + } + + @Test + public void txAndBlockDigestsDifferForSameContext() { + byte[] shared = new byte[32]; + byte[] addr = new byte[] {1, 2, 3, 4, 5}; + byte[] txDigest = PqAuthDigest.tx(shared, 0, addr); + byte[] blockDigest = PqAuthDigest.block(shared, addr); + assertFalse("tx and block digests must not collide for shared inputs", + java.util.Arrays.equals(txDigest, blockDigest)); + } + + @Test + public void differentSignersProduceDifferentTxDigest() { + byte[] txid = new byte[32]; + byte[] a = new byte[] {0x10}; + byte[] b = new byte[] {0x20}; + assertNotEquals( + new String(PqAuthDigest.tx(txid, 0, a)), + new String(PqAuthDigest.tx(txid, 0, b))); + } + + @Test + public void differentPermissionIdsProduceDifferentDigest() { + byte[] txid = new byte[32]; + byte[] addr = new byte[] {1}; + byte[] d0 = PqAuthDigest.tx(txid, 0, addr); + byte[] d1 = PqAuthDigest.tx(txid, 1, addr); + assertFalse(java.util.Arrays.equals(d0, d1)); + } + + @Test + public void differentWitnessesProduceDifferentBlockDigest() { + byte[] hdr = new byte[32]; + byte[] w1 = new byte[] {1}; + byte[] w2 = new byte[] {2}; + byte[] d1 = PqAuthDigest.block(hdr, w1); + byte[] d2 = PqAuthDigest.block(hdr, w2); + assertFalse(java.util.Arrays.equals(d1, d2)); + } + + @Test + public void domainPrefixesAreExact() { + assertEquals("TRON_TX_AUTH_V1", PqAuthDigest.TX_DOMAIN); + assertEquals("TRON_BLOCK_AUTH_V1", PqAuthDigest.BLOCK_DOMAIN); + } + + @Test + public void nullInputsRejected() { + try { + PqAuthDigest.tx(null, 0, new byte[1]); + fail("null txid should be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("txid")); + } + try { + PqAuthDigest.tx(new byte[1], 0, null); + fail("null signer should be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("signerAddress")); + } + try { + PqAuthDigest.block(null, new byte[1]); + fail("null hdr should be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("blockHeaderRawHash")); + } + try { + PqAuthDigest.block(new byte[1], null); + fail("null witness should be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("witnessAddress")); + } + } +} diff --git a/crypto/src/test/java/org/tron/common/crypto/pqc/SignatureVerifierRegistryTest.java b/crypto/src/test/java/org/tron/common/crypto/pqc/SignatureVerifierRegistryTest.java new file mode 100644 index 00000000000..769fe55ca1e --- /dev/null +++ b/crypto/src/test/java/org/tron/common/crypto/pqc/SignatureVerifierRegistryTest.java @@ -0,0 +1,62 @@ +package org.tron.common.crypto.pqc; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import org.junit.Test; +import org.tron.protos.Protocol.SignatureScheme; + +public class SignatureVerifierRegistryTest { + + @Test + public void mlDsa44Registered() { + SignatureVerifier v = SignatureVerifierRegistry.get(SignatureScheme.ML_DSA_44); + assertNotNull(v); + assertSame(SignatureScheme.ML_DSA_44, v.getScheme()); + assertTrue(SignatureVerifierRegistry.contains(SignatureScheme.ML_DSA_44)); + } + + @Test + public void mlDsa65Registered() { + SignatureVerifier v = SignatureVerifierRegistry.get(SignatureScheme.ML_DSA_65); + assertNotNull(v); + assertSame(SignatureScheme.ML_DSA_65, v.getScheme()); + assertTrue(SignatureVerifierRegistry.contains(SignatureScheme.ML_DSA_65)); + } + + @Test + public void ecdsaNotRegistered() { + assertFalse(SignatureVerifierRegistry.contains(SignatureScheme.ECDSA_SECP256K1)); + try { + SignatureVerifierRegistry.get(SignatureScheme.ECDSA_SECP256K1); + fail("expected IllegalArgumentException for ECDSA_SECP256K1"); + } catch (IllegalArgumentException e) { + assertTrue(e.getMessage().contains("ECDSA_SECP256K1")); + } + } + + @Test + public void sm2NotRegistered() { + assertFalse(SignatureVerifierRegistry.contains(SignatureScheme.SM2_SM3)); + try { + SignatureVerifierRegistry.get(SignatureScheme.SM2_SM3); + fail("expected IllegalArgumentException for SM2_SM3"); + } catch (IllegalArgumentException e) { + assertTrue(e.getMessage().contains("SM2_SM3")); + } + } + + @Test + public void unknownSchemeRejected() { + assertFalse(SignatureVerifierRegistry.contains(SignatureScheme.UNKNOWN_SIG_SCHEME)); + try { + SignatureVerifierRegistry.get(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/main/java/org/tron/core/consensus/ProposalService.java b/framework/src/main/java/org/tron/core/consensus/ProposalService.java index 1bec0c2bda3..0d5f63f1bed 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,10 @@ public static boolean process(Manager manager, ProposalCapsule proposalCapsule) manager.getDynamicPropertiesStore().saveAllowTvmOsaka(entry.getValue()); break; } + case ALLOW_ML_DSA: { + manager.getDynamicPropertiesStore().saveAllowMlDsa(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..72e5b048c94 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.MLDSA65Signer; +import org.tron.common.crypto.pqc.PqAuthDigest; import org.tron.common.es.ExecutorServiceManager; import org.tron.common.exit.ExitManager; import org.tron.common.logsfilter.EventPluginLoader; @@ -168,7 +170,9 @@ import org.tron.core.utils.TransactionRegister; import org.tron.protos.Protocol; import org.tron.protos.Protocol.AccountType; +import org.tron.protos.Protocol.AuthWitness; 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 +1742,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 +1758,50 @@ public BlockCapsule generateBlock(Miner miner, long blockTime, long timeout) { return capsule; } + private void signBlockCapsule(BlockCapsule blockCapsule, Miner miner) { + SignatureScheme scheme = resolveWitnessScheme(miner); + if (scheme == SignatureScheme.ML_DSA_65) { + signWitnessAuth(blockCapsule, miner); + } else { + blockCapsule.sign(miner.getPrivateKey()); + } + } + + private SignatureScheme resolveWitnessScheme(Miner miner) { + if (!chainBaseManager.getDynamicPropertiesStore().allowMlDsa()) { + 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; + } + return witnessPermission.getKeys(0).getScheme(); + } + + private void signWitnessAuth(BlockCapsule blockCapsule, Miner miner) { + 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 ML_DSA_65 but local PQ private key is not configured"); + } + byte[] signerAddress = witnessPermission.getKeys(0).getAddress().toByteArray(); + byte[] digest = PqAuthDigest.block(blockCapsule.getRawHashBytes(), signerAddress); + byte[] signature = MLDSA65Signer.sign(pqPrivateKey, digest); + AuthWitness witnessAuth = AuthWitness.newBuilder() + .setSignerAddress(ByteString.copyFrom(signerAddress)) + .setSignature(ByteString.copyFrom(signature)) + .build(); + blockCapsule.setWitnessAuth(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..4a709e87e93 100644 --- a/framework/src/main/resources/config.conf +++ b/framework/src/main/resources/config.conf @@ -673,6 +673,17 @@ localwitness = [ # "localwitnesskeystore.json" # ] +# Post-quantum (ML-DSA-65) witness signing key. Required once the on-chain witness +# Permission has been upgraded to scheme = ML_DSA_65 and ALLOW_ML_DSA = 1 is active. +# Each entry is the 4032-byte FIPS 204 ML-DSA-65 private key encoded as a hex string +# (no SubjectPublicKeyInfo / PEM / Base64 wrapping). The node uses this key to +# produce the BlockHeader.witness_auth field during Dual-Sign block production. +# If the witness Permission is still legacy (scheme = UNKNOWN_SIG_SCHEME), leave this +# empty; the node will use `localwitness` / `localwitnesskeystore` as before. +# localwitness_pq = [ +# "0123abcd...<8064-hex-char ML-DSA-65 private key>" +# ] + block = { needSyncCheck = true maintenanceTimeInterval = 21600000 // 6 hours: 21600000(ms) diff --git a/framework/src/test/java/org/tron/core/BandwidthProcessorTest.java b/framework/src/test/java/org/tron/core/BandwidthProcessorTest.java index cf652af3650..08b061a1ea5 100755 --- a/framework/src/test/java/org/tron/core/BandwidthProcessorTest.java +++ b/framework/src/test/java/org/tron/core/BandwidthProcessorTest.java @@ -881,4 +881,122 @@ public void testCalculateGlobalNetLimit() { .calculateGlobalNetLimitV2(accountCapsule.getAllFrozenBalanceForBandwidth()); Assert.assertTrue(netLimitV2 > 0); } + + @Test + public void pqAuthWitnessBytesSubtractedInCreateAccountCap() 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[] signerAddr = ByteArray.fromHexString(OWNER_ADDRESS); + byte[] fakeSig = new byte[3309]; + Protocol.AuthWitness authWitness = Protocol.AuthWitness.newBuilder() + .setSignerAddress(ByteString.copyFrom(signerAddr)) + .setSignature(ByteString.copyFrom(fakeSig)) + .build(); + + TransactionCapsule baseTrx = new TransactionCapsule(contract, + chainBaseManager.getAccountStore()); + Transaction withAuth = baseTrx.getInstance().toBuilder() + .addAuthWitness(authWitness) + .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 auth_witness", + rawSize > cap); + + BandwidthProcessor processor = new BandwidthProcessor(chainBaseManager); + try { + processor.consume(trx, trace); + } catch (TooBigTransactionException e) { + Assert.fail("PQ auth_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 pqAuthWitnessCountedInBandwidthUsage() 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[] signerAddr = ByteArray.fromHexString(OWNER_ADDRESS); + byte[] fakeSig = new byte[3309]; + Protocol.AuthWitness authWitness = Protocol.AuthWitness.newBuilder() + .setSignerAddress(ByteString.copyFrom(signerAddr)) + .setSignature(ByteString.copyFrom(fakeSig)) + .build(); + + TransactionCapsule baseTrx = new TransactionCapsule(contract, + chainBaseManager.getAccountStore()); + Transaction withAuth = baseTrx.getInstance().toBuilder() + .addAuthWitness(authWitness) + .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..e2222644aad 100644 --- a/framework/src/test/java/org/tron/core/actuator/AccountPermissionUpdateActuatorTest.java +++ b/framework/src/test/java/org/tron/core/actuator/AccountPermissionUpdateActuatorTest.java @@ -24,6 +24,7 @@ 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.AccountCreateContract; @@ -1019,4 +1020,266 @@ 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) + .setScheme(scheme) + .setPublicKey(ByteString.copyFrom(fixedBytes(pkLen, seed))) + .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) + .setPublicKey(ByteString.copyFrom(new byte[] {1, 2, 3})) + .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 witnessMlDsa44Rejected() { + 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)); + + try { + actuatorFor(any).validate(); + fail("Witness permission with ML-DSA-44 should be rejected"); + } catch (ContractValidateException e) { + Assert.assertTrue(e.getMessage().contains("Witness permission only supports ML_DSA_65")); + } + } + + @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) + .setScheme(SignatureScheme.ML_DSA_44) + .setPublicKey(ByteString.copyFrom(sharedPk)) + .build(); + Key k2 = Key.newBuilder() + .setAddress(ByteString.copyFrom(ByteArray.fromHexString(KEY_ADDRESS1))) + .setWeight(KEY_WEIGHT) + .setScheme(SignatureScheme.ML_DSA_44) + .setPublicKey(ByteString.copyFrom(sharedPk)) + .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()); + } } \ No newline at end of file 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..839b77b1efe --- /dev/null +++ b/framework/src/test/java/org/tron/core/capsule/BlockCapsulePqTest.java @@ -0,0 +1,260 @@ +package org.tron.core.capsule; + +import com.google.protobuf.ByteString; +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.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.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.AuthWitness; +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; + +public class BlockCapsulePqTest extends BaseTest { + + private ECKey witnessKey; + private byte[] witnessAddress; + private MLDSAPrivateKeyParameters pqSk; + private MLDSAPublicKeyParameters pqPk; + + @BeforeClass + public static void init() { + Args.setParam(new String[] {"-d", dbPath()}, TestConstants.TEST_CONF); + } + + @Before + public void setUp() { + witnessKey = new ECKey(); + witnessAddress = witnessKey.getAddress(); + MLDSAKeyPairGenerator gen = new MLDSAKeyPairGenerator(); + gen.init(new MLDSAKeyGenerationParameters(new SecureRandom(), MLDSAParameters.ml_dsa_65)); + AsymmetricCipherKeyPair kp = gen.generateKeyPair(); + pqPk = (MLDSAPublicKeyParameters) kp.getPublic(); + pqSk = (MLDSAPrivateKeyParameters) kp.getPrivate(); + } + + private AccountCapsule buildWitnessAccount(SignatureScheme scheme) { + Key.Builder kb = Key.newBuilder() + .setAddress(ByteString.copyFrom(witnessAddress)) + .setWeight(1) + .setScheme(scheme); + if (scheme == SignatureScheme.ML_DSA_65) { + kb.setPublicKey(ByteString.copyFrom(pqPk.getEncoded())); + } + 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) { + MLDSASigner signer = new MLDSASigner(); + signer.init(true, pqSk); + signer.update(message, 0, message.length); + try { + return signer.generateSignature(); + } catch (Exception e) { + throw new AssertionError(e); + } + } + + @Test + public void legacyValidateWithoutAuthWitnessAcceptedBeforeActivation() 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 authWitnessBeforeActivationRejected() 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(), witnessAddress); + block.setWitnessAuth(AuthWitness.newBuilder() + .setSignerAddress(ByteString.copyFrom(witnessAddress)) + .setSignature(ByteString.copyFrom(signPq(digest))) + .build()); + block.validateSignature( + dbManager.getDynamicPropertiesStore(), dbManager.getAccountStore()); + } + + @Test(expected = ValidateSignatureException.class) + public void bothLegacyAndAuthWitnessRejected() 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(), witnessAddress); + block.setWitnessAuth(AuthWitness.newBuilder() + .setSignerAddress(ByteString.copyFrom(witnessAddress)) + .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 legacySchemeWithAuthWitnessOnlyRejected() 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(), witnessAddress); + block.setWitnessAuth(AuthWitness.newBuilder() + .setSignerAddress(ByteString.copyFrom(witnessAddress)) + .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(), witnessAddress); + block.setWitnessAuth(AuthWitness.newBuilder() + .setSignerAddress(ByteString.copyFrom(witnessAddress)) + .setSignature(ByteString.copyFrom(signPq(digest))) + .build()); + Assert.assertTrue(block.validateSignature( + dbManager.getDynamicPropertiesStore(), dbManager.getAccountStore())); + } + + @Test + public void tamperedAuthWitnessFails() 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(), witnessAddress); + byte[] pqSig = signPq(digest); + pqSig[pqSig.length - 1] ^= 0x01; + block.setWitnessAuth(AuthWitness.newBuilder() + .setSignerAddress(ByteString.copyFrom(witnessAddress)) + .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[] otherAddr = ByteArray.fromHexString( + "41" + "abababababababababababababababababababab"); + byte[] digest = PqAuthDigest.block(block.getRawHashBytes(), otherAddr); + block.setWitnessAuth(AuthWitness.newBuilder() + .setSignerAddress(ByteString.copyFrom(otherAddr)) + .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..0e4c67aad45 100644 --- a/framework/src/test/java/org/tron/core/capsule/TransactionCapsuleTest.java +++ b/framework/src/test/java/org/tron/core/capsule/TransactionCapsuleTest.java @@ -4,23 +4,42 @@ 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 java.security.SecureRandom; import lombok.extern.slf4j.Slf4j; +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.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.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.AuthWitness; +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; import org.tron.protos.Protocol.Transaction.Result.contractResult; import org.tron.protos.Protocol.Transaction.raw; +import org.tron.protos.contract.BalanceContract.TransferContract; @Slf4j public class TransactionCapsuleTest extends BaseTest { @@ -69,4 +88,215 @@ public void testRemoveRedundantRet() { Assert.assertEquals(1, transactionCapsule.getInstance().getRetCount()); Assert.assertEquals(SUCCESS, transactionCapsule.getInstance().getRet(0).getContractRet()); } + + // --------------------- ML-DSA auth_witness verification --------------------- + + private static final String PQ_OWNER_HEX = + "41abd4b9367799eaa3197fecb144eb71de1e049abc"; + private static final String PQ_SIGNER_HEX = + "41548794500882809695a8a687866e76d4271a1abc"; + + private AsymmetricCipherKeyPair newMlDsa65KeyPair() { + MLDSAKeyPairGenerator gen = new MLDSAKeyPairGenerator(); + gen.init(new MLDSAKeyGenerationParameters(new SecureRandom(), MLDSAParameters.ml_dsa_65)); + return gen.generateKeyPair(); + } + + private byte[] sign(MLDSAPrivateKeyParameters sk, byte[] msg) throws Exception { + MLDSASigner s = new MLDSASigner(); + s.init(true, sk); + s.update(msg, 0, msg.length); + return s.generateSignature(); + } + + 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 putAccountWithMlDsa65Permission(String ownerHex, byte[] pqPublicKey) { + byte[] addr = ByteArray.fromHexString(ownerHex); + byte[] signerAddr = ByteArray.fromHexString(PQ_SIGNER_HEX); + Key pqKey = Key.newBuilder() + .setAddress(ByteString.copyFrom(signerAddr)) + .setWeight(1L) + .setScheme(SignatureScheme.ML_DSA_65) + .setPublicKey(ByteString.copyFrom(pqPublicKey)) + .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 authWitnessBeforeActivationRejected() { + dbManager.getDynamicPropertiesStore().saveAllowMlDsa(0L); + Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0).toBuilder() + .addAuthWitness(AuthWitness.newBuilder() + .setSignerAddress(ByteString.copyFrom(ByteArray.fromHexString(PQ_SIGNER_HEX))) + .setSignature(ByteString.copyFrom(new byte[3309])) + .build()) + .build(); + TransactionCapsule cap = new TransactionCapsule(tx); + try { + cap.validatePubSignature(dbManager.getAccountStore(), + dbManager.getDynamicPropertiesStore()); + Assert.fail("should reject auth_witness before activation"); + } catch (ValidateSignatureException e) { + Assert.assertTrue(e.getMessage().contains("ML-DSA not activated")); + } + } + + @Test + public void signatureAndAuthWitnessAreMutuallyExclusive() { + dbManager.getDynamicPropertiesStore().saveAllowMlDsa(1L); + Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0).toBuilder() + .addSignature(ByteString.copyFrom(new byte[65])) + .addAuthWitness(AuthWitness.newBuilder() + .setSignerAddress(ByteString.copyFrom(ByteArray.fromHexString(PQ_SIGNER_HEX))) + .setSignature(ByteString.copyFrom(new byte[3309])) + .build()) + .build(); + TransactionCapsule cap = new TransactionCapsule(tx); + try { + cap.validatePubSignature(dbManager.getAccountStore(), + dbManager.getDynamicPropertiesStore()); + Assert.fail("should reject when both signature and auth_witness present"); + } catch (ValidateSignatureException e) { + Assert.assertTrue(e.getMessage().contains("mutually exclusive")); + } + } + + @Test + public void validAuthWitnessAccepted() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowMlDsa(1L); + AsymmetricCipherKeyPair kp = newMlDsa65KeyPair(); + byte[] pkBytes = ((MLDSAPublicKeyParameters) kp.getPublic()).getEncoded(); + MLDSAPrivateKeyParameters sk = (MLDSAPrivateKeyParameters) kp.getPrivate(); + putAccountWithMlDsa65Permission(PQ_OWNER_HEX, pkBytes); + + Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0); + byte[] txid = Sha256Hash.of(true, tx.getRawData().toByteArray()).getBytes(); + byte[] signerAddr = ByteArray.fromHexString(PQ_SIGNER_HEX); + byte[] digest = PqAuthDigest.tx(txid, 0, signerAddr); + byte[] sig = sign(sk, digest); + + Transaction signed = tx.toBuilder() + .addAuthWitness(AuthWitness.newBuilder() + .setSignerAddress(ByteString.copyFrom(signerAddr)) + .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); + AsymmetricCipherKeyPair kp = newMlDsa65KeyPair(); + byte[] pkBytes = ((MLDSAPublicKeyParameters) kp.getPublic()).getEncoded(); + MLDSAPrivateKeyParameters sk = (MLDSAPrivateKeyParameters) kp.getPrivate(); + putAccountWithMlDsa65Permission(PQ_OWNER_HEX, pkBytes); + + Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0); + byte[] txid = Sha256Hash.of(true, tx.getRawData().toByteArray()).getBytes(); + byte[] signerAddr = ByteArray.fromHexString(PQ_SIGNER_HEX); + byte[] digest = PqAuthDigest.tx(txid, 0, signerAddr); + byte[] sig = sign(sk, digest); + AuthWitness aw = AuthWitness.newBuilder() + .setSignerAddress(ByteString.copyFrom(signerAddr)) + .setSignature(ByteString.copyFrom(sig)) + .build(); + Transaction signed = tx.toBuilder().addAuthWitness(aw).addAuthWitness(aw).build(); + + TransactionCapsule cap = new TransactionCapsule(signed); + try { + cap.validatePubSignature(dbManager.getAccountStore(), + dbManager.getDynamicPropertiesStore()); + Assert.fail("duplicate signer should be rejected"); + } catch (ValidateSignatureException e) { + Assert.assertTrue(e.getMessage().contains("duplicate signer")); + } + } + + @Test + public void tamperedAuthWitnessRejected() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowMlDsa(1L); + AsymmetricCipherKeyPair kp = newMlDsa65KeyPair(); + byte[] pkBytes = ((MLDSAPublicKeyParameters) kp.getPublic()).getEncoded(); + MLDSAPrivateKeyParameters sk = (MLDSAPrivateKeyParameters) kp.getPrivate(); + putAccountWithMlDsa65Permission(PQ_OWNER_HEX, pkBytes); + + Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0); + byte[] txid = Sha256Hash.of(true, tx.getRawData().toByteArray()).getBytes(); + byte[] signerAddr = ByteArray.fromHexString(PQ_SIGNER_HEX); + byte[] digest = PqAuthDigest.tx(txid, 0, signerAddr); + byte[] sig = sign(sk, digest); + sig[0] ^= 0x01; + + Transaction signed = tx.toBuilder() + .addAuthWitness(AuthWitness.newBuilder() + .setSignerAddress(ByteString.copyFrom(signerAddr)) + .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); + AsymmetricCipherKeyPair kp = newMlDsa65KeyPair(); + byte[] pkBytes = ((MLDSAPublicKeyParameters) kp.getPublic()).getEncoded(); + MLDSAPrivateKeyParameters sk = (MLDSAPrivateKeyParameters) kp.getPrivate(); + putAccountWithMlDsa65Permission(PQ_OWNER_HEX, pkBytes); + + Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0); + byte[] txid = Sha256Hash.of(true, tx.getRawData().toByteArray()).getBytes(); + byte[] otherSigner = ByteArray.fromHexString( + "41BCE23C7D683B889326F762DDA2223A861EDA2E5C"); + byte[] digest = PqAuthDigest.tx(txid, 0, otherSigner); + byte[] sig = sign(sk, digest); + + Transaction signed = tx.toBuilder() + .addAuthWitness(AuthWitness.newBuilder() + .setSignerAddress(ByteString.copyFrom(otherSigner)) + .setSignature(ByteString.copyFrom(sig)) + .build()) + .build(); + TransactionCapsule cap = new TransactionCapsule(signed); + try { + cap.validatePubSignature(dbManager.getAccountStore(), + dbManager.getDynamicPropertiesStore()); + Assert.fail("unknown signer should be rejected"); + } catch (ValidateSignatureException e) { + Assert.assertTrue(e.getMessage().contains("not in permission")); + } + } } \ No newline at end of file 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..1d2852ba400 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.AuthWitness; import org.tron.protos.Protocol.Transaction; public class UtilTest extends BaseTest { @@ -166,4 +167,35 @@ public void testPackTransaction() { TransactionSignWeight txSignWeight = transactionUtil.getTransactionSignWeight(transaction); Assert.assertNotNull(txSignWeight); } + + @Test + public void roundtripAuthWitnessJson() throws Exception { + byte[] signer = ByteArray.fromHexString(OWNER_ADDRESS); + byte[] sig = new byte[3309]; + for (int i = 0; i < sig.length; i++) { + sig[i] = (byte) (i & 0xff); + } + AuthWitness authWitness = AuthWitness.newBuilder() + .setSignerAddress(ByteString.copyFrom(signer)) + .setSignature(ByteString.copyFrom(sig)) + .build(); + Transaction original = Transaction.newBuilder() + .setRawData(Transaction.raw.newBuilder().setTimestamp(1L).build()) + .addAuthWitness(authWitness) + .build(); + + String json = Util.printTransactionToJSON(original, false).toJSONString(); + Assert.assertTrue("JSON output should contain auth_witness field", + json.contains("auth_witness")); + + Transaction.Builder rebuilt = Transaction.newBuilder(); + JsonFormat.merge(json, rebuilt, false); + Transaction decoded = rebuilt.build(); + + Assert.assertEquals(1, decoded.getAuthWitnessCount()); + Assert.assertEquals(authWitness.getSignerAddress(), + decoded.getAuthWitness(0).getSignerAddress()); + Assert.assertEquals(authWitness.getSignature(), + decoded.getAuthWitness(0).getSignature()); + } } diff --git a/protocol/src/main/protos/core/Tron.proto b/protocol/src/main/protos/core/Tron.proto index 2b104b86d34..5be62364ebe 100644 --- a/protocol/src/main/protos/core/Tron.proto +++ b/protocol/src/main/protos/core/Tron.proto @@ -17,6 +17,20 @@ enum AccountType { Contract = 2; } +// Signature scheme identifier used by Permission.Key and AuthWitness. +// 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 +// auth_witness / witness_auth fields. +enum SignatureScheme { + UNKNOWN_SIG_SCHEME = 0; + ECDSA_SECP256K1 = 1; + SM2_SM3 = 2; + ML_DSA_44 = 3; + ML_DSA_65 = 4; + reserved 5 to 15; +} + // AccountId, (name, address) use name, (null, address) use address, (name, null) use name, message AccountId { bytes name = 1; @@ -243,6 +257,21 @@ message Account { message Key { bytes address = 1; int64 weight = 2; + // Signature scheme for this key. UNKNOWN_SIG_SCHEME = legacy; otherwise a + // post-quantum algorithm identifier requiring public_key to be populated. + SignatureScheme scheme = 3; + // Raw public key bytes. Empty for legacy keys. For ML-DSA-44 = 1312 bytes; + // for ML-DSA-65 = 1952 bytes. No SubjectPublicKeyInfo / PEM / Base64. + bytes public_key = 4; +} + +// Per-signer post-quantum authentication witness for a transaction or block. +// signer_address SHALL match a Permission.Key.address entry; signature SHALL +// verify against that key's scheme + public_key over the domain-separated +// digest defined in the PQ authentication spec. +message AuthWitness { + bytes signer_address = 1; + bytes signature = 2; } message DelegatedResource { @@ -449,6 +478,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 auth_witness and empty + // signature; a transaction against a legacy Permission MUST use signature + // and empty auth_witness. + repeated AuthWitness auth_witness = 6; } message TransactionInfo { @@ -515,6 +549,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, witness_auth SHALL be + // present in addition to witness_signature (Dual-Sign). Otherwise this + // field SHALL be empty. + AuthWitness witness_auth = 3; } // block From 9ca04fbf6ee2b152b2de997a1b5dbdcbb8f7b6c0 Mon Sep 17 00:00:00 2001 From: federico Date: Fri, 24 Apr 2026 11:04:53 +0800 Subject: [PATCH 02/17] refactor(crypto): consolidate ML-DSA PQC signer/verifier into unified PqSignature API --- .../AccountPermissionUpdateActuator.java | 8 +- .../org/tron/common/utils/LocalWitnesses.java | 35 +++ .../org/tron/core/capsule/BlockCapsule.java | 6 +- .../tron/core/capsule/TransactionCapsule.java | 12 +- .../org/tron/common/crypto/pqc/MLDSA44.java | 197 +++++++++++++++ .../tron/common/crypto/pqc/MLDSA44Signer.java | 45 ---- .../common/crypto/pqc/MLDSA44Verifier.java | 46 ---- .../org/tron/common/crypto/pqc/MLDSA65.java | 197 +++++++++++++++ .../tron/common/crypto/pqc/MLDSA65Signer.java | 45 ---- .../common/crypto/pqc/MLDSA65Verifier.java | 46 ---- .../tron/common/crypto/pqc/PqSignature.java | 64 +++++ .../crypto/pqc/PqSignatureRegistry.java | 74 ++++++ .../common/crypto/pqc/SignatureVerifier.java | 45 ---- .../crypto/pqc/SignatureVerifierRegistry.java | 39 --- .../crypto/pqc/MLDSA44VerifierTest.java | 134 ---------- .../pqc/SignatureVerifierRegistryTest.java | 62 ----- .../java/org/tron/core/config/args/Args.java | 17 ++ .../org/tron/core/config/args/ConfigKey.java | 2 + .../core/config/args/WitnessInitializer.java | 39 +++ .../tron/core/consensus/ConsensusService.java | 46 ++++ .../main/java/org/tron/core/db/Manager.java | 4 +- framework/src/main/resources/config.conf | 17 +- .../tron/common/crypto/pqc/MLDSA44Test.java | 228 ++++++++++++++++++ .../tron/common/crypto/pqc/MLDSA65Test.java | 71 ++++-- .../common/crypto/pqc/PqAuthDigestTest.java | 0 .../crypto/pqc/PqSignatureRegistryTest.java | 82 +++++++ .../org/tron/common/crypto/pqc/PqcClient.java | 139 +++++++++++ .../common/crypto/pqc/PqcWitnessNode.java | 158 ++++++++++++ .../tron/core/capsule/BlockCapsulePqTest.java | 29 +-- .../core/capsule/TransactionCapsuleTest.java | 195 +++++++++++---- framework/src/test/resources/config-test.conf | 9 +- 31 files changed, 1513 insertions(+), 578 deletions(-) create mode 100644 crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA44.java delete mode 100644 crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA44Signer.java delete mode 100644 crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA44Verifier.java create mode 100644 crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA65.java delete mode 100644 crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA65Signer.java delete mode 100644 crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA65Verifier.java create mode 100644 crypto/src/main/java/org/tron/common/crypto/pqc/PqSignature.java create mode 100644 crypto/src/main/java/org/tron/common/crypto/pqc/PqSignatureRegistry.java delete mode 100644 crypto/src/main/java/org/tron/common/crypto/pqc/SignatureVerifier.java delete mode 100644 crypto/src/main/java/org/tron/common/crypto/pqc/SignatureVerifierRegistry.java delete mode 100644 crypto/src/test/java/org/tron/common/crypto/pqc/MLDSA44VerifierTest.java delete mode 100644 crypto/src/test/java/org/tron/common/crypto/pqc/SignatureVerifierRegistryTest.java create mode 100644 framework/src/test/java/org/tron/common/crypto/pqc/MLDSA44Test.java rename crypto/src/test/java/org/tron/common/crypto/pqc/MLDSA65VerifierTest.java => framework/src/test/java/org/tron/common/crypto/pqc/MLDSA65Test.java (69%) rename {crypto => framework}/src/test/java/org/tron/common/crypto/pqc/PqAuthDigestTest.java (100%) create mode 100644 framework/src/test/java/org/tron/common/crypto/pqc/PqSignatureRegistryTest.java create mode 100644 framework/src/test/java/org/tron/common/crypto/pqc/PqcClient.java create mode 100644 framework/src/test/java/org/tron/common/crypto/pqc/PqcWitnessNode.java diff --git a/actuator/src/main/java/org/tron/core/actuator/AccountPermissionUpdateActuator.java b/actuator/src/main/java/org/tron/core/actuator/AccountPermissionUpdateActuator.java index e319aaab8a6..5f94917e50d 100644 --- a/actuator/src/main/java/org/tron/core/actuator/AccountPermissionUpdateActuator.java +++ b/actuator/src/main/java/org/tron/core/actuator/AccountPermissionUpdateActuator.java @@ -16,8 +16,8 @@ import org.tron.core.exception.ContractValidateException; import org.tron.core.store.AccountStore; import org.tron.core.store.DynamicPropertiesStore; -import org.tron.common.crypto.pqc.MLDSA44Verifier; -import org.tron.common.crypto.pqc.MLDSA65Verifier; +import org.tron.common.crypto.pqc.MLDSA44; +import org.tron.common.crypto.pqc.MLDSA65; import org.tron.protos.Protocol.Key; import org.tron.protos.Protocol.Permission; import org.tron.protos.Protocol.Permission.PermissionType; @@ -303,9 +303,9 @@ private void validatePermissionScheme(Permission permission) throws ContractVali private static int expectedPublicKeyLength(SignatureScheme scheme) { switch (scheme) { case ML_DSA_44: - return MLDSA44Verifier.PUBLIC_KEY_LENGTH; + return MLDSA44.PUBLIC_KEY_LENGTH; case ML_DSA_65: - return MLDSA65Verifier.PUBLIC_KEY_LENGTH; + return MLDSA65.PUBLIC_KEY_LENGTH; default: return -1; } 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..039b44ba114 100644 --- a/chainbase/src/main/java/org/tron/common/utils/LocalWitnesses.java +++ b/chainbase/src/main/java/org/tron/common/utils/LocalWitnesses.java @@ -18,12 +18,14 @@ 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.MLDSA65; import org.tron.core.config.Parameter.ChainConstant; import org.tron.core.exception.TronError; @@ -33,6 +35,11 @@ public class LocalWitnesses { @Getter private List privateKeys = Lists.newArrayList(); + /** ML-DSA-65 seed values in hex format (64 hex chars = 32 bytes). */ + @Getter + private List pqSeeds = Lists.newArrayList(); + + @Setter @Getter private byte[] witnessAccountAddress; @@ -95,6 +102,34 @@ public void addPrivateKeys(String privateKey) { this.privateKeys.add(privateKey); } + /** ML-DSA-65 seed values (32 bytes = 64 hex chars). Keys are derived from seeds. */ + public void setPqSeeds(final List pqSeeds) { + if (CollectionUtils.isEmpty(pqSeeds)) { + return; + } + for (String seed : pqSeeds) { + validatePqSeed(seed); + } + this.pqSeeds = pqSeeds; + } + + private static void validatePqSeed(String seed) { + String hex = seed; + if (StringUtils.startsWithIgnoreCase(hex, "0X")) { + hex = hex.substring(2); + } + int expectedHexLen = MLDSA65.SEED_LENGTH * 2; + if (StringUtils.isBlank(hex) || hex.length() != expectedHexLen) { + throw new TronError(String.format("ML-DSA-65 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("ML-DSA-65 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/BlockCapsule.java b/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java index a783d3b01e9..1b46cd6d8d8 100755 --- a/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java +++ b/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java @@ -32,8 +32,7 @@ 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.SignatureVerifier; -import org.tron.common.crypto.pqc.SignatureVerifierRegistry; +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; @@ -284,12 +283,11 @@ private boolean validateWitnessAuth(DynamicPropertiesStore dynamicPropertiesStor throw new ValidateSignatureException( "witness_auth signer not found in witness permission"); } - SignatureVerifier verifier = SignatureVerifierRegistry.get(scheme); byte[] publicKey = matched.getPublicKey().toByteArray(); byte[] signature = witnessAuth.getSignature().toByteArray(); byte[] rawHdrHash = getRawHash().getBytes(); byte[] digest = PqAuthDigest.block(rawHdrHash, signerAddr); - return verifier.verify(publicKey, digest, signature); + return PqSignatureRegistry.verify(scheme, publicKey, digest, signature); } public BlockId getBlockId() { 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 72b6f3b5245..59066833a4a 100755 --- a/chainbase/src/main/java/org/tron/core/capsule/TransactionCapsule.java +++ b/chainbase/src/main/java/org/tron/core/capsule/TransactionCapsule.java @@ -45,8 +45,7 @@ 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.SignatureVerifier; -import org.tron.common.crypto.pqc.SignatureVerifierRegistry; +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; @@ -746,18 +745,17 @@ static boolean validateStructuredSignature(Transaction transaction, throw new PermissionException("signer is not in permission"); } SignatureScheme scheme = key.getScheme(); - if (!SignatureVerifierRegistry.contains(scheme)) { + if (!PqSignatureRegistry.contains(scheme)) { throw new PermissionException("unsupported scheme: " + scheme); } - SignatureVerifier verifier = SignatureVerifierRegistry.get(scheme); byte[] digest = PqAuthDigest.tx(txid, permissionId, signer.toByteArray()); byte[] pk = key.getPublicKey().toByteArray(); byte[] sig = aw.getSignature().toByteArray(); - if (pk.length != verifier.getPublicKeyLength() - || sig.length != verifier.getSignatureLength()) { + if (pk.length != PqSignatureRegistry.getPublicKeyLength(scheme) + || sig.length != PqSignatureRegistry.getSignatureLength(scheme)) { throw new PermissionException("public key or signature length mismatch"); } - if (!verifier.verify(pk, digest, sig)) { + if (!PqSignatureRegistry.verify(scheme, pk, digest, sig)) { throw new PermissionException("pq sig invalid"); } weight = StrictMathWrapper.addExact(weight, key.getWeight()); 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..a51c0a299ff --- /dev/null +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA44.java @@ -0,0 +1,197 @@ +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); + } + + 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/MLDSA44Signer.java b/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA44Signer.java deleted file mode 100644 index 9ea743ed3a1..00000000000 --- a/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA44Signer.java +++ /dev/null @@ -1,45 +0,0 @@ -package org.tron.common.crypto.pqc; - -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; - -public final class MLDSA44Signer { - - public static final int PRIVATE_KEY_LENGTH = 2560; - - private MLDSA44Signer() { - } - - public static byte[] sign(byte[] privateKey, byte[] message) { - if (privateKey == null || privateKey.length != PRIVATE_KEY_LENGTH) { - throw new IllegalArgumentException( - "ML-DSA-44 private key length must be " + PRIVATE_KEY_LENGTH); - } - 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); - } - } - - public static byte[] derivePublicKey(byte[] privateKey) { - if (privateKey == null || privateKey.length != PRIVATE_KEY_LENGTH) { - throw new IllegalArgumentException( - "ML-DSA-44 private key length must be " + PRIVATE_KEY_LENGTH); - } - MLDSAPrivateKeyParameters sk = new MLDSAPrivateKeyParameters( - MLDSAParameters.ml_dsa_44, privateKey); - MLDSAPublicKeyParameters pk = sk.getPublicKeyParameters(); - return pk.getEncoded(); - } -} diff --git a/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA44Verifier.java b/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA44Verifier.java deleted file mode 100644 index 7e46fb1fb30..00000000000 --- a/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA44Verifier.java +++ /dev/null @@ -1,46 +0,0 @@ -package org.tron.common.crypto.pqc; - -import org.bouncycastle.pqc.crypto.mldsa.MLDSAParameters; -import org.bouncycastle.pqc.crypto.mldsa.MLDSAPublicKeyParameters; -import org.bouncycastle.pqc.crypto.mldsa.MLDSASigner; -import org.tron.protos.Protocol.SignatureScheme; - -/** - * FIPS 204 ML-DSA-44 verifier. Consumes raw public key and signature bytes — - * no SubjectPublicKeyInfo, PEM, or Base64 wrapping. - */ -public class MLDSA44Verifier implements SignatureVerifier { - - public static final int PUBLIC_KEY_LENGTH = 1312; - public static final int SIGNATURE_LENGTH = 2420; - - @Override - public SignatureScheme getScheme() { - return SignatureScheme.ML_DSA_44; - } - - @Override - public int getPublicKeyLength() { - return PUBLIC_KEY_LENGTH; - } - - @Override - public int getSignatureLength() { - return SIGNATURE_LENGTH; - } - - @Override - public boolean verify(byte[] publicKey, byte[] message, byte[] signature) { - validatePublicKey(publicKey); - validateSignature(signature); - if (message == null) { - throw new IllegalArgumentException("message must not be null"); - } - MLDSAPublicKeyParameters pk = new MLDSAPublicKeyParameters( - MLDSAParameters.ml_dsa_44, publicKey); - MLDSASigner signer = new MLDSASigner(); - signer.init(false, pk); - signer.update(message, 0, message.length); - return signer.verifySignature(signature); - } -} 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..36ed1b08ae9 --- /dev/null +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA65.java @@ -0,0 +1,197 @@ +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); + } + + 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/MLDSA65Signer.java b/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA65Signer.java deleted file mode 100644 index 1365243ad8b..00000000000 --- a/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA65Signer.java +++ /dev/null @@ -1,45 +0,0 @@ -package org.tron.common.crypto.pqc; - -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; - -public final class MLDSA65Signer { - - public static final int PRIVATE_KEY_LENGTH = 4032; - - private MLDSA65Signer() { - } - - public static byte[] sign(byte[] privateKey, byte[] message) { - if (privateKey == null || privateKey.length != PRIVATE_KEY_LENGTH) { - throw new IllegalArgumentException( - "ML-DSA-65 private key length must be " + PRIVATE_KEY_LENGTH); - } - 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); - } - } - - public static byte[] derivePublicKey(byte[] privateKey) { - if (privateKey == null || privateKey.length != PRIVATE_KEY_LENGTH) { - throw new IllegalArgumentException( - "ML-DSA-65 private key length must be " + PRIVATE_KEY_LENGTH); - } - MLDSAPrivateKeyParameters sk = new MLDSAPrivateKeyParameters( - MLDSAParameters.ml_dsa_65, privateKey); - MLDSAPublicKeyParameters pk = sk.getPublicKeyParameters(); - return pk.getEncoded(); - } -} diff --git a/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA65Verifier.java b/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA65Verifier.java deleted file mode 100644 index 816bfd367a8..00000000000 --- a/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA65Verifier.java +++ /dev/null @@ -1,46 +0,0 @@ -package org.tron.common.crypto.pqc; - -import org.bouncycastle.pqc.crypto.mldsa.MLDSAParameters; -import org.bouncycastle.pqc.crypto.mldsa.MLDSAPublicKeyParameters; -import org.bouncycastle.pqc.crypto.mldsa.MLDSASigner; -import org.tron.protos.Protocol.SignatureScheme; - -/** - * FIPS 204 ML-DSA-65 verifier. Consumes raw public key and signature bytes — - * no SubjectPublicKeyInfo, PEM, or Base64 wrapping. - */ -public class MLDSA65Verifier implements SignatureVerifier { - - public static final int PUBLIC_KEY_LENGTH = 1952; - public static final int SIGNATURE_LENGTH = 3309; - - @Override - public SignatureScheme getScheme() { - return SignatureScheme.ML_DSA_65; - } - - @Override - public int getPublicKeyLength() { - return PUBLIC_KEY_LENGTH; - } - - @Override - public int getSignatureLength() { - return SIGNATURE_LENGTH; - } - - @Override - public boolean verify(byte[] publicKey, byte[] message, byte[] signature) { - validatePublicKey(publicKey); - validateSignature(signature); - if (message == null) { - throw new IllegalArgumentException("message must not be null"); - } - MLDSAPublicKeyParameters pk = new MLDSAPublicKeyParameters( - MLDSAParameters.ml_dsa_65, publicKey); - MLDSASigner signer = new MLDSASigner(); - signer.init(false, pk); - signer.update(message, 0, message.length); - return signer.verifySignature(signature); - } -} 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..e2a332c8a04 --- /dev/null +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/PqSignature.java @@ -0,0 +1,64 @@ +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 void validateSignature(byte[] signature) { + if (signature == null || signature.length != getSignatureLength()) { + throw new IllegalArgumentException( + "invalid " + getScheme() + " signature length: " + + (signature == null ? "null" : signature.length) + + ", expected " + 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..284dba565e6 --- /dev/null +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/PqSignatureRegistry.java @@ -0,0 +1,74 @@ +package org.tron.common.crypto.pqc; + +import java.util.Collections; +import java.util.EnumMap; +import java.util.Map; +import org.tron.protos.Protocol.SignatureScheme; + +/** + * Static dispatch table for post-quantum signature verification keyed by + * {@link SignatureScheme}. Each entry binds a scheme to its public-key length, + * signature length, and a stateless verify function (typically a method + * reference to the concrete implementation's {@code verify}). Legacy + * schemes (ECDSA secp256k1, SM2/SM3) are NOT registered — they flow through + * the existing {@code SignInterface} path. + */ +public final class PqSignatureRegistry { + + @FunctionalInterface + public interface Verifier { + boolean verify(byte[] publicKey, byte[] message, byte[] signature); + } + + private static final class SchemeInfo { + final int publicKeyLength; + final int signatureLength; + final Verifier verifier; + + SchemeInfo(int publicKeyLength, int signatureLength, Verifier verifier) { + this.publicKeyLength = publicKeyLength; + this.signatureLength = signatureLength; + this.verifier = verifier; + } + } + + 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::verify)); + m.put(SignatureScheme.ML_DSA_65, new SchemeInfo( + MLDSA65.PUBLIC_KEY_LENGTH, MLDSA65.SIGNATURE_LENGTH, MLDSA65::verify)); + 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 boolean verify( + SignatureScheme scheme, byte[] publicKey, byte[] message, byte[] signature) { + return require(scheme).verifier.verify(publicKey, message, signature); + } + + 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; + } +} diff --git a/crypto/src/main/java/org/tron/common/crypto/pqc/SignatureVerifier.java b/crypto/src/main/java/org/tron/common/crypto/pqc/SignatureVerifier.java deleted file mode 100644 index a2f49b2f5b0..00000000000 --- a/crypto/src/main/java/org/tron/common/crypto/pqc/SignatureVerifier.java +++ /dev/null @@ -1,45 +0,0 @@ -package org.tron.common.crypto.pqc; - -import org.tron.protos.Protocol.SignatureScheme; - -/** - * Stateless verifier for a single post-quantum signature scheme. Independent of - * the legacy {@code SignInterface} because PQ flows do not expose public-key - * recovery and have no notion of node-id / private-key handling in verification - * paths. - */ -public interface SignatureVerifier { - - SignatureScheme getScheme(); - - int getPublicKeyLength(); - - int getSignatureLength(); - - /** - * Verify a raw-byte signature against a raw-byte public key over {@code message}. - * The caller SHALL pre-validate lengths via {@link #validatePublicKey(byte[])} and - * {@link #validateSignature(byte[])}; implementations still defensively re-check. - * - * @return true iff the signature is cryptographically valid for the given inputs - */ - boolean verify(byte[] publicKey, byte[] message, byte[] signature); - - 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 void validateSignature(byte[] signature) { - if (signature == null || signature.length != getSignatureLength()) { - throw new IllegalArgumentException( - "invalid " + getScheme() + " signature length: " - + (signature == null ? "null" : signature.length) - + ", expected " + getSignatureLength()); - } - } -} diff --git a/crypto/src/main/java/org/tron/common/crypto/pqc/SignatureVerifierRegistry.java b/crypto/src/main/java/org/tron/common/crypto/pqc/SignatureVerifierRegistry.java deleted file mode 100644 index 3b64cd637cd..00000000000 --- a/crypto/src/main/java/org/tron/common/crypto/pqc/SignatureVerifierRegistry.java +++ /dev/null @@ -1,39 +0,0 @@ -package org.tron.common.crypto.pqc; - -import java.util.Collections; -import java.util.EnumMap; -import java.util.Map; -import org.tron.protos.Protocol.SignatureScheme; - -/** - * Static registry of post-quantum {@link SignatureVerifier} instances keyed by - * {@link SignatureScheme}. Legacy schemes (ECDSA secp256k1, SM2/SM3) are NOT - * registered — they flow through the existing {@code SignInterface} path. - */ -public final class SignatureVerifierRegistry { - - private static final Map VERIFIERS; - - static { - EnumMap m = new EnumMap<>(SignatureScheme.class); - m.put(SignatureScheme.ML_DSA_44, new MLDSA44Verifier()); - m.put(SignatureScheme.ML_DSA_65, new MLDSA65Verifier()); - VERIFIERS = Collections.unmodifiableMap(m); - } - - private SignatureVerifierRegistry() { - } - - public static SignatureVerifier get(SignatureScheme scheme) { - SignatureVerifier v = VERIFIERS.get(scheme); - if (v == null) { - throw new IllegalArgumentException( - "no SignatureVerifier registered for scheme: " + scheme); - } - return v; - } - - public static boolean contains(SignatureScheme scheme) { - return VERIFIERS.containsKey(scheme); - } -} diff --git a/crypto/src/test/java/org/tron/common/crypto/pqc/MLDSA44VerifierTest.java b/crypto/src/test/java/org/tron/common/crypto/pqc/MLDSA44VerifierTest.java deleted file mode 100644 index 2f59ed8855d..00000000000 --- a/crypto/src/test/java/org/tron/common/crypto/pqc/MLDSA44VerifierTest.java +++ /dev/null @@ -1,134 +0,0 @@ -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 MLDSA44VerifierTest { - - private MLDSA44Verifier verifier; - private MLDSAPublicKeyParameters pk; - private MLDSAPrivateKeyParameters sk; - - @Before - public void setUp() { - verifier = new MLDSA44Verifier(); - 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(); - } - - private byte[] sign(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, verifier.getScheme()); - assertEquals(1312, verifier.getPublicKeyLength()); - assertEquals(2420, verifier.getSignatureLength()); - assertArrayEquals(pk.getEncoded(), pk.getEncoded()); - assertEquals(1312, pk.getEncoded().length); - } - - @Test - public void validSignatureVerifies() { - byte[] msg = "tron-pq-mldsa44".getBytes(); - byte[] sig = sign(msg); - assertEquals(2420, sig.length); - assertTrue(verifier.verify(pk.getEncoded(), msg, sig)); - } - - @Test - public void signatureBoundToMessage() { - byte[] msg = "hello".getBytes(); - byte[] sig = sign(msg); - byte[] tamperedMsg = "hellp".getBytes(); - assertFalse(verifier.verify(pk.getEncoded(), tamperedMsg, sig)); - } - - @Test - public void tamperedSignatureFailsVerification() { - byte[] msg = "payload".getBytes(); - byte[] sig = sign(msg); - sig[0] ^= 0x01; - assertFalse(verifier.verify(pk.getEncoded(), msg, sig)); - } - - @Test - public void wrongPublicKeyFailsVerification() { - byte[] msg = "payload".getBytes(); - byte[] sig = sign(msg); - MLDSAKeyPairGenerator gen = new MLDSAKeyPairGenerator(); - gen.init(new MLDSAKeyGenerationParameters(new SecureRandom(), MLDSAParameters.ml_dsa_44)); - MLDSAPublicKeyParameters otherPk = - (MLDSAPublicKeyParameters) gen.generateKeyPair().getPublic(); - assertFalse(verifier.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 { - verifier.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 { - verifier.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 { - verifier.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 = sign(msg); - assertTrue(verifier.verify(pk.getEncoded(), msg, sig)); - } -} diff --git a/crypto/src/test/java/org/tron/common/crypto/pqc/SignatureVerifierRegistryTest.java b/crypto/src/test/java/org/tron/common/crypto/pqc/SignatureVerifierRegistryTest.java deleted file mode 100644 index 769fe55ca1e..00000000000 --- a/crypto/src/test/java/org/tron/common/crypto/pqc/SignatureVerifierRegistryTest.java +++ /dev/null @@ -1,62 +0,0 @@ -package org.tron.common.crypto.pqc; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertSame; -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; - -import org.junit.Test; -import org.tron.protos.Protocol.SignatureScheme; - -public class SignatureVerifierRegistryTest { - - @Test - public void mlDsa44Registered() { - SignatureVerifier v = SignatureVerifierRegistry.get(SignatureScheme.ML_DSA_44); - assertNotNull(v); - assertSame(SignatureScheme.ML_DSA_44, v.getScheme()); - assertTrue(SignatureVerifierRegistry.contains(SignatureScheme.ML_DSA_44)); - } - - @Test - public void mlDsa65Registered() { - SignatureVerifier v = SignatureVerifierRegistry.get(SignatureScheme.ML_DSA_65); - assertNotNull(v); - assertSame(SignatureScheme.ML_DSA_65, v.getScheme()); - assertTrue(SignatureVerifierRegistry.contains(SignatureScheme.ML_DSA_65)); - } - - @Test - public void ecdsaNotRegistered() { - assertFalse(SignatureVerifierRegistry.contains(SignatureScheme.ECDSA_SECP256K1)); - try { - SignatureVerifierRegistry.get(SignatureScheme.ECDSA_SECP256K1); - fail("expected IllegalArgumentException for ECDSA_SECP256K1"); - } catch (IllegalArgumentException e) { - assertTrue(e.getMessage().contains("ECDSA_SECP256K1")); - } - } - - @Test - public void sm2NotRegistered() { - assertFalse(SignatureVerifierRegistry.contains(SignatureScheme.SM2_SM3)); - try { - SignatureVerifierRegistry.get(SignatureScheme.SM2_SM3); - fail("expected IllegalArgumentException for SM2_SM3"); - } catch (IllegalArgumentException e) { - assertTrue(e.getMessage().contains("SM2_SM3")); - } - } - - @Test - public void unknownSchemeRejected() { - assertFalse(SignatureVerifierRegistry.contains(SignatureScheme.UNKNOWN_SIG_SCHEME)); - try { - SignatureVerifierRegistry.get(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/main/java/org/tron/core/config/args/Args.java b/framework/src/main/java/org/tron/core/config/args/Args.java index 83d7fd2c63d..e0716e9be31 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 @@ -1041,6 +1041,10 @@ 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; + logConfig(); } @@ -1220,6 +1224,19 @@ 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.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..ba71236a4c3 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,7 @@ 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"; // crypto public static final String CRYPTO_ENGINE = "crypto.engine"; @@ -248,6 +249,7 @@ 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 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..8bd81b6d1d5 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..1f8e057f381 100644 --- a/framework/src/main/java/org/tron/core/consensus/ConsensusService.java +++ b/framework/src/main/java/org/tron/core/consensus/ConsensusService.java @@ -10,6 +10,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.tron.common.crypto.SignUtils; +import org.tron.common.crypto.pqc.MLDSA65; import org.tron.common.parameter.CommonParameter; import org.tron.consensus.Consensus; import org.tron.consensus.base.Param; @@ -46,6 +47,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 +78,27 @@ public void start() { Miner miner = param.new Miner(privateKey, ByteString.copyFrom(privateKeyAddress), ByteString.copyFrom(witnessAddress)); miners.add(miner); + } else if (pqSeeds.size() > 1) { + for (String seed : pqSeeds) { + byte[] seedBytes = fromHexString(seed); + MLDSA65 keypair = new MLDSA65(seedBytes); + byte[] sk = keypair.getPrivateKey(); + byte[] pk = keypair.getPublicKey(); + byte[] pqAddress = MLDSA65.computeAddress(pk); + 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 ML-DSA witness (from seed): {}, size: {}", + Hex.toHexString(pqAddress), miners.size()); + } + } else if (pqSeeds.size() == 1) { + miners.add(buildPqOnlyMinerFromSeed(param, pqSeeds.get(0))); } param.setMiners(miners); @@ -85,6 +108,29 @@ public void start() { logger.info("consensus service start success"); } + private Miner buildPqOnlyMinerFromSeed(Param param, String pqSeed) { + byte[] seedBytes = fromHexString(pqSeed); + MLDSA65 keypair = new MLDSA65(seedBytes); + byte[] sk = keypair.getPrivateKey(); + byte[] pk = keypair.getPublicKey(); + byte[] pqAddress = MLDSA65.computeAddress(pk); + 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 ML-DSA witness (from seed): {}", Hex.toHexString(witnessAddress)); + return miner; + } + public void stop() { logger.info("consensus service closed start."); consensus.stop(); 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 72e5b048c94..33cf1852482 100644 --- a/framework/src/main/java/org/tron/core/db/Manager.java +++ b/framework/src/main/java/org/tron/core/db/Manager.java @@ -54,7 +54,7 @@ import org.tron.common.args.GenesisBlock; import org.tron.common.bloom.Bloom; import org.tron.common.cron.CronExpression; -import org.tron.common.crypto.pqc.MLDSA65Signer; +import org.tron.common.crypto.pqc.MLDSA65; import org.tron.common.crypto.pqc.PqAuthDigest; import org.tron.common.es.ExecutorServiceManager; import org.tron.common.exit.ExitManager; @@ -1794,7 +1794,7 @@ private void signWitnessAuth(BlockCapsule blockCapsule, Miner miner) { } byte[] signerAddress = witnessPermission.getKeys(0).getAddress().toByteArray(); byte[] digest = PqAuthDigest.block(blockCapsule.getRawHashBytes(), signerAddress); - byte[] signature = MLDSA65Signer.sign(pqPrivateKey, digest); + byte[] signature = MLDSA65.sign(pqPrivateKey, digest); AuthWitness witnessAuth = AuthWitness.newBuilder() .setSignerAddress(ByteString.copyFrom(signerAddress)) .setSignature(ByteString.copyFrom(signature)) diff --git a/framework/src/main/resources/config.conf b/framework/src/main/resources/config.conf index 4a709e87e93..a3fa7c11019 100644 --- a/framework/src/main/resources/config.conf +++ b/framework/src/main/resources/config.conf @@ -673,15 +673,15 @@ localwitness = [ # "localwitnesskeystore.json" # ] -# Post-quantum (ML-DSA-65) witness signing key. Required once the on-chain witness +# Post-quantum (ML-DSA-65) witness signing seed. Required once the on-chain witness # Permission has been upgraded to scheme = ML_DSA_65 and ALLOW_ML_DSA = 1 is active. -# Each entry is the 4032-byte FIPS 204 ML-DSA-65 private key encoded as a hex string -# (no SubjectPublicKeyInfo / PEM / Base64 wrapping). The node uses this key to -# produce the BlockHeader.witness_auth field during Dual-Sign block production. -# If the witness Permission is still legacy (scheme = UNKNOWN_SIG_SCHEME), leave this -# empty; the node will use `localwitness` / `localwitnesskeystore` as before. -# localwitness_pq = [ -# "0123abcd...<8064-hex-char ML-DSA-65 private key>" +# Each entry is a 32-byte seed encoded as a hex string (64 hex chars). +# The ML-DSA-65 keypair is deterministically derived from the seed using FIPS 204. +# The node uses this key to produce the BlockHeader.witness_auth field during Dual-Sign +# block production. If the witness Permission is still legacy (scheme = UNKNOWN_SIG_SCHEME), +# leave this empty; the node will use `localwitness` / `localwitnesskeystore` as before. +# localwitness_seed_pq = [ +# "0101010101010101010101010101010101010101010101010101010101010101" # ] block = { @@ -771,6 +771,7 @@ committee = { # consensusLogicOptimization = 0 # allowOptimizedReturnValueOfChainId = 0 # allowTvmOsaka = 0 + # allowMlDsa = 0 } event.subscribe = { diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/MLDSA44Test.java b/framework/src/test/java/org/tron/common/crypto/pqc/MLDSA44Test.java new file mode 100644 index 00000000000..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/crypto/src/test/java/org/tron/common/crypto/pqc/MLDSA65VerifierTest.java b/framework/src/test/java/org/tron/common/crypto/pqc/MLDSA65Test.java similarity index 69% rename from crypto/src/test/java/org/tron/common/crypto/pqc/MLDSA65VerifierTest.java rename to framework/src/test/java/org/tron/common/crypto/pqc/MLDSA65Test.java index c6ee8077df5..370fb6772e1 100644 --- a/crypto/src/test/java/org/tron/common/crypto/pqc/MLDSA65VerifierTest.java +++ b/framework/src/test/java/org/tron/common/crypto/pqc/MLDSA65Test.java @@ -1,5 +1,6 @@ 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; @@ -17,23 +18,23 @@ import org.junit.Test; import org.tron.protos.Protocol.SignatureScheme; -public class MLDSA65VerifierTest { +public class MLDSA65Test { - private MLDSA65Verifier verifier; + private MLDSA65 keypair; private MLDSAPublicKeyParameters pk; private MLDSAPrivateKeyParameters sk; @Before public void setUp() { - verifier = new MLDSA65Verifier(); 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[] sign(byte[] message) { + private byte[] rawSign(byte[] message) { MLDSASigner signer = new MLDSASigner(); signer.init(true, sk); signer.update(message, 0, message.length); @@ -46,45 +47,45 @@ private byte[] sign(byte[] message) { @Test public void schemeAndLengthsMatchFips204() { - assertEquals(SignatureScheme.ML_DSA_65, verifier.getScheme()); - assertEquals(1952, verifier.getPublicKeyLength()); - assertEquals(3309, verifier.getSignatureLength()); + 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 = sign(msg); + byte[] sig = rawSign(msg); assertEquals(3309, sig.length); - assertTrue(verifier.verify(pk.getEncoded(), msg, sig)); + assertTrue(keypair.verify(msg, sig)); } @Test public void signatureBoundToMessage() { byte[] msg = "block-header".getBytes(); - byte[] sig = sign(msg); + byte[] sig = rawSign(msg); byte[] tamperedMsg = "block-footer".getBytes(); - assertFalse(verifier.verify(pk.getEncoded(), tamperedMsg, sig)); + assertFalse(keypair.verify(tamperedMsg, sig)); } @Test public void tamperedSignatureFailsVerification() { byte[] msg = "payload".getBytes(); - byte[] sig = sign(msg); + byte[] sig = rawSign(msg); sig[sig.length - 1] ^= 0x01; - assertFalse(verifier.verify(pk.getEncoded(), msg, sig)); + assertFalse(keypair.verify(msg, sig)); } @Test public void wrongPublicKeyFailsVerification() { byte[] msg = "payload".getBytes(); - byte[] sig = sign(msg); + 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(verifier.verify(otherPk.getEncoded(), msg, sig)); + assertFalse(MLDSA65.verify(otherPk.getEncoded(), msg, sig)); } @Test @@ -93,7 +94,7 @@ public void invalidPublicKeyLengthRejected() { byte[] msg = new byte[] {1}; byte[] sig = new byte[3309]; try { - verifier.verify(badPk, msg, sig); + MLDSA65.verify(badPk, msg, sig); fail("short public key should be rejected"); } catch (IllegalArgumentException expected) { assertTrue(expected.getMessage().contains("public key length")); @@ -105,7 +106,7 @@ public void invalidSignatureLengthRejected() { byte[] badSig = new byte[3310]; byte[] msg = new byte[] {1}; try { - verifier.verify(pk.getEncoded(), msg, badSig); + MLDSA65.verify(pk.getEncoded(), msg, badSig); fail("wrong-length signature should be rejected"); } catch (IllegalArgumentException expected) { assertTrue(expected.getMessage().contains("signature length")); @@ -116,7 +117,7 @@ public void invalidSignatureLengthRejected() { public void nullMessageRejected() { byte[] sig = new byte[3309]; try { - verifier.verify(pk.getEncoded(), null, sig); + MLDSA65.verify(pk.getEncoded(), null, sig); fail("null message should be rejected"); } catch (IllegalArgumentException expected) { assertTrue(expected.getMessage().contains("message")); @@ -126,8 +127,8 @@ public void nullMessageRejected() { @Test public void emptyMessageVerifiesConsistently() { byte[] msg = new byte[0]; - byte[] sig = sign(msg); - assertTrue(verifier.verify(pk.getEncoded(), msg, sig)); + byte[] sig = rawSign(msg); + assertTrue(keypair.verify(msg, sig)); } @Test @@ -140,10 +141,38 @@ public void crossSchemeKeyFailsVerification() { byte[] msg = new byte[] {1}; byte[] sig = new byte[3309]; try { - verifier.verify(pk44Bytes, msg, sig); + 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/crypto/src/test/java/org/tron/common/crypto/pqc/PqAuthDigestTest.java b/framework/src/test/java/org/tron/common/crypto/pqc/PqAuthDigestTest.java similarity index 100% rename from crypto/src/test/java/org/tron/common/crypto/pqc/PqAuthDigestTest.java rename to framework/src/test/java/org/tron/common/crypto/pqc/PqAuthDigestTest.java 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..c9b9917994d --- /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/PqcClient.java b/framework/src/test/java/org/tron/common/crypto/pqc/PqcClient.java new file mode 100644 index 00000000000..66cc87533d7 --- /dev/null +++ b/framework/src/test/java/org/tron/common/crypto/pqc/PqcClient.java @@ -0,0 +1,139 @@ +package org.tron.common.crypto.pqc; + +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.utils.ByteArray; +import org.tron.protos.Protocol.AuthWitness; +import org.tron.protos.Protocol.Block; +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 PqcWitnessNode} and broadcasts an ML-DSA-44 + * signed transfer transaction. + * + * The keypair is derived from the same fixed seed used by PqcWitnessNode, 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.PqcWitnessNode + * Terminal 2 — broadcast a PQC transaction: + * ./gradlew :framework:run -PmainClass=org.tron.common.crypto.pqc.PqcClient + * + * Optional JVM args: + * -Dpqc.host=localhost (default: localhost) + * -Dpqc.port=50051 (default: 50051) + */ +public class PqcClient { + + 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 { + // ── 1. Derive user keypair from same fixed seed as PqcWitnessNode ───── + 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 = PqcWitnessNode.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 auth_witness ────────────────────────── + byte[] txId = sha256(rawData.toByteArray()); + byte[] digest = PqAuthDigest.tx(txId, 0, signerAddr); + byte[] sig = MLDSA44.sign(userPriv, digest); + + Transaction signedTx = tx.toBuilder() + .addAuthWitness(AuthWitness.newBuilder() + .setSignerAddress(ByteString.copyFrom(signerAddr)) + .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/PqcWitnessNode.java b/framework/src/test/java/org/tron/common/crypto/pqc/PqcWitnessNode.java new file mode 100644 index 00000000000..4c418cb96c3 --- /dev/null +++ b/framework/src/test/java/org/tron/common/crypto/pqc/PqcWitnessNode.java @@ -0,0 +1,158 @@ +package org.tron.common.crypto.pqc; + +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.utils.ByteArray; +import org.tron.common.utils.LocalWitnesses; +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.Permission; +import org.tron.protos.Protocol.Permission.PermissionType; +import org.tron.protos.Protocol.SignatureScheme; + +/** + * Demo witness node with ML-DSA-65 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 PqcClient}. + * + * Keypairs are derived from fixed seeds so PqcClient 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.PqcWitnessNode + * Terminal 2 — broadcast a PQC transaction: + * ./gradlew :framework:run -PmainClass=org.tron.common.crypto.pqc.PqcClient + */ +public class PqcWitnessNode { + + /** Fixed seed for the ML-DSA-65 witness keypair (shared with PqcClient for derivation). */ + static final byte[] WITNESS_SEED = filledSeed(0x01); + /** Fixed seed for the ML-DSA-44 user keypair (shared with PqcClient for derivation). */ + static final byte[] USER_SEED = filledSeed(0x02); + + /** gRPC port the node listens on. */ + static final int GRPC_PORT = 50051; + + /** 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 { + // ── 1. Derive deterministic keypairs ────────────────────────────────── + MLDSA65 witnessKp = new MLDSA65(WITNESS_SEED); + MLDSA44 userKp = new MLDSA44(USER_SEED); + + byte[] witnessPub = witnessKp.getPublicKey(); + byte[] witnessPriv = witnessKp.getPrivateKey(); + byte[] witnessAddr = MLDSA65.computeAddress(witnessPub); + ByteString witnessAddrBs = ByteString.copyFrom(witnessAddr); + + byte[] userPub = userKp.getPublicKey(); + byte[] signerAddr = MLDSA44.computeAddress(userPub); + ByteString signerAddrBs = ByteString.copyFrom(signerAddr); + + System.out.println("=== PQC Witness Node ==="); + System.out.println("Witness address (ML-DSA-65): " + 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); + + // ── 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().setRpcPort(GRPC_PORT); + Args.getInstance().setP2pDisable(true); + Args.getInstance().setNeedSyncCheck(false); + Args.getInstance().setMinEffectiveConnection(0); + + // ── 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. Enable PQ features ───────────────────────────────────────────── + db.getDynamicPropertiesStore().saveAllowMlDsa(1L); + db.getDynamicPropertiesStore().saveAllowMultiSign(1L); + + // ── 5. Register witness account with ML-DSA-65 witness permission ───── + Permission witnessPerm = Permission.newBuilder() + .setType(PermissionType.Witness) + .setId(1).setPermissionName("witness").setThreshold(1) + .addKeys(Key.newBuilder() + .setAddress(witnessAddrBs).setWeight(1) + .setScheme(SignatureScheme.ML_DSA_65) + .setPublicKey(ByteString.copyFrom(witnessPub))) + .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); + + // ── 6. Register 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) + .setScheme(SignatureScheme.ML_DSA_44) + .setPublicKey(ByteString.copyFrom(userPub))) + .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); + + // ── 7. Start consensus (DposTask auto-produces blocks) ──────────────── + context.getBean(ConsensusService.class).start(); + + // ── 8. Start gRPC server ────────────────────────────────────────────── + app.startup(); + + System.out.println("\nNode is running. Send Ctrl-C to stop."); + System.out.println("Run PqcClient in another terminal to broadcast a PQC transaction.\n"); + + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + System.out.println("Shutting down..."); + context.close(); + Args.clearParam(); + })); + + Thread.currentThread().join(); // block until Ctrl-C + } + + 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/core/capsule/BlockCapsulePqTest.java b/framework/src/test/java/org/tron/core/capsule/BlockCapsulePqTest.java index 839b77b1efe..43dc4dc9de1 100644 --- a/framework/src/test/java/org/tron/core/capsule/BlockCapsulePqTest.java +++ b/framework/src/test/java/org/tron/core/capsule/BlockCapsulePqTest.java @@ -1,15 +1,8 @@ package org.tron.core.capsule; import com.google.protobuf.ByteString; -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.Assert; +import org.tron.common.crypto.pqc.MLDSA65; import org.junit.Before; import org.junit.BeforeClass; import org.junit.Test; @@ -33,8 +26,7 @@ public class BlockCapsulePqTest extends BaseTest { private ECKey witnessKey; private byte[] witnessAddress; - private MLDSAPrivateKeyParameters pqSk; - private MLDSAPublicKeyParameters pqPk; + private MLDSA65 pqKeypair; @BeforeClass public static void init() { @@ -45,11 +37,7 @@ public static void init() { public void setUp() { witnessKey = new ECKey(); witnessAddress = witnessKey.getAddress(); - MLDSAKeyPairGenerator gen = new MLDSAKeyPairGenerator(); - gen.init(new MLDSAKeyGenerationParameters(new SecureRandom(), MLDSAParameters.ml_dsa_65)); - AsymmetricCipherKeyPair kp = gen.generateKeyPair(); - pqPk = (MLDSAPublicKeyParameters) kp.getPublic(); - pqSk = (MLDSAPrivateKeyParameters) kp.getPrivate(); + pqKeypair = new MLDSA65(); } private AccountCapsule buildWitnessAccount(SignatureScheme scheme) { @@ -58,7 +46,7 @@ private AccountCapsule buildWitnessAccount(SignatureScheme scheme) { .setWeight(1) .setScheme(scheme); if (scheme == SignatureScheme.ML_DSA_65) { - kb.setPublicKey(ByteString.copyFrom(pqPk.getEncoded())); + kb.setPublicKey(ByteString.copyFrom(pqKeypair.getPublicKey())); } Permission witnessPerm = Permission.newBuilder() .setType(PermissionType.Witness) @@ -97,14 +85,7 @@ private BlockCapsule buildUnsignedBlock(byte[] parentHash) { } private byte[] signPq(byte[] message) { - MLDSASigner signer = new MLDSASigner(); - signer.init(true, pqSk); - signer.update(message, 0, message.length); - try { - return signer.generateSignature(); - } catch (Exception e) { - throw new AssertionError(e); - } + return MLDSA65.sign(pqKeypair.getPrivateKey(), message); } @Test 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 0e4c67aad45..f3ee6e3e826 100644 --- a/framework/src/test/java/org/tron/core/capsule/TransactionCapsuleTest.java +++ b/framework/src/test/java/org/tron/core/capsule/TransactionCapsuleTest.java @@ -6,16 +6,11 @@ import com.google.protobuf.Any; import com.google.protobuf.ByteString; -import java.security.SecureRandom; import lombok.extern.slf4j.Slf4j; -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.Assert; +import org.tron.common.crypto.ECKey; +import org.tron.common.crypto.pqc.MLDSA44; +import org.tron.common.crypto.pqc.MLDSA65; import org.junit.Before; import org.junit.BeforeClass; import org.junit.Test; @@ -40,6 +35,7 @@ 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 { @@ -96,17 +92,8 @@ public void testRemoveRedundantRet() { private static final String PQ_SIGNER_HEX = "41548794500882809695a8a687866e76d4271a1abc"; - private AsymmetricCipherKeyPair newMlDsa65KeyPair() { - MLDSAKeyPairGenerator gen = new MLDSAKeyPairGenerator(); - gen.init(new MLDSAKeyGenerationParameters(new SecureRandom(), MLDSAParameters.ml_dsa_65)); - return gen.generateKeyPair(); - } - - private byte[] sign(MLDSAPrivateKeyParameters sk, byte[] msg) throws Exception { - MLDSASigner s = new MLDSASigner(); - s.init(true, sk); - s.update(msg, 0, msg.length); - return s.generateSignature(); + private byte[] sign(MLDSA44 kp, byte[] msg) { + return MLDSA44.sign(kp.getPrivateKey(), msg); } private Transaction buildTransferTx(String ownerHex, int permissionId) { @@ -124,13 +111,14 @@ private Transaction buildTransferTx(String ownerHex, int permissionId) { return Transaction.newBuilder().setRawData(rawData).build(); } - private void putAccountWithMlDsa65Permission(String ownerHex, byte[] pqPublicKey) { + 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) - .setScheme(SignatureScheme.ML_DSA_65) + .setScheme(scheme) .setPublicKey(ByteString.copyFrom(pqPublicKey)) .build(); Permission owner = Permission.newBuilder() @@ -151,7 +139,7 @@ public void authWitnessBeforeActivationRejected() { Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0).toBuilder() .addAuthWitness(AuthWitness.newBuilder() .setSignerAddress(ByteString.copyFrom(ByteArray.fromHexString(PQ_SIGNER_HEX))) - .setSignature(ByteString.copyFrom(new byte[3309])) + .setSignature(ByteString.copyFrom(new byte[2420])) .build()) .build(); TransactionCapsule cap = new TransactionCapsule(tx); @@ -171,7 +159,7 @@ public void signatureAndAuthWitnessAreMutuallyExclusive() { .addSignature(ByteString.copyFrom(new byte[65])) .addAuthWitness(AuthWitness.newBuilder() .setSignerAddress(ByteString.copyFrom(ByteArray.fromHexString(PQ_SIGNER_HEX))) - .setSignature(ByteString.copyFrom(new byte[3309])) + .setSignature(ByteString.copyFrom(new byte[2420])) .build()) .build(); TransactionCapsule cap = new TransactionCapsule(tx); @@ -187,16 +175,14 @@ public void signatureAndAuthWitnessAreMutuallyExclusive() { @Test public void validAuthWitnessAccepted() throws Exception { dbManager.getDynamicPropertiesStore().saveAllowMlDsa(1L); - AsymmetricCipherKeyPair kp = newMlDsa65KeyPair(); - byte[] pkBytes = ((MLDSAPublicKeyParameters) kp.getPublic()).getEncoded(); - MLDSAPrivateKeyParameters sk = (MLDSAPrivateKeyParameters) kp.getPrivate(); - putAccountWithMlDsa65Permission(PQ_OWNER_HEX, pkBytes); + 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[] signerAddr = ByteArray.fromHexString(PQ_SIGNER_HEX); byte[] digest = PqAuthDigest.tx(txid, 0, signerAddr); - byte[] sig = sign(sk, digest); + byte[] sig = sign(kp, digest); Transaction signed = tx.toBuilder() .addAuthWitness(AuthWitness.newBuilder() @@ -212,16 +198,14 @@ public void validAuthWitnessAccepted() throws Exception { @Test public void duplicateSignerRejected() throws Exception { dbManager.getDynamicPropertiesStore().saveAllowMlDsa(1L); - AsymmetricCipherKeyPair kp = newMlDsa65KeyPair(); - byte[] pkBytes = ((MLDSAPublicKeyParameters) kp.getPublic()).getEncoded(); - MLDSAPrivateKeyParameters sk = (MLDSAPrivateKeyParameters) kp.getPrivate(); - putAccountWithMlDsa65Permission(PQ_OWNER_HEX, pkBytes); + 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[] signerAddr = ByteArray.fromHexString(PQ_SIGNER_HEX); byte[] digest = PqAuthDigest.tx(txid, 0, signerAddr); - byte[] sig = sign(sk, digest); + byte[] sig = sign(kp, digest); AuthWitness aw = AuthWitness.newBuilder() .setSignerAddress(ByteString.copyFrom(signerAddr)) .setSignature(ByteString.copyFrom(sig)) @@ -241,16 +225,14 @@ public void duplicateSignerRejected() throws Exception { @Test public void tamperedAuthWitnessRejected() throws Exception { dbManager.getDynamicPropertiesStore().saveAllowMlDsa(1L); - AsymmetricCipherKeyPair kp = newMlDsa65KeyPair(); - byte[] pkBytes = ((MLDSAPublicKeyParameters) kp.getPublic()).getEncoded(); - MLDSAPrivateKeyParameters sk = (MLDSAPrivateKeyParameters) kp.getPrivate(); - putAccountWithMlDsa65Permission(PQ_OWNER_HEX, pkBytes); + 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[] signerAddr = ByteArray.fromHexString(PQ_SIGNER_HEX); byte[] digest = PqAuthDigest.tx(txid, 0, signerAddr); - byte[] sig = sign(sk, digest); + byte[] sig = sign(kp, digest); sig[0] ^= 0x01; Transaction signed = tx.toBuilder() @@ -272,17 +254,15 @@ public void tamperedAuthWitnessRejected() throws Exception { @Test public void signerNotInPermissionRejected() throws Exception { dbManager.getDynamicPropertiesStore().saveAllowMlDsa(1L); - AsymmetricCipherKeyPair kp = newMlDsa65KeyPair(); - byte[] pkBytes = ((MLDSAPublicKeyParameters) kp.getPublic()).getEncoded(); - MLDSAPrivateKeyParameters sk = (MLDSAPrivateKeyParameters) kp.getPrivate(); - putAccountWithMlDsa65Permission(PQ_OWNER_HEX, pkBytes); + 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[] otherSigner = ByteArray.fromHexString( "41BCE23C7D683B889326F762DDA2223A861EDA2E5C"); byte[] digest = PqAuthDigest.tx(txid, 0, otherSigner); - byte[] sig = sign(sk, digest); + byte[] sig = sign(kp, digest); Transaction signed = tx.toBuilder() .addAuthWitness(AuthWitness.newBuilder() @@ -299,4 +279,133 @@ public void signerNotInPermissionRejected() throws Exception { Assert.assertTrue(e.getMessage().contains("not in permission")); } } + + /** + * 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; + byte[] signerAddr = ByteArray.fromHexString(PQ_SIGNER_HEX); + + // 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 auth_witness + MLDSA44 kp44 = new MLDSA44(); + byte[] txid44 = Sha256Hash.of(true, baseTx.getRawData().toByteArray()).getBytes(); + byte[] sig44 = MLDSA44.sign(kp44.getPrivateKey(), PqAuthDigest.tx(txid44, 0, signerAddr)); + Transaction tx44 = baseTx.toBuilder() + .addAuthWitness(AuthWitness.newBuilder() + .setSignerAddress(ByteString.copyFrom(signerAddr)) + .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 auth_witness + MLDSA65 kp65 = new MLDSA65(); + byte[] txid65 = Sha256Hash.of(true, baseTx.getRawData().toByteArray()).getBytes(); + byte[] sig65 = MLDSA65.sign(kp65.getPrivateKey(), PqAuthDigest.tx(txid65, 0, signerAddr)); + Transaction tx65 = baseTx.toBuilder() + .addAuthWitness(AuthWitness.newBuilder() + .setSignerAddress(ByteString.copyFrom(signerAddr)) + .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 mlDsa65AuthWitnessAlsoAccepted() 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[] signerAddr = ByteArray.fromHexString(PQ_SIGNER_HEX); + byte[] digest = PqAuthDigest.tx(txid, 0, signerAddr); + byte[] sig = MLDSA65.sign(kp.getPrivateKey(), digest); + + Transaction signed = tx.toBuilder() + .addAuthWitness(AuthWitness.newBuilder() + .setSignerAddress(ByteString.copyFrom(signerAddr)) + .setSignature(ByteString.copyFrom(sig)) + .build()) + .build(); + TransactionCapsule cap = new TransactionCapsule(signed); + Assert.assertTrue(cap.validatePubSignature(dbManager.getAccountStore(), + dbManager.getDynamicPropertiesStore())); + } } \ No newline at end of file diff --git a/framework/src/test/resources/config-test.conf b/framework/src/test/resources/config-test.conf index 71e93f84db5..a3c98f2b3a7 100644 --- a/framework/src/test/resources/config-test.conf +++ b/framework/src/test/resources/config-test.conf @@ -348,12 +348,15 @@ 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 = [ + "0101010101010101010101010101010101010101010101010101010101010101" + ] + block = { needSyncCheck = true # first node : false, other : true } @@ -386,4 +389,4 @@ node.dynamicConfig.enable = true event.subscribe = { enable = false } -node.dynamicConfig.checkInterval = 0 \ No newline at end of file +node.dynamicConfig.checkInterval = 0 From c5a7bac6bc39392cb3c2f22fa9291deb432934b8 Mon Sep 17 00:00:00 2001 From: federico Date: Fri, 24 Apr 2026 18:34:18 +0800 Subject: [PATCH 03/17] chore(crypto): extend pq demo with fullnode sync --- .../src/main/java/org/tron/core/Wallet.java | 5 + framework/src/main/resources/config.conf | 10 +- .../common/crypto/pqc/program/PqFullNode.java | 118 ++++++++++++++++++ .../crypto/pqc/{ => program}/PqcClient.java | 14 ++- .../pqc/{ => program}/PqcWitnessNode.java | 87 ++++++++----- 5 files changed, 195 insertions(+), 39 deletions(-) create mode 100644 framework/src/test/java/org/tron/common/crypto/pqc/program/PqFullNode.java rename framework/src/test/java/org/tron/common/crypto/pqc/{ => program}/PqcClient.java (92%) rename framework/src/test/java/org/tron/common/crypto/pqc/{ => program}/PqcWitnessNode.java (75%) diff --git a/framework/src/main/java/org/tron/core/Wallet.java b/framework/src/main/java/org/tron/core/Wallet.java index 39e8f06c281..6079294e193 100755 --- a/framework/src/main/java/org/tron/core/Wallet.java +++ b/framework/src/main/java/org/tron/core/Wallet.java @@ -1514,6 +1514,11 @@ public Protocol.ChainParameters getChainParameters() { .setValue(dbManager.getDynamicPropertiesStore().getAllowTvmOsaka()) .build()); + builder.addChainParameter(Protocol.ChainParameters.ChainParameter.newBuilder() + .setKey("getAllowMlDsa") + .setValue(dbManager.getDynamicPropertiesStore().getAllowMlDsa()) + .build()); + return builder.build(); } diff --git a/framework/src/main/resources/config.conf b/framework/src/main/resources/config.conf index a3fa7c11019..a011a882c97 100644 --- a/framework/src/main/resources/config.conf +++ b/framework/src/main/resources/config.conf @@ -673,13 +673,9 @@ localwitness = [ # "localwitnesskeystore.json" # ] -# Post-quantum (ML-DSA-65) witness signing seed. Required once the on-chain witness -# Permission has been upgraded to scheme = ML_DSA_65 and ALLOW_ML_DSA = 1 is active. -# Each entry is a 32-byte seed encoded as a hex string (64 hex chars). -# The ML-DSA-65 keypair is deterministically derived from the seed using FIPS 204. -# The node uses this key to produce the BlockHeader.witness_auth field during Dual-Sign -# block production. If the witness Permission is still legacy (scheme = UNKNOWN_SIG_SCHEME), -# leave this empty; the node will use `localwitness` / `localwitnesskeystore` as before. +# ML-DSA-65 witness signing seed (FIPS 204), 32-byte hex. Used only after +# ALLOW_ML_DSA = 1 and the witness Permission is upgraded to scheme = ML_DSA_65. +# MUST be produced by a CSPRNG; the value below is an example, never use in prod. # localwitness_seed_pq = [ # "0101010101010101010101010101010101010101010101010101010101010101" # ] 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..738064a529c --- /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.crypto.pqc.MLDSA65; +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 PqcWitnessNode} via P2P and syncs PQ-signed blocks. + * + * Both nodes share the same deterministic PQ genesis pre-state (witness account with an + * ML-DSA-65 witness permission + demo user account with an ML-DSA-44 owner permission), + * installed via {@link PqcWitnessNode#installPqGenesisState}. Once the witness produces + * a block it is broadcast over P2P; this node validates {@code BlockHeader.witness_auth} + * 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.PqcWitnessNode + * 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: PqcWitnessNode.P2P_PORT) + */ +public class PqFullNode { + + /** gRPC port (different from PqcWitnessNode so both can run on one host). */ + static final int GRPC_PORT = 50052; + /** Full-node HTTP port (different from PqcWitnessNode). */ + static final int HTTP_PORT = 8091; + /** P2P listen port (different from PqcWitnessNode). */ + 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(PqcWitnessNode.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 PqcWitnessNode ────── + MLDSA65 witnessKp = new MLDSA65(PqcWitnessNode.WITNESS_SEED); + MLDSA44 userKp = new MLDSA44(PqcWitnessNode.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(MLDSA65.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. + Args.getInstance().getSeedNode().setAddressList(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 witness_auth would fail to validate because + // this node wouldn't know the witness's ML-DSA-65 public key. + PqcWitnessNode.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/PqcClient.java b/framework/src/test/java/org/tron/common/crypto/pqc/program/PqcClient.java similarity index 92% rename from framework/src/test/java/org/tron/common/crypto/pqc/PqcClient.java rename to framework/src/test/java/org/tron/common/crypto/pqc/program/PqcClient.java index 66cc87533d7..79b8c8e4b89 100644 --- a/framework/src/test/java/org/tron/common/crypto/pqc/PqcClient.java +++ b/framework/src/test/java/org/tron/common/crypto/pqc/program/PqcClient.java @@ -1,4 +1,4 @@ -package org.tron.common.crypto.pqc; +package org.tron.common.crypto.pqc.program; import com.google.protobuf.Any; import com.google.protobuf.ByteString; @@ -12,6 +12,8 @@ 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.AuthWitness; import org.tron.protos.Protocol.Block; @@ -28,9 +30,9 @@ * * Usage: * Terminal 1 — start the witness node first: - * ./gradlew :framework:run -PmainClass=org.tron.common.crypto.pqc.PqcWitnessNode + * ./gradlew :framework:run -PmainClass=org.tron.common.crypto.pqc.program.PqcWitnessNode * Terminal 2 — broadcast a PQC transaction: - * ./gradlew :framework:run -PmainClass=org.tron.common.crypto.pqc.PqcClient + * ./gradlew :framework:run -PmainClass=org.tron.common.crypto.pqc.program.PqcClient * * Optional JVM args: * -Dpqc.host=localhost (default: localhost) @@ -48,6 +50,12 @@ public class PqcClient { 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 PqcWitnessNode ───── byte[] userSeed = new byte[32]; Arrays.fill(userSeed, (byte) 0x02); diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/PqcWitnessNode.java b/framework/src/test/java/org/tron/common/crypto/pqc/program/PqcWitnessNode.java similarity index 75% rename from framework/src/test/java/org/tron/common/crypto/pqc/PqcWitnessNode.java rename to framework/src/test/java/org/tron/common/crypto/pqc/program/PqcWitnessNode.java index 4c418cb96c3..01f0145d037 100644 --- a/framework/src/test/java/org/tron/common/crypto/pqc/PqcWitnessNode.java +++ b/framework/src/test/java/org/tron/common/crypto/pqc/program/PqcWitnessNode.java @@ -1,4 +1,4 @@ -package org.tron.common.crypto.pqc; +package org.tron.common.crypto.pqc.program; import com.google.protobuf.ByteString; import java.io.File; @@ -9,8 +9,9 @@ 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.crypto.pqc.MLDSA65; import org.tron.common.utils.ByteArray; -import org.tron.common.utils.LocalWitnesses; import org.tron.core.ChainBaseManager; import org.tron.core.capsule.AccountCapsule; import org.tron.core.capsule.WitnessCapsule; @@ -37,9 +38,9 @@ * * Usage: * Terminal 1 — start this node: - * ./gradlew :framework:run -PmainClass=org.tron.common.crypto.pqc.PqcWitnessNode + * ./gradlew :framework:run -PmainClass=org.tron.common.crypto.pqc.program.PqcWitnessNode * Terminal 2 — broadcast a PQC transaction: - * ./gradlew :framework:run -PmainClass=org.tron.common.crypto.pqc.PqcClient + * ./gradlew :framework:run -PmainClass=org.tron.common.crypto.pqc.program.PqcClient */ public class PqcWitnessNode { @@ -51,29 +52,39 @@ public class PqcWitnessNode { /** 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 ────────────────────────────────── MLDSA65 witnessKp = new MLDSA65(WITNESS_SEED); MLDSA44 userKp = new MLDSA44(USER_SEED); byte[] witnessPub = witnessKp.getPublicKey(); - byte[] witnessPriv = witnessKp.getPrivateKey(); byte[] witnessAddr = MLDSA65.computeAddress(witnessPub); - ByteString witnessAddrBs = ByteString.copyFrom(witnessAddr); - byte[] userPub = userKp.getPublicKey(); byte[] signerAddr = MLDSA44.computeAddress(userPub); - ByteString signerAddrBs = ByteString.copyFrom(signerAddr); System.out.println("=== PQC Witness Node ==="); System.out.println("Witness address (ML-DSA-65): " + 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(); @@ -83,10 +94,12 @@ public static void main(String[] args) throws Exception { "config-test.conf"); Args.getInstance().setRpcEnable(true); Args.getInstance().setFullNodeHttpEnable(true); + Args.getInstance().setFullNodeHttpPort(HTTP_PORT); Args.getInstance().setRpcPort(GRPC_PORT); - Args.getInstance().setP2pDisable(true); + 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); @@ -94,11 +107,44 @@ public static void main(String[] args) throws Exception { Manager db = context.getBean(Manager.class); ChainBaseManager chain = context.getBean(ChainBaseManager.class); - // ── 4. Enable PQ features ───────────────────────────────────────────── + // ── 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 PqcClient 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 PqcWitnessNode 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 = MLDSA65.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); - // ── 5. Register witness account with ML-DSA-65 witness permission ───── + // Witness account with ML-DSA-65 witness permission. Permission witnessPerm = Permission.newBuilder() .setType(PermissionType.Witness) .setId(1).setPermissionName("witness").setThreshold(1) @@ -118,7 +164,7 @@ public static void main(String[] args) throws Exception { chain.getWitnessScheduleStore().saveActiveWitnesses(new ArrayList<>()); chain.addWitness(witnessAddrBs); - // ── 6. Register user account with ML-DSA-44 owner permission ───────── + // User account with ML-DSA-44 owner permission. Permission userOwnerPerm = Permission.newBuilder() .setType(PermissionType.Owner).setPermissionName("owner").setThreshold(1) .addKeys(Key.newBuilder() @@ -131,23 +177,6 @@ public static void main(String[] args) throws Exception { userCapsule.setBalance(100_000_000L); // 100 TRX userCapsule.updatePermissions(userOwnerPerm, null, Collections.emptyList()); db.getAccountStore().put(USER_ADDR, userCapsule); - - // ── 7. Start consensus (DposTask auto-produces blocks) ──────────────── - context.getBean(ConsensusService.class).start(); - - // ── 8. Start gRPC server ────────────────────────────────────────────── - app.startup(); - - System.out.println("\nNode is running. Send Ctrl-C to stop."); - System.out.println("Run PqcClient in another terminal to broadcast a PQC transaction.\n"); - - Runtime.getRuntime().addShutdownHook(new Thread(() -> { - System.out.println("Shutting down..."); - context.close(); - Args.clearParam(); - })); - - Thread.currentThread().join(); // block until Ctrl-C } private static byte[] filledSeed(int value) { From a08de98249a5bc58ebd1fec6630088ce5f179079 Mon Sep 17 00:00:00 2001 From: federico Date: Sun, 26 Apr 2026 21:16:47 +0800 Subject: [PATCH 04/17] feat(crypto): configurable PQ witness scheme --- .../AccountPermissionUpdateActuator.java | 5 +- .../org/tron/common/utils/LocalWitnesses.java | 14 ++-- .../org/tron/core/capsule/BlockCapsule.java | 8 ++- .../crypto/pqc/PqSignatureRegistry.java | 65 +++++++++++++++---- .../java/org/tron/core/config/args/Args.java | 10 +++ .../org/tron/core/config/args/ConfigKey.java | 1 + .../tron/core/consensus/ConsensusService.java | 20 +++--- .../main/java/org/tron/core/db/Manager.java | 13 ++-- framework/src/main/resources/config.conf | 8 ++- .../common/crypto/pqc/program/PqFullNode.java | 9 ++- .../crypto/pqc/program/PqcWitnessNode.java | 17 +++-- .../AccountPermissionUpdateActuatorTest.java | 9 +-- framework/src/test/resources/config-test.conf | 1 + 13 files changed, 122 insertions(+), 58 deletions(-) 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 5f94917e50d..10abdffec4c 100644 --- a/actuator/src/main/java/org/tron/core/actuator/AccountPermissionUpdateActuator.java +++ b/actuator/src/main/java/org/tron/core/actuator/AccountPermissionUpdateActuator.java @@ -18,6 +18,7 @@ import org.tron.core.store.DynamicPropertiesStore; 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; @@ -294,9 +295,9 @@ private void validatePermissionScheme(Permission permission) throws ContractVali if (permission.getType() == PermissionType.Witness && first != SignatureScheme.UNKNOWN_SIG_SCHEME - && first != SignatureScheme.ML_DSA_65) { + && !PqSignatureRegistry.contains(first)) { throw new ContractValidateException( - "Witness permission only supports ML_DSA_65 or legacy scheme, got " + first); + "Witness permission only supports legacy or registered PQ schemes, got " + first); } } 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 039b44ba114..ad9246d5502 100644 --- a/chainbase/src/main/java/org/tron/common/utils/LocalWitnesses.java +++ b/chainbase/src/main/java/org/tron/common/utils/LocalWitnesses.java @@ -28,6 +28,7 @@ import org.tron.common.crypto.pqc.MLDSA65; 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 { @@ -35,10 +36,15 @@ public class LocalWitnesses { @Getter private List privateKeys = Lists.newArrayList(); - /** ML-DSA-65 seed values in hex format (64 hex chars = 32 bytes). */ + /** ML-DSA seed values in hex format (64 hex chars = 32 bytes). */ @Getter private List pqSeeds = Lists.newArrayList(); + /** PQ signature scheme used to derive keys from {@link #pqSeeds}. */ + @Getter + @Setter + private SignatureScheme pqScheme = SignatureScheme.ML_DSA_65; + @Setter @Getter private byte[] witnessAccountAddress; @@ -102,7 +108,7 @@ public void addPrivateKeys(String privateKey) { this.privateKeys.add(privateKey); } - /** ML-DSA-65 seed values (32 bytes = 64 hex chars). Keys are derived from seeds. */ + /** ML-DSA seed values (32 bytes = 64 hex chars). Keys are derived from seeds. */ public void setPqSeeds(final List pqSeeds) { if (CollectionUtils.isEmpty(pqSeeds)) { return; @@ -120,12 +126,12 @@ private static void validatePqSeed(String seed) { } int expectedHexLen = MLDSA65.SEED_LENGTH * 2; if (StringUtils.isBlank(hex) || hex.length() != expectedHexLen) { - throw new TronError(String.format("ML-DSA-65 seed must be %d hex chars, actual: %d", + throw new TronError(String.format("ML-DSA 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("ML-DSA-65 seed must be hex string", + throw new TronError("ML-DSA seed must be hex string", TronError.ErrCode.WITNESS_INIT); } } 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 1b46cd6d8d8..b4c6e596e3d 100755 --- a/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java +++ b/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java @@ -227,9 +227,11 @@ private boolean validateLegacySignature(DynamicPropertiesStore dynamicProperties if (accountCapsule != null && accountCapsule.getInstance().hasWitnessPermission()) { Permission witnessPermission = accountCapsule.getInstance().getWitnessPermission(); if (witnessPermission.getKeysCount() > 0 - && witnessPermission.getKeys(0).getScheme() == SignatureScheme.ML_DSA_65) { + && PqSignatureRegistry.contains(witnessPermission.getKeys(0).getScheme())) { throw new ValidateSignatureException( - "witness permission requires ML_DSA_65 but witness_signature is legacy"); + "witness permission requires PQ scheme " + + witnessPermission.getKeys(0).getScheme() + + " but witness_signature is legacy"); } } } @@ -266,7 +268,7 @@ private boolean validateWitnessAuth(DynamicPropertiesStore dynamicPropertiesStor "witness_auth present but witness permission is not configured"); } SignatureScheme scheme = witnessPermission.getKeys(0).getScheme(); - if (scheme != SignatureScheme.ML_DSA_65) { + if (!PqSignatureRegistry.contains(scheme)) { throw new ValidateSignatureException( "witness permission scheme " + scheme + " is not allowed for block signing"); } 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 index 284dba565e6..7843ad66b2d 100644 --- a/crypto/src/main/java/org/tron/common/crypto/pqc/PqSignatureRegistry.java +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/PqSignatureRegistry.java @@ -6,29 +6,32 @@ import org.tron.protos.Protocol.SignatureScheme; /** - * Static dispatch table for post-quantum signature verification keyed by + * 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 a stateless verify function (typically a method - * reference to the concrete implementation's {@code verify}). Legacy + * 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 { - @FunctionalInterface - public interface Verifier { + /** 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 Verifier verifier; + final SignatureOps ops; - SchemeInfo(int publicKeyLength, int signatureLength, Verifier verifier) { + SchemeInfo(int publicKeyLength, int signatureLength, SignatureOps ops) { this.publicKeyLength = publicKeyLength; this.signatureLength = signatureLength; - this.verifier = verifier; + this.ops = ops; } } @@ -37,9 +40,39 @@ private static final class SchemeInfo { static { EnumMap m = new EnumMap<>(SignatureScheme.class); m.put(SignatureScheme.ML_DSA_44, new SchemeInfo( - MLDSA44.PUBLIC_KEY_LENGTH, MLDSA44.SIGNATURE_LENGTH, MLDSA44::verify)); + MLDSA44.PUBLIC_KEY_LENGTH, MLDSA44.SIGNATURE_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::verify)); + MLDSA65.PUBLIC_KEY_LENGTH, MLDSA65.SIGNATURE_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); + } + })); SCHEMES = Collections.unmodifiableMap(m); } @@ -58,9 +91,17 @@ public static int getSignatureLength(SignatureScheme scheme) { return require(scheme).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).verifier.verify(publicKey, message, signature); + return require(scheme).ops.verify(publicKey, message, signature); + } + + public static PqSignature fromSeed(SignatureScheme scheme, byte[] seed) { + return require(scheme).ops.fromSeed(seed); } private static SchemeInfo require(SignatureScheme scheme) { @@ -71,4 +112,4 @@ private static SchemeInfo require(SignatureScheme scheme) { } return info; } -} +} \ No newline at end of file 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 e0716e9be31..3db9c81330f 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 @@ -78,6 +78,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 @@ -1229,6 +1230,15 @@ private static void initLocalWitnesses(Config config, CLIParameter cmd) { List pqSeeds = config.getStringList(ConfigKey.LOCAL_WITNESS_SEED_PQ); if (!pqSeeds.isEmpty()) { localWitnesses.setPqSeeds(pqSeeds); + if (config.hasPath(ConfigKey.LOCAL_WITNESS_SEED_PQ_SCHEME)) { + String schemeName = config.getString(ConfigKey.LOCAL_WITNESS_SEED_PQ_SCHEME); + try { + localWitnesses.setPqScheme(SignatureScheme.valueOf(schemeName)); + } catch (IllegalArgumentException e) { + throw new TronError("invalid " + ConfigKey.LOCAL_WITNESS_SEED_PQ_SCHEME + + ": " + schemeName, TronError.ErrCode.WITNESS_INIT); + } + } byte[] address = WitnessInitializer.resolvePqWitnessAddress(witnessAddr); if (address != null) { localWitnesses.setWitnessAccountAddress(address); 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 ba71236a4c3..5f8def68eeb 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 @@ -14,6 +14,7 @@ private ConfigKey() { 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"; 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 1f8e057f381..cebae77fccc 100644 --- a/framework/src/main/java/org/tron/core/consensus/ConsensusService.java +++ b/framework/src/main/java/org/tron/core/consensus/ConsensusService.java @@ -10,7 +10,8 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.tron.common.crypto.SignUtils; -import org.tron.common.crypto.pqc.MLDSA65; +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; @@ -18,6 +19,7 @@ import org.tron.core.capsule.WitnessCapsule; import org.tron.core.config.args.Args; import org.tron.core.store.WitnessStore; +import org.tron.protos.Protocol.SignatureScheme; @Slf4j(topic = "consensus") @Component @@ -79,12 +81,13 @@ public void start() { ByteString.copyFrom(witnessAddress)); miners.add(miner); } else if (pqSeeds.size() > 1) { + SignatureScheme scheme = Args.getLocalWitnesses().getPqScheme(); for (String seed : pqSeeds) { byte[] seedBytes = fromHexString(seed); - MLDSA65 keypair = new MLDSA65(seedBytes); + PqSignature keypair = PqSignatureRegistry.fromSeed(scheme, seedBytes); byte[] sk = keypair.getPrivateKey(); byte[] pk = keypair.getPublicKey(); - byte[] pqAddress = MLDSA65.computeAddress(pk); + byte[] pqAddress = keypair.getAddress(); WitnessCapsule witnessCapsule = witnessStore.get(pqAddress); if (null == witnessCapsule) { logger.warn("Witness {} is not in witnessStore.", Hex.toHexString(pqAddress)); @@ -94,8 +97,8 @@ public void start() { miner.setPqPrivateKey(sk); miner.setPqPublicKey(pk); miners.add(miner); - logger.info("Add ML-DSA witness (from seed): {}, size: {}", - Hex.toHexString(pqAddress), miners.size()); + 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))); @@ -109,11 +112,12 @@ public void start() { } private Miner buildPqOnlyMinerFromSeed(Param param, String pqSeed) { + SignatureScheme scheme = Args.getLocalWitnesses().getPqScheme(); byte[] seedBytes = fromHexString(pqSeed); - MLDSA65 keypair = new MLDSA65(seedBytes); + PqSignature keypair = PqSignatureRegistry.fromSeed(scheme, seedBytes); byte[] sk = keypair.getPrivateKey(); byte[] pk = keypair.getPublicKey(); - byte[] pqAddress = MLDSA65.computeAddress(pk); + byte[] pqAddress = keypair.getAddress(); byte[] witnessAddress = Args.getLocalWitnesses().getWitnessAccountAddress(); if (witnessAddress == null || witnessAddress.length == 0) { witnessAddress = pqAddress; @@ -127,7 +131,7 @@ private Miner buildPqOnlyMinerFromSeed(Param param, String pqSeed) { ByteString.copyFrom(witnessAddress)); miner.setPqPrivateKey(sk); miner.setPqPublicKey(pk); - logger.info("Add ML-DSA witness (from seed): {}", Hex.toHexString(witnessAddress)); + logger.info("Add {} witness (from seed): {}", scheme, Hex.toHexString(witnessAddress)); return miner; } diff --git a/framework/src/main/java/org/tron/core/db/Manager.java b/framework/src/main/java/org/tron/core/db/Manager.java index 33cf1852482..16b943c8ecf 100644 --- a/framework/src/main/java/org/tron/core/db/Manager.java +++ b/framework/src/main/java/org/tron/core/db/Manager.java @@ -54,8 +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.MLDSA65; 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; @@ -1760,8 +1760,8 @@ public BlockCapsule generateBlock(Miner miner, long blockTime, long timeout) { private void signBlockCapsule(BlockCapsule blockCapsule, Miner miner) { SignatureScheme scheme = resolveWitnessScheme(miner); - if (scheme == SignatureScheme.ML_DSA_65) { - signWitnessAuth(blockCapsule, miner); + if (PqSignatureRegistry.contains(scheme)) { + signWitnessAuth(blockCapsule, miner, scheme); } else { blockCapsule.sign(miner.getPrivateKey()); } @@ -1783,18 +1783,19 @@ private SignatureScheme resolveWitnessScheme(Miner miner) { return witnessPermission.getKeys(0).getScheme(); } - private void signWitnessAuth(BlockCapsule blockCapsule, Miner miner) { + 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 ML_DSA_65 but local PQ private key is not configured"); + "witness permission requires " + scheme + + " but local PQ private key is not configured"); } byte[] signerAddress = witnessPermission.getKeys(0).getAddress().toByteArray(); byte[] digest = PqAuthDigest.block(blockCapsule.getRawHashBytes(), signerAddress); - byte[] signature = MLDSA65.sign(pqPrivateKey, digest); + byte[] signature = PqSignatureRegistry.sign(scheme, pqPrivateKey, digest); AuthWitness witnessAuth = AuthWitness.newBuilder() .setSignerAddress(ByteString.copyFrom(signerAddress)) .setSignature(ByteString.copyFrom(signature)) diff --git a/framework/src/main/resources/config.conf b/framework/src/main/resources/config.conf index a011a882c97..8406785ef03 100644 --- a/framework/src/main/resources/config.conf +++ b/framework/src/main/resources/config.conf @@ -673,8 +673,12 @@ localwitness = [ # "localwitnesskeystore.json" # ] -# ML-DSA-65 witness signing seed (FIPS 204), 32-byte hex. Used only after -# ALLOW_ML_DSA = 1 and the witness Permission is upgraded to scheme = ML_DSA_65. +# Scheme used to derive keys from localwitness_seed_pq. Defaults to ML_DSA_65. +# Allowed values: ML_DSA_44, ML_DSA_65. +# localwitness_seed_pq_scheme = "ML_DSA_65" + +# ML-DSA witness signing seed (FIPS 204), 32-byte hex. Used only after +# ALLOW_ML_DSA = 1 and the witness Permission is upgraded to an ML-DSA scheme. # MUST be produced by a CSPRNG; the value below is an example, never use in prod. # localwitness_seed_pq = [ # "0101010101010101010101010101010101010101010101010101010101010101" diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/program/PqFullNode.java b/framework/src/test/java/org/tron/common/crypto/pqc/program/PqFullNode.java index 738064a529c..a47878281ea 100644 --- a/framework/src/test/java/org/tron/common/crypto/pqc/program/PqFullNode.java +++ b/framework/src/test/java/org/tron/common/crypto/pqc/program/PqFullNode.java @@ -9,7 +9,6 @@ import org.tron.common.application.ApplicationFactory; import org.tron.common.application.TronApplicationContext; import org.tron.common.crypto.pqc.MLDSA44; -import org.tron.common.crypto.pqc.MLDSA65; import org.tron.common.utils.ByteArray; import org.tron.core.ChainBaseManager; import org.tron.core.config.DefaultConfig; @@ -20,7 +19,7 @@ * Demo fullnode that dials {@link PqcWitnessNode} via P2P and syncs PQ-signed blocks. * * Both nodes share the same deterministic PQ genesis pre-state (witness account with an - * ML-DSA-65 witness permission + demo user account with an ML-DSA-44 owner permission), + * ML-DSA-44 witness permission + demo user account with an ML-DSA-44 owner permission), * installed via {@link PqcWitnessNode#installPqGenesisState}. Once the witness produces * a block it is broadcast over P2P; this node validates {@code BlockHeader.witness_auth} * against the same on-chain public key and applies the block. @@ -57,7 +56,7 @@ public static void main(String[] args) throws Exception { .setLevel(ch.qos.logback.classic.Level.INFO); // ── 1. Derive the same deterministic keys used by PqcWitnessNode ────── - MLDSA65 witnessKp = new MLDSA65(PqcWitnessNode.WITNESS_SEED); + MLDSA44 witnessKp = new MLDSA44(PqcWitnessNode.WITNESS_SEED); MLDSA44 userKp = new MLDSA44(PqcWitnessNode.USER_SEED); byte[] witnessPub = witnessKp.getPublicKey(); @@ -69,7 +68,7 @@ public static void main(String[] args) throws Exception { System.out.println("HTTP port: " + HTTP_PORT); System.out.println("P2P port: " + P2P_PORT); System.out.println("Witness address (expected): " - + ByteArray.toHexString(MLDSA65.computeAddress(witnessPub))); + + ByteArray.toHexString(MLDSA44.computeAddress(witnessPub))); // ── 2. Configure node (no -w: this is a pure fullnode) ──────────────── File dbDir = Files.createTempDirectory("pqc-fullnode-").toFile(); @@ -99,7 +98,7 @@ public static void main(String[] args) throws Exception { // ── 4. Install matching PQ genesis pre-state ────────────────────────── // Without this the incoming witness_auth would fail to validate because - // this node wouldn't know the witness's ML-DSA-65 public key. + // this node wouldn't know the witness's ML-DSA-44 public key. PqcWitnessNode.installPqGenesisState(db, chain, witnessPub, userPub); // ── 5. Start P2P + gRPC (no ConsensusService.start — we don't produce) ─ diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/program/PqcWitnessNode.java b/framework/src/test/java/org/tron/common/crypto/pqc/program/PqcWitnessNode.java index 01f0145d037..bf133036e2c 100644 --- a/framework/src/test/java/org/tron/common/crypto/pqc/program/PqcWitnessNode.java +++ b/framework/src/test/java/org/tron/common/crypto/pqc/program/PqcWitnessNode.java @@ -10,7 +10,6 @@ import org.tron.common.application.ApplicationFactory; import org.tron.common.application.TronApplicationContext; import org.tron.common.crypto.pqc.MLDSA44; -import org.tron.common.crypto.pqc.MLDSA65; import org.tron.common.utils.ByteArray; import org.tron.core.ChainBaseManager; import org.tron.core.capsule.AccountCapsule; @@ -27,7 +26,7 @@ import org.tron.protos.Protocol.SignatureScheme; /** - * Demo witness node with ML-DSA-65 block production. + * 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 @@ -44,7 +43,7 @@ */ public class PqcWitnessNode { - /** Fixed seed for the ML-DSA-65 witness keypair (shared with PqcClient for derivation). */ + /** Fixed seed for the ML-DSA-44 witness keypair (shared with PqcClient for derivation). */ static final byte[] WITNESS_SEED = filledSeed(0x01); /** Fixed seed for the ML-DSA-44 user keypair (shared with PqcClient for derivation). */ static final byte[] USER_SEED = filledSeed(0x02); @@ -70,16 +69,16 @@ public static void main(String[] args) throws Exception { .setLevel(ch.qos.logback.classic.Level.INFO); // ── 1. Derive deterministic keypairs ────────────────────────────────── - MLDSA65 witnessKp = new MLDSA65(WITNESS_SEED); + MLDSA44 witnessKp = new MLDSA44(WITNESS_SEED); MLDSA44 userKp = new MLDSA44(USER_SEED); byte[] witnessPub = witnessKp.getPublicKey(); - byte[] witnessAddr = MLDSA65.computeAddress(witnessPub); + 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-65): " + ByteArray.toHexString(witnessAddr)); + 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); @@ -135,7 +134,7 @@ public static void main(String[] args) throws Exception { */ static void installPqGenesisState(Manager db, ChainBaseManager chain, byte[] witnessPub, byte[] userPub) { - byte[] witnessAddr = MLDSA65.computeAddress(witnessPub); + byte[] witnessAddr = MLDSA44.computeAddress(witnessPub); ByteString witnessAddrBs = ByteString.copyFrom(witnessAddr); byte[] signerAddr = MLDSA44.computeAddress(userPub); ByteString signerAddrBs = ByteString.copyFrom(signerAddr); @@ -144,13 +143,13 @@ static void installPqGenesisState(Manager db, ChainBaseManager chain, db.getDynamicPropertiesStore().saveAllowMlDsa(1L); db.getDynamicPropertiesStore().saveAllowMultiSign(1L); - // Witness account with ML-DSA-65 witness permission. + // 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) - .setScheme(SignatureScheme.ML_DSA_65) + .setScheme(SignatureScheme.ML_DSA_44) .setPublicKey(ByteString.copyFrom(witnessPub))) .build(); db.getAccountStore().put(witnessAddr, new AccountCapsule(Account.newBuilder() 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 e2222644aad..2a359425023 100644 --- a/framework/src/test/java/org/tron/core/actuator/AccountPermissionUpdateActuatorTest.java +++ b/framework/src/test/java/org/tron/core/actuator/AccountPermissionUpdateActuatorTest.java @@ -1192,7 +1192,7 @@ public void mlDsa44WrongPublicKeyLengthRejected() { } @Test - public void witnessMlDsa44Rejected() { + public void witnessMlDsa44Accepted() throws ContractValidateException { dbManager.getDynamicPropertiesStore().saveAllowMlDsa(1L); ByteString address = ByteString.copyFrom(ByteArray.fromHexString(WITNESS_ADDRESS)); Permission owner = ownerPermissionWithKeys( @@ -1206,12 +1206,7 @@ public void witnessMlDsa44Rejected() { Any any = getContract(address, owner, witness, java.util.Collections.singletonList(active)); - try { - actuatorFor(any).validate(); - fail("Witness permission with ML-DSA-44 should be rejected"); - } catch (ContractValidateException e) { - Assert.assertTrue(e.getMessage().contains("Witness permission only supports ML_DSA_65")); - } + actuatorFor(any).validate(); } @Test diff --git a/framework/src/test/resources/config-test.conf b/framework/src/test/resources/config-test.conf index a3c98f2b3a7..f920e10253f 100644 --- a/framework/src/test/resources/config-test.conf +++ b/framework/src/test/resources/config-test.conf @@ -353,6 +353,7 @@ genesis.block = { localwitness = [ ] +localwitness_seed_pq_scheme = "ML_DSA_44" localwitness_seed_pq = [ "0101010101010101010101010101010101010101010101010101010101010101" ] From 51af6b6e91af8502d9b170c71c3b5cc6f114d6b3 Mon Sep 17 00:00:00 2001 From: federico Date: Sun, 26 Apr 2026 21:54:05 +0800 Subject: [PATCH 05/17] fix(crypto): pq witness init hardening and test fixes --- .../org/tron/common/utils/LocalWitnesses.java | 13 ++++++++++-- .../tron/core/capsule/TransactionCapsule.java | 6 +++++- .../java/org/tron/consensus/base/Param.java | 20 +++++++++++++++---- .../java/org/tron/core/config/args/Args.java | 1 + .../tron/core/consensus/ConsensusService.java | 10 ++++++++++ .../common/crypto/pqc/program/PqFullNode.java | 5 +++-- .../tron/core/capsule/BlockCapsulePqTest.java | 2 +- .../core/capsule/TransactionCapsuleTest.java | 6 +++--- .../tron/core/exception/TronErrorTest.java | 12 +++++++++-- 9 files changed, 60 insertions(+), 15 deletions(-) diff --git a/chainbase/src/main/java/org/tron/common/utils/LocalWitnesses.java b/chainbase/src/main/java/org/tron/common/utils/LocalWitnesses.java index ad9246d5502..f078d2113a9 100644 --- a/chainbase/src/main/java/org/tron/common/utils/LocalWitnesses.java +++ b/chainbase/src/main/java/org/tron/common/utils/LocalWitnesses.java @@ -26,6 +26,7 @@ import org.tron.common.crypto.SignInterface; import org.tron.common.crypto.SignUtils; import org.tron.common.crypto.pqc.MLDSA65; +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; @@ -42,9 +43,16 @@ public class LocalWitnesses { /** PQ signature scheme used to derive keys from {@link #pqSeeds}. */ @Getter - @Setter 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; @@ -121,7 +129,8 @@ public void setPqSeeds(final List pqSeeds) { private static void validatePqSeed(String seed) { String hex = seed; - if (StringUtils.startsWithIgnoreCase(hex, "0X")) { + // Match downstream ByteArray.fromHexString, which only strips lowercase "0x". + if (StringUtils.startsWith(hex, "0x")) { hex = hex.substring(2); } int expectedHexLen = MLDSA65.SEED_LENGTH * 2; 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 59066833a4a..0a853242923 100755 --- a/chainbase/src/main/java/org/tron/core/capsule/TransactionCapsule.java +++ b/chainbase/src/main/java/org/tron/core/capsule/TransactionCapsule.java @@ -758,7 +758,11 @@ static boolean validateStructuredSignature(Transaction transaction, if (!PqSignatureRegistry.verify(scheme, pk, digest, sig)) { throw new PermissionException("pq sig invalid"); } - weight = StrictMathWrapper.addExact(weight, key.getWeight()); + try { + weight = StrictMathWrapper.addExact(weight, key.getWeight()); + } catch (ArithmeticException e) { + throw new PermissionException("weight overflow"); + } } return weight >= permission.getThreshold(); } 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 060d1627f53..c82afded31f 100644 --- a/consensus/src/main/java/org/tron/consensus/base/Param.java +++ b/consensus/src/main/java/org/tron/consensus/base/Param.java @@ -67,12 +67,8 @@ public class Miner { @Setter private ByteString witnessAddress; - @Getter - @Setter private byte[] pqPrivateKey; - @Getter - @Setter private byte[] pqPublicKey; public Miner(byte[] privateKey, ByteString privateKeyAddress, ByteString witnessAddress) { @@ -80,6 +76,22 @@ public Miner(byte[] privateKey, ByteString privateKeyAddress, ByteString witness 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/framework/src/main/java/org/tron/core/config/args/Args.java b/framework/src/main/java/org/tron/core/config/args/Args.java index 3db9c81330f..2e749e3e39c 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 @@ -1229,6 +1229,7 @@ private static void initLocalWitnesses(Config config, CLIParameter cmd) { if (config.hasPath(ConfigKey.LOCAL_WITNESS_SEED_PQ)) { List pqSeeds = config.getStringList(ConfigKey.LOCAL_WITNESS_SEED_PQ); if (!pqSeeds.isEmpty()) { + localWitnesses = new LocalWitnesses(); localWitnesses.setPqSeeds(pqSeeds); if (config.hasPath(ConfigKey.LOCAL_WITNESS_SEED_PQ_SCHEME)) { String schemeName = config.getString(ConfigKey.LOCAL_WITNESS_SEED_PQ_SCHEME); 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 cebae77fccc..69b6fe4f0f7 100644 --- a/framework/src/main/java/org/tron/core/consensus/ConsensusService.java +++ b/framework/src/main/java/org/tron/core/consensus/ConsensusService.java @@ -18,6 +18,7 @@ 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; @@ -82,6 +83,7 @@ public void start() { 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); @@ -113,6 +115,7 @@ public void start() { 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(); @@ -135,6 +138,13 @@ private Miner buildPqOnlyMinerFromSeed(Param param, String pqSeed) { 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/test/java/org/tron/common/crypto/pqc/program/PqFullNode.java b/framework/src/test/java/org/tron/common/crypto/pqc/program/PqFullNode.java index a47878281ea..2a71ef6f794 100644 --- a/framework/src/test/java/org/tron/common/crypto/pqc/program/PqFullNode.java +++ b/framework/src/test/java/org/tron/common/crypto/pqc/program/PqFullNode.java @@ -87,8 +87,9 @@ public static void main(String[] args) throws Exception { Args.getInstance().genesisBlock.setWitnesses(new ArrayList<>()); // Point to the witness node as the only seed peer. - Args.getInstance().getSeedNode().setAddressList(Collections.singletonList( - new InetSocketAddress(WITNESS_HOST, WITNESS_P2P_PORT))); + // 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); diff --git a/framework/src/test/java/org/tron/core/capsule/BlockCapsulePqTest.java b/framework/src/test/java/org/tron/core/capsule/BlockCapsulePqTest.java index 43dc4dc9de1..dddb9ad6675 100644 --- a/framework/src/test/java/org/tron/core/capsule/BlockCapsulePqTest.java +++ b/framework/src/test/java/org/tron/core/capsule/BlockCapsulePqTest.java @@ -2,13 +2,13 @@ import com.google.protobuf.ByteString; import org.junit.Assert; -import org.tron.common.crypto.pqc.MLDSA65; 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; 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 f3ee6e3e826..a9a662a69ce 100644 --- a/framework/src/test/java/org/tron/core/capsule/TransactionCapsuleTest.java +++ b/framework/src/test/java/org/tron/core/capsule/TransactionCapsuleTest.java @@ -8,14 +8,14 @@ import com.google.protobuf.ByteString; import lombok.extern.slf4j.Slf4j; import org.junit.Assert; -import org.tron.common.crypto.ECKey; -import org.tron.common.crypto.pqc.MLDSA44; -import org.tron.common.crypto.pqc.MLDSA65; 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.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; 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()); } From dc59e65650ac7cdfc1c1c74e8d8360b1af75f6f7 Mon Sep 17 00:00:00 2001 From: federico Date: Mon, 27 Apr 2026 11:18:16 +0800 Subject: [PATCH 06/17] feat(crypto): per-scheme PQ activation flags and proposal governance --- .../AccountPermissionUpdateActuator.java | 19 ++-- .../org/tron/core/utils/ProposalUtil.java | 40 ++++++- .../org/tron/core/capsule/BlockCapsule.java | 14 ++- .../tron/core/capsule/TransactionCapsule.java | 8 +- .../core/store/DynamicPropertiesStore.java | 106 ++++++++++++++++-- .../common/parameter/CommonParameter.java | 18 ++- .../src/main/java/org/tron/core/Wallet.java | 24 +++- .../java/org/tron/core/config/args/Args.java | 22 +++- .../org/tron/core/config/args/ConfigKey.java | 7 +- .../tron/core/consensus/ProposalService.java | 20 +++- .../main/java/org/tron/core/db/Manager.java | 8 +- .../crypto/pqc/program/PqcWitnessNode.java | 2 +- .../AccountPermissionUpdateActuatorTest.java | 25 +++-- .../core/actuator/utils/ProposalUtilTest.java | 50 ++++++--- .../tron/core/capsule/BlockCapsulePqTest.java | 27 +++-- .../core/capsule/TransactionCapsuleTest.java | 18 +-- .../core/services/ProposalServiceTest.java | 14 +-- protocol/src/main/protos/core/Tron.proto | 5 +- 18 files changed, 338 insertions(+), 89 deletions(-) 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 10abdffec4c..c90bc3abce9 100644 --- a/actuator/src/main/java/org/tron/core/actuator/AccountPermissionUpdateActuator.java +++ b/actuator/src/main/java/org/tron/core/actuator/AccountPermissionUpdateActuator.java @@ -261,7 +261,6 @@ public long calcFee() { private void validatePermissionScheme(Permission permission) throws ContractValidateException { DynamicPropertiesStore dynamicStore = chainBaseManager.getDynamicPropertiesStore(); - boolean mlDsaAllowed = dynamicStore.allowMlDsa(); SignatureScheme first = permission.getKeysList().get(0).getScheme(); for (Key key : permission.getKeysList()) { @@ -276,9 +275,9 @@ private void validatePermissionScheme(Permission permission) throws ContractVali "public_key must be empty when scheme is UNKNOWN_SIG_SCHEME"); } } else { - if (!mlDsaAllowed) { + if (!dynamicStore.isPqSchemeAllowed(scheme)) { throw new ContractValidateException( - "ML-DSA is not activated, scheme " + scheme + " is not allowed"); + scheme + " is not activated, this scheme is not allowed"); } int expected = expectedPublicKeyLength(scheme); if (expected < 0) { @@ -294,10 +293,16 @@ private void validatePermissionScheme(Permission permission) throws ContractVali } 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); + && first != SignatureScheme.UNKNOWN_SIG_SCHEME) { + if (first == SignatureScheme.EPHEMERAL_SECP256K1) { + throw new ContractValidateException( + "EPHEMERAL_SECP256K1 is incompatible with witness block production " + + "and is permanently rejected for Witness permission"); + } + if (!PqSignatureRegistry.contains(first)) { + throw new ContractValidateException( + "Witness permission only supports legacy or registered PQ schemes, got " + first); + } } } 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 383bdd37879..93dc084c4d3 100644 --- a/actuator/src/main/java/org/tron/core/utils/ProposalUtil.java +++ b/actuator/src/main/java/org/tron/core/utils/ProposalUtil.java @@ -886,14 +886,38 @@ public static void validator(DynamicPropertiesStore dynamicPropertiesStore, } break; } - case ALLOW_ML_DSA: { - if (dynamicPropertiesStore.getAllowMlDsa() == 1) { + case ALLOW_ML_DSA_44: { + if (value != 0 && value != 1) { throw new ContractValidateException( - "[ALLOW_ML_DSA] has been valid, no need to propose again"); + "This value[ALLOW_ML_DSA_44] is only allowed to be 0 or 1"); } - if (value != 1) { + break; + } + case ALLOW_ML_DSA_65: { + if (value != 0 && value != 1) { + throw new ContractValidateException( + "This value[ALLOW_ML_DSA_65] is only allowed to be 0 or 1"); + } + break; + } + case ALLOW_SLH_DSA: { + if (value != 0 && value != 1) { + throw new ContractValidateException( + "This value[ALLOW_SLH_DSA] is only allowed to be 0 or 1"); + } + break; + } + case ALLOW_FN_DSA: { + if (value != 0 && value != 1) { + throw new ContractValidateException( + "This value[ALLOW_FN_DSA] is only allowed to be 0 or 1"); + } + break; + } + case ALLOW_EPHEMERAL_SECP256K1: { + if (value != 0 && value != 1) { throw new ContractValidateException( - "This value[ALLOW_ML_DSA] is only allowed to be 1"); + "This value[ALLOW_EPHEMERAL_SECP256K1] is only allowed to be 0 or 1"); } break; } @@ -983,7 +1007,11 @@ public enum ProposalType { // current value, value range PROPOSAL_EXPIRE_TIME(92), // (0, 31536003000) ALLOW_TVM_SELFDESTRUCT_RESTRICTION(94), // 0, 1 ALLOW_TVM_OSAKA(96), // 0, 1 - ALLOW_ML_DSA(97); // 0, 1 + ALLOW_ML_DSA_44(97), // 0, 1 (renamed from ALLOW_ML_DSA; ID preserved) + ALLOW_ML_DSA_65(98), // 0, 1 + ALLOW_SLH_DSA(99), // 0, 1 + ALLOW_FN_DSA(100), // 0, 1 + ALLOW_EPHEMERAL_SECP256K1(101); // 0, 1 private long code; 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 b4c6e596e3d..54d63b875c8 100755 --- a/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java +++ b/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java @@ -222,7 +222,7 @@ public boolean validateSignature(DynamicPropertiesStore dynamicPropertiesStore, private boolean validateLegacySignature(DynamicPropertiesStore dynamicPropertiesStore, AccountStore accountStore, byte[] witnessAccountAddress) throws ValidateSignatureException { - if (dynamicPropertiesStore.allowMlDsa()) { + if (dynamicPropertiesStore.isAnyPqSchemeAllowed()) { AccountCapsule accountCapsule = accountStore.get(witnessAccountAddress); if (accountCapsule != null && accountCapsule.getInstance().hasWitnessPermission()) { Permission witnessPermission = accountCapsule.getInstance().getWitnessPermission(); @@ -254,10 +254,6 @@ private boolean validateLegacySignature(DynamicPropertiesStore dynamicProperties private boolean validateWitnessAuth(DynamicPropertiesStore dynamicPropertiesStore, AccountStore accountStore, byte[] witnessAccountAddress, AuthWitness witnessAuth) throws ValidateSignatureException { - if (!dynamicPropertiesStore.allowMlDsa()) { - throw new ValidateSignatureException( - "witness_auth present but ML-DSA is not activated"); - } AccountCapsule accountCapsule = accountStore.get(witnessAccountAddress); Permission witnessPermission = null; if (accountCapsule != null && accountCapsule.getInstance().hasWitnessPermission()) { @@ -268,10 +264,18 @@ private boolean validateWitnessAuth(DynamicPropertiesStore dynamicPropertiesStor "witness_auth present but witness permission is not configured"); } SignatureScheme scheme = witnessPermission.getKeys(0).getScheme(); + if (scheme == SignatureScheme.EPHEMERAL_SECP256K1) { + throw new ValidateSignatureException( + "EPHEMERAL_SECP256K1 is not allowed for witness permission"); + } 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_auth present but " + scheme + " is not activated"); + } byte[] signerAddr = witnessAuth.getSignerAddress().toByteArray(); Key matched = null; 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 0a853242923..6a271e5597a 100755 --- a/chainbase/src/main/java/org/tron/core/capsule/TransactionCapsule.java +++ b/chainbase/src/main/java/org/tron/core/capsule/TransactionCapsule.java @@ -650,8 +650,9 @@ public boolean validatePubSignature(AccountStore accountStore, int legacyCount = this.transaction.getSignatureCount(); int pqCount = this.transaction.getAuthWitnessCount(); - if (pqCount > 0 && !dynamicPropertiesStore.allowMlDsa()) { - throw new ValidateSignatureException("auth_witness not allowed: ML-DSA not activated"); + if (pqCount > 0 && !dynamicPropertiesStore.isAnyPqSchemeAllowed()) { + throw new ValidateSignatureException( + "auth_witness not allowed: no PQ scheme is activated"); } if (legacyCount > 0 && pqCount > 0) { throw new ValidateSignatureException( @@ -748,6 +749,9 @@ static boolean validateStructuredSignature(Transaction transaction, 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, signer.toByteArray()); byte[] pk = key.getPublicKey().toByteArray(); byte[] sig = aw.getSignature().toByteArray(); 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 e713cd51a4c..db4b3e7850e 100644 --- a/chainbase/src/main/java/org/tron/core/store/DynamicPropertiesStore.java +++ b/chainbase/src/main/java/org/tron/core/store/DynamicPropertiesStore.java @@ -24,6 +24,7 @@ import org.tron.core.db.TronStoreWithRevoking; import org.tron.core.exception.BadItemException; import org.tron.core.exception.ItemNotFoundException; +import org.tron.protos.Protocol.SignatureScheme; @Slf4j(topic = "DB") @Component @@ -240,7 +241,14 @@ 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(); + // Storage key preserved as "ALLOW_ML_DSA" for on-chain compat with + // add-ml-dsa-signature-support; Java symbol renamed to ALLOW_ML_DSA_44. + private static final byte[] ALLOW_ML_DSA_44 = "ALLOW_ML_DSA".getBytes(); + private static final byte[] ALLOW_ML_DSA_65 = "ALLOW_ML_DSA_65".getBytes(); + private static final byte[] ALLOW_SLH_DSA = "ALLOW_SLH_DSA".getBytes(); + private static final byte[] ALLOW_FN_DSA = "ALLOW_FN_DSA".getBytes(); + private static final byte[] ALLOW_EPHEMERAL_SECP256K1 = + "ALLOW_EPHEMERAL_SECP256K1".getBytes(); @Autowired private DynamicPropertiesStore(@Value("properties") String dbName) { @@ -2995,19 +3003,101 @@ 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)) + public long getAllowMlDsa44() { + return Optional.ofNullable(getUnchecked(ALLOW_ML_DSA_44)) .map(BytesCapsule::getData) .map(ByteArray::toLong) - .orElse(CommonParameter.getInstance().getAllowMlDsa()); + .orElse(CommonParameter.getInstance().getAllowMlDsa44()); } - public void saveAllowMlDsa(long value) { - this.put(ALLOW_ML_DSA, new BytesCapsule(ByteArray.fromLong(value))); + public void saveAllowMlDsa44(long value) { + this.put(ALLOW_ML_DSA_44, new BytesCapsule(ByteArray.fromLong(value))); } - public boolean allowMlDsa() { - return getAllowMlDsa() == 1L; + public boolean allowMlDsa44() { + return getAllowMlDsa44() == 1L; + } + + public long getAllowMlDsa65() { + return Optional.ofNullable(getUnchecked(ALLOW_ML_DSA_65)) + .map(BytesCapsule::getData) + .map(ByteArray::toLong) + .orElse(CommonParameter.getInstance().getAllowMlDsa65()); + } + + public void saveAllowMlDsa65(long value) { + this.put(ALLOW_ML_DSA_65, new BytesCapsule(ByteArray.fromLong(value))); + } + + public boolean allowMlDsa65() { + return getAllowMlDsa65() == 1L; + } + + public long getAllowSlhDsa() { + return Optional.ofNullable(getUnchecked(ALLOW_SLH_DSA)) + .map(BytesCapsule::getData) + .map(ByteArray::toLong) + .orElse(CommonParameter.getInstance().getAllowSlhDsa()); + } + + public void saveAllowSlhDsa(long value) { + this.put(ALLOW_SLH_DSA, new BytesCapsule(ByteArray.fromLong(value))); + } + + public boolean allowSlhDsa() { + return getAllowSlhDsa() == 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; + } + + public long getAllowEphemeralSecp256k1() { + return Optional.ofNullable(getUnchecked(ALLOW_EPHEMERAL_SECP256K1)) + .map(BytesCapsule::getData) + .map(ByteArray::toLong) + .orElse(CommonParameter.getInstance().getAllowEphemeralSecp256k1()); + } + + public void saveAllowEphemeralSecp256k1(long value) { + this.put(ALLOW_EPHEMERAL_SECP256K1, new BytesCapsule(ByteArray.fromLong(value))); + } + + public boolean allowEphemeralSecp256k1() { + return getAllowEphemeralSecp256k1() == 1L; + } + + public boolean isPqSchemeAllowed(SignatureScheme scheme) { + switch (scheme) { + case ML_DSA_44: + return allowMlDsa44(); + case ML_DSA_65: + return allowMlDsa65(); + case SLH_DSA: + return allowSlhDsa(); + case FN_DSA: + return allowFnDsa(); + case EPHEMERAL_SECP256K1: + return allowEphemeralSecp256k1(); + default: + return false; + } + } + + public boolean isAnyPqSchemeAllowed() { + return allowMlDsa44() || allowMlDsa65() || allowSlhDsa() || allowFnDsa() + || allowEphemeralSecp256k1(); } private static class DynamicResourceProperties { 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 047213a2738..01db6fa753a 100644 --- a/common/src/main/java/org/tron/common/parameter/CommonParameter.java +++ b/common/src/main/java/org/tron/common/parameter/CommonParameter.java @@ -639,7 +639,23 @@ public class CommonParameter { @Getter @Setter - public long allowMlDsa; + public long allowMlDsa44; + + @Getter + @Setter + public long allowMlDsa65; + + @Getter + @Setter + public long allowSlhDsa; + + @Getter + @Setter + public long allowFnDsa; + + @Getter + @Setter + public long allowEphemeralSecp256k1; private static double calcMaxTimeRatio() { return 5.0; diff --git a/framework/src/main/java/org/tron/core/Wallet.java b/framework/src/main/java/org/tron/core/Wallet.java index 6079294e193..cd57bbeef42 100755 --- a/framework/src/main/java/org/tron/core/Wallet.java +++ b/framework/src/main/java/org/tron/core/Wallet.java @@ -1515,8 +1515,28 @@ public Protocol.ChainParameters getChainParameters() { .build()); builder.addChainParameter(Protocol.ChainParameters.ChainParameter.newBuilder() - .setKey("getAllowMlDsa") - .setValue(dbManager.getDynamicPropertiesStore().getAllowMlDsa()) + .setKey("getAllowMlDsa44") + .setValue(dbManager.getDynamicPropertiesStore().getAllowMlDsa44()) + .build()); + + builder.addChainParameter(Protocol.ChainParameters.ChainParameter.newBuilder() + .setKey("getAllowMlDsa65") + .setValue(dbManager.getDynamicPropertiesStore().getAllowMlDsa65()) + .build()); + + builder.addChainParameter(Protocol.ChainParameters.ChainParameter.newBuilder() + .setKey("getAllowSlhDsa") + .setValue(dbManager.getDynamicPropertiesStore().getAllowSlhDsa()) + .build()); + + builder.addChainParameter(Protocol.ChainParameters.ChainParameter.newBuilder() + .setKey("getAllowFnDsa") + .setValue(dbManager.getDynamicPropertiesStore().getAllowFnDsa()) + .build()); + + builder.addChainParameter(Protocol.ChainParameters.ChainParameter.newBuilder() + .setKey("getAllowEphemeralSecp256k1") + .setValue(dbManager.getDynamicPropertiesStore().getAllowEphemeralSecp256k1()) .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 2e749e3e39c..a41594af385 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 @@ -1042,9 +1042,25 @@ 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.allowMlDsa44 = + config.hasPath(ConfigKey.COMMITTEE_ALLOW_ML_DSA_44) ? config + .getInt(ConfigKey.COMMITTEE_ALLOW_ML_DSA_44) : 0; + + PARAMETER.allowMlDsa65 = + config.hasPath(ConfigKey.COMMITTEE_ALLOW_ML_DSA_65) ? config + .getInt(ConfigKey.COMMITTEE_ALLOW_ML_DSA_65) : 0; + + PARAMETER.allowSlhDsa = + config.hasPath(ConfigKey.COMMITTEE_ALLOW_SLH_DSA) ? config + .getInt(ConfigKey.COMMITTEE_ALLOW_SLH_DSA) : 0; + + PARAMETER.allowFnDsa = + config.hasPath(ConfigKey.COMMITTEE_ALLOW_FN_DSA) ? config + .getInt(ConfigKey.COMMITTEE_ALLOW_FN_DSA) : 0; + + PARAMETER.allowEphemeralSecp256k1 = + config.hasPath(ConfigKey.COMMITTEE_ALLOW_EPHEMERAL_SECP256K1) ? config + .getInt(ConfigKey.COMMITTEE_ALLOW_EPHEMERAL_SECP256K1) : 0; logConfig(); } 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 5f8def68eeb..5d01bfcd2ad 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 @@ -250,7 +250,12 @@ 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_ML_DSA_44 = "committee.allowMlDsa44"; + public static final String COMMITTEE_ALLOW_ML_DSA_65 = "committee.allowMlDsa65"; + public static final String COMMITTEE_ALLOW_SLH_DSA = "committee.allowSlhDsa"; + public static final String COMMITTEE_ALLOW_FN_DSA = "committee.allowFnDsa"; + public static final String COMMITTEE_ALLOW_EPHEMERAL_SECP256K1 = + "committee.allowEphemeralSecp256k1"; 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/consensus/ProposalService.java b/framework/src/main/java/org/tron/core/consensus/ProposalService.java index 0d5f63f1bed..47eb888bacd 100644 --- a/framework/src/main/java/org/tron/core/consensus/ProposalService.java +++ b/framework/src/main/java/org/tron/core/consensus/ProposalService.java @@ -396,8 +396,24 @@ public static boolean process(Manager manager, ProposalCapsule proposalCapsule) manager.getDynamicPropertiesStore().saveAllowTvmOsaka(entry.getValue()); break; } - case ALLOW_ML_DSA: { - manager.getDynamicPropertiesStore().saveAllowMlDsa(entry.getValue()); + case ALLOW_ML_DSA_44: { + manager.getDynamicPropertiesStore().saveAllowMlDsa44(entry.getValue()); + break; + } + case ALLOW_ML_DSA_65: { + manager.getDynamicPropertiesStore().saveAllowMlDsa65(entry.getValue()); + break; + } + case ALLOW_SLH_DSA: { + manager.getDynamicPropertiesStore().saveAllowSlhDsa(entry.getValue()); + break; + } + case ALLOW_FN_DSA: { + manager.getDynamicPropertiesStore().saveAllowFnDsa(entry.getValue()); + break; + } + case ALLOW_EPHEMERAL_SECP256K1: { + manager.getDynamicPropertiesStore().saveAllowEphemeralSecp256k1(entry.getValue()); break; } default: 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 16b943c8ecf..db26d58073b 100644 --- a/framework/src/main/java/org/tron/core/db/Manager.java +++ b/framework/src/main/java/org/tron/core/db/Manager.java @@ -1768,7 +1768,7 @@ private void signBlockCapsule(BlockCapsule blockCapsule, Miner miner) { } private SignatureScheme resolveWitnessScheme(Miner miner) { - if (!chainBaseManager.getDynamicPropertiesStore().allowMlDsa()) { + if (!chainBaseManager.getDynamicPropertiesStore().isAnyPqSchemeAllowed()) { return SignatureScheme.UNKNOWN_SIG_SCHEME; } byte[] witnessAddress = miner.getWitnessAddress().toByteArray(); @@ -1780,7 +1780,11 @@ private SignatureScheme resolveWitnessScheme(Miner miner) { if (witnessPermission.getKeysCount() == 0) { return SignatureScheme.UNKNOWN_SIG_SCHEME; } - return witnessPermission.getKeys(0).getScheme(); + SignatureScheme scheme = witnessPermission.getKeys(0).getScheme(); + if (!chainBaseManager.getDynamicPropertiesStore().isPqSchemeAllowed(scheme)) { + return SignatureScheme.UNKNOWN_SIG_SCHEME; + } + return scheme; } private void signWitnessAuth(BlockCapsule blockCapsule, Miner miner, SignatureScheme scheme) { diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/program/PqcWitnessNode.java b/framework/src/test/java/org/tron/common/crypto/pqc/program/PqcWitnessNode.java index bf133036e2c..a222988b9c8 100644 --- a/framework/src/test/java/org/tron/common/crypto/pqc/program/PqcWitnessNode.java +++ b/framework/src/test/java/org/tron/common/crypto/pqc/program/PqcWitnessNode.java @@ -140,7 +140,7 @@ static void installPqGenesisState(Manager db, ChainBaseManager chain, ByteString signerAddrBs = ByteString.copyFrom(signerAddr); // Activate ML-DSA on the local chain params. - db.getDynamicPropertiesStore().saveAllowMlDsa(1L); + db.getDynamicPropertiesStore().saveAllowMlDsa44(1L); db.getDynamicPropertiesStore().saveAllowMultiSign(1L); // Witness account with ML-DSA-44 witness permission. 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 2a359425023..4caa7c1a835 100644 --- a/framework/src/test/java/org/tron/core/actuator/AccountPermissionUpdateActuatorTest.java +++ b/framework/src/test/java/org/tron/core/actuator/AccountPermissionUpdateActuatorTest.java @@ -1084,7 +1084,7 @@ private AccountPermissionUpdateActuator actuatorFor(Any any) { @Test public void mlDsaPermissionRejectedWhenNotAllowed() { - dbManager.getDynamicPropertiesStore().saveAllowMlDsa(0L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa44(0L); ByteString address = ByteString.copyFrom(ByteArray.fromHexString(OWNER_ADDRESS)); Permission owner = ownerPermissionWithKeys( java.util.Collections.singletonList( @@ -1096,15 +1096,15 @@ public void mlDsaPermissionRejectedWhenNotAllowed() { try { actuatorFor(any).validate(); - fail("should reject ML-DSA key when ALLOW_ML_DSA = 0"); + fail("should reject ML-DSA key when ALLOW_ML_DSA_44 = 0"); } catch (ContractValidateException e) { - Assert.assertTrue(e.getMessage().contains("ML-DSA is not activated")); + Assert.assertTrue(e.getMessage().contains("ML_DSA_44 is not activated")); } } @Test public void legacyKeyWithNonEmptyPublicKeyRejected() { - dbManager.getDynamicPropertiesStore().saveAllowMlDsa(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa44(1L); ByteString address = ByteString.copyFrom(ByteArray.fromHexString(OWNER_ADDRESS)); Key badLegacy = Key.newBuilder() .setAddress(ByteString.copyFrom(ByteArray.fromHexString(KEY_ADDRESS))) @@ -1128,7 +1128,7 @@ public void legacyKeyWithNonEmptyPublicKeyRejected() { @Test public void mixedSchemeInSamePermissionRejected() { - dbManager.getDynamicPropertiesStore().saveAllowMlDsa(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa44(1L); ByteString address = ByteString.copyFrom(ByteArray.fromHexString(OWNER_ADDRESS)); Permission owner = ownerPermissionWithKeys( java.util.Arrays.asList( @@ -1150,7 +1150,8 @@ public void mixedSchemeInSamePermissionRejected() { @Test public void mixedMlDsaSchemesRejected() { - dbManager.getDynamicPropertiesStore().saveAllowMlDsa(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa44(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa65(1L); ByteString address = ByteString.copyFrom(ByteArray.fromHexString(OWNER_ADDRESS)); Permission owner = ownerPermissionWithKeys( java.util.Arrays.asList( @@ -1172,7 +1173,7 @@ public void mixedMlDsaSchemesRejected() { @Test public void mlDsa44WrongPublicKeyLengthRejected() { - dbManager.getDynamicPropertiesStore().saveAllowMlDsa(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa44(1L); ByteString address = ByteString.copyFrom(ByteArray.fromHexString(OWNER_ADDRESS)); Permission owner = ownerPermissionWithKeys( java.util.Collections.singletonList( @@ -1193,7 +1194,8 @@ public void mlDsa44WrongPublicKeyLengthRejected() { @Test public void witnessMlDsa44Accepted() throws ContractValidateException { - dbManager.getDynamicPropertiesStore().saveAllowMlDsa(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa44(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa65(1L); ByteString address = ByteString.copyFrom(ByteArray.fromHexString(WITNESS_ADDRESS)); Permission owner = ownerPermissionWithKeys( java.util.Collections.singletonList( @@ -1211,7 +1213,7 @@ public void witnessMlDsa44Accepted() throws ContractValidateException { @Test public void duplicatePublicKeyRejected() { - dbManager.getDynamicPropertiesStore().saveAllowMlDsa(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa44(1L); ByteString address = ByteString.copyFrom(ByteArray.fromHexString(OWNER_ADDRESS)); byte[] sharedPk = fixedBytes(1312, 1); Key k1 = Key.newBuilder() @@ -1242,7 +1244,7 @@ public void duplicatePublicKeyRejected() { @Test public void validMlDsa44PermissionAccepted() throws ContractValidateException { - dbManager.getDynamicPropertiesStore().saveAllowMlDsa(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa44(1L); ByteString address = ByteString.copyFrom(ByteArray.fromHexString(OWNER_ADDRESS)); Permission owner = ownerPermissionWithKeys( java.util.Collections.singletonList( @@ -1260,7 +1262,8 @@ public void validMlDsa44PermissionAccepted() throws ContractValidateException { @Test public void validMlDsa65WitnessPermissionAccepted() throws ContractValidateException { - dbManager.getDynamicPropertiesStore().saveAllowMlDsa(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa44(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa65(1L); ByteString address = ByteString.copyFrom(ByteArray.fromHexString(WITNESS_ADDRESS)); Permission owner = ownerPermissionWithKeys( java.util.Collections.singletonList( 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 50017aa1a4f..4d310d009d8 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 @@ -774,27 +774,51 @@ public void blockVersionCheck() { } @Test - public void validateAllowMlDsa() { - long code = ProposalType.ALLOW_ML_DSA.getCode(); + public void validateAllowMlDsa44() { + assertPqAllowFlagAcceptsZeroAndOne( + ProposalType.ALLOW_ML_DSA_44.getCode(), "ALLOW_ML_DSA_44"); + } + @Test + public void validateAllowMlDsa65() { + assertPqAllowFlagAcceptsZeroAndOne( + ProposalType.ALLOW_ML_DSA_65.getCode(), "ALLOW_ML_DSA_65"); + } + + @Test + public void validateAllowSlhDsa() { + assertPqAllowFlagAcceptsZeroAndOne( + ProposalType.ALLOW_SLH_DSA.getCode(), "ALLOW_SLH_DSA"); + } + + @Test + public void validateAllowFnDsa() { + assertPqAllowFlagAcceptsZeroAndOne( + ProposalType.ALLOW_FN_DSA.getCode(), "ALLOW_FN_DSA"); + } + + @Test + public void validateAllowEphemeralSecp256k1() { + assertPqAllowFlagAcceptsZeroAndOne( + ProposalType.ALLOW_EPHEMERAL_SECP256K1.getCode(), "ALLOW_EPHEMERAL_SECP256K1"); + } + + private void assertPqAllowFlagAcceptsZeroAndOne(long code, String name) { 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()); + () -> ProposalUtil.validator(dynamicPropertiesStore, forkUtils, code, 2)); + assertEquals( + "This value[" + name + "] is only allowed to be 0 or 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()); + () -> ProposalUtil.validator(dynamicPropertiesStore, forkUtils, code, -1)); + assertEquals( + "This value[" + name + "] is only allowed to be 0 or 1", thrown.getMessage()); try { + ProposalUtil.validator(dynamicPropertiesStore, forkUtils, code, 0); ProposalUtil.validator(dynamicPropertiesStore, forkUtils, code, 1); } catch (ContractValidateException e) { - Assert.fail("value=1 should be accepted: " + e.getMessage()); + Assert.fail("value=0 and value=1 should both 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 index dddb9ad6675..56bc715933e 100644 --- a/framework/src/test/java/org/tron/core/capsule/BlockCapsulePqTest.java +++ b/framework/src/test/java/org/tron/core/capsule/BlockCapsulePqTest.java @@ -91,7 +91,8 @@ private byte[] signPq(byte[] message) { @Test public void legacyValidateWithoutAuthWitnessAcceptedBeforeActivation() throws Exception { dbManager.getDynamicPropertiesStore().saveAllowMultiSign(1L); - dbManager.getDynamicPropertiesStore().saveAllowMlDsa(0L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa44(0L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa65(0L); AccountCapsule witness = buildWitnessAccount(SignatureScheme.UNKNOWN_SIG_SCHEME); dbManager.getAccountStore().put(witnessAddress, witness); @@ -104,7 +105,8 @@ public void legacyValidateWithoutAuthWitnessAcceptedBeforeActivation() throws Ex @Test(expected = ValidateSignatureException.class) public void authWitnessBeforeActivationRejected() throws Exception { dbManager.getDynamicPropertiesStore().saveAllowMultiSign(1L); - dbManager.getDynamicPropertiesStore().saveAllowMlDsa(0L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa44(0L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa65(0L); AccountCapsule witness = buildWitnessAccount(SignatureScheme.UNKNOWN_SIG_SCHEME); dbManager.getAccountStore().put(witnessAddress, witness); @@ -122,7 +124,8 @@ public void authWitnessBeforeActivationRejected() throws Exception { @Test(expected = ValidateSignatureException.class) public void bothLegacyAndAuthWitnessRejected() throws Exception { dbManager.getDynamicPropertiesStore().saveAllowMultiSign(1L); - dbManager.getDynamicPropertiesStore().saveAllowMlDsa(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa44(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa65(1L); AccountCapsule witness = buildWitnessAccount(SignatureScheme.ML_DSA_65); dbManager.getAccountStore().put(witnessAddress, witness); @@ -140,7 +143,8 @@ public void bothLegacyAndAuthWitnessRejected() throws Exception { @Test(expected = ValidateSignatureException.class) public void mlDsaSchemeWithLegacyOnlyRejected() throws Exception { dbManager.getDynamicPropertiesStore().saveAllowMultiSign(1L); - dbManager.getDynamicPropertiesStore().saveAllowMlDsa(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa44(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa65(1L); AccountCapsule witness = buildWitnessAccount(SignatureScheme.ML_DSA_65); dbManager.getAccountStore().put(witnessAddress, witness); @@ -153,7 +157,8 @@ public void mlDsaSchemeWithLegacyOnlyRejected() throws Exception { @Test(expected = ValidateSignatureException.class) public void legacySchemeWithAuthWitnessOnlyRejected() throws Exception { dbManager.getDynamicPropertiesStore().saveAllowMultiSign(1L); - dbManager.getDynamicPropertiesStore().saveAllowMlDsa(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa44(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa65(1L); AccountCapsule witness = buildWitnessAccount(SignatureScheme.UNKNOWN_SIG_SCHEME); dbManager.getAccountStore().put(witnessAddress, witness); @@ -171,7 +176,8 @@ public void legacySchemeWithAuthWitnessOnlyRejected() throws Exception { @Test(expected = ValidateSignatureException.class) public void neitherLegacyNorAuthRejected() throws Exception { dbManager.getDynamicPropertiesStore().saveAllowMultiSign(1L); - dbManager.getDynamicPropertiesStore().saveAllowMlDsa(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa44(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa65(1L); AccountCapsule witness = buildWitnessAccount(SignatureScheme.ML_DSA_65); dbManager.getAccountStore().put(witnessAddress, witness); @@ -184,7 +190,8 @@ public void neitherLegacyNorAuthRejected() throws Exception { @Test public void pqOnlyAccepted() throws Exception { dbManager.getDynamicPropertiesStore().saveAllowMultiSign(1L); - dbManager.getDynamicPropertiesStore().saveAllowMlDsa(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa44(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa65(1L); AccountCapsule witness = buildWitnessAccount(SignatureScheme.ML_DSA_65); dbManager.getAccountStore().put(witnessAddress, witness); @@ -202,7 +209,8 @@ public void pqOnlyAccepted() throws Exception { @Test public void tamperedAuthWitnessFails() throws Exception { dbManager.getDynamicPropertiesStore().saveAllowMultiSign(1L); - dbManager.getDynamicPropertiesStore().saveAllowMlDsa(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa44(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa65(1L); AccountCapsule witness = buildWitnessAccount(SignatureScheme.ML_DSA_65); dbManager.getAccountStore().put(witnessAddress, witness); @@ -222,7 +230,8 @@ public void tamperedAuthWitnessFails() throws Exception { @Test(expected = ValidateSignatureException.class) public void signerNotInWitnessPermissionRejected() throws Exception { dbManager.getDynamicPropertiesStore().saveAllowMultiSign(1L); - dbManager.getDynamicPropertiesStore().saveAllowMlDsa(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa44(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa65(1L); AccountCapsule witness = buildWitnessAccount(SignatureScheme.ML_DSA_65); dbManager.getAccountStore().put(witnessAddress, witness); 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 a9a662a69ce..10fe2a29ca0 100644 --- a/framework/src/test/java/org/tron/core/capsule/TransactionCapsuleTest.java +++ b/framework/src/test/java/org/tron/core/capsule/TransactionCapsuleTest.java @@ -135,7 +135,8 @@ private void putAccountWithPqPermission( @Test public void authWitnessBeforeActivationRejected() { - dbManager.getDynamicPropertiesStore().saveAllowMlDsa(0L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa44(0L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa65(0L); Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0).toBuilder() .addAuthWitness(AuthWitness.newBuilder() .setSignerAddress(ByteString.copyFrom(ByteArray.fromHexString(PQ_SIGNER_HEX))) @@ -148,13 +149,13 @@ public void authWitnessBeforeActivationRejected() { dbManager.getDynamicPropertiesStore()); Assert.fail("should reject auth_witness before activation"); } catch (ValidateSignatureException e) { - Assert.assertTrue(e.getMessage().contains("ML-DSA not activated")); + Assert.assertTrue(e.getMessage().contains("no PQ scheme is activated")); } } @Test public void signatureAndAuthWitnessAreMutuallyExclusive() { - dbManager.getDynamicPropertiesStore().saveAllowMlDsa(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa44(1L); Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0).toBuilder() .addSignature(ByteString.copyFrom(new byte[65])) .addAuthWitness(AuthWitness.newBuilder() @@ -174,7 +175,7 @@ public void signatureAndAuthWitnessAreMutuallyExclusive() { @Test public void validAuthWitnessAccepted() throws Exception { - dbManager.getDynamicPropertiesStore().saveAllowMlDsa(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa44(1L); MLDSA44 kp = new MLDSA44(); putAccountWithPqPermission(PQ_OWNER_HEX, kp.getPublicKey(), SignatureScheme.ML_DSA_44); @@ -197,7 +198,7 @@ public void validAuthWitnessAccepted() throws Exception { @Test public void duplicateSignerRejected() throws Exception { - dbManager.getDynamicPropertiesStore().saveAllowMlDsa(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa44(1L); MLDSA44 kp = new MLDSA44(); putAccountWithPqPermission(PQ_OWNER_HEX, kp.getPublicKey(), SignatureScheme.ML_DSA_44); @@ -224,7 +225,7 @@ public void duplicateSignerRejected() throws Exception { @Test public void tamperedAuthWitnessRejected() throws Exception { - dbManager.getDynamicPropertiesStore().saveAllowMlDsa(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa44(1L); MLDSA44 kp = new MLDSA44(); putAccountWithPqPermission(PQ_OWNER_HEX, kp.getPublicKey(), SignatureScheme.ML_DSA_44); @@ -253,7 +254,7 @@ public void tamperedAuthWitnessRejected() throws Exception { @Test public void signerNotInPermissionRejected() throws Exception { - dbManager.getDynamicPropertiesStore().saveAllowMlDsa(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa44(1L); MLDSA44 kp = new MLDSA44(); putAccountWithPqPermission(PQ_OWNER_HEX, kp.getPublicKey(), SignatureScheme.ML_DSA_44); @@ -388,7 +389,8 @@ public void transactionSizeComparisonByScheme() { @Test public void mlDsa65AuthWitnessAlsoAccepted() throws Exception { - dbManager.getDynamicPropertiesStore().saveAllowMlDsa(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa44(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa65(1L); MLDSA65 kp = new MLDSA65(); putAccountWithPqPermission(PQ_OWNER_HEX, kp.getPublicKey(), SignatureScheme.ML_DSA_65); 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 b30016d3dba..e9435e9dfcb 100644 --- a/framework/src/test/java/org/tron/core/services/ProposalServiceTest.java +++ b/framework/src/test/java/org/tron/core/services/ProposalServiceTest.java @@ -1,7 +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.ALLOW_ML_DSA_44; import static org.tron.core.utils.ProposalUtil.ProposalType.CONSENSUS_LOGIC_OPTIMIZATION; import static org.tron.core.utils.ProposalUtil.ProposalType.ENERGY_FEE; import static org.tron.core.utils.ProposalUtil.ProposalType.PROPOSAL_EXPIRE_TIME; @@ -154,18 +154,18 @@ public void testProposalExpireTime() { @Test public void testProcessAllowMlDsa() { - dbManager.getDynamicPropertiesStore().saveAllowMlDsa(0L); - Assert.assertFalse(dbManager.getDynamicPropertiesStore().allowMlDsa()); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa44(0L); + Assert.assertFalse(dbManager.getDynamicPropertiesStore().allowMlDsa44()); Proposal proposal = Proposal.newBuilder() - .putParameters(ALLOW_ML_DSA.getCode(), 1L).build(); + .putParameters(ALLOW_ML_DSA_44.getCode(), 1L).build(); ProposalCapsule proposalCapsule = new ProposalCapsule(proposal); boolean result = ProposalService.process(dbManager, proposalCapsule); Assert.assertTrue(result); - Assert.assertEquals(1L, dbManager.getDynamicPropertiesStore().getAllowMlDsa()); - Assert.assertTrue(dbManager.getDynamicPropertiesStore().allowMlDsa()); + Assert.assertEquals(1L, dbManager.getDynamicPropertiesStore().getAllowMlDsa44()); + Assert.assertTrue(dbManager.getDynamicPropertiesStore().allowMlDsa44()); - dbManager.getDynamicPropertiesStore().saveAllowMlDsa(0L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa44(0L); } } \ No newline at end of file diff --git a/protocol/src/main/protos/core/Tron.proto b/protocol/src/main/protos/core/Tron.proto index 5be62364ebe..9fbc3feb4de 100644 --- a/protocol/src/main/protos/core/Tron.proto +++ b/protocol/src/main/protos/core/Tron.proto @@ -28,7 +28,10 @@ enum SignatureScheme { SM2_SM3 = 2; ML_DSA_44 = 3; ML_DSA_65 = 4; - reserved 5 to 15; + SLH_DSA = 5; // FIPS 205 SLH-DSA-SHA2-128s + FN_DSA = 6; // FIPS 206 draft Falcon-512 + EPHEMERAL_SECP256K1 = 7; // PQ-root + Merkle commitment + one-time secp256k1 + reserved 8 to 15; } // AccountId, (name, address) use name, (null, address) use address, (name, null) use name, From d793a84eabfe9c7ebc6fce0fbe983d178c63731c Mon Sep 17 00:00:00 2001 From: federico Date: Mon, 27 Apr 2026 11:20:27 +0800 Subject: [PATCH 07/17] feat(crypto): add SLH-DSA-SHA2-128s post-quantum signature support --- .../AccountPermissionUpdateActuator.java | 4 + .../src/main/java/org/tron/core/Constant.java | 3 + .../crypto/pqc/PqSignatureRegistry.java | 17 ++ .../org/tron/common/crypto/pqc/SLHDSA.java | 180 ++++++++++++ .../tron/common/crypto/pqc/SLHDSATest.java | 263 ++++++++++++++++++ .../AccountPermissionUpdateActuatorTest.java | 101 +++++++ .../tron/core/capsule/BlockCapsulePqTest.java | 40 +++ .../core/capsule/TransactionCapsuleTest.java | 54 ++++ 8 files changed, 662 insertions(+) create mode 100644 crypto/src/main/java/org/tron/common/crypto/pqc/SLHDSA.java create mode 100644 framework/src/test/java/org/tron/common/crypto/pqc/SLHDSATest.java diff --git a/actuator/src/main/java/org/tron/core/actuator/AccountPermissionUpdateActuator.java b/actuator/src/main/java/org/tron/core/actuator/AccountPermissionUpdateActuator.java index c90bc3abce9..3156aeb84e4 100644 --- a/actuator/src/main/java/org/tron/core/actuator/AccountPermissionUpdateActuator.java +++ b/actuator/src/main/java/org/tron/core/actuator/AccountPermissionUpdateActuator.java @@ -19,6 +19,7 @@ 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.crypto.pqc.SLHDSA; import org.tron.protos.Protocol.Key; import org.tron.protos.Protocol.Permission; import org.tron.protos.Protocol.Permission.PermissionType; @@ -312,6 +313,9 @@ private static int expectedPublicKeyLength(SignatureScheme scheme) { return MLDSA44.PUBLIC_KEY_LENGTH; case ML_DSA_65: return MLDSA65.PUBLIC_KEY_LENGTH; + case SLH_DSA: + return SLHDSA.PUBLIC_KEY_LENGTH; + // FN_DSA / EPHEMERAL_SECP256K1 lengths added in later phases. default: return -1; } diff --git a/common/src/main/java/org/tron/core/Constant.java b/common/src/main/java/org/tron/core/Constant.java index aacf31b4005..89c65749033 100644 --- a/common/src/main/java/org/tron/core/Constant.java +++ b/common/src/main/java/org/tron/core/Constant.java @@ -65,6 +65,9 @@ public class Constant { 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 (SLH-DSA-SHA2-128s / FIPS 205) signature constants + public static final int SLH_DSA_PUBLIC_KEY_LENGTH = 32; + public static final int SLH_DSA_SIGNATURE_LENGTH = 7856; public static final String PQ_TX_AUTH_DOMAIN = "TRON_TX_AUTH_V1"; public static final String PQ_BLOCK_AUTH_DOMAIN = "TRON_BLOCK_AUTH_V1"; 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 index 7843ad66b2d..03eb2f3005b 100644 --- a/crypto/src/main/java/org/tron/common/crypto/pqc/PqSignatureRegistry.java +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/PqSignatureRegistry.java @@ -73,6 +73,23 @@ public PqSignature fromSeed(byte[] seed) { return new MLDSA65(seed); } })); + m.put(SignatureScheme.SLH_DSA, new SchemeInfo( + SLHDSA.PUBLIC_KEY_LENGTH, SLHDSA.SIGNATURE_LENGTH, new SignatureOps() { + @Override + public byte[] sign(byte[] privateKey, byte[] message) { + return SLHDSA.sign(privateKey, message); + } + + @Override + public boolean verify(byte[] publicKey, byte[] message, byte[] signature) { + return SLHDSA.verify(publicKey, message, signature); + } + + @Override + public PqSignature fromSeed(byte[] seed) { + return new SLHDSA(seed); + } + })); SCHEMES = Collections.unmodifiableMap(m); } diff --git a/crypto/src/main/java/org/tron/common/crypto/pqc/SLHDSA.java b/crypto/src/main/java/org/tron/common/crypto/pqc/SLHDSA.java new file mode 100644 index 00000000000..e5c8a82b6dd --- /dev/null +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/SLHDSA.java @@ -0,0 +1,180 @@ +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.slhdsa.SLHDSAKeyGenerationParameters; +import org.bouncycastle.pqc.crypto.slhdsa.SLHDSAKeyPairGenerator; +import org.bouncycastle.pqc.crypto.slhdsa.SLHDSAParameters; +import org.bouncycastle.pqc.crypto.slhdsa.SLHDSAPrivateKeyParameters; +import org.bouncycastle.pqc.crypto.slhdsa.SLHDSAPublicKeyParameters; +import org.bouncycastle.pqc.crypto.slhdsa.SLHDSASigner; +import org.tron.common.crypto.Hash; +import org.tron.protos.Protocol.SignatureScheme; + +/** + * FIPS 205 SLH-DSA-SHA2-128s keypair-bound signer/verifier. Mirrors the + * {@link MLDSA44} / {@link MLDSA65} shape: instance methods sign/verify with + * the bound keypair, static {@link #sign(byte[], byte[])} / {@link #verify} + * provide stateless entry points used by {@link PqSignatureRegistry}. + */ +public final class SLHDSA implements PqSignature { + + public static final int PRIVATE_KEY_LENGTH = 64; + public static final int PUBLIC_KEY_LENGTH = 32; + public static final int SIGNATURE_LENGTH = 7856; + /** SLH-DSA-SHA2-128s requires 3 × n = 48 bytes of randomness for keygen (n = 16). */ + public static final int SEED_LENGTH = 48; + + private static final SLHDSAParameters PARAMS = SLHDSAParameters.sha2_128s; + + private final byte[] privateKey; + private final byte[] publicKey; + + public SLHDSA() { + this.privateKey = generatePrivateKey(); + this.publicKey = derivePublicKey(this.privateKey); + } + + public SLHDSA(byte[] seed) { + if (seed == null || seed.length != SEED_LENGTH) { + throw new IllegalArgumentException("SLH-DSA seed length must be " + SEED_LENGTH); + } + this.privateKey = generatePrivateKeyFromSeed(seed); + this.publicKey = derivePublicKey(this.privateKey); + } + + public SLHDSA(byte[] privateKey, byte[] publicKey) { + validatePrivateKeyBytes(privateKey); + if (publicKey == null || publicKey.length != PUBLIC_KEY_LENGTH) { + throw new IllegalArgumentException( + "SLH-DSA public key length must be " + PUBLIC_KEY_LENGTH); + } + this.privateKey = privateKey.clone(); + this.publicKey = publicKey.clone(); + } + + public static SLHDSA fromPrivate(byte[] privateKey) { + validatePrivateKeyBytes(privateKey); + byte[] sk = privateKey.clone(); + return new SLHDSA(sk, derivePublicKey(sk)); + } + + @Override + public SignatureScheme getScheme() { + return SignatureScheme.SLH_DSA; + } + + @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(); + } + + @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( + "SLH-DSA public key length must be " + PUBLIC_KEY_LENGTH); + } + if (signature == null || signature.length != SIGNATURE_LENGTH) { + throw new IllegalArgumentException( + "SLH-DSA signature length must be " + SIGNATURE_LENGTH); + } + if (message == null) { + throw new IllegalArgumentException("message must not be null"); + } + SLHDSAPublicKeyParameters pk = new SLHDSAPublicKeyParameters(PARAMS, publicKey); + SLHDSASigner verifier = new SLHDSASigner(); + verifier.init(false, pk); + return verifier.verifySignature(message, signature); + } + + public static byte[] sign(byte[] privateKey, byte[] message) { + validatePrivateKeyBytes(privateKey); + if (message == null) { + throw new IllegalArgumentException("message must not be null"); + } + SLHDSAPrivateKeyParameters sk = new SLHDSAPrivateKeyParameters(PARAMS, privateKey); + SLHDSASigner signer = new SLHDSASigner(); + signer.init(true, sk); + try { + return signer.generateSignature(message); + } catch (Exception e) { + throw new IllegalStateException("SLH-DSA signing failed", e); + } + } + + public static byte[] generatePrivateKey() { + return generatePrivateKey(new SecureRandom()); + } + + public static byte[] generatePrivateKeyFromSeed(byte[] seed) { + if (seed == null || seed.length != SEED_LENGTH) { + throw new IllegalArgumentException( + "SLH-DSA seed length must be " + SEED_LENGTH); + } + return generatePrivateKey(new FixedSecureRandom(seed)); + } + + private static byte[] generatePrivateKey(SecureRandom random) { + SLHDSAKeyPairGenerator generator = new SLHDSAKeyPairGenerator(); + generator.init(new SLHDSAKeyGenerationParameters(random, PARAMS)); + AsymmetricCipherKeyPair keyPair = generator.generateKeyPair(); + return ((SLHDSAPrivateKeyParameters) keyPair.getPrivate()).getEncoded(); + } + + public static byte[] derivePublicKey(byte[] privateKey) { + validatePrivateKeyBytes(privateKey); + SLHDSAPrivateKeyParameters sk = new SLHDSAPrivateKeyParameters(PARAMS, privateKey); + return sk.getEncodedPublicKey(); + } + + public static byte[] computeAddress(byte[] publicKey) { + if (publicKey == null || publicKey.length != PUBLIC_KEY_LENGTH) { + throw new IllegalArgumentException( + "SLH-DSA 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( + "SLH-DSA private key length must be " + PRIVATE_KEY_LENGTH); + } + } +} diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/SLHDSATest.java b/framework/src/test/java/org/tron/common/crypto/pqc/SLHDSATest.java new file mode 100644 index 00000000000..1d1e3c7c908 --- /dev/null +++ b/framework/src/test/java/org/tron/common/crypto/pqc/SLHDSATest.java @@ -0,0 +1,263 @@ +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.slhdsa.SLHDSAKeyGenerationParameters; +import org.bouncycastle.pqc.crypto.slhdsa.SLHDSAKeyPairGenerator; +import org.bouncycastle.pqc.crypto.slhdsa.SLHDSAParameters; +import org.bouncycastle.pqc.crypto.slhdsa.SLHDSAPrivateKeyParameters; +import org.bouncycastle.pqc.crypto.slhdsa.SLHDSAPublicKeyParameters; +import org.bouncycastle.pqc.crypto.slhdsa.SLHDSASigner; +import org.junit.Before; +import org.junit.Test; +import org.tron.protos.Protocol.SignatureScheme; + +public class SLHDSATest { + + private static final SLHDSAParameters PARAMS = SLHDSAParameters.sha2_128s; + + private SLHDSA keypair; + private SLHDSAPublicKeyParameters pk; + private SLHDSAPrivateKeyParameters sk; + + @Before + public void setUp() { + SLHDSAKeyPairGenerator gen = new SLHDSAKeyPairGenerator(); + gen.init(new SLHDSAKeyGenerationParameters(new SecureRandom(), PARAMS)); + AsymmetricCipherKeyPair kp = gen.generateKeyPair(); + pk = (SLHDSAPublicKeyParameters) kp.getPublic(); + sk = (SLHDSAPrivateKeyParameters) kp.getPrivate(); + keypair = new SLHDSA(sk.getEncoded(), pk.getEncoded()); + } + + private static byte[] freshPrivateKey() { + SLHDSAKeyPairGenerator gen = new SLHDSAKeyPairGenerator(); + gen.init(new SLHDSAKeyGenerationParameters(new SecureRandom(), PARAMS)); + AsymmetricCipherKeyPair kp = gen.generateKeyPair(); + return ((SLHDSAPrivateKeyParameters) kp.getPrivate()).getEncoded(); + } + + private byte[] rawSign(byte[] message) { + SLHDSASigner signer = new SLHDSASigner(); + signer.init(true, sk); + try { + return signer.generateSignature(message); + } catch (Exception e) { + throw new AssertionError("failed to sign in test setup", e); + } + } + + @Test + public void schemeAndLengthsMatchFips205() { + assertEquals(SignatureScheme.SLH_DSA, keypair.getScheme()); + assertEquals(SLHDSA.PUBLIC_KEY_LENGTH, keypair.getPublicKeyLength()); + assertEquals(SLHDSA.SIGNATURE_LENGTH, keypair.getSignatureLength()); + assertEquals(SLHDSA.PUBLIC_KEY_LENGTH, pk.getEncoded().length); + } + + @Test + public void privateKeyLengthMatchesFips205() { + byte[] skBytes = freshPrivateKey(); + assertEquals(SLHDSA.PRIVATE_KEY_LENGTH, skBytes.length); + } + + @Test + public void derivedPublicKeyLengthMatchesFips205() { + byte[] skBytes = freshPrivateKey(); + byte[] pkBytes = SLHDSA.derivePublicKey(skBytes); + assertEquals(SLHDSA.PUBLIC_KEY_LENGTH, pkBytes.length); + } + + @Test + public void signProducesVerifiableSignature() { + byte[] skBytes = freshPrivateKey(); + byte[] pkBytes = SLHDSA.derivePublicKey(skBytes); + byte[] message = "hello, slh-dsa".getBytes(); + + byte[] sig = SLHDSA.sign(skBytes, message); + assertEquals(SLHDSA.SIGNATURE_LENGTH, sig.length); + + assertTrue(SLHDSA.verify(pkBytes, message, sig)); + } + + @Test + public void roundTripSignVerifyWithTamperRejected() { + byte[] skBytes = freshPrivateKey(); + byte[] pkBytes = SLHDSA.derivePublicKey(skBytes); + byte[] message = "roundtrip".getBytes(); + byte[] sig = SLHDSA.sign(skBytes, message); + + assertTrue(SLHDSA.verify(pkBytes, message, sig)); + + byte[] tampered = sig.clone(); + tampered[0] ^= 0x01; + if (SLHDSA.verify(pkBytes, message, tampered)) { + fail("tampered signature should not verify"); + } + } + + @Test + public void deterministicPublicKeyDerivation() { + byte[] skBytes = freshPrivateKey(); + byte[] pk1 = SLHDSA.derivePublicKey(skBytes); + byte[] pk2 = SLHDSA.derivePublicKey(skBytes); + assertArrayEquals(pk1, pk2); + } + + @Test(expected = IllegalArgumentException.class) + public void rejectsShortPrivateKey() { + SLHDSA.sign(new byte[10], new byte[4]); + } + + @Test(expected = IllegalArgumentException.class) + public void rejectsNullMessage() { + byte[] skBytes = freshPrivateKey(); + SLHDSA.sign(skBytes, null); + } + + @Test + public void validSignatureVerifiesViaInstance() { + byte[] msg = "tron-pq-slhdsa".getBytes(); + byte[] sig = rawSign(msg); + assertEquals(SLHDSA.SIGNATURE_LENGTH, 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); + SLHDSAKeyPairGenerator gen = new SLHDSAKeyPairGenerator(); + gen.init(new SLHDSAKeyGenerationParameters(new SecureRandom(), PARAMS)); + SLHDSAPublicKeyParameters otherPk = + (SLHDSAPublicKeyParameters) gen.generateKeyPair().getPublic(); + assertFalse(SLHDSA.verify(otherPk.getEncoded(), msg, sig)); + } + + @Test + public void invalidPublicKeyLengthRejected() { + byte[] badPk = new byte[SLHDSA.PUBLIC_KEY_LENGTH - 1]; + byte[] msg = new byte[] {1}; + byte[] sig = new byte[SLHDSA.SIGNATURE_LENGTH]; + try { + SLHDSA.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[SLHDSA.SIGNATURE_LENGTH - 1]; + byte[] msg = new byte[] {1}; + try { + SLHDSA.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[SLHDSA.SIGNATURE_LENGTH]; + try { + SLHDSA.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() { + SLHDSA signer = new SLHDSA(); + byte[] msg = "keypair-bound".getBytes(); + byte[] sig = signer.sign(msg); + assertEquals(SLHDSA.SIGNATURE_LENGTH, sig.length); + assertTrue(signer.verify(msg, sig)); + } + + @Test + public void fromSeedIsDeterministic() { + byte[] seed = new byte[SLHDSA.SEED_LENGTH]; + for (int i = 0; i < seed.length; i++) { + seed[i] = (byte) i; + } + SLHDSA a = new SLHDSA(seed); + SLHDSA b = new SLHDSA(seed); + assertArrayEquals(a.getPublicKey(), b.getPublicKey()); + assertArrayEquals(a.getPrivateKey(), b.getPrivateKey()); + } + + @Test(expected = IllegalArgumentException.class) + public void invalidSeedLengthRejected() { + new SLHDSA(new byte[SLHDSA.SEED_LENGTH - 1]); + } + + @Test + public void computeAddressIs21Bytes() { + byte[] skBytes = freshPrivateKey(); + byte[] pkBytes = SLHDSA.derivePublicKey(skBytes); + assertEquals(21, SLHDSA.computeAddress(pkBytes).length); + } + + @Test + public void crossAlgoSignatureRejected() { + // SLH-DSA signature size differs from ML-DSA-44 (2420) and ML-DSA-65 (3309). + // A signature of the wrong length must be rejected at the length check. + byte[] msg = "cross-algo".getBytes(); + byte[] mlDsa44Size = new byte[2420]; + try { + SLHDSA.verify(pk.getEncoded(), msg, mlDsa44Size); + fail("ML-DSA-44-sized signature should be rejected for SLH-DSA"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("signature length")); + } + } + + @Test + public void registryDispatchMatchesDirectCalls() { + byte[] msg = "registry-dispatch".getBytes(); + byte[] sigDirect = SLHDSA.sign(sk.getEncoded(), msg); + assertTrue(PqSignatureRegistry.verify( + SignatureScheme.SLH_DSA, pk.getEncoded(), msg, sigDirect)); + byte[] sigViaRegistry = PqSignatureRegistry.sign( + SignatureScheme.SLH_DSA, sk.getEncoded(), msg); + assertTrue(SLHDSA.verify(pk.getEncoded(), msg, sigViaRegistry)); + assertEquals(SLHDSA.PUBLIC_KEY_LENGTH, + PqSignatureRegistry.getPublicKeyLength(SignatureScheme.SLH_DSA)); + assertEquals(SLHDSA.SIGNATURE_LENGTH, + PqSignatureRegistry.getSignatureLength(SignatureScheme.SLH_DSA)); + } +} 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 4caa7c1a835..0eb32efd0f9 100644 --- a/framework/src/test/java/org/tron/core/actuator/AccountPermissionUpdateActuatorTest.java +++ b/framework/src/test/java/org/tron/core/actuator/AccountPermissionUpdateActuatorTest.java @@ -1280,4 +1280,105 @@ public void validMlDsa65WitnessPermissionAccepted() throws ContractValidateExcep Assert.assertTrue(actuatorFor(any).validate()); } + + @Test + public void slhDsaPermissionRejectedWhenNotAllowed() { + dbManager.getDynamicPropertiesStore().saveAllowSlhDsa(0L); + ByteString address = ByteString.copyFrom(ByteArray.fromHexString(OWNER_ADDRESS)); + Permission owner = ownerPermissionWithKeys( + java.util.Collections.singletonList( + mlDsaKey(KEY_ADDRESS, SignatureScheme.SLH_DSA, 32, 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 SLH-DSA key when ALLOW_SLH_DSA = 0"); + } catch (ContractValidateException e) { + Assert.assertTrue(e.getMessage().contains("SLH_DSA is not activated")); + } + } + + @Test + public void slhDsaWrongPublicKeyLengthRejected() { + dbManager.getDynamicPropertiesStore().saveAllowSlhDsa(1L); + ByteString address = ByteString.copyFrom(ByteArray.fromHexString(OWNER_ADDRESS)); + Permission owner = ownerPermissionWithKeys( + java.util.Collections.singletonList( + mlDsaKey(KEY_ADDRESS, SignatureScheme.SLH_DSA, 31, 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("SLH-DSA wrong public_key length should be rejected"); + } catch (ContractValidateException e) { + Assert.assertTrue(e.getMessage().contains("public_key length")); + } + } + + @Test + public void validSlhDsaPermissionAccepted() throws ContractValidateException { + dbManager.getDynamicPropertiesStore().saveAllowSlhDsa(1L); + ByteString address = ByteString.copyFrom(ByteArray.fromHexString(OWNER_ADDRESS)); + Permission owner = ownerPermissionWithKeys( + java.util.Collections.singletonList( + mlDsaKey(KEY_ADDRESS, SignatureScheme.SLH_DSA, 32, 1)), + 2); + Permission active = activePermissionWithKeys( + java.util.Collections.singletonList( + mlDsaKey(KEY_ADDRESS1, SignatureScheme.SLH_DSA, 32, 2)), + 2); + Any any = getContract(address, owner, null, + java.util.Collections.singletonList(active)); + + Assert.assertTrue(actuatorFor(any).validate()); + } + + @Test + public void validSlhDsaWitnessPermissionAccepted() throws ContractValidateException { + dbManager.getDynamicPropertiesStore().saveAllowSlhDsa(1L); + ByteString address = ByteString.copyFrom(ByteArray.fromHexString(WITNESS_ADDRESS)); + Permission owner = ownerPermissionWithKeys( + java.util.Collections.singletonList( + mlDsaKey(KEY_ADDRESS, SignatureScheme.SLH_DSA, 32, 1)), + 2); + Permission witness = witnessPermissionWithKey( + mlDsaKey(KEY_ADDRESS1, SignatureScheme.SLH_DSA, 32, 2)); + Permission active = activePermissionWithKeys( + java.util.Collections.singletonList( + mlDsaKey(KEY_ADDRESS2, SignatureScheme.SLH_DSA, 32, 3)), + 2); + Any any = getContract(address, owner, witness, + java.util.Collections.singletonList(active)); + + Assert.assertTrue(actuatorFor(any).validate()); + } + + @Test + public void slhDsaMixedWithMlDsaInSamePermissionRejected() { + dbManager.getDynamicPropertiesStore().saveAllowMlDsa44(1L); + dbManager.getDynamicPropertiesStore().saveAllowSlhDsa(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.SLH_DSA, 32, 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 SLH-DSA and ML-DSA in one permission should be rejected"); + } catch (ContractValidateException e) { + Assert.assertTrue(e.getMessage().contains("same scheme")); + } + } } \ No newline at end of file diff --git a/framework/src/test/java/org/tron/core/capsule/BlockCapsulePqTest.java b/framework/src/test/java/org/tron/core/capsule/BlockCapsulePqTest.java index 56bc715933e..ca3c6ff0696 100644 --- a/framework/src/test/java/org/tron/core/capsule/BlockCapsulePqTest.java +++ b/framework/src/test/java/org/tron/core/capsule/BlockCapsulePqTest.java @@ -10,6 +10,7 @@ import org.tron.common.crypto.ECKey; import org.tron.common.crypto.pqc.MLDSA65; import org.tron.common.crypto.pqc.PqAuthDigest; +import org.tron.common.crypto.pqc.SLHDSA; import org.tron.common.utils.ByteArray; import org.tron.common.utils.Sha256Hash; import org.tron.core.config.args.Args; @@ -93,6 +94,7 @@ public void legacyValidateWithoutAuthWitnessAcceptedBeforeActivation() throws Ex dbManager.getDynamicPropertiesStore().saveAllowMultiSign(1L); dbManager.getDynamicPropertiesStore().saveAllowMlDsa44(0L); dbManager.getDynamicPropertiesStore().saveAllowMlDsa65(0L); + dbManager.getDynamicPropertiesStore().saveAllowSlhDsa(0L); AccountCapsule witness = buildWitnessAccount(SignatureScheme.UNKNOWN_SIG_SCHEME); dbManager.getAccountStore().put(witnessAddress, witness); @@ -107,6 +109,7 @@ public void authWitnessBeforeActivationRejected() throws Exception { dbManager.getDynamicPropertiesStore().saveAllowMultiSign(1L); dbManager.getDynamicPropertiesStore().saveAllowMlDsa44(0L); dbManager.getDynamicPropertiesStore().saveAllowMlDsa65(0L); + dbManager.getDynamicPropertiesStore().saveAllowSlhDsa(0L); AccountCapsule witness = buildWitnessAccount(SignatureScheme.UNKNOWN_SIG_SCHEME); dbManager.getAccountStore().put(witnessAddress, witness); @@ -227,6 +230,43 @@ public void tamperedAuthWitnessFails() throws Exception { dbManager.getDynamicPropertiesStore(), dbManager.getAccountStore())); } + @Test + public void slhDsaPqOnlyAccepted() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowMultiSign(1L); + dbManager.getDynamicPropertiesStore().saveAllowSlhDsa(1L); + SLHDSA slhKp = new SLHDSA(); + Permission witnessPerm = Permission.newBuilder() + .setType(PermissionType.Witness) + .setId(1) + .setPermissionName("witness") + .setThreshold(1) + .addKeys(Key.newBuilder() + .setAddress(ByteString.copyFrom(witnessAddress)) + .setWeight(1) + .setScheme(SignatureScheme.SLH_DSA) + .setPublicKey(ByteString.copyFrom(slhKp.getPublicKey()))) + .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(); + dbManager.getAccountStore().put(witnessAddress, new AccountCapsule(account)); + + byte[] parentHash = new byte[32]; + BlockCapsule block = buildUnsignedBlock(parentHash); + byte[] digest = PqAuthDigest.block(block.getRawHashBytes(), witnessAddress); + block.setWitnessAuth(AuthWitness.newBuilder() + .setSignerAddress(ByteString.copyFrom(witnessAddress)) + .setSignature(ByteString.copyFrom(SLHDSA.sign(slhKp.getPrivateKey(), digest))) + .build()); + Assert.assertTrue(block.validateSignature( + dbManager.getDynamicPropertiesStore(), dbManager.getAccountStore())); + } + @Test(expected = ValidateSignatureException.class) public void signerNotInWitnessPermissionRejected() throws Exception { dbManager.getDynamicPropertiesStore().saveAllowMultiSign(1L); diff --git a/framework/src/test/java/org/tron/core/capsule/TransactionCapsuleTest.java b/framework/src/test/java/org/tron/core/capsule/TransactionCapsuleTest.java index 10fe2a29ca0..86a67f9133f 100644 --- a/framework/src/test/java/org/tron/core/capsule/TransactionCapsuleTest.java +++ b/framework/src/test/java/org/tron/core/capsule/TransactionCapsuleTest.java @@ -16,6 +16,7 @@ import org.tron.common.crypto.ECKey; import org.tron.common.crypto.pqc.MLDSA44; import org.tron.common.crypto.pqc.MLDSA65; +import org.tron.common.crypto.pqc.SLHDSA; import org.tron.common.crypto.pqc.PqAuthDigest; import org.tron.common.utils.ByteArray; import org.tron.common.utils.Sha256Hash; @@ -137,6 +138,7 @@ private void putAccountWithPqPermission( public void authWitnessBeforeActivationRejected() { dbManager.getDynamicPropertiesStore().saveAllowMlDsa44(0L); dbManager.getDynamicPropertiesStore().saveAllowMlDsa65(0L); + dbManager.getDynamicPropertiesStore().saveAllowSlhDsa(0L); Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0).toBuilder() .addAuthWitness(AuthWitness.newBuilder() .setSignerAddress(ByteString.copyFrom(ByteArray.fromHexString(PQ_SIGNER_HEX))) @@ -410,4 +412,56 @@ public void mlDsa65AuthWitnessAlsoAccepted() throws Exception { Assert.assertTrue(cap.validatePubSignature(dbManager.getAccountStore(), dbManager.getDynamicPropertiesStore())); } + + @Test + public void slhDsaAuthWitnessAccepted() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowSlhDsa(1L); + SLHDSA kp = new SLHDSA(); + putAccountWithPqPermission(PQ_OWNER_HEX, kp.getPublicKey(), SignatureScheme.SLH_DSA); + + Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0); + byte[] txid = Sha256Hash.of(true, tx.getRawData().toByteArray()).getBytes(); + byte[] signerAddr = ByteArray.fromHexString(PQ_SIGNER_HEX); + byte[] digest = PqAuthDigest.tx(txid, 0, signerAddr); + byte[] sig = SLHDSA.sign(kp.getPrivateKey(), digest); + + Transaction signed = tx.toBuilder() + .addAuthWitness(AuthWitness.newBuilder() + .setSignerAddress(ByteString.copyFrom(signerAddr)) + .setSignature(ByteString.copyFrom(sig)) + .build()) + .build(); + TransactionCapsule cap = new TransactionCapsule(signed); + Assert.assertTrue(cap.validatePubSignature(dbManager.getAccountStore(), + dbManager.getDynamicPropertiesStore())); + } + + @Test + public void slhDsaTamperedAuthWitnessRejected() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowSlhDsa(1L); + SLHDSA kp = new SLHDSA(); + putAccountWithPqPermission(PQ_OWNER_HEX, kp.getPublicKey(), SignatureScheme.SLH_DSA); + + Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0); + byte[] txid = Sha256Hash.of(true, tx.getRawData().toByteArray()).getBytes(); + byte[] signerAddr = ByteArray.fromHexString(PQ_SIGNER_HEX); + byte[] digest = PqAuthDigest.tx(txid, 0, signerAddr); + byte[] sig = SLHDSA.sign(kp.getPrivateKey(), digest); + sig[0] ^= 0x01; + + Transaction signed = tx.toBuilder() + .addAuthWitness(AuthWitness.newBuilder() + .setSignerAddress(ByteString.copyFrom(signerAddr)) + .setSignature(ByteString.copyFrom(sig)) + .build()) + .build(); + TransactionCapsule cap = new TransactionCapsule(signed); + try { + cap.validatePubSignature(dbManager.getAccountStore(), + dbManager.getDynamicPropertiesStore()); + Assert.fail("tampered SLH-DSA signature should be rejected"); + } catch (ValidateSignatureException e) { + Assert.assertTrue(e.getMessage().contains("pq sig invalid")); + } + } } \ No newline at end of file From 0c5d4c9b20de8af25cca48b48729cbb3d9fdc477 Mon Sep 17 00:00:00 2001 From: federico Date: Mon, 27 Apr 2026 12:20:29 +0800 Subject: [PATCH 08/17] feat(crypto): add FN-DSA / Falcon-512 post-quantum signature support --- .../AccountPermissionUpdateActuator.java | 5 +- .../tron/core/capsule/TransactionCapsule.java | 2 +- .../src/main/java/org/tron/core/Constant.java | 3 + .../org/tron/common/crypto/pqc/FNDSA.java | 194 ++++++++++ .../org/tron/common/crypto/pqc/MLDSA44.java | 11 + .../org/tron/common/crypto/pqc/MLDSA65.java | 11 + .../tron/common/crypto/pqc/PqSignature.java | 10 +- .../crypto/pqc/PqSignatureRegistry.java | 31 ++ .../org/tron/common/crypto/pqc/SLHDSA.java | 11 + .../org/tron/common/crypto/pqc/FNDSATest.java | 366 ++++++++++++++++++ .../AccountPermissionUpdateActuatorTest.java | 78 ++++ .../core/capsule/TransactionCapsuleTest.java | 86 ++++ 12 files changed, 804 insertions(+), 4 deletions(-) create mode 100644 crypto/src/main/java/org/tron/common/crypto/pqc/FNDSA.java create mode 100644 framework/src/test/java/org/tron/common/crypto/pqc/FNDSATest.java diff --git a/actuator/src/main/java/org/tron/core/actuator/AccountPermissionUpdateActuator.java b/actuator/src/main/java/org/tron/core/actuator/AccountPermissionUpdateActuator.java index 3156aeb84e4..b493885a19c 100644 --- a/actuator/src/main/java/org/tron/core/actuator/AccountPermissionUpdateActuator.java +++ b/actuator/src/main/java/org/tron/core/actuator/AccountPermissionUpdateActuator.java @@ -16,6 +16,7 @@ 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; @@ -315,7 +316,9 @@ private static int expectedPublicKeyLength(SignatureScheme scheme) { return MLDSA65.PUBLIC_KEY_LENGTH; case SLH_DSA: return SLHDSA.PUBLIC_KEY_LENGTH; - // FN_DSA / EPHEMERAL_SECP256K1 lengths added in later phases. + case FN_DSA: + return FNDSA.PUBLIC_KEY_LENGTH; + // EPHEMERAL_SECP256K1 length added in later phases. default: return -1; } 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 6a271e5597a..389bc4a0868 100755 --- a/chainbase/src/main/java/org/tron/core/capsule/TransactionCapsule.java +++ b/chainbase/src/main/java/org/tron/core/capsule/TransactionCapsule.java @@ -756,7 +756,7 @@ static boolean validateStructuredSignature(Transaction transaction, byte[] pk = key.getPublicKey().toByteArray(); byte[] sig = aw.getSignature().toByteArray(); if (pk.length != PqSignatureRegistry.getPublicKeyLength(scheme) - || sig.length != PqSignatureRegistry.getSignatureLength(scheme)) { + || !PqSignatureRegistry.isValidSignatureLength(scheme, sig.length)) { throw new PermissionException("public key or signature length mismatch"); } if (!PqSignatureRegistry.verify(scheme, pk, digest, sig)) { diff --git a/common/src/main/java/org/tron/core/Constant.java b/common/src/main/java/org/tron/core/Constant.java index 89c65749033..5ffa450d56c 100644 --- a/common/src/main/java/org/tron/core/Constant.java +++ b/common/src/main/java/org/tron/core/Constant.java @@ -68,6 +68,9 @@ public class Constant { // Post-quantum (SLH-DSA-SHA2-128s / FIPS 205) signature constants public static final int SLH_DSA_PUBLIC_KEY_LENGTH = 32; public static final int SLH_DSA_SIGNATURE_LENGTH = 7856; + // Post-quantum (FN-DSA / Falcon-512 / FIPS 206 draft) signature constants + 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"; 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..8fe1a6154b8 --- /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 index a51c0a299ff..80faf20942c 100644 --- a/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA44.java +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA44.java @@ -113,6 +113,17 @@ 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( 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 index 36ed1b08ae9..cecc4377450 100644 --- a/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA65.java +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA65.java @@ -113,6 +113,17 @@ 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( diff --git a/crypto/src/main/java/org/tron/common/crypto/pqc/PqSignature.java b/crypto/src/main/java/org/tron/common/crypto/pqc/PqSignature.java index e2a332c8a04..6afd5e6fa97 100644 --- a/crypto/src/main/java/org/tron/common/crypto/pqc/PqSignature.java +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/PqSignature.java @@ -53,12 +53,18 @@ default void validatePublicKey(byte[] publicKey) { } } + /** + * Default signature-length validation: treats {@link #getSignatureLength()} as the + * upper bound, allowing variable-length schemes (e.g. FN-DSA / Falcon). + * Fixed-length schemes (ML-DSA-44 / ML-DSA-65 / SLH-DSA) override this method to + * enforce strict equality. + */ default void validateSignature(byte[] signature) { - if (signature == null || signature.length != getSignatureLength()) { + if (signature == null || signature.length == 0 || signature.length > getSignatureLength()) { throw new IllegalArgumentException( "invalid " + getScheme() + " signature length: " + (signature == null ? "null" : signature.length) - + ", expected " + getSignatureLength()); + + ", 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 index 03eb2f3005b..0891a2bbbbc 100644 --- a/crypto/src/main/java/org/tron/common/crypto/pqc/PqSignatureRegistry.java +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/PqSignatureRegistry.java @@ -90,6 +90,23 @@ public PqSignature fromSeed(byte[] seed) { return new SLHDSA(seed); } })); + m.put(SignatureScheme.FN_DSA, new SchemeInfo( + FNDSA.PUBLIC_KEY_LENGTH, FNDSA.SIGNATURE_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); } @@ -108,6 +125,20 @@ public static int getSignatureLength(SignatureScheme scheme) { return require(scheme).signatureLength; } + /** + * Per-scheme signature-length predicate. Fixed-length schemes (ML-DSA-44 / ML-DSA-65 / + * SLH-DSA) 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); } diff --git a/crypto/src/main/java/org/tron/common/crypto/pqc/SLHDSA.java b/crypto/src/main/java/org/tron/common/crypto/pqc/SLHDSA.java index e5c8a82b6dd..c2dd2f5d3e3 100644 --- a/crypto/src/main/java/org/tron/common/crypto/pqc/SLHDSA.java +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/SLHDSA.java @@ -105,6 +105,17 @@ public boolean verify(byte[] message, byte[] signature) { return verify(publicKey, message, signature); } + /** SLH-DSA-SHA2-128s 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( 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..f9ce24f0d20 --- /dev/null +++ b/framework/src/test/java/org/tron/common/crypto/pqc/FNDSATest.java @@ -0,0 +1,366 @@ +package org.tron.common.crypto.pqc; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.security.SecureRandom; +import org.bouncycastle.crypto.AsymmetricCipherKeyPair; +import org.bouncycastle.crypto.params.ParametersWithRandom; +import org.bouncycastle.pqc.crypto.falcon.FalconKeyGenerationParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconKeyPairGenerator; +import org.bouncycastle.pqc.crypto.falcon.FalconParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconPrivateKeyParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconPublicKeyParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconSigner; +import org.junit.Before; +import org.junit.Test; +import org.tron.protos.Protocol.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 slhDsaValidateSignatureRemainsStrictEquality() { + SLHDSA slhDsa = new SLHDSA(); + slhDsa.validateSignature(new byte[SLHDSA.SIGNATURE_LENGTH]); + try { + slhDsa.validateSignature(new byte[SLHDSA.SIGNATURE_LENGTH - 1]); + fail("SLH-DSA must reject undersized signature"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("signature length")); + } + try { + slhDsa.validateSignature(new byte[SLHDSA.SIGNATURE_LENGTH + 1]); + fail("SLH-DSA 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)); + + assertTrue(PqSignatureRegistry.isValidSignatureLength( + SignatureScheme.SLH_DSA, SLHDSA.SIGNATURE_LENGTH)); + assertFalse(PqSignatureRegistry.isValidSignatureLength( + SignatureScheme.SLH_DSA, SLHDSA.SIGNATURE_LENGTH - 1)); + assertFalse(PqSignatureRegistry.isValidSignatureLength( + SignatureScheme.SLH_DSA, SLHDSA.SIGNATURE_LENGTH + 1)); + } +} 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 0eb32efd0f9..2eaa9fe959d 100644 --- a/framework/src/test/java/org/tron/core/actuator/AccountPermissionUpdateActuatorTest.java +++ b/framework/src/test/java/org/tron/core/actuator/AccountPermissionUpdateActuatorTest.java @@ -1381,4 +1381,82 @@ public void slhDsaMixedWithMlDsaInSamePermissionRejected() { Assert.assertTrue(e.getMessage().contains("same scheme")); } } + + @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, 897, 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 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(); + 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/capsule/TransactionCapsuleTest.java b/framework/src/test/java/org/tron/core/capsule/TransactionCapsuleTest.java index 86a67f9133f..3e3cd7a2770 100644 --- a/framework/src/test/java/org/tron/core/capsule/TransactionCapsuleTest.java +++ b/framework/src/test/java/org/tron/core/capsule/TransactionCapsuleTest.java @@ -14,6 +14,7 @@ import org.tron.common.BaseTest; import org.tron.common.TestConstants; import org.tron.common.crypto.ECKey; +import org.tron.common.crypto.pqc.FNDSA; import org.tron.common.crypto.pqc.MLDSA44; import org.tron.common.crypto.pqc.MLDSA65; import org.tron.common.crypto.pqc.SLHDSA; @@ -464,4 +465,89 @@ public void slhDsaTamperedAuthWitnessRejected() throws Exception { Assert.assertTrue(e.getMessage().contains("pq sig invalid")); } } + + @Test + public void fnDsaAuthWitnessAccepted() 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[] signerAddr = ByteArray.fromHexString(PQ_SIGNER_HEX); + byte[] digest = PqAuthDigest.tx(txid, 0, signerAddr); + 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() + .addAuthWitness(AuthWitness.newBuilder() + .setSignerAddress(ByteString.copyFrom(signerAddr)) + .setSignature(ByteString.copyFrom(sig)) + .build()) + .build(); + TransactionCapsule cap = new TransactionCapsule(signed); + Assert.assertTrue(cap.validatePubSignature(dbManager.getAccountStore(), + dbManager.getDynamicPropertiesStore())); + } + + @Test + public void fnDsaTamperedAuthWitnessRejected() 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[] signerAddr = ByteArray.fromHexString(PQ_SIGNER_HEX); + byte[] digest = PqAuthDigest.tx(txid, 0, signerAddr); + byte[] sig = FNDSA.sign(kp.getPrivateKey(), digest); + sig[0] ^= 0x01; + + Transaction signed = tx.toBuilder() + .addAuthWitness(AuthWitness.newBuilder() + .setSignerAddress(ByteString.copyFrom(signerAddr)) + .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 fnDsaAuthWitnessRejectedWhenNotActivated() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowMlDsa44(0L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa65(0L); + dbManager.getDynamicPropertiesStore().saveAllowSlhDsa(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[] signerAddr = ByteArray.fromHexString(PQ_SIGNER_HEX); + byte[] digest = PqAuthDigest.tx(txid, 0, signerAddr); + byte[] sig = FNDSA.sign(kp.getPrivateKey(), digest); + + Transaction signed = tx.toBuilder() + .addAuthWitness(AuthWitness.newBuilder() + .setSignerAddress(ByteString.copyFrom(signerAddr)) + .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 From 15c1742de451f0dcf5a0b88107d8ef8dd9376a13 Mon Sep 17 00:00:00 2001 From: federico Date: Mon, 27 Apr 2026 13:08:55 +0800 Subject: [PATCH 09/17] feat(protocol): add Ephemeral secp256k1 wire format --- protocol/src/main/protos/core/Tron.proto | 28 ++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/protocol/src/main/protos/core/Tron.proto b/protocol/src/main/protos/core/Tron.proto index 9fbc3feb4de..cede255ec06 100644 --- a/protocol/src/main/protos/core/Tron.proto +++ b/protocol/src/main/protos/core/Tron.proto @@ -255,6 +255,16 @@ message Account { int64 delegated_frozenV2_balance_for_bandwidth = 36; int64 acquired_delegated_frozenV2_balance_for_bandwidth = 37; + + // Bitmap of consumed Ephemeral leaf indices (1 bit per leaf, little-endian + // byte order). Capped at 8 KiB (2^16 leaves per account) — once exhausted + // the account MUST rotate its Ephemeral PQ root. Empty / absent = no leaves + // consumed yet, fully backward-compatible with non-Ephemeral accounts. + bytes ephemeral_used_bitmap = 61; + // Highest Transaction.raw.nonce accepted from this account against an + // Ephemeral permission. Each new Ephemeral transaction MUST carry a nonce + // strictly greater than this value. 0 = no Ephemeral activity yet. + int64 last_ephemeral_nonce = 62; } message Key { @@ -277,6 +287,20 @@ message AuthWitness { bytes signature = 2; } +// Ephemeral secp256k1 signature payload (TIP-PQ Ephemeral). For an Ephemeral +// permission the on-chain Permission.Key.public_key is a 32-byte SHA-256 +// Merkle root committing to a list of one-time secp256k1 public keys; each +// transaction reveals one leaf (one_time_pubkey), its Merkle path, leaf index, +// and a regular ECDSA signature produced by the corresponding one-time secret +// key. Serialized into AuthWitness.signature (variable length, bounded by the +// max Merkle depth). +message EphemeralWitness { + bytes one_time_pubkey = 1; + repeated bytes merkle_path = 2; + uint32 leaf_index = 3; + bytes ecdsa_signature = 4; +} + message DelegatedResource { bytes from = 1; bytes to = 2; @@ -475,6 +499,10 @@ message Transaction { bytes scripts = 12; int64 timestamp = 14; int64 fee_limit = 18; + // Replay-protection nonce for EPHEMERAL_SECP256K1 transactions only. Other + // schemes ignore this field and SHOULD leave it 0. The signing account's + // last_ephemeral_nonce in the Account state advances strictly monotonically. + int64 nonce = 20; } raw raw_data = 1; From 7531eaeacab6736f8b5df5b0bd1adbad186861bd Mon Sep 17 00:00:00 2001 From: federico Date: Mon, 27 Apr 2026 13:09:02 +0800 Subject: [PATCH 10/17] feat(crypto): add Ephemeral secp256k1 PQ scheme --- .../src/main/java/org/tron/core/Constant.java | 5 + .../common/crypto/pqc/EphemeralSecp256k1.java | 260 ++++++++++++++ .../tron/common/crypto/pqc/MerkleTree.java | 172 ++++++++++ .../tron/common/crypto/pqc/PqAuthDigest.java | 42 +++ .../crypto/pqc/PqSignatureRegistry.java | 22 +- .../crypto/pqc/EphemeralSecp256k1Test.java | 322 ++++++++++++++++++ .../common/crypto/pqc/MerkleTreeTest.java | 181 ++++++++++ .../common/crypto/pqc/PqAuthDigestTest.java | 75 ++++ 8 files changed, 1078 insertions(+), 1 deletion(-) create mode 100644 crypto/src/main/java/org/tron/common/crypto/pqc/EphemeralSecp256k1.java create mode 100644 crypto/src/main/java/org/tron/common/crypto/pqc/MerkleTree.java create mode 100644 framework/src/test/java/org/tron/common/crypto/pqc/EphemeralSecp256k1Test.java create mode 100644 framework/src/test/java/org/tron/common/crypto/pqc/MerkleTreeTest.java diff --git a/common/src/main/java/org/tron/core/Constant.java b/common/src/main/java/org/tron/core/Constant.java index 5ffa450d56c..29c20dc7227 100644 --- a/common/src/main/java/org/tron/core/Constant.java +++ b/common/src/main/java/org/tron/core/Constant.java @@ -73,6 +73,11 @@ public class Constant { 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"; + public static final String PQ_EPHEMERAL_TX_AUTH_DOMAIN = "TRON_EPHEMERAL_TX_AUTH_V1"; + // Ephemeral secp256k1 (PQ-root + Merkle commitment + one-time secp256k1) + public static final int EPHEMERAL_PQ_ROOT_LENGTH = 32; + public static final int EPHEMERAL_MAX_PROOF_DEPTH = 16; + public static final int EPHEMERAL_BITMAP_MAX_BYTES = 8 * 1024; // Network public static final String LOCAL_HOST = "127.0.0.1"; diff --git a/crypto/src/main/java/org/tron/common/crypto/pqc/EphemeralSecp256k1.java b/crypto/src/main/java/org/tron/common/crypto/pqc/EphemeralSecp256k1.java new file mode 100644 index 00000000000..98922421616 --- /dev/null +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/EphemeralSecp256k1.java @@ -0,0 +1,260 @@ +package org.tron.common.crypto.pqc; + +import com.google.protobuf.InvalidProtocolBufferException; +import java.math.BigInteger; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.List; +import org.bouncycastle.asn1.x9.X9ECParameters; +import org.bouncycastle.crypto.ec.CustomNamedCurves; +import org.bouncycastle.crypto.params.ECDomainParameters; +import org.bouncycastle.crypto.params.ECPublicKeyParameters; +import org.bouncycastle.crypto.signers.ECDSASigner; +import org.bouncycastle.math.ec.ECPoint; +import org.tron.common.utils.Sha256Hash; +import org.tron.protos.Protocol.EphemeralWitness; +import org.tron.protos.Protocol.SignatureScheme; + +/** + * Ephemeral secp256k1 PQ scheme: the on-chain "public key" is a 32-byte SHA-256 + * Merkle root committing to a fixed set of one-time secp256k1 keys. Each + * spending transaction reveals one leaf (one-time secp256k1 pubkey), its + * Merkle inclusion proof, and an ECDSA signature over the auth digest using + * the corresponding one-time private key. Double-spend prevention is enforced + * by the per-account {@code ephemeral_used_bitmap} and {@code last_ephemeral_nonce} + * fields. + * + *

The node never holds one-time private keys — they are managed by the + * user / wallet. As a result {@link #sign(byte[])} and the static + * {@link #sign(byte[], byte[])} dispatch entry both throw + * {@link UnsupportedOperationException}. + * + *

The "signature" wire bytes are the Protobuf-serialized {@link EphemeralWitness}. + * {@link #SIGNATURE_LENGTH} is the upper bound assuming Ephemeral permissions + * stay within 2^16 leaves (depth ≤ 16) per task C.2.2. + */ +public final class EphemeralSecp256k1 implements PqSignature { + + /** 32-byte SHA-256 Merkle root. */ + public static final int PUBLIC_KEY_LENGTH = 32; + /** + * Upper bound for the serialized {@link EphemeralWitness}. Based on a depth-16 + * Merkle path (16 × 32-byte siblings), uncompressed (65-byte) one-time pubkey, + * 64-byte raw r||s ECDSA signature, plus protobuf tag/length overhead — rounded + * up for safety. + */ + public static final int SIGNATURE_LENGTH = 800; + /** Maximum Merkle proof depth for Ephemeral permissions (≤ 2^16 leaves). */ + public static final int MAX_PROOF_DEPTH = 16; + /** Compressed secp256k1 public key length (0x02/0x03 || X). */ + public static final int COMPRESSED_PUBKEY_LENGTH = 33; + /** Uncompressed secp256k1 public key length (0x04 || X || Y). */ + public static final int UNCOMPRESSED_PUBKEY_LENGTH = 65; + /** Raw ECDSA signature length: 32-byte big-endian r || 32-byte big-endian s. */ + public static final int ECDSA_SIGNATURE_LENGTH = 64; + + private static final X9ECParameters CURVE_PARAMS = + CustomNamedCurves.getByName("secp256k1"); + private static final ECDomainParameters CURVE = new ECDomainParameters( + CURVE_PARAMS.getCurve(), CURVE_PARAMS.getG(), CURVE_PARAMS.getN(), + CURVE_PARAMS.getH()); + private static final BigInteger HALF_CURVE_ORDER = CURVE_PARAMS.getN().shiftRight(1); + + private final byte[] root; + + /** + * Bind to the 32-byte PQ Merkle root that serves as this scheme's "public key". + * The node never holds one-time secp256k1 private keys. + */ + public EphemeralSecp256k1(byte[] root) { + if (root == null || root.length != PUBLIC_KEY_LENGTH) { + throw new IllegalArgumentException( + "Ephemeral PQ root length must be " + PUBLIC_KEY_LENGTH); + } + this.root = root.clone(); + } + + @Override + public SignatureScheme getScheme() { + return SignatureScheme.EPHEMERAL_SECP256K1; + } + + /** Ephemeral has no node-side private key; reported as 0 for interface conformance. */ + @Override + public int getPrivateKeyLength() { + return 0; + } + + @Override + public int getPublicKeyLength() { + return PUBLIC_KEY_LENGTH; + } + + /** Upper bound on the serialized {@link EphemeralWitness} signature. */ + @Override + public int getSignatureLength() { + return SIGNATURE_LENGTH; + } + + /** Ephemeral has no node-side private key. */ + @Override + public byte[] getPrivateKey() { + throw new UnsupportedOperationException( + "EPHEMERAL_SECP256K1 has no node-held private key"); + } + + @Override + public byte[] getPublicKey() { + return root.clone(); + } + + /** + * Address derived from the PQ root. Mirrors the other PQ schemes + * ({@code sha3omit12}) so account-id derivation is consistent across schemes. + */ + @Override + public byte[] getAddress() { + return org.tron.common.crypto.Hash.sha3omit12(root); + } + + /** Signing is the wallet / client's responsibility — the node does not hold one-time keys. */ + @Override + public byte[] sign(byte[] message) { + throw new UnsupportedOperationException( + "EPHEMERAL_SECP256K1 signing is performed off-node by the wallet"); + } + + @Override + public boolean verify(byte[] message, byte[] signature) { + return verify(this.root, message, signature); + } + + /** + * Static verify entry used by {@link PqSignatureRegistry}. + * + *

Verification flow: + *

    + *
  1. Parse {@code signature} as {@link EphemeralWitness} bytes.
  2. + *
  3. Hash the one-time pubkey to a 32-byte leaf: + * {@code leaf = SHA-256(one_time_pubkey)}.
  4. + *
  5. Verify the Merkle inclusion proof against {@code publicKey} (the PQ root).
  6. + *
  7. ECDSA-verify {@code message} against the parsed one-time pubkey + * (no public key recovery — the pubkey is explicit).
  8. + *
+ */ + public static boolean verify(byte[] publicKey, byte[] message, byte[] signature) { + if (publicKey == null || publicKey.length != PUBLIC_KEY_LENGTH) { + throw new IllegalArgumentException( + "Ephemeral PQ root length must be " + PUBLIC_KEY_LENGTH); + } + if (message == null) { + throw new IllegalArgumentException("message must not be null"); + } + if (signature == null || signature.length == 0 || signature.length > SIGNATURE_LENGTH) { + throw new IllegalArgumentException( + "Ephemeral signature length must be 1.." + SIGNATURE_LENGTH); + } + + EphemeralWitness witness; + try { + witness = EphemeralWitness.parseFrom(signature); + } catch (InvalidProtocolBufferException e) { + return false; + } + + byte[] oneTimePubkey = witness.getOneTimePubkey().toByteArray(); + if (!isValidSecp256k1Pubkey(oneTimePubkey)) { + return false; + } + + int proofSize = witness.getMerklePathCount(); + if (proofSize > MAX_PROOF_DEPTH) { + return false; + } + int leafIndex = witness.getLeafIndex(); + if (leafIndex < 0) { + return false; + } + // leaf_index must fit in proofSize bits (i.e. < 2^proofSize). + if (proofSize < 32 && (leafIndex >>> proofSize) != 0) { + return false; + } + + List merklePath = new ArrayList<>(proofSize); + for (int i = 0; i < proofSize; i++) { + byte[] sibling = witness.getMerklePath(i).toByteArray(); + if (sibling.length != MerkleTree.LEAF_LENGTH) { + return false; + } + merklePath.add(sibling); + } + + byte[] leaf = sha256(oneTimePubkey); + if (!MerkleTree.verifyProof(publicKey, leaf, merklePath, leafIndex)) { + return false; + } + + byte[] ecdsaSignature = witness.getEcdsaSignature().toByteArray(); + if (ecdsaSignature.length != ECDSA_SIGNATURE_LENGTH) { + return false; + } + return verifyEcdsa(oneTimePubkey, message, ecdsaSignature); + } + + /** Signing is wallet-side; static dispatch surface kept for registry symmetry. */ + public static byte[] sign(byte[] privateKey, byte[] message) { + throw new UnsupportedOperationException( + "EPHEMERAL_SECP256K1 signing is performed off-node by the wallet"); + } + + /** + * secp256k1 ECDSA verification with explicit public key (no recovery), enforcing + * low-s canonicalization and r/s ∈ [1, n-1]. + */ + private static boolean verifyEcdsa(byte[] pubkey, byte[] message, byte[] signature) { + BigInteger r = new BigInteger(1, java.util.Arrays.copyOfRange(signature, 0, 32)); + BigInteger s = new BigInteger(1, java.util.Arrays.copyOfRange(signature, 32, 64)); + if (r.signum() <= 0 || s.signum() <= 0) { + return false; + } + if (r.compareTo(CURVE.getN()) >= 0 || s.compareTo(CURVE.getN()) >= 0) { + return false; + } + // Reject high-s (BIP-62 / EIP-2 style malleability guard). + if (s.compareTo(HALF_CURVE_ORDER) > 0) { + return false; + } + ECPoint point; + try { + point = CURVE.getCurve().decodePoint(pubkey); + } catch (RuntimeException e) { + return false; + } + ECPublicKeyParameters params = new ECPublicKeyParameters(point, CURVE); + ECDSASigner verifier = new ECDSASigner(); + verifier.init(false, params); + try { + return verifier.verifySignature(message, r, s); + } catch (RuntimeException e) { + return false; + } + } + + private static boolean isValidSecp256k1Pubkey(byte[] pubkey) { + if (pubkey == null) { + return false; + } + if (pubkey.length == COMPRESSED_PUBKEY_LENGTH) { + return pubkey[0] == 0x02 || pubkey[0] == 0x03; + } + if (pubkey.length == UNCOMPRESSED_PUBKEY_LENGTH) { + return pubkey[0] == 0x04; + } + return false; + } + + private static byte[] sha256(byte[] input) { + MessageDigest md = Sha256Hash.newDigest(); + return md.digest(input); + } +} diff --git a/crypto/src/main/java/org/tron/common/crypto/pqc/MerkleTree.java b/crypto/src/main/java/org/tron/common/crypto/pqc/MerkleTree.java new file mode 100644 index 00000000000..c50ee9fa2b0 --- /dev/null +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/MerkleTree.java @@ -0,0 +1,172 @@ +package org.tron.common.crypto.pqc; + +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.List; +import org.tron.common.utils.Sha256Hash; + +/** + * SHA-256 binary Merkle tree for PQ ephemeral key commitments. + * + *

Leaves are 32-byte hashes supplied by the caller (typically + * {@code SHA-256(one_time_pubkey)}). The leaf count must be a power of two and + * {@code >= 1}; the caller pads with a domain-specific sentinel if needed. + * Internal nodes use the Bitcoin-style concatenation: + *

parent = SHA-256(left || right)
+ * + *

Proofs are ordered bottom-up: {@code proof[0]} is the sibling at the leaf + * level, {@code proof[depth-1]} is the sibling adjacent to the root. The leaf + * index encodes left/right at each level (bit 0 = leaf level, bit {@code depth-1} + * = top level). + * + *

Tree depth is capped at {@link #MAX_DEPTH} (2^32 leaves) so a single proof + * fits in a fixed buffer; Phase C constrains usage to {@code <= 2^16} leaves + * per Ephemeral permission via the account bitmap, but the tree itself does + * not enforce that lower bound. + */ +public final class MerkleTree { + + public static final int LEAF_LENGTH = 32; + public static final int MAX_DEPTH = 32; + + private MerkleTree() { + } + + /** + * Build the Merkle root from {@code leaves}. + * + * @param leaves non-empty, power-of-two-sized list of 32-byte leaf hashes + * @return 32-byte SHA-256 Merkle root + */ + public static byte[] buildRoot(List leaves) { + validateLeaves(leaves); + byte[][] level = copyLeaves(leaves); + while (level.length > 1) { + level = nextLevel(level); + } + return level[0]; + } + + /** + * Generate the inclusion proof for the leaf at {@code index}. + * + * @param leaves non-empty, power-of-two-sized list of 32-byte leaf hashes + * @param index 0-based leaf index + * @return ordered list of {@code log2(leaves.size())} sibling hashes + */ + public static List generateProof(List leaves, int index) { + validateLeaves(leaves); + if (index < 0 || index >= leaves.size()) { + throw new IllegalArgumentException( + "leaf index out of range: " + index + ", size=" + leaves.size()); + } + byte[][] level = copyLeaves(leaves); + List proof = new ArrayList<>(); + int idx = index; + while (level.length > 1) { + int siblingIdx = idx ^ 1; + proof.add(level[siblingIdx].clone()); + level = nextLevel(level); + idx >>>= 1; + } + return proof; + } + + /** + * Verify that {@code leaf} occupies position {@code index} under {@code root}. + * + * @param root expected 32-byte Merkle root + * @param leaf 32-byte leaf hash being proven + * @param proof ordered sibling hashes from leaf level upward + * @param index 0-based leaf index encoding left/right at each level + * @return true iff the proof recomputes to {@code root} + */ + public static boolean verifyProof(byte[] root, byte[] leaf, List proof, int index) { + if (root == null || root.length != LEAF_LENGTH) { + throw new IllegalArgumentException("root must be " + LEAF_LENGTH + " bytes"); + } + if (leaf == null || leaf.length != LEAF_LENGTH) { + throw new IllegalArgumentException("leaf must be " + LEAF_LENGTH + " bytes"); + } + if (proof == null) { + throw new IllegalArgumentException("proof must not be null"); + } + int depth = proof.size(); + if (depth > MAX_DEPTH) { + throw new IllegalArgumentException("proof depth exceeds " + MAX_DEPTH); + } + if (index < 0) { + throw new IllegalArgumentException("leaf index must be non-negative"); + } + // Index must fit in `depth` bits (leaf range = [0, 2^depth)). + if (depth < 32 && (index >>> depth) != 0) { + throw new IllegalArgumentException( + "leaf index " + index + " exceeds depth " + depth); + } + byte[] node = leaf.clone(); + for (int i = 0; i < depth; i++) { + byte[] sibling = proof.get(i); + if (sibling == null || sibling.length != LEAF_LENGTH) { + throw new IllegalArgumentException("proof[" + i + "] must be " + LEAF_LENGTH + " bytes"); + } + boolean rightChild = ((index >>> i) & 1) == 1; + node = rightChild ? hashPair(sibling, node) : hashPair(node, sibling); + } + return constantTimeEquals(node, root); + } + + private static void validateLeaves(List leaves) { + if (leaves == null || leaves.isEmpty()) { + throw new IllegalArgumentException("leaves must not be null or empty"); + } + int n = leaves.size(); + if ((n & (n - 1)) != 0) { + throw new IllegalArgumentException("leaf count must be a power of two: " + n); + } + int depth = Integer.numberOfTrailingZeros(n); + if (depth > MAX_DEPTH) { + throw new IllegalArgumentException("tree depth exceeds " + MAX_DEPTH); + } + for (int i = 0; i < n; i++) { + byte[] leaf = leaves.get(i); + if (leaf == null || leaf.length != LEAF_LENGTH) { + throw new IllegalArgumentException( + "leaves[" + i + "] must be " + LEAF_LENGTH + " bytes"); + } + } + } + + private static byte[][] copyLeaves(List leaves) { + byte[][] out = new byte[leaves.size()][]; + for (int i = 0; i < leaves.size(); i++) { + out[i] = leaves.get(i).clone(); + } + return out; + } + + private static byte[][] nextLevel(byte[][] current) { + byte[][] next = new byte[current.length / 2][]; + for (int i = 0; i < next.length; i++) { + next[i] = hashPair(current[2 * i], current[2 * i + 1]); + } + return next; + } + + private static byte[] hashPair(byte[] left, byte[] right) { + MessageDigest md = Sha256Hash.newDigest(); + md.update(left); + md.update(right); + return md.digest(); + } + + private static boolean constantTimeEquals(byte[] a, byte[] b) { + if (a.length != b.length) { + return false; + } + int diff = 0; + for (int i = 0; i < a.length; i++) { + diff |= a[i] ^ b[i]; + } + return diff == 0; + } +} 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 index 32ab9a8659e..b744dc0b4bc 100644 --- a/crypto/src/main/java/org/tron/common/crypto/pqc/PqAuthDigest.java +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/PqAuthDigest.java @@ -16,9 +16,12 @@ public final class PqAuthDigest { public static final String TX_DOMAIN = "TRON_TX_AUTH_V1"; public static final String BLOCK_DOMAIN = "TRON_BLOCK_AUTH_V1"; + public static final String EPHEMERAL_TX_DOMAIN = "TRON_EPHEMERAL_TX_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); + static final byte[] EPHEMERAL_TX_DOMAIN_BYTES = + EPHEMERAL_TX_DOMAIN.getBytes(StandardCharsets.UTF_8); private PqAuthDigest() { } @@ -54,6 +57,32 @@ public static byte[] block(byte[] blockHeaderRawHash, byte[] witnessAddress) { return md.digest(); } + /** + * Transaction-level PQ authentication digest for {@code EPHEMERAL_SECP256K1}. + * Distinct from {@link #tx(byte[], int, byte[])} via the dedicated domain + * prefix {@link #EPHEMERAL_TX_DOMAIN}, and additionally binds {@code nonce} + * and {@code leafIndex} so each one-time leaf authorizes exactly one tx. + * + *

digest = SHA-256(
+   *     "TRON_EPHEMERAL_TX_AUTH_V1" || txid || permission_id_be4
+   *     || signer_address || nonce_be8 || leaf_index_be4)
+ */ + public static byte[] ephemeralTx(byte[] txid, int permissionId, byte[] signerAddress, + long nonce, int leafIndex) { + requireNonNull(txid, "txid"); + requireNonNull(signerAddress, "signerAddress"); + // leafIndex is the proto uint32 wire value; full 32-bit range is bound to the digest. + // Semantic bounds (leafIndex < proof depth) are enforced during verify, not here. + MessageDigest md = Sha256Hash.newDigest(); + md.update(EPHEMERAL_TX_DOMAIN_BYTES); + md.update(txid); + md.update(intToBe4(permissionId)); + md.update(signerAddress); + md.update(longToBe8(nonce)); + md.update(intToBe4(leafIndex)); + return md.digest(); + } + private static byte[] intToBe4(int v) { return new byte[] { (byte) ((v >>> 24) & 0xff), @@ -63,6 +92,19 @@ private static byte[] intToBe4(int v) { }; } + private static byte[] longToBe8(long v) { + return new byte[] { + (byte) ((v >>> 56) & 0xff), + (byte) ((v >>> 48) & 0xff), + (byte) ((v >>> 40) & 0xff), + (byte) ((v >>> 32) & 0xff), + (byte) ((v >>> 24) & 0xff), + (byte) ((v >>> 16) & 0xff), + (byte) ((v >>> 8) & 0xff), + (byte) (v & 0xff) + }; + } + private 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/PqSignatureRegistry.java b/crypto/src/main/java/org/tron/common/crypto/pqc/PqSignatureRegistry.java index 0891a2bbbbc..52dc7e180bc 100644 --- a/crypto/src/main/java/org/tron/common/crypto/pqc/PqSignatureRegistry.java +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/PqSignatureRegistry.java @@ -107,6 +107,26 @@ public PqSignature fromSeed(byte[] seed) { return new FNDSA(seed); } })); + m.put(SignatureScheme.EPHEMERAL_SECP256K1, new SchemeInfo( + EphemeralSecp256k1.PUBLIC_KEY_LENGTH, + EphemeralSecp256k1.SIGNATURE_LENGTH, + new SignatureOps() { + @Override + public byte[] sign(byte[] privateKey, byte[] message) { + return EphemeralSecp256k1.sign(privateKey, message); + } + + @Override + public boolean verify(byte[] publicKey, byte[] message, byte[] signature) { + return EphemeralSecp256k1.verify(publicKey, message, signature); + } + + @Override + public PqSignature fromSeed(byte[] seed) { + throw new UnsupportedOperationException( + "EPHEMERAL_SECP256K1 has no node-side keypair to derive from seed"); + } + })); SCHEMES = Collections.unmodifiableMap(m); } @@ -133,7 +153,7 @@ public static int getSignatureLength(SignatureScheme scheme) { */ public static boolean isValidSignatureLength(SignatureScheme scheme, int length) { SchemeInfo info = require(scheme); - if (scheme == SignatureScheme.FN_DSA) { + if (scheme == SignatureScheme.FN_DSA || scheme == SignatureScheme.EPHEMERAL_SECP256K1) { return length > 0 && length <= info.signatureLength; } return length == info.signatureLength; diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/EphemeralSecp256k1Test.java b/framework/src/test/java/org/tron/common/crypto/pqc/EphemeralSecp256k1Test.java new file mode 100644 index 00000000000..205468c25d9 --- /dev/null +++ b/framework/src/test/java/org/tron/common/crypto/pqc/EphemeralSecp256k1Test.java @@ -0,0 +1,322 @@ +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 com.google.protobuf.ByteString; +import java.math.BigInteger; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.List; +import org.junit.Test; +import org.tron.common.crypto.ECKey; +import org.tron.common.utils.Sha256Hash; +import org.tron.protos.Protocol.EphemeralWitness; +import org.tron.protos.Protocol.SignatureScheme; + +public class EphemeralSecp256k1Test { + + private static final SecureRandom RNG = new SecureRandom(); + + private static byte[] sha256(byte[] in) { + MessageDigest md = Sha256Hash.newDigest(); + return md.digest(in); + } + + /** Sign {@code digest} with {@code key} and return raw 32-byte r || 32-byte s (low-s). */ + private static byte[] rawEcdsaSign(ECKey key, byte[] digest) { + ECKey.ECDSASignature sig = key.sign(digest).toCanonicalised(); + byte[] r = unsignedFixed(sig.r, 32); + byte[] s = unsignedFixed(sig.s, 32); + byte[] out = new byte[64]; + System.arraycopy(r, 0, out, 0, 32); + System.arraycopy(s, 0, out, 32, 32); + return out; + } + + private static byte[] unsignedFixed(BigInteger v, int len) { + byte[] raw = v.toByteArray(); + if (raw.length == len) { + return raw; + } + if (raw.length == len + 1 && raw[0] == 0) { + byte[] out = new byte[len]; + System.arraycopy(raw, 1, out, 0, len); + return out; + } + if (raw.length < len) { + byte[] out = new byte[len]; + System.arraycopy(raw, 0, out, len - raw.length, raw.length); + return out; + } + throw new IllegalArgumentException("value does not fit in " + len + " bytes"); + } + + /** Build a fresh tree of {@code n} one-time secp256k1 keys and return all the parts. */ + private static class Tree { + final List keys; + final List pubkeysCompressed; + final List leaves; + final byte[] root; + + Tree(int n) { + this.keys = new ArrayList<>(); + this.pubkeysCompressed = new ArrayList<>(); + this.leaves = new ArrayList<>(); + for (int i = 0; i < n; i++) { + ECKey k = new ECKey(RNG); + byte[] pk = k.getPubKeyPoint().getEncoded(true); // 33-byte compressed + keys.add(k); + pubkeysCompressed.add(pk); + leaves.add(sha256(pk)); + } + this.root = MerkleTree.buildRoot(leaves); + } + } + + private static byte[] buildWitness(byte[] oneTimePub, List path, + int leafIndex, byte[] ecdsaSig) { + EphemeralWitness.Builder b = EphemeralWitness.newBuilder() + .setOneTimePubkey(ByteString.copyFrom(oneTimePub)) + .setLeafIndex(leafIndex) + .setEcdsaSignature(ByteString.copyFrom(ecdsaSig)); + for (byte[] p : path) { + b.addMerklePath(ByteString.copyFrom(p)); + } + return b.build().toByteArray(); + } + + @Test + public void schemeMetadata() { + EphemeralSecp256k1 e = new EphemeralSecp256k1(new byte[32]); + assertEquals(SignatureScheme.EPHEMERAL_SECP256K1, e.getScheme()); + assertEquals(32, e.getPublicKeyLength()); + assertEquals(0, e.getPrivateKeyLength()); + assertEquals(EphemeralSecp256k1.SIGNATURE_LENGTH, e.getSignatureLength()); + } + + @Test + public void rejectsInvalidRootLength() { + try { + new EphemeralSecp256k1(new byte[31]); + fail("31-byte root must be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("32")); + } + } + + @Test + public void getPrivateKeyThrows() { + EphemeralSecp256k1 e = new EphemeralSecp256k1(new byte[32]); + try { + e.getPrivateKey(); + fail("Ephemeral has no node-side private key"); + } catch (UnsupportedOperationException expected) { + // ok + } + } + + @Test + public void instanceAndStaticSignBothThrow() { + EphemeralSecp256k1 e = new EphemeralSecp256k1(new byte[32]); + try { + e.sign(new byte[32]); + fail("instance sign must throw"); + } catch (UnsupportedOperationException expected) { + // ok + } + try { + EphemeralSecp256k1.sign(new byte[0], new byte[32]); + fail("static sign must throw"); + } catch (UnsupportedOperationException expected) { + // ok + } + } + + @Test + public void publicKeyAndAddressDerivedFromRoot() { + byte[] root = new byte[32]; + for (int i = 0; i < 32; i++) { + root[i] = (byte) i; + } + EphemeralSecp256k1 e = new EphemeralSecp256k1(root); + assertArrayEquals(root, e.getPublicKey()); + byte[] addr = e.getAddress(); + assertEquals(21, addr.length); + } + + @Test + public void verifyRoundTripCompressedPubkey() { + Tree t = new Tree(8); + int idx = 3; + byte[] digest = new byte[32]; + RNG.nextBytes(digest); + List path = MerkleTree.generateProof(t.leaves, idx); + byte[] sig = rawEcdsaSign(t.keys.get(idx), digest); + byte[] witness = buildWitness(t.pubkeysCompressed.get(idx), path, idx, sig); + assertTrue(EphemeralSecp256k1.verify(t.root, digest, witness)); + } + + @Test + public void verifyRoundTripUncompressedPubkey() { + Tree t = new Tree(4); + int idx = 2; + byte[] digest = new byte[32]; + RNG.nextBytes(digest); + // commit using the uncompressed leaf + byte[] pubUncompressed = t.keys.get(idx).getPubKeyPoint().getEncoded(false); + List leaves = new ArrayList<>(t.leaves); + leaves.set(idx, sha256(pubUncompressed)); + byte[] root = MerkleTree.buildRoot(leaves); + List path = MerkleTree.generateProof(leaves, idx); + byte[] sig = rawEcdsaSign(t.keys.get(idx), digest); + byte[] witness = buildWitness(pubUncompressed, path, idx, sig); + assertTrue(EphemeralSecp256k1.verify(root, digest, witness)); + } + + @Test + public void verifyFailsWithTamperedMerklePath() { + Tree t = new Tree(8); + int idx = 1; + byte[] digest = new byte[32]; + RNG.nextBytes(digest); + List path = MerkleTree.generateProof(t.leaves, idx); + path.set(0, new byte[32]); + byte[] sig = rawEcdsaSign(t.keys.get(idx), digest); + byte[] witness = buildWitness(t.pubkeysCompressed.get(idx), path, idx, sig); + assertFalse(EphemeralSecp256k1.verify(t.root, digest, witness)); + } + + @Test + public void verifyFailsWithTamperedEcdsa() { + Tree t = new Tree(4); + int idx = 0; + byte[] digest = new byte[32]; + RNG.nextBytes(digest); + List path = MerkleTree.generateProof(t.leaves, idx); + byte[] sig = rawEcdsaSign(t.keys.get(idx), digest); + sig[0] ^= 0x01; + byte[] witness = buildWitness(t.pubkeysCompressed.get(idx), path, idx, sig); + assertFalse(EphemeralSecp256k1.verify(t.root, digest, witness)); + } + + @Test + public void verifyFailsCrossLeafReplay() { + // Sign with key i but claim leaf index j (different one-time key). Merkle proof + // is the legitimate path for leaf j, so SHA-256(claimed_pubkey) won't match. + Tree t = new Tree(8); + int legitIdx = 2; + int spoofIdx = 5; + byte[] digest = new byte[32]; + RNG.nextBytes(digest); + // Build a path for spoofIdx but advertise the legit pubkey in the witness. + List path = MerkleTree.generateProof(t.leaves, spoofIdx); + byte[] sig = rawEcdsaSign(t.keys.get(legitIdx), digest); + byte[] witness = buildWitness(t.pubkeysCompressed.get(legitIdx), path, spoofIdx, sig); + assertFalse(EphemeralSecp256k1.verify(t.root, digest, witness)); + } + + @Test + public void verifyFailsWithLeafIndexOutOfDepth() { + Tree t = new Tree(4); // depth = 2, valid leaf indices 0..3 + int idx = 1; + byte[] digest = new byte[32]; + RNG.nextBytes(digest); + List path = MerkleTree.generateProof(t.leaves, idx); + byte[] sig = rawEcdsaSign(t.keys.get(idx), digest); + // Replace leaf_index with a value that exceeds the proof depth. + byte[] witness = buildWitness(t.pubkeysCompressed.get(idx), path, 16, sig); + assertFalse(EphemeralSecp256k1.verify(t.root, digest, witness)); + } + + @Test + public void verifyFailsWithInvalidPubkeyByte() { + Tree t = new Tree(4); + int idx = 0; + byte[] digest = new byte[32]; + RNG.nextBytes(digest); + List path = MerkleTree.generateProof(t.leaves, idx); + byte[] sig = rawEcdsaSign(t.keys.get(idx), digest); + // 32-byte pubkey (invalid length) - should be rejected + byte[] witness = buildWitness(new byte[32], path, idx, sig); + assertFalse(EphemeralSecp256k1.verify(t.root, digest, witness)); + } + + @Test + public void verifyFailsWithExcessProofDepth() { + Tree t = new Tree(4); + int idx = 0; + byte[] digest = new byte[32]; + RNG.nextBytes(digest); + // 17 fake siblings - exceeds MAX_PROOF_DEPTH (16) + List path = new ArrayList<>(); + for (int i = 0; i < 17; i++) { + path.add(new byte[32]); + } + byte[] sig = rawEcdsaSign(t.keys.get(idx), digest); + byte[] witness = buildWitness(t.pubkeysCompressed.get(idx), path, idx, sig); + assertFalse(EphemeralSecp256k1.verify(t.root, digest, witness)); + } + + @Test + public void verifyFailsWithMalformedWitnessBytes() { + EphemeralSecp256k1 e = new EphemeralSecp256k1(new byte[32]); + assertFalse(e.verify(new byte[32], new byte[] {(byte) 0xff})); + } + + @Test + public void verifyRejectsHighSEcdsa() { + Tree t = new Tree(2); + int idx = 0; + byte[] digest = new byte[32]; + RNG.nextBytes(digest); + List path = MerkleTree.generateProof(t.leaves, idx); + byte[] sig = rawEcdsaSign(t.keys.get(idx), digest); // canonical low-s + // Flip s to its high counterpart: s' = n - s. The verifier must reject high-s. + BigInteger n = new BigInteger( + "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141", 16); + BigInteger s = new BigInteger(1, java.util.Arrays.copyOfRange(sig, 32, 64)); + BigInteger highS = n.subtract(s); + byte[] sBytes = unsignedFixed(highS, 32); + System.arraycopy(sBytes, 0, sig, 32, 32); + byte[] witness = buildWitness(t.pubkeysCompressed.get(idx), path, idx, sig); + assertFalse("high-s ECDSA must be rejected", EphemeralSecp256k1.verify(t.root, digest, witness)); + } + + @Test + public void registryDispatchSucceeds() { + Tree t = new Tree(4); + int idx = 0; + byte[] digest = new byte[32]; + RNG.nextBytes(digest); + List path = MerkleTree.generateProof(t.leaves, idx); + byte[] sig = rawEcdsaSign(t.keys.get(idx), digest); + byte[] witness = buildWitness(t.pubkeysCompressed.get(idx), path, idx, sig); + assertTrue(PqSignatureRegistry.contains(SignatureScheme.EPHEMERAL_SECP256K1)); + assertEquals(32, PqSignatureRegistry.getPublicKeyLength(SignatureScheme.EPHEMERAL_SECP256K1)); + assertTrue(PqSignatureRegistry.verify( + SignatureScheme.EPHEMERAL_SECP256K1, t.root, digest, witness)); + assertTrue(PqSignatureRegistry.isValidSignatureLength( + SignatureScheme.EPHEMERAL_SECP256K1, witness.length)); + assertTrue(PqSignatureRegistry.isValidSignatureLength( + SignatureScheme.EPHEMERAL_SECP256K1, 1)); + assertFalse(PqSignatureRegistry.isValidSignatureLength( + SignatureScheme.EPHEMERAL_SECP256K1, 0)); + assertFalse(PqSignatureRegistry.isValidSignatureLength( + SignatureScheme.EPHEMERAL_SECP256K1, EphemeralSecp256k1.SIGNATURE_LENGTH + 1)); + } + + @Test + public void registryFromSeedThrows() { + try { + PqSignatureRegistry.fromSeed(SignatureScheme.EPHEMERAL_SECP256K1, new byte[32]); + fail("Ephemeral has no seed-keypair derivation"); + } catch (UnsupportedOperationException expected) { + // ok + } + } +} diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/MerkleTreeTest.java b/framework/src/test/java/org/tron/common/crypto/pqc/MerkleTreeTest.java new file mode 100644 index 00000000000..b2b6d7e0504 --- /dev/null +++ b/framework/src/test/java/org/tron/common/crypto/pqc/MerkleTreeTest.java @@ -0,0 +1,181 @@ +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.MessageDigest; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.junit.Test; +import org.tron.common.utils.Sha256Hash; + +public class MerkleTreeTest { + + private static byte[] leaf(int seed) { + byte[] buf = new byte[MerkleTree.LEAF_LENGTH]; + for (int i = 0; i < buf.length; i++) { + buf[i] = (byte) (seed + i); + } + return buf; + } + + private static byte[] hashPair(byte[] l, byte[] r) { + MessageDigest md = Sha256Hash.newDigest(); + md.update(l); + md.update(r); + return md.digest(); + } + + private static List leaves(int n) { + List out = new ArrayList<>(); + for (int i = 0; i < n; i++) { + out.add(leaf(i)); + } + return out; + } + + @Test + public void singleLeafRootEqualsLeaf() { + byte[] only = leaf(7); + byte[] root = MerkleTree.buildRoot(Collections.singletonList(only)); + assertArrayEquals(only, root); + } + + @Test + public void twoLeafRootMatchesManualHash() { + byte[] l0 = leaf(0); + byte[] l1 = leaf(1); + byte[] expected = hashPair(l0, l1); + byte[] root = MerkleTree.buildRoot(java.util.Arrays.asList(l0, l1)); + assertArrayEquals(expected, root); + } + + @Test + public void fourLeafRootMatchesManualHash() { + List ls = leaves(4); + byte[] h01 = hashPair(ls.get(0), ls.get(1)); + byte[] h23 = hashPair(ls.get(2), ls.get(3)); + byte[] expected = hashPair(h01, h23); + assertArrayEquals(expected, MerkleTree.buildRoot(ls)); + } + + @Test + public void proofVerifiesAtEveryIndex() { + int n = 16; + List ls = leaves(n); + byte[] root = MerkleTree.buildRoot(ls); + for (int i = 0; i < n; i++) { + List proof = MerkleTree.generateProof(ls, i); + assertEquals(4, proof.size()); + assertTrue("proof must verify for leaf " + i, + MerkleTree.verifyProof(root, ls.get(i), proof, i)); + } + } + + @Test + public void proofRejectsWrongIndex() { + List ls = leaves(8); + byte[] root = MerkleTree.buildRoot(ls); + List proof = MerkleTree.generateProof(ls, 3); + assertFalse(MerkleTree.verifyProof(root, ls.get(3), proof, 4)); + } + + @Test + public void proofRejectsTamperedSibling() { + List ls = leaves(8); + byte[] root = MerkleTree.buildRoot(ls); + List proof = MerkleTree.generateProof(ls, 5); + proof.get(0)[0] ^= 0x01; + assertFalse(MerkleTree.verifyProof(root, ls.get(5), proof, 5)); + } + + @Test + public void proofRejectsWrongLeaf() { + List ls = leaves(8); + byte[] root = MerkleTree.buildRoot(ls); + List proof = MerkleTree.generateProof(ls, 2); + byte[] wrong = leaf(99); + assertFalse(MerkleTree.verifyProof(root, wrong, proof, 2)); + } + + @Test + public void proofDepthMatchesLog2OfLeafCount() { + int[] sizes = {1, 2, 4, 8, 16, 256, 65536}; + for (int n : sizes) { + List ls = leaves(n); + List proof = MerkleTree.generateProof(ls, 0); + assertEquals("depth for n=" + n, Integer.numberOfTrailingZeros(n), proof.size()); + } + } + + @Test + public void powerOfTwoEnforced() { + try { + MerkleTree.buildRoot(leaves(3)); + fail("non-power-of-two leaf count should throw"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("power of two")); + } + } + + @Test + public void emptyLeavesRejected() { + try { + MerkleTree.buildRoot(Collections.emptyList()); + fail("empty leaves should throw"); + } catch (IllegalArgumentException expected) { + // ok + } + } + + @Test + public void wrongLeafLengthRejected() { + try { + MerkleTree.buildRoot(Collections.singletonList(new byte[31])); + fail("31-byte leaf should be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("32")); + } + } + + @Test + public void verifyRejectsWrongRoot() { + List ls = leaves(4); + byte[] root = MerkleTree.buildRoot(ls); + List proof = MerkleTree.generateProof(ls, 1); + byte[] wrongRoot = root.clone(); + wrongRoot[0] ^= 0x01; + assertFalse(MerkleTree.verifyProof(wrongRoot, ls.get(1), proof, 1)); + } + + @Test + public void verifyRejectsIndexOutOfDepth() { + List ls = leaves(4); + byte[] root = MerkleTree.buildRoot(ls); + List proof = MerkleTree.generateProof(ls, 1); + try { + // proof depth = 2, valid indices 0..3; index 4 has bit 2 set -> rejected + MerkleTree.verifyProof(root, ls.get(1), proof, 4); + fail("index out of depth should throw"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("exceeds depth")); + } + } + + @Test + public void depth16Stress() { + int n = 1 << 16; + List ls = leaves(n); + byte[] root = MerkleTree.buildRoot(ls); + int[] sample = {0, 1, 7, 1234, 32767, 32768, 65534, 65535}; + for (int idx : sample) { + List proof = MerkleTree.generateProof(ls, idx); + assertEquals(16, proof.size()); + assertTrue("idx=" + idx, MerkleTree.verifyProof(root, ls.get(idx), proof, idx)); + } + } +} 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 index c8ad1cd8c36..a985f3f4b7b 100644 --- a/framework/src/test/java/org/tron/common/crypto/pqc/PqAuthDigestTest.java +++ b/framework/src/test/java/org/tron/common/crypto/pqc/PqAuthDigestTest.java @@ -101,6 +101,81 @@ public void differentWitnessesProduceDifferentBlockDigest() { public void domainPrefixesAreExact() { assertEquals("TRON_TX_AUTH_V1", PqAuthDigest.TX_DOMAIN); assertEquals("TRON_BLOCK_AUTH_V1", PqAuthDigest.BLOCK_DOMAIN); + assertEquals("TRON_EPHEMERAL_TX_AUTH_V1", PqAuthDigest.EPHEMERAL_TX_DOMAIN); + } + + @Test + public void ephemeralTxDigestEqualsExpectedSha256() throws Exception { + byte[] txid = bytes(0x11, 0x22, 0x33, 0x44); + int permissionId = 5; + byte[] signer = bytes(0xaa, 0xbb, 0xcc); + long nonce = 0x0102030405060708L; + int leafIndex = 0xCAFEBABE; + + MessageDigest md = MessageDigest.getInstance("SHA-256"); + md.update("TRON_EPHEMERAL_TX_AUTH_V1".getBytes(StandardCharsets.UTF_8)); + md.update(txid); + md.update(bytes(0, 0, 0, 5)); + md.update(signer); + md.update(bytes(0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08)); + md.update(bytes(0xCA, 0xFE, 0xBA, 0xBE)); + byte[] expected = md.digest(); + + byte[] actual = PqAuthDigest.ephemeralTx(txid, permissionId, signer, nonce, leafIndex); + assertArrayEquals(expected, actual); + assertEquals(32, actual.length); + } + + @Test + public void ephemeralTxDistinctFromTxDigest() { + byte[] txid = new byte[32]; + byte[] addr = new byte[] {1, 2, 3}; + byte[] tx = PqAuthDigest.tx(txid, 1, addr); + byte[] eph = PqAuthDigest.ephemeralTx(txid, 1, addr, 0L, 0); + assertFalse("ephemeralTx must not collide with tx", + java.util.Arrays.equals(tx, eph)); + } + + @Test + public void ephemeralTxNonceChangesDigest() { + byte[] txid = new byte[32]; + byte[] addr = new byte[] {1}; + byte[] d0 = PqAuthDigest.ephemeralTx(txid, 0, addr, 0L, 0); + byte[] d1 = PqAuthDigest.ephemeralTx(txid, 0, addr, 1L, 0); + assertNotEquals(new String(d0), new String(d1)); + } + + @Test + public void ephemeralTxLeafIndexChangesDigest() { + byte[] txid = new byte[32]; + byte[] addr = new byte[] {1}; + byte[] d0 = PqAuthDigest.ephemeralTx(txid, 0, addr, 0L, 0); + byte[] d1 = PqAuthDigest.ephemeralTx(txid, 0, addr, 0L, 1); + assertNotEquals(new String(d0), new String(d1)); + } + + @Test + public void ephemeralTxAcceptsFullUint32Range() { + // Proto uint32 maps to Java int; negative-as-signed values are valid wire indices. + byte[] hi = PqAuthDigest.ephemeralTx(new byte[32], 0, new byte[1], 0L, 0xFFFFFFFF); + byte[] zero = PqAuthDigest.ephemeralTx(new byte[32], 0, new byte[1], 0L, 0); + assertNotEquals(new String(hi), new String(zero)); + } + + @Test + public void ephemeralTxNullInputsRejected() { + try { + PqAuthDigest.ephemeralTx(null, 0, new byte[1], 0L, 0); + fail("null txid must be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("txid")); + } + try { + PqAuthDigest.ephemeralTx(new byte[1], 0, null, 0L, 0); + fail("null signer must be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("signerAddress")); + } } @Test From 8de44b45809d146299b2115428df8cad8aa6329a Mon Sep 17 00:00:00 2001 From: federico Date: Mon, 27 Apr 2026 13:18:17 +0800 Subject: [PATCH 11/17] feat(actuator): permission-update validation for Ephemeral secp256k1 --- .../AccountPermissionUpdateActuator.java | 4 +- .../AccountPermissionUpdateActuatorTest.java | 118 ++++++++++++++++++ 2 files changed, 121 insertions(+), 1 deletion(-) 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 b493885a19c..8bbfa284f1b 100644 --- a/actuator/src/main/java/org/tron/core/actuator/AccountPermissionUpdateActuator.java +++ b/actuator/src/main/java/org/tron/core/actuator/AccountPermissionUpdateActuator.java @@ -16,6 +16,7 @@ import org.tron.core.exception.ContractValidateException; import org.tron.core.store.AccountStore; import org.tron.core.store.DynamicPropertiesStore; +import org.tron.common.crypto.pqc.EphemeralSecp256k1; import org.tron.common.crypto.pqc.FNDSA; import org.tron.common.crypto.pqc.MLDSA44; import org.tron.common.crypto.pqc.MLDSA65; @@ -318,7 +319,8 @@ private static int expectedPublicKeyLength(SignatureScheme scheme) { return SLHDSA.PUBLIC_KEY_LENGTH; case FN_DSA: return FNDSA.PUBLIC_KEY_LENGTH; - // EPHEMERAL_SECP256K1 length added in later phases. + case EPHEMERAL_SECP256K1: + return EphemeralSecp256k1.PUBLIC_KEY_LENGTH; default: return -1; } 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 2eaa9fe959d..f4ad26537e0 100644 --- a/framework/src/test/java/org/tron/core/actuator/AccountPermissionUpdateActuatorTest.java +++ b/framework/src/test/java/org/tron/core/actuator/AccountPermissionUpdateActuatorTest.java @@ -1459,4 +1459,122 @@ public void validFnDsaWitnessPermissionAccepted() throws ContractValidateExcepti Assert.assertTrue(actuatorFor(any).validate()); } + + @Test + public void ephemeralPermissionRejectedWhenNotAllowed() { + dbManager.getDynamicPropertiesStore().saveAllowEphemeralSecp256k1(0L); + ByteString address = ByteString.copyFrom(ByteArray.fromHexString(OWNER_ADDRESS)); + Permission owner = ownerPermissionWithKeys( + java.util.Collections.singletonList(legacyKey(KEY_ADDRESS)), 2); + Permission active = activePermissionWithKeys( + java.util.Collections.singletonList( + mlDsaKey(KEY_ADDRESS1, SignatureScheme.EPHEMERAL_SECP256K1, 32, 1)), + 2); + Any any = getContract(address, owner, null, + java.util.Collections.singletonList(active)); + + try { + actuatorFor(any).validate(); + fail("should reject Ephemeral key when ALLOW_EPHEMERAL_SECP256K1 = 0"); + } catch (ContractValidateException e) { + Assert.assertTrue(e.getMessage().contains("EPHEMERAL_SECP256K1 is not activated")); + } + } + + @Test + public void ephemeralWrongPublicKeyLengthRejected() { + dbManager.getDynamicPropertiesStore().saveAllowEphemeralSecp256k1(1L); + ByteString address = ByteString.copyFrom(ByteArray.fromHexString(OWNER_ADDRESS)); + Permission owner = ownerPermissionWithKeys( + java.util.Collections.singletonList(legacyKey(KEY_ADDRESS)), 2); + Permission active = activePermissionWithKeys( + java.util.Collections.singletonList( + mlDsaKey(KEY_ADDRESS1, SignatureScheme.EPHEMERAL_SECP256K1, 31, 1)), + 2); + Any any = getContract(address, owner, null, + java.util.Collections.singletonList(active)); + + try { + actuatorFor(any).validate(); + fail("Ephemeral wrong public_key length should be rejected"); + } catch (ContractValidateException e) { + Assert.assertTrue(e.getMessage().contains("public_key length")); + Assert.assertTrue(e.getMessage().contains("32")); + } + } + + @Test + public void validEphemeralActivePermissionAccepted() throws ContractValidateException { + dbManager.getDynamicPropertiesStore().saveAllowEphemeralSecp256k1(1L); + ByteString address = ByteString.copyFrom(ByteArray.fromHexString(OWNER_ADDRESS)); + Permission owner = ownerPermissionWithKeys( + java.util.Collections.singletonList(legacyKey(KEY_ADDRESS)), 2); + Permission active = activePermissionWithKeys( + java.util.Collections.singletonList( + mlDsaKey(KEY_ADDRESS1, SignatureScheme.EPHEMERAL_SECP256K1, 32, 1)), + 2); + Any any = getContract(address, owner, null, + java.util.Collections.singletonList(active)); + + Assert.assertTrue(actuatorFor(any).validate()); + } + + @Test + public void validEphemeralOwnerPermissionAccepted() throws ContractValidateException { + dbManager.getDynamicPropertiesStore().saveAllowEphemeralSecp256k1(1L); + ByteString address = ByteString.copyFrom(ByteArray.fromHexString(OWNER_ADDRESS)); + Permission owner = ownerPermissionWithKeys( + java.util.Collections.singletonList( + mlDsaKey(KEY_ADDRESS, SignatureScheme.EPHEMERAL_SECP256K1, 32, 1)), + 2); + Permission active = activePermissionWithKeys( + java.util.Collections.singletonList(legacyKey(KEY_ADDRESS1)), 2); + Any any = getContract(address, owner, null, + java.util.Collections.singletonList(active)); + + Assert.assertTrue(actuatorFor(any).validate()); + } + + @Test + public void ephemeralWitnessPermissionAlwaysRejected() { + // Even with the activation flag on, Ephemeral must not be permitted for witness + // production because each leaf is one-shot and incompatible with continuous block signing. + dbManager.getDynamicPropertiesStore().saveAllowEphemeralSecp256k1(1L); + ByteString address = ByteString.copyFrom(ByteArray.fromHexString(WITNESS_ADDRESS)); + Permission owner = ownerPermissionWithKeys( + java.util.Collections.singletonList(legacyKey(KEY_ADDRESS)), 2); + Permission witness = witnessPermissionWithKey( + mlDsaKey(KEY_ADDRESS1, SignatureScheme.EPHEMERAL_SECP256K1, 32, 2)); + Permission active = activePermissionWithKeys( + java.util.Collections.singletonList(legacyKey(KEY_ADDRESS2)), 2); + Any any = getContract(address, owner, witness, + java.util.Collections.singletonList(active)); + + try { + actuatorFor(any).validate(); + fail("Witness permission with EPHEMERAL_SECP256K1 must be rejected"); + } catch (ContractValidateException e) { + Assert.assertTrue( + e.getMessage().contains("EPHEMERAL_SECP256K1 is incompatible with witness")); + } + } + + @Test + public void ephemeralAndMlDsaCoexistAcrossPermissions() throws ContractValidateException { + dbManager.getDynamicPropertiesStore().saveAllowEphemeralSecp256k1(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa44(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.EPHEMERAL_SECP256K1, 32, 2)), + 2); + Any any = getContract(address, owner, null, + java.util.Collections.singletonList(active)); + + Assert.assertTrue(actuatorFor(any).validate()); + } } \ No newline at end of file From 3476710ae0ee2745a5e885be87593f76ef106950 Mon Sep 17 00:00:00 2001 From: federico Date: Mon, 27 Apr 2026 13:27:46 +0800 Subject: [PATCH 12/17] feat(chainbase): ephemeral secp256k1 replay-protection state --- .../org/tron/core/capsule/AccountCapsule.java | 64 ++++++++++++ .../tron/core/capsule/TransactionCapsule.java | 98 ++++++++++++++++++- .../main/java/org/tron/core/db/Manager.java | 8 ++ 3 files changed, 169 insertions(+), 1 deletion(-) 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..2a384db5cce 100644 --- a/chainbase/src/main/java/org/tron/core/capsule/AccountCapsule.java +++ b/chainbase/src/main/java/org/tron/core/capsule/AccountCapsule.java @@ -1477,4 +1477,68 @@ public void setLatestTime(ResourceCode resourceCode, long time) { } } + // ---------------- EPHEMERAL_SECP256K1 replay-protection state ---------------- + + public static final int EPHEMERAL_BITMAP_MAX_BYTES = 8 * 1024; + + public ByteString getEphemeralUsedBitmap() { + return this.account.getEphemeralUsedBitmap(); + } + + public long getLastEphemeralNonce() { + return this.account.getLastEphemeralNonce(); + } + + public void setLastEphemeralNonce(long nonce) { + this.account = this.account.toBuilder().setLastEphemeralNonce(nonce).build(); + } + + /** + * @return true iff the bit for {@code leafIndex} is set in + * {@code ephemeral_used_bitmap}. Out-of-range / absent bits return false + * (an unallocated tail byte is treated as zero, matching the wire spec). + */ + public boolean isEphemeralLeafConsumed(int leafIndex) { + if (leafIndex < 0) { + throw new IllegalArgumentException("leafIndex must be non-negative"); + } + int byteIdx = leafIndex >>> 3; + ByteString bitmap = this.account.getEphemeralUsedBitmap(); + if (byteIdx >= bitmap.size()) { + return false; + } + int bitInByte = leafIndex & 7; + return ((bitmap.byteAt(byteIdx) & 0xff) & (1 << bitInByte)) != 0; + } + + /** + * Marks {@code leafIndex} as consumed in {@code ephemeral_used_bitmap}, + * growing the bitmap if needed up to {@link #EPHEMERAL_BITMAP_MAX_BYTES}. + * Throws {@link IllegalStateException} if the resulting bitmap would exceed + * the cap (per-account leaf count is capped at 2^16). + */ + public void markEphemeralLeafConsumed(int leafIndex) { + if (leafIndex < 0) { + throw new IllegalArgumentException("leafIndex must be non-negative"); + } + int byteIdx = leafIndex >>> 3; + if (byteIdx >= EPHEMERAL_BITMAP_MAX_BYTES) { + throw new IllegalStateException( + "ephemeral leaf index " + leafIndex + " exceeds per-account cap (2^16 leaves)"); + } + ByteString cur = this.account.getEphemeralUsedBitmap(); + int needed = byteIdx + 1; + byte[] buf; + if (cur.size() >= needed) { + buf = cur.toByteArray(); + } else { + buf = new byte[needed]; + cur.copyTo(buf, 0); + } + int bitInByte = leafIndex & 7; + buf[byteIdx] = (byte) ((buf[byteIdx] & 0xff) | (1 << bitInByte)); + this.account = this.account.toBuilder() + .setEphemeralUsedBitmap(ByteString.copyFrom(buf)) + .build(); + } } 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 389bc4a0868..5ea4ec62137 100755 --- a/chainbase/src/main/java/org/tron/core/capsule/TransactionCapsule.java +++ b/chainbase/src/main/java/org/tron/core/capsule/TransactionCapsule.java @@ -44,6 +44,7 @@ import org.tron.common.crypto.Rsv; import org.tron.common.crypto.SignInterface; import org.tron.common.crypto.SignUtils; +import org.tron.common.crypto.pqc.EphemeralSecp256k1; import org.tron.common.crypto.pqc.PqAuthDigest; import org.tron.common.crypto.pqc.PqSignatureRegistry; import org.tron.common.es.ExecutorServiceManager; @@ -68,6 +69,7 @@ import org.tron.core.store.AccountStore; import org.tron.core.store.DynamicPropertiesStore; import org.tron.protos.Protocol.AuthWitness; +import org.tron.protos.Protocol.EphemeralWitness; import org.tron.protos.Protocol.Key; import org.tron.protos.Protocol.Permission; import org.tron.protos.Protocol.Permission.PermissionType; @@ -752,13 +754,22 @@ static boolean validateStructuredSignature(Transaction transaction, if (!dynamicPropertiesStore.isPqSchemeAllowed(scheme)) { throw new PermissionException(scheme + " is not activated"); } - byte[] digest = PqAuthDigest.tx(txid, permissionId, signer.toByteArray()); byte[] pk = key.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"); } + byte[] digest; + if (scheme == SignatureScheme.EPHEMERAL_SECP256K1) { + if (account == null) { + throw new PermissionException( + "EPHEMERAL_SECP256K1 requires an existing account for replay protection"); + } + digest = ephemeralPreVerifyChecks(transaction, permissionId, signer, sig, account); + } else { + digest = PqAuthDigest.tx(txid, permissionId, signer.toByteArray()); + } if (!PqSignatureRegistry.verify(scheme, pk, digest, sig)) { throw new PermissionException("pq sig invalid"); } @@ -776,6 +787,91 @@ private static Sha256Hash computeRawHash(Transaction transaction) { transaction.getRawData().toByteArray()); } + /** + * Pre-verify checks for an EPHEMERAL_SECP256K1 auth witness: parses the + * inner {@link EphemeralWitness}, enforces nonce monotonicity and that the + * advertised leaf has not been consumed, and returns the domain-separated + * digest the ECDSA signature must verify over. State is read but never + * mutated here — bitmap / nonce updates happen at execution time. + */ + private static byte[] ephemeralPreVerifyChecks(Transaction transaction, int permissionId, + ByteString signer, byte[] sig, AccountCapsule account) throws PermissionException { + EphemeralWitness witness; + try { + witness = EphemeralWitness.parseFrom(sig); + } catch (InvalidProtocolBufferException e) { + throw new PermissionException("malformed EphemeralWitness: " + e.getMessage()); + } + int leafIndex = witness.getLeafIndex(); + // proto uint32 -> Java signed int; negative-as-signed (>=2^31) exceeds the + // per-account 2^16 cap and must be rejected outright. + if (leafIndex < 0 || leafIndex >= (1 << 16)) { + throw new PermissionException( + "ephemeral leaf_index out of range [0, 2^16): " + Integer.toUnsignedString(leafIndex)); + } + long txNonce = transaction.getRawData().getNonce(); + long lastNonce = account.getLastEphemeralNonce(); + if (txNonce <= lastNonce) { + throw new PermissionException( + "ephemeral nonce must be > last_ephemeral_nonce (got " + txNonce + + ", last " + lastNonce + ")"); + } + if (account.isEphemeralLeafConsumed(leafIndex)) { + throw new PermissionException( + "ephemeral leaf already consumed: " + leafIndex); + } + byte[] txid = computeRawHash(transaction).getBytes(); + return PqAuthDigest.ephemeralTx(txid, permissionId, signer.toByteArray(), + txNonce, leafIndex); + } + + /** + * Commits replay-protection state for every EPHEMERAL_SECP256K1 auth witness + * in {@code transaction}: records each consumed leaf in the signing account's + * bitmap and advances {@code last_ephemeral_nonce} to {@code raw.nonce}. + * Idempotent on already-consumed leaves only when called against the same + * committed state — callers MUST run this exactly once per accepted tx, after + * structured-signature validation and actuator execution have both succeeded. + */ + public static void commitEphemeralReplayState(Transaction transaction, + AccountStore accountStore, DynamicPropertiesStore dynamicPropertiesStore) { + Transaction.Contract contract = transaction.getRawData().getContractList().get(0); + int permissionId = contract.getPermissionId(); + byte[] owner = getOwner(contract); + AccountCapsule account = accountStore.get(owner); + if (account == null) { + return; + } + Permission permission = account.getPermissionById(permissionId); + if (permission == null || permission.getKeysCount() == 0) { + return; + } + if (permission.getKeysList().get(0).getScheme() != SignatureScheme.EPHEMERAL_SECP256K1) { + return; + } + long txNonce = transaction.getRawData().getNonce(); + boolean mutated = false; + for (AuthWitness aw : transaction.getAuthWitnessList()) { + Key key = findKeyByAddress(permission, aw.getSignerAddress()); + if (key == null || key.getScheme() != SignatureScheme.EPHEMERAL_SECP256K1) { + continue; + } + EphemeralWitness witness; + try { + witness = EphemeralWitness.parseFrom(aw.getSignature()); + } catch (InvalidProtocolBufferException e) { + // pre-verify already rejected malformed witnesses; defensive skip + continue; + } + account.markEphemeralLeafConsumed(witness.getLeafIndex()); + mutated = true; + } + if (mutated || txNonce > account.getLastEphemeralNonce()) { + account.setLastEphemeralNonce(txNonce); + accountStore.put(owner, account); + } + } + private static Key findKeyByAddress(Permission permission, ByteString address) { for (Key k : permission.getKeysList()) { if (k.getAddress().equals(address)) { 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 db26d58073b..6bd54c49168 100644 --- a/framework/src/main/java/org/tron/core/db/Manager.java +++ b/framework/src/main/java/org/tron/core/db/Manager.java @@ -1532,6 +1532,14 @@ public TransactionInfo processTransaction(final TransactionCapsule trxCap, Block String.format(" %s transaction signature validate failed", txId)); } + // Commit replay-protection state for EPHEMERAL_SECP256K1 witnesses before + // the actuator can mutate any other state. This must happen exactly once per + // accepted tx; rollback of the surrounding snapshot will revert it + // atomically with the rest of the tx side effects. + TransactionCapsule.commitEphemeralReplayState(trxCap.getInstance(), + chainBaseManager.getAccountStore(), + chainBaseManager.getDynamicPropertiesStore()); + TransactionTrace trace = new TransactionTrace(trxCap, StoreFactory.getInstance(), new RuntimeImpl()); trxCap.setTrxTrace(trace); From b94123ee1088ae0868748873de4cc0df94146767 Mon Sep 17 00:00:00 2001 From: federico Date: Mon, 27 Apr 2026 13:29:58 +0800 Subject: [PATCH 13/17] test(crypto): add ML-DSA vs ECKey signature scheme benchmark Micro-benchmark comparing keygen/sign/verify latency for secp256k1 ECKey, ML-DSA-44 and ML-DSA-65, for tracking PQ signature integration performance characteristics across releases. --- .../pqc/SignatureSchemeBenchmarkTest.java | 166 ++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 framework/src/test/java/org/tron/common/crypto/pqc/SignatureSchemeBenchmarkTest.java 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; + } + } +} From a802fb0f7c20370097a50a2c39f7be024763ae92 Mon Sep 17 00:00:00 2001 From: federico Date: Mon, 27 Apr 2026 13:41:48 +0800 Subject: [PATCH 14/17] test(chainbase): ephemeral replay-protection scenarios --- .../crypto/pqc/EphemeralSecp256k1Test.java | 3 +- .../core/capsule/TransactionCapsuleTest.java | 303 +++++++++++++++++- 2 files changed, 304 insertions(+), 2 deletions(-) diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/EphemeralSecp256k1Test.java b/framework/src/test/java/org/tron/common/crypto/pqc/EphemeralSecp256k1Test.java index 205468c25d9..6ab0e8ac284 100644 --- a/framework/src/test/java/org/tron/common/crypto/pqc/EphemeralSecp256k1Test.java +++ b/framework/src/test/java/org/tron/common/crypto/pqc/EphemeralSecp256k1Test.java @@ -284,7 +284,8 @@ public void verifyRejectsHighSEcdsa() { byte[] sBytes = unsignedFixed(highS, 32); System.arraycopy(sBytes, 0, sig, 32, 32); byte[] witness = buildWitness(t.pubkeysCompressed.get(idx), path, idx, sig); - assertFalse("high-s ECDSA must be rejected", EphemeralSecp256k1.verify(t.root, digest, witness)); + assertFalse("high-s ECDSA must be rejected", + EphemeralSecp256k1.verify(t.root, digest, witness)); } @Test 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 3e3cd7a2770..07ecd79d032 100644 --- a/framework/src/test/java/org/tron/core/capsule/TransactionCapsuleTest.java +++ b/framework/src/test/java/org/tron/core/capsule/TransactionCapsuleTest.java @@ -6,6 +6,12 @@ import com.google.protobuf.Any; import com.google.protobuf.ByteString; +import java.math.BigInteger; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; import lombok.extern.slf4j.Slf4j; import org.junit.Assert; import org.junit.Before; @@ -17,8 +23,9 @@ 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.SLHDSA; +import org.tron.common.crypto.pqc.MerkleTree; import org.tron.common.crypto.pqc.PqAuthDigest; +import org.tron.common.crypto.pqc.SLHDSA; import org.tron.common.utils.ByteArray; import org.tron.common.utils.Sha256Hash; import org.tron.common.utils.StringUtil; @@ -27,6 +34,7 @@ import org.tron.core.exception.ValidateSignatureException; import org.tron.protos.Protocol.AccountType; import org.tron.protos.Protocol.AuthWitness; +import org.tron.protos.Protocol.EphemeralWitness; import org.tron.protos.Protocol.Key; import org.tron.protos.Protocol.Permission; import org.tron.protos.Protocol.Permission.PermissionType; @@ -140,6 +148,8 @@ public void authWitnessBeforeActivationRejected() { dbManager.getDynamicPropertiesStore().saveAllowMlDsa44(0L); dbManager.getDynamicPropertiesStore().saveAllowMlDsa65(0L); dbManager.getDynamicPropertiesStore().saveAllowSlhDsa(0L); + dbManager.getDynamicPropertiesStore().saveAllowFnDsa(0L); + dbManager.getDynamicPropertiesStore().saveAllowEphemeralSecp256k1(0L); Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0).toBuilder() .addAuthWitness(AuthWitness.newBuilder() .setSignerAddress(ByteString.copyFrom(ByteArray.fromHexString(PQ_SIGNER_HEX))) @@ -550,4 +560,295 @@ public void fnDsaAuthWitnessRejectedWhenNotActivated() throws Exception { // accepted: rejection path triggered } } + + // --------------------- EPHEMERAL_SECP256K1 integration --------------------- + + private static final SecureRandom EPH_RNG = new SecureRandom(); + + private static byte[] sha256(byte[] in) { + MessageDigest md = Sha256Hash.newDigest(); + return md.digest(in); + } + + private static byte[] unsignedFixed(BigInteger v, int len) { + byte[] raw = v.toByteArray(); + if (raw.length == len) { + return raw; + } + if (raw.length == len + 1 && raw[0] == 0) { + return Arrays.copyOfRange(raw, 1, raw.length); + } + if (raw.length < len) { + byte[] out = new byte[len]; + System.arraycopy(raw, 0, out, len - raw.length, raw.length); + return out; + } + throw new IllegalArgumentException("value does not fit in " + len + " bytes"); + } + + private static byte[] rawEcdsaSign(ECKey key, byte[] digest) { + ECKey.ECDSASignature sig = key.sign(digest).toCanonicalised(); + byte[] r = unsignedFixed(sig.r, 32); + byte[] s = unsignedFixed(sig.s, 32); + byte[] out = new byte[64]; + System.arraycopy(r, 0, out, 0, 32); + System.arraycopy(s, 0, out, 32, 32); + return out; + } + + /** Pre-built tree of {@code n} one-time secp256k1 keys with leaves and root. */ + private static class EphemeralTree { + final List keys = new ArrayList<>(); + final List pubkeysCompressed = new ArrayList<>(); + final List leaves = new ArrayList<>(); + final byte[] root; + + EphemeralTree(int n) { + for (int i = 0; i < n; i++) { + ECKey k = new ECKey(EPH_RNG); + byte[] pk = k.getPubKeyPoint().getEncoded(true); + keys.add(k); + pubkeysCompressed.add(pk); + leaves.add(sha256(pk)); + } + this.root = MerkleTree.buildRoot(leaves); + } + } + + private static byte[] buildEphemeralWitness(byte[] oneTimePub, List path, + int leafIndex, byte[] ecdsaSig) { + EphemeralWitness.Builder b = EphemeralWitness.newBuilder() + .setOneTimePubkey(ByteString.copyFrom(oneTimePub)) + .setLeafIndex(leafIndex) + .setEcdsaSignature(ByteString.copyFrom(ecdsaSig)); + for (byte[] p : path) { + b.addMerklePath(ByteString.copyFrom(p)); + } + return b.build().toByteArray(); + } + + private Transaction buildEphemeralTx(String ownerHex, int permissionId, long nonce) { + 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).setNonce(nonce).build(); + return Transaction.newBuilder().setRawData(rawData).build(); + } + + /** Sign a fresh tx for {@code leafIndex} with this tree at the given nonce. */ + private Transaction signEphemeralTx(EphemeralTree t, String ownerHex, int permissionId, + long nonce, int leafIndex) { + Transaction tx = buildEphemeralTx(ownerHex, permissionId, nonce); + byte[] txid = Sha256Hash.of(true, tx.getRawData().toByteArray()).getBytes(); + byte[] signerAddr = ByteArray.fromHexString(PQ_SIGNER_HEX); + byte[] digest = PqAuthDigest.ephemeralTx(txid, permissionId, signerAddr, nonce, leafIndex); + byte[] ecdsa = rawEcdsaSign(t.keys.get(leafIndex), digest); + List path = MerkleTree.generateProof(t.leaves, leafIndex); + byte[] witness = buildEphemeralWitness(t.pubkeysCompressed.get(leafIndex), + path, leafIndex, ecdsa); + return tx.toBuilder() + .addAuthWitness(AuthWitness.newBuilder() + .setSignerAddress(ByteString.copyFrom(signerAddr)) + .setSignature(ByteString.copyFrom(witness)) + .build()) + .build(); + } + + @Test + public void ephemeralAuthWitnessAccepted() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowEphemeralSecp256k1(1L); + EphemeralTree t = new EphemeralTree(8); + putAccountWithPqPermission(PQ_OWNER_HEX, t.root, SignatureScheme.EPHEMERAL_SECP256K1); + + Transaction signed = signEphemeralTx(t, PQ_OWNER_HEX, 0, 1L, 3); + TransactionCapsule cap = new TransactionCapsule(signed); + Assert.assertTrue(cap.validatePubSignature(dbManager.getAccountStore(), + dbManager.getDynamicPropertiesStore())); + } + + @Test + public void ephemeralAuthWitnessRejectedWhenNotActivated() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowMlDsa44(0L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa65(0L); + dbManager.getDynamicPropertiesStore().saveAllowSlhDsa(0L); + dbManager.getDynamicPropertiesStore().saveAllowFnDsa(0L); + dbManager.getDynamicPropertiesStore().saveAllowEphemeralSecp256k1(0L); + EphemeralTree t = new EphemeralTree(4); + putAccountWithPqPermission(PQ_OWNER_HEX, t.root, SignatureScheme.EPHEMERAL_SECP256K1); + + Transaction signed = signEphemeralTx(t, PQ_OWNER_HEX, 0, 1L, 0); + TransactionCapsule cap = new TransactionCapsule(signed); + try { + cap.validatePubSignature(dbManager.getAccountStore(), + dbManager.getDynamicPropertiesStore()); + Assert.fail("ephemeral must be rejected when ALLOW_EPHEMERAL_SECP256K1 is 0"); + } catch (ValidateSignatureException expected) { + // ok + } + } + + @Test + public void ephemeralNonceMustAdvance() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowEphemeralSecp256k1(1L); + EphemeralTree t = new EphemeralTree(4); + putAccountWithPqPermission(PQ_OWNER_HEX, t.root, SignatureScheme.EPHEMERAL_SECP256K1); + // Bump the on-chain last nonce to 5; tx must use nonce > 5. + byte[] addr = ByteArray.fromHexString(PQ_OWNER_HEX); + AccountCapsule acc = dbManager.getAccountStore().get(addr); + acc.setLastEphemeralNonce(5L); + dbManager.getAccountStore().put(addr, acc); + + Transaction signed = signEphemeralTx(t, PQ_OWNER_HEX, 0, 5L, 0); + TransactionCapsule cap = new TransactionCapsule(signed); + try { + cap.validatePubSignature(dbManager.getAccountStore(), + dbManager.getDynamicPropertiesStore()); + Assert.fail("nonce <= last_ephemeral_nonce must be rejected"); + } catch (ValidateSignatureException e) { + Assert.assertTrue(e.getMessage().contains("ephemeral nonce must be")); + } + } + + @Test + public void ephemeralLeafAlreadyConsumed() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowEphemeralSecp256k1(1L); + EphemeralTree t = new EphemeralTree(4); + putAccountWithPqPermission(PQ_OWNER_HEX, t.root, SignatureScheme.EPHEMERAL_SECP256K1); + byte[] addr = ByteArray.fromHexString(PQ_OWNER_HEX); + AccountCapsule acc = dbManager.getAccountStore().get(addr); + acc.markEphemeralLeafConsumed(2); + dbManager.getAccountStore().put(addr, acc); + + Transaction signed = signEphemeralTx(t, PQ_OWNER_HEX, 0, 1L, 2); + TransactionCapsule cap = new TransactionCapsule(signed); + try { + cap.validatePubSignature(dbManager.getAccountStore(), + dbManager.getDynamicPropertiesStore()); + Assert.fail("re-using a consumed leaf must be rejected"); + } catch (ValidateSignatureException e) { + Assert.assertTrue(e.getMessage().contains("ephemeral leaf already consumed")); + } + } + + @Test + public void ephemeralLeafIndexOutOfRange() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowEphemeralSecp256k1(1L); + EphemeralTree t = new EphemeralTree(2); + putAccountWithPqPermission(PQ_OWNER_HEX, t.root, SignatureScheme.EPHEMERAL_SECP256K1); + + Transaction tx = buildEphemeralTx(PQ_OWNER_HEX, 0, 1L); + byte[] txid = Sha256Hash.of(true, tx.getRawData().toByteArray()).getBytes(); + byte[] signerAddr = ByteArray.fromHexString(PQ_SIGNER_HEX); + int outOfRange = 1 << 16; // == 2^16, just past the cap + byte[] digest = PqAuthDigest.ephemeralTx(txid, 0, signerAddr, 1L, outOfRange); + byte[] ecdsa = rawEcdsaSign(t.keys.get(0), digest); + List path = MerkleTree.generateProof(t.leaves, 0); + byte[] witness = buildEphemeralWitness(t.pubkeysCompressed.get(0), path, outOfRange, ecdsa); + Transaction signed = tx.toBuilder() + .addAuthWitness(AuthWitness.newBuilder() + .setSignerAddress(ByteString.copyFrom(signerAddr)) + .setSignature(ByteString.copyFrom(witness)) + .build()) + .build(); + TransactionCapsule cap = new TransactionCapsule(signed); + try { + cap.validatePubSignature(dbManager.getAccountStore(), + dbManager.getDynamicPropertiesStore()); + Assert.fail("leaf_index >= 2^16 must be rejected"); + } catch (ValidateSignatureException e) { + Assert.assertTrue(e.getMessage().contains("ephemeral leaf_index out of range")); + } + } + + @Test + public void ephemeralRequiresExistingAccount() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowEphemeralSecp256k1(1L); + EphemeralTree t = new EphemeralTree(2); + // Owner is referenced in TransferContract but absent from the store: the + // default permission falls back to legacy scheme, so the auth_witness path + // must reject before any bitmap state is consulted. + String missingOwnerHex = "41dead0000000000000000000000000000000000"; + Transaction tx = buildEphemeralTx(missingOwnerHex, 0, 1L); + byte[] txid = Sha256Hash.of(true, tx.getRawData().toByteArray()).getBytes(); + byte[] signerAddr = ByteArray.fromHexString(PQ_SIGNER_HEX); + byte[] digest = PqAuthDigest.ephemeralTx(txid, 0, signerAddr, 1L, 0); + byte[] ecdsa = rawEcdsaSign(t.keys.get(0), digest); + List path = MerkleTree.generateProof(t.leaves, 0); + byte[] witness = buildEphemeralWitness(t.pubkeysCompressed.get(0), path, 0, ecdsa); + Transaction signed = tx.toBuilder() + .addAuthWitness(AuthWitness.newBuilder() + .setSignerAddress(ByteString.copyFrom(signerAddr)) + .setSignature(ByteString.copyFrom(witness)) + .build()) + .build(); + TransactionCapsule cap = new TransactionCapsule(signed); + try { + cap.validatePubSignature(dbManager.getAccountStore(), + dbManager.getDynamicPropertiesStore()); + Assert.fail("ephemeral with missing account must be rejected"); + } catch (ValidateSignatureException expected) { + // any ValidateSignatureException is acceptable here — the missing-account + // path can surface as either "account not found" or the pq-specific message. + } + } + + @Test + public void ephemeralCommitAdvancesNonceAndBitmap() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowEphemeralSecp256k1(1L); + EphemeralTree t = new EphemeralTree(8); + putAccountWithPqPermission(PQ_OWNER_HEX, t.root, SignatureScheme.EPHEMERAL_SECP256K1); + + Transaction signed = signEphemeralTx(t, PQ_OWNER_HEX, 0, 7L, 5); + new TransactionCapsule(signed).validatePubSignature( + dbManager.getAccountStore(), dbManager.getDynamicPropertiesStore()); + TransactionCapsule.commitEphemeralReplayState(signed, + dbManager.getAccountStore(), dbManager.getDynamicPropertiesStore()); + + byte[] addr = ByteArray.fromHexString(PQ_OWNER_HEX); + AccountCapsule after = dbManager.getAccountStore().get(addr); + Assert.assertEquals(7L, after.getLastEphemeralNonce()); + Assert.assertTrue("leaf 5 must be marked consumed", after.isEphemeralLeafConsumed(5)); + Assert.assertFalse("untouched leaves must remain free", after.isEphemeralLeafConsumed(0)); + Assert.assertFalse("untouched leaves must remain free", after.isEphemeralLeafConsumed(7)); + } + + @Test + public void ephemeralMultiLeafConsumeAndContinue() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowEphemeralSecp256k1(1L); + EphemeralTree t = new EphemeralTree(8); + putAccountWithPqPermission(PQ_OWNER_HEX, t.root, SignatureScheme.EPHEMERAL_SECP256K1); + byte[] addr = ByteArray.fromHexString(PQ_OWNER_HEX); + + int[] leafSequence = {0, 1, 2, 3}; + long nonce = 0L; + for (int leaf : leafSequence) { + nonce++; + Transaction signed = signEphemeralTx(t, PQ_OWNER_HEX, 0, nonce, leaf); + Assert.assertTrue("nonce=" + nonce + " leaf=" + leaf, + new TransactionCapsule(signed).validatePubSignature( + dbManager.getAccountStore(), dbManager.getDynamicPropertiesStore())); + TransactionCapsule.commitEphemeralReplayState(signed, + dbManager.getAccountStore(), dbManager.getDynamicPropertiesStore()); + } + + AccountCapsule after = dbManager.getAccountStore().get(addr); + Assert.assertEquals(4L, after.getLastEphemeralNonce()); + for (int leaf : leafSequence) { + Assert.assertTrue("leaf " + leaf + " should be consumed", + after.isEphemeralLeafConsumed(leaf)); + } + Assert.assertFalse("leaf 4 must still be free", after.isEphemeralLeafConsumed(4)); + + // A fresh leaf with the next strictly-greater nonce must still verify. + Transaction next = signEphemeralTx(t, PQ_OWNER_HEX, 0, 5L, 4); + Assert.assertTrue(new TransactionCapsule(next).validatePubSignature( + dbManager.getAccountStore(), dbManager.getDynamicPropertiesStore())); + } } \ No newline at end of file From 22b0341217f25ad9a82814f66a72cd002184255a Mon Sep 17 00:00:00 2001 From: federico Date: Mon, 27 Apr 2026 15:33:12 +0800 Subject: [PATCH 15/17] feat(vm): add ML-DSA verify precompiles at 0x12 and 0x14 Introduce two TVM precompiles for post-quantum signature verification, gated on the existing ALLOW_ML_DSA proposal: - 0x12 VerifyMlDsa44: ML-DSA-44 (FIPS-204, SHAKE256), 4500 energy. Compatible with EIP-8051 0x12 at the algorithm level; uses raw 1312-byte public keys (BouncyCastle-native form) rather than the 20512-byte expanded form, so wire bytes differ. - 0x14 VerifyMlDsa65: ML-DSA-65 (TRON extension), 7000 energy. Input layout for both: [msg 32B | signature | publicKey], output is a 32-byte word (1 on success, 0 otherwise). Wires VMConfig.allowMlDsa() through ConfigLoader so the flag is loaded from the store on startup. --- .../tron/core/vm/PrecompiledContracts.java | 89 ++++++++ .../org/tron/core/vm/config/ConfigLoader.java | 1 + .../org/tron/core/vm/config/VMConfig.java | 10 + .../runtime/vm/MlDsaPrecompileTest.java | 193 ++++++++++++++++++ 4 files changed, 293 insertions(+) create mode 100644 framework/src/test/java/org/tron/common/runtime/vm/MlDsaPrecompileTest.java diff --git a/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java b/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java index 634f7f2d3d1..eb7f3f0305b 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,8 @@ import org.tron.common.crypto.Rsv; import org.tron.common.crypto.SignUtils; import org.tron.common.crypto.SignatureInterface; +import org.tron.common.crypto.pqc.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 +109,9 @@ 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(); + // FreezeV2 PrecompileContracts private static final GetChainParameter getChainParameter = new GetChainParameter(); private static final AvailableUnfreezeV2Size availableUnfreezeV2Size = new AvailableUnfreezeV2Size(); @@ -200,6 +205,15 @@ 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"); + public static PrecompiledContract getOptimizedContractForConstant(PrecompiledContract contract) { try { Constructor constructor = contract.getClass().getDeclaredConstructor(); @@ -282,6 +296,13 @@ 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.allowTvmFreezeV2()) { if (address.equals(getChainParameterAddr)) { return getChainParameter; @@ -2221,4 +2242,72 @@ 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()); + } + } + } + } 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..a7d74418f6c 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,7 @@ public static void load(StoreFactory storeFactory) { VMConfig.initAllowTvmBlob(ds.getAllowTvmBlob()); VMConfig.initAllowTvmSelfdestructRestriction(ds.getAllowTvmSelfdestructRestriction()); VMConfig.initAllowTvmOsaka(ds.getAllowTvmOsaka()); + VMConfig.initAllowMlDsa(ds.getAllowMlDsa()); } } } 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..1a45b6ed786 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,8 @@ public class VMConfig { private static boolean ALLOW_TVM_OSAKA = false; + private static boolean ALLOW_ML_DSA = false; + private VMConfig() { } @@ -178,6 +180,10 @@ public static void initAllowTvmOsaka(long allow) { ALLOW_TVM_OSAKA = allow == 1; } + public static void initAllowMlDsa(long allow) { + ALLOW_ML_DSA = allow == 1; + } + public static boolean getEnergyLimitHardFork() { return CommonParameter.ENERGY_LIMIT_HARD_FORK; } @@ -281,4 +287,8 @@ public static boolean allowTvmSelfdestructRestriction() { public static boolean allowTvmOsaka() { return ALLOW_TVM_OSAKA; } + + public static boolean allowMlDsa() { + return ALLOW_ML_DSA; + } } 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)); + } +} From fe5ce69922383eb3e1f25f6de207a5d02b2bcef5 Mon Sep 17 00:00:00 2001 From: federico Date: Mon, 27 Apr 2026 15:56:13 +0800 Subject: [PATCH 16/17] refactor(crypto): tighten merkle tree max depth to 20 --- .../org/tron/common/crypto/pqc/MerkleTree.java | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/crypto/src/main/java/org/tron/common/crypto/pqc/MerkleTree.java b/crypto/src/main/java/org/tron/common/crypto/pqc/MerkleTree.java index c50ee9fa2b0..d1996dcb1e8 100644 --- a/crypto/src/main/java/org/tron/common/crypto/pqc/MerkleTree.java +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/MerkleTree.java @@ -19,15 +19,16 @@ * index encodes left/right at each level (bit 0 = leaf level, bit {@code depth-1} * = top level). * - *

Tree depth is capped at {@link #MAX_DEPTH} (2^32 leaves) so a single proof - * fits in a fixed buffer; Phase C constrains usage to {@code <= 2^16} leaves - * per Ephemeral permission via the account bitmap, but the tree itself does - * not enforce that lower bound. + *

Tree depth is capped at {@link #MAX_DEPTH} (2^20 leaves) — chosen to keep + * a fully-materialised tree within ~32 MiB of working memory while leaving + * headroom above the Ephemeral consumer's own 2^16 cap. Each consumer is free + * to enforce a tighter bound (e.g. {@code EphemeralSecp256k1.MAX_PROOF_DEPTH} + * = 16); the tree itself does not enforce those lower bounds. */ public final class MerkleTree { public static final int LEAF_LENGTH = 32; - public static final int MAX_DEPTH = 32; + public static final int MAX_DEPTH = 20; private MerkleTree() { } @@ -98,8 +99,9 @@ public static boolean verifyProof(byte[] root, byte[] leaf, List proof, if (index < 0) { throw new IllegalArgumentException("leaf index must be non-negative"); } - // Index must fit in `depth` bits (leaf range = [0, 2^depth)). - if (depth < 32 && (index >>> depth) != 0) { + // Index must fit in `depth` bits (leaf range = [0, 2^depth)). Java's + // `>>>` shifts mod 32, so this is only correct because MAX_DEPTH < 32. + if ((index >>> depth) != 0) { throw new IllegalArgumentException( "leaf index " + index + " exceeds depth " + depth); } From 775404dd6b96b0202a73a2ae52bf729cf8d22f99 Mon Sep 17 00:00:00 2001 From: federico Date: Mon, 27 Apr 2026 16:22:41 +0800 Subject: [PATCH 17/17] chore(framework): tighten localwitness pq seed scheme validation --- .../java/org/tron/core/config/args/Args.java | 19 ++++++++++++++++++- framework/src/main/resources/config.conf | 12 ++++++++---- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/framework/src/main/java/org/tron/core/config/args/Args.java b/framework/src/main/java/org/tron/core/config/args/Args.java index a41594af385..ac03221945e 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; @@ -1205,6 +1206,14 @@ private static void applyCLIParams(CLIParameter cmd, JCommander jc) { } } + // Schemes accepted for the witness PQ seed config. Excludes EPHEMERAL_SECP256K1 + // (no deterministic seed) and any future entries that lack a fromSeed path. + private static final EnumSet WITNESS_PQ_SEED_SCHEMES = EnumSet.of( + SignatureScheme.ML_DSA_44, + SignatureScheme.ML_DSA_65, + SignatureScheme.FN_DSA, + SignatureScheme.SLH_DSA); + private static void initLocalWitnesses(Config config, CLIParameter cmd) { // not a witness node, skip if (!PARAMETER.isWitness()) { @@ -1249,12 +1258,20 @@ private static void initLocalWitnesses(Config config, CLIParameter cmd) { localWitnesses.setPqSeeds(pqSeeds); if (config.hasPath(ConfigKey.LOCAL_WITNESS_SEED_PQ_SCHEME)) { String schemeName = config.getString(ConfigKey.LOCAL_WITNESS_SEED_PQ_SCHEME); + SignatureScheme scheme; try { - localWitnesses.setPqScheme(SignatureScheme.valueOf(schemeName)); + scheme = SignatureScheme.valueOf(schemeName); } catch (IllegalArgumentException e) { throw new TronError("invalid " + ConfigKey.LOCAL_WITNESS_SEED_PQ_SCHEME + ": " + schemeName, TronError.ErrCode.WITNESS_INIT); } + if (!WITNESS_PQ_SEED_SCHEMES.contains(scheme)) { + throw new TronError(ConfigKey.LOCAL_WITNESS_SEED_PQ_SCHEME + + "=" + schemeName + " is not allowed for witness signing; " + + "valid values: " + WITNESS_PQ_SEED_SCHEMES, + TronError.ErrCode.WITNESS_INIT); + } + localWitnesses.setPqScheme(scheme); } byte[] address = WitnessInitializer.resolvePqWitnessAddress(witnessAddr); if (address != null) { diff --git a/framework/src/main/resources/config.conf b/framework/src/main/resources/config.conf index 8406785ef03..7e5115e5cba 100644 --- a/framework/src/main/resources/config.conf +++ b/framework/src/main/resources/config.conf @@ -674,12 +674,16 @@ localwitness = [ # ] # Scheme used to derive keys from localwitness_seed_pq. Defaults to ML_DSA_65. -# Allowed values: ML_DSA_44, ML_DSA_65. +# Allowed values: ML_DSA_44, ML_DSA_65, FN_DSA, SLH_DSA. EPHEMERAL_SECP256K1 +# is NOT a valid witness scheme (no deterministic seed path). # localwitness_seed_pq_scheme = "ML_DSA_65" -# ML-DSA witness signing seed (FIPS 204), 32-byte hex. Used only after -# ALLOW_ML_DSA = 1 and the witness Permission is upgraded to an ML-DSA scheme. -# MUST be produced by a CSPRNG; the value below is an example, never use in prod. +# Witness signing seed (CSPRNG-generated hex). Length depends on the scheme: +# - ML_DSA_44 / ML_DSA_65 (FIPS 204): 32 bytes (64 hex chars) +# - FN_DSA / SLH_DSA: 48 bytes (96 hex chars) +# Used only after the matching ALLOW_ proposal is active and the +# witness Permission is upgraded to the same scheme. The example below is +# 32-byte and only valid for the ML-DSA family; never use in prod. # localwitness_seed_pq = [ # "0101010101010101010101010101010101010101010101010101010101010101" # ]