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..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,9 +16,16 @@ 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; +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; +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 +109,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 +261,68 @@ public ByteString getOwnerAddress() throws InvalidProtocolBufferException { public long calcFee() { return chainBaseManager.getDynamicPropertiesStore().getUpdateAccountPermissionFee(); } + + private void validatePermissionScheme(Permission permission) throws ContractValidateException { + DynamicPropertiesStore dynamicStore = chainBaseManager.getDynamicPropertiesStore(); + + SignatureScheme first = 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 (!dynamicStore.isPqSchemeAllowed(scheme)) { + throw new ContractValidateException( + scheme + " is not activated, this 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) { + 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); + } + } + } + + private static int expectedPublicKeyLength(SignatureScheme scheme) { + switch (scheme) { + case ML_DSA_44: + return MLDSA44.PUBLIC_KEY_LENGTH; + case ML_DSA_65: + return MLDSA65.PUBLIC_KEY_LENGTH; + case SLH_DSA: + return SLHDSA.PUBLIC_KEY_LENGTH; + case FN_DSA: + return FNDSA.PUBLIC_KEY_LENGTH; + case EPHEMERAL_SECP256K1: + return EphemeralSecp256k1.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..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,6 +886,41 @@ public static void validator(DynamicPropertiesStore dynamicPropertiesStore, } break; } + case ALLOW_ML_DSA_44: { + if (value != 0 && value != 1) { + throw new ContractValidateException( + "This value[ALLOW_ML_DSA_44] is only allowed to be 0 or 1"); + } + 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_EPHEMERAL_SECP256K1] is only allowed to be 0 or 1"); + } + break; + } default: break; } @@ -971,7 +1006,12 @@ 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_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/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java b/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java index 634f7f2d3d1..266b9918277 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.allowMlDsa44() && address.equals(verifyMlDsa44Addr)) { + return verifyMlDsa44; + } + if (VMConfig.allowMlDsa65() && 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..0de527feebd 100644 --- a/actuator/src/main/java/org/tron/core/vm/config/ConfigLoader.java +++ b/actuator/src/main/java/org/tron/core/vm/config/ConfigLoader.java @@ -46,6 +46,8 @@ public static void load(StoreFactory storeFactory) { VMConfig.initAllowTvmBlob(ds.getAllowTvmBlob()); VMConfig.initAllowTvmSelfdestructRestriction(ds.getAllowTvmSelfdestructRestriction()); VMConfig.initAllowTvmOsaka(ds.getAllowTvmOsaka()); + VMConfig.initAllowMlDsa44(ds.getAllowMlDsa44()); + VMConfig.initAllowMlDsa65(ds.getAllowMlDsa65()); } } } 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..f078d2113a9 100644 --- a/chainbase/src/main/java/org/tron/common/utils/LocalWitnesses.java +++ b/chainbase/src/main/java/org/tron/common/utils/LocalWitnesses.java @@ -18,14 +18,18 @@ import com.google.common.collect.Lists; import java.util.List; import lombok.Getter; +import lombok.Setter; import lombok.extern.slf4j.Slf4j; import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.StringUtils; import org.tron.common.crypto.ECKey; import org.tron.common.crypto.SignInterface; import org.tron.common.crypto.SignUtils; +import org.tron.common.crypto.pqc.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; @Slf4j(topic = "app") public class LocalWitnesses { @@ -33,6 +37,23 @@ public class LocalWitnesses { @Getter private List privateKeys = Lists.newArrayList(); + /** 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 + private SignatureScheme pqScheme = SignatureScheme.ML_DSA_65; + + public void setPqScheme(SignatureScheme pqScheme) { + if (pqScheme == null || !PqSignatureRegistry.contains(pqScheme)) { + throw new TronError("unsupported PQ signature scheme: " + pqScheme, + TronError.ErrCode.WITNESS_INIT); + } + this.pqScheme = pqScheme; + } + + @Setter @Getter private byte[] witnessAccountAddress; @@ -95,6 +116,35 @@ public void addPrivateKeys(String privateKey) { this.privateKeys.add(privateKey); } + /** 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; + } + for (String seed : pqSeeds) { + validatePqSeed(seed); + } + this.pqSeeds = pqSeeds; + } + + private static void validatePqSeed(String seed) { + String hex = seed; + // Match downstream ByteArray.fromHexString, which only strips lowercase "0x". + if (StringUtils.startsWith(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 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 seed must be hex string", + TronError.ErrCode.WITNESS_INIT); + } + } + //get the first one recently public String getPrivateKey() { if (CollectionUtils.isEmpty(privateKeys)) { diff --git a/chainbase/src/main/java/org/tron/core/capsule/AccountCapsule.java b/chainbase/src/main/java/org/tron/core/capsule/AccountCapsule.java index 1af7b55c8b2..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/BlockCapsule.java b/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java index 01ff7fb5365..54d63b875c8 100755 --- a/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java +++ b/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java @@ -31,6 +31,8 @@ import org.tron.common.bloom.Bloom; import org.tron.common.crypto.SignInterface; import org.tron.common.crypto.SignUtils; +import org.tron.common.crypto.pqc.PqAuthDigest; +import org.tron.common.crypto.pqc.PqSignatureRegistry; import org.tron.common.parameter.CommonParameter; import org.tron.common.utils.ByteArray; import org.tron.common.utils.Sha256Hash; @@ -41,8 +43,12 @@ import org.tron.core.exception.ValidateSignatureException; import org.tron.core.store.AccountStore; import org.tron.core.store.DynamicPropertiesStore; +import org.tron.protos.Protocol.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 +179,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 +196,106 @@ 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.isAnyPqSchemeAllowed()) { + AccountCapsule accountCapsule = accountStore.get(witnessAccountAddress); + if (accountCapsule != null && accountCapsule.getInstance().hasWitnessPermission()) { + Permission witnessPermission = accountCapsule.getInstance().getWitnessPermission(); + if (witnessPermission.getKeysCount() > 0 + && PqSignatureRegistry.contains(witnessPermission.getKeys(0).getScheme())) { + throw new ValidateSignatureException( + "witness permission requires PQ scheme " + + witnessPermission.getKeys(0).getScheme() + + " 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 { + 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.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; + 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"); + } + byte[] publicKey = matched.getPublicKey().toByteArray(); + byte[] signature = witnessAuth.getSignature().toByteArray(); + byte[] rawHdrHash = getRawHash().getBytes(); + byte[] digest = PqAuthDigest.block(rawHdrHash, signerAddr); + return PqSignatureRegistry.verify(scheme, publicKey, digest, signature); + } + public BlockId getBlockId() { if (blockId.equals(Sha256Hash.ZERO_HASH)) { blockId = @@ -308,7 +403,12 @@ public long getTimeStamp() { } public boolean hasWitnessSignature() { - return !getInstance().getBlockHeader().getWitnessSignature().isEmpty(); + BlockHeader header = getInstance().getBlockHeader(); + if (!header.getWitnessSignature().isEmpty()) { + return true; + } + 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..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,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.EphemeralSecp256k1; +import org.tron.common.crypto.pqc.PqAuthDigest; +import org.tron.common.crypto.pqc.PqSignatureRegistry; import org.tron.common.es.ExecutorServiceManager; +import org.tron.common.math.StrictMathWrapper; import org.tron.common.overlay.message.Message; import org.tron.common.parameter.CommonParameter; import org.tron.common.utils.ByteArray; @@ -64,9 +68,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.EphemeralWitness; 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 +491,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 +649,42 @@ public boolean validatePubSignature(AccountStore accountStore, DynamicPropertiesStore dynamicPropertiesStore) throws ValidateSignatureException { if (!isVerified) { - if (this.transaction.getSignatureCount() <= 0 - || this.transaction.getRawData().getContractCount() <= 0) { + int legacyCount = this.transaction.getSignatureCount(); + int pqCount = this.transaction.getAuthWitnessCount(); + + 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( + "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 +704,183 @@ 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 (!PqSignatureRegistry.contains(scheme)) { + throw new PermissionException("unsupported scheme: " + scheme); + } + if (!dynamicPropertiesStore.isPqSchemeAllowed(scheme)) { + throw new PermissionException(scheme + " is not activated"); + } + 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"); + } + try { + weight = StrictMathWrapper.addExact(weight, key.getWeight()); + } catch (ArithmeticException e) { + throw new PermissionException("weight overflow"); + } + } + return weight >= permission.getThreshold(); + } + + private static Sha256Hash computeRawHash(Transaction transaction) { + return Sha256Hash.of(CommonParameter.getInstance().isECKeyCryptoEngine(), + transaction.getRawData().toByteArray()); + } + + /** + * 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)) { + 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..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,6 +241,15 @@ public class DynamicPropertiesStore extends TronStoreWithRevoking private static final byte[] ALLOW_TVM_OSAKA = "ALLOW_TVM_OSAKA".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) { super(dbName); @@ -2993,6 +3003,103 @@ public void saveAllowTvmOsaka(long value) { this.put(ALLOW_TVM_OSAKA, new BytesCapsule(ByteArray.fromLong(value))); } + public long getAllowMlDsa44() { + return Optional.ofNullable(getUnchecked(ALLOW_ML_DSA_44)) + .map(BytesCapsule::getData) + .map(ByteArray::toLong) + .orElse(CommonParameter.getInstance().getAllowMlDsa44()); + } + + public void saveAllowMlDsa44(long value) { + this.put(ALLOW_ML_DSA_44, new BytesCapsule(ByteArray.fromLong(value))); + } + + public boolean allowMlDsa44() { + return getAllowMlDsa44() == 1L; + } + + 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 { 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..01db6fa753a 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,26 @@ public class CommonParameter { @Setter public long allowTvmOsaka; + @Getter + @Setter + 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/common/src/main/java/org/tron/core/Constant.java b/common/src/main/java/org/tron/core/Constant.java index 1437d319346..29c20dc7227 100644 --- a/common/src/main/java/org/tron/core/Constant.java +++ b/common/src/main/java/org/tron/core/Constant.java @@ -60,6 +60,25 @@ public class Constant { // Crypto engine public static final String ECKey_ENGINE = "ECKey"; + // Post-quantum (ML-DSA / FIPS 204) signature constants + public static final int ML_DSA_44_PUBLIC_KEY_LENGTH = 1312; + public static final int ML_DSA_44_SIGNATURE_LENGTH = 2420; + public static final int ML_DSA_65_PUBLIC_KEY_LENGTH = 1952; + public static final int ML_DSA_65_SIGNATURE_LENGTH = 3309; + // Post-quantum (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"; + 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/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..1c752cbe68a 100644 --- a/common/src/main/java/org/tron/core/vm/config/VMConfig.java +++ b/common/src/main/java/org/tron/core/vm/config/VMConfig.java @@ -63,6 +63,10 @@ public class VMConfig { private static boolean ALLOW_TVM_OSAKA = false; + private static boolean ALLOW_ML_DSA_44 = false; + + private static boolean ALLOW_ML_DSA_65 = false; + private VMConfig() { } @@ -178,6 +182,14 @@ public static void initAllowTvmOsaka(long allow) { ALLOW_TVM_OSAKA = allow == 1; } + public static void initAllowMlDsa44(long allow) { + ALLOW_ML_DSA_44 = allow == 1; + } + + public static void initAllowMlDsa65(long allow) { + ALLOW_ML_DSA_65 = allow == 1; + } + public static boolean getEnergyLimitHardFork() { return CommonParameter.ENERGY_LIMIT_HARD_FORK; } @@ -281,4 +293,12 @@ public static boolean allowTvmSelfdestructRestriction() { public static boolean allowTvmOsaka() { return ALLOW_TVM_OSAKA; } + + public static boolean allowMlDsa44() { + return ALLOW_ML_DSA_44; + } + + public static boolean allowMlDsa65() { + return ALLOW_ML_DSA_65; + } } 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..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,11 +67,31 @@ public class Miner { @Setter private ByteString witnessAddress; + private byte[] pqPrivateKey; + + private byte[] pqPublicKey; + public Miner(byte[] privateKey, ByteString privateKeyAddress, ByteString witnessAddress) { this.privateKey = privateKey; this.privateKeyAddress = privateKeyAddress; this.witnessAddress = witnessAddress; } + + public byte[] getPqPrivateKey() { + return pqPrivateKey == null ? null : pqPrivateKey.clone(); + } + + public void setPqPrivateKey(byte[] pqPrivateKey) { + this.pqPrivateKey = pqPrivateKey == null ? null : pqPrivateKey.clone(); + } + + public byte[] getPqPublicKey() { + return pqPublicKey == null ? null : pqPublicKey.clone(); + } + + public void setPqPublicKey(byte[] pqPublicKey) { + this.pqPublicKey = pqPublicKey == null ? null : pqPublicKey.clone(); + } } public Miner getMiner() { diff --git a/crypto/src/main/java/org/tron/common/crypto/pqc/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/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 new file mode 100644 index 00000000000..80faf20942c --- /dev/null +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA44.java @@ -0,0 +1,208 @@ +package org.tron.common.crypto.pqc; + +import java.security.SecureRandom; +import org.bouncycastle.crypto.AsymmetricCipherKeyPair; +import org.bouncycastle.crypto.prng.FixedSecureRandom; +import org.bouncycastle.pqc.crypto.mldsa.MLDSAKeyGenerationParameters; +import org.bouncycastle.pqc.crypto.mldsa.MLDSAKeyPairGenerator; +import org.bouncycastle.pqc.crypto.mldsa.MLDSAParameters; +import org.bouncycastle.pqc.crypto.mldsa.MLDSAPrivateKeyParameters; +import org.bouncycastle.pqc.crypto.mldsa.MLDSAPublicKeyParameters; +import org.bouncycastle.pqc.crypto.mldsa.MLDSASigner; +import org.tron.common.crypto.Hash; +import org.tron.protos.Protocol.SignatureScheme; + +/** + * FIPS 204 ML-DSA-44 keypair-bound signer/verifier. Instance verify and sign + * use the bound keypair; stateless dispatch is available via the static + * {@link #verify} / {@link #sign(byte[], byte[])} entry points and the + * {@link PqSignatureRegistry}. Consumes raw public key / signature bytes — + * no SubjectPublicKeyInfo, PEM, or Base64 wrapping. + */ +public final class MLDSA44 implements PqSignature { + + public static final int PRIVATE_KEY_LENGTH = 2560; + public static final int PUBLIC_KEY_LENGTH = 1312; + public static final int SIGNATURE_LENGTH = 2420; + /** FIPS 204 ML-DSA seed (ξ) is 32 bytes. */ + public static final int SEED_LENGTH = 32; + + private final byte[] privateKey; + private final byte[] publicKey; + + /** Generate a fresh keypair using a cryptographically secure random source. */ + public MLDSA44() { + this.privateKey = generatePrivateKey(); + this.publicKey = derivePublicKey(this.privateKey); + } + + /** Deterministically generate a keypair from a 32-byte seed (FIPS 204 ξ). */ + public MLDSA44(byte[] seed) { + if (seed == null || seed.length != SEED_LENGTH) { + throw new IllegalArgumentException("ML-DSA-44 seed length must be " + SEED_LENGTH); + } + this.privateKey = generatePrivateKeyFromSeed(seed); + this.publicKey = derivePublicKey(this.privateKey); + } + + /** + * Build a keypair-bound instance from an existing keypair without re-deriving the public key. + * The caller is responsible for ensuring that {@code publicKey} is the correct public key + * corresponding to {@code privateKey}; no consistency check is performed. + */ + public MLDSA44(byte[] privateKey, byte[] publicKey) { + validatePrivateKeyBytes(privateKey); + if (publicKey == null || publicKey.length != PUBLIC_KEY_LENGTH) { + throw new IllegalArgumentException( + "ML-DSA-44 public key length must be " + PUBLIC_KEY_LENGTH); + } + this.privateKey = privateKey.clone(); + this.publicKey = publicKey.clone(); + } + + /** Load an existing 2560-byte ML-DSA-44 private key; the public key is derived. */ + public static MLDSA44 fromPrivate(byte[] privateKey) { + validatePrivateKeyBytes(privateKey); + byte[] sk = privateKey.clone(); + return new MLDSA44(sk, derivePublicKey(sk)); + } + + @Override + public SignatureScheme getScheme() { + return SignatureScheme.ML_DSA_44; + } + + @Override + public int getPrivateKeyLength() { + return PRIVATE_KEY_LENGTH; + } + + @Override + public int getPublicKeyLength() { + return PUBLIC_KEY_LENGTH; + } + + @Override + public int getSignatureLength() { + return SIGNATURE_LENGTH; + } + + @Override + public byte[] getPrivateKey() { + return privateKey.clone(); + } + + @Override + public byte[] getPublicKey() { + return publicKey.clone(); + } + + /** 21-byte TRON address derived from the public key. */ + @Override + public byte[] getAddress() { + return Hash.sha3omit12(publicKey); + } + + @Override + public byte[] sign(byte[] message) { + return sign(privateKey, message); + } + + @Override + public boolean verify(byte[] message, byte[] signature) { + return verify(publicKey, message, signature); + } + + /** ML-DSA-44 produces fixed-length signatures; override the default upper-bound check. */ + @Override + public void validateSignature(byte[] signature) { + if (signature == null || signature.length != SIGNATURE_LENGTH) { + throw new IllegalArgumentException( + "invalid " + getScheme() + " signature length: " + + (signature == null ? "null" : signature.length) + + ", expected " + SIGNATURE_LENGTH); + } + } + + public static boolean verify(byte[] publicKey, byte[] message, byte[] signature) { + if (publicKey == null || publicKey.length != PUBLIC_KEY_LENGTH) { + throw new IllegalArgumentException( + "ML-DSA-44 public key length must be " + PUBLIC_KEY_LENGTH); + } + if (signature == null || signature.length != SIGNATURE_LENGTH) { + throw new IllegalArgumentException( + "ML-DSA-44 signature length must be " + SIGNATURE_LENGTH); + } + if (message == null) { + throw new IllegalArgumentException("message must not be null"); + } + MLDSAPublicKeyParameters pk = new MLDSAPublicKeyParameters( + MLDSAParameters.ml_dsa_44, publicKey); + MLDSASigner verifier = new MLDSASigner(); + verifier.init(false, pk); + verifier.update(message, 0, message.length); + return verifier.verifySignature(signature); + } + + public static byte[] sign(byte[] privateKey, byte[] message) { + validatePrivateKeyBytes(privateKey); + if (message == null) { + throw new IllegalArgumentException("message must not be null"); + } + MLDSAPrivateKeyParameters sk = new MLDSAPrivateKeyParameters( + MLDSAParameters.ml_dsa_44, privateKey); + MLDSASigner signer = new MLDSASigner(); + signer.init(true, sk); + signer.update(message, 0, message.length); + try { + return signer.generateSignature(); + } catch (Exception e) { + throw new IllegalStateException("ML-DSA-44 signing failed", e); + } + } + + /** Generate a fresh ML-DSA-44 private key; returns the NIST-encoded 2560-byte key. */ + public static byte[] generatePrivateKey() { + return generatePrivateKey(new SecureRandom()); + } + + /** Deterministically generate an ML-DSA-44 private key from a 32-byte seed. */ + public static byte[] generatePrivateKeyFromSeed(byte[] seed) { + if (seed == null || seed.length != SEED_LENGTH) { + throw new IllegalArgumentException( + "ML-DSA-44 seed length must be " + SEED_LENGTH); + } + return generatePrivateKey(new FixedSecureRandom(seed)); + } + + private static byte[] generatePrivateKey(SecureRandom random) { + MLDSAKeyPairGenerator generator = new MLDSAKeyPairGenerator(); + generator.init(new MLDSAKeyGenerationParameters(random, MLDSAParameters.ml_dsa_44)); + AsymmetricCipherKeyPair keyPair = generator.generateKeyPair(); + return ((MLDSAPrivateKeyParameters) keyPair.getPrivate()).getEncoded(); + } + + public static byte[] derivePublicKey(byte[] privateKey) { + validatePrivateKeyBytes(privateKey); + MLDSAPrivateKeyParameters sk = new MLDSAPrivateKeyParameters( + MLDSAParameters.ml_dsa_44, privateKey); + MLDSAPublicKeyParameters pk = sk.getPublicKeyParameters(); + return pk.getEncoded(); + } + + /** Derive a TRON 21-byte address from an ML-DSA-44 public key via {@code sha3omit12(pubKey)}. */ + public static byte[] computeAddress(byte[] publicKey) { + if (publicKey == null || publicKey.length != PUBLIC_KEY_LENGTH) { + throw new IllegalArgumentException( + "ML-DSA-44 public key length must be " + PUBLIC_KEY_LENGTH); + } + return Hash.sha3omit12(publicKey); + } + + private static void validatePrivateKeyBytes(byte[] privateKey) { + if (privateKey == null || privateKey.length != PRIVATE_KEY_LENGTH) { + throw new IllegalArgumentException( + "ML-DSA-44 private key length must be " + PRIVATE_KEY_LENGTH); + } + } +} diff --git a/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA65.java b/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA65.java new file mode 100644 index 00000000000..cecc4377450 --- /dev/null +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/MLDSA65.java @@ -0,0 +1,208 @@ +package org.tron.common.crypto.pqc; + +import java.security.SecureRandom; +import org.bouncycastle.crypto.AsymmetricCipherKeyPair; +import org.bouncycastle.crypto.prng.FixedSecureRandom; +import org.bouncycastle.pqc.crypto.mldsa.MLDSAKeyGenerationParameters; +import org.bouncycastle.pqc.crypto.mldsa.MLDSAKeyPairGenerator; +import org.bouncycastle.pqc.crypto.mldsa.MLDSAParameters; +import org.bouncycastle.pqc.crypto.mldsa.MLDSAPrivateKeyParameters; +import org.bouncycastle.pqc.crypto.mldsa.MLDSAPublicKeyParameters; +import org.bouncycastle.pqc.crypto.mldsa.MLDSASigner; +import org.tron.common.crypto.Hash; +import org.tron.protos.Protocol.SignatureScheme; + +/** + * FIPS 204 ML-DSA-65 keypair-bound signer/verifier. Instance verify and sign + * use the bound keypair; stateless dispatch is available via the static + * {@link #verify} / {@link #sign(byte[], byte[])} entry points and the + * {@link PqSignatureRegistry}. Consumes raw public key / signature bytes — + * no SubjectPublicKeyInfo, PEM, or Base64 wrapping. + */ +public final class MLDSA65 implements PqSignature { + + public static final int PRIVATE_KEY_LENGTH = 4032; + public static final int PUBLIC_KEY_LENGTH = 1952; + public static final int SIGNATURE_LENGTH = 3309; + /** FIPS 204 ML-DSA seed (ξ) is 32 bytes. */ + public static final int SEED_LENGTH = 32; + + private final byte[] privateKey; + private final byte[] publicKey; + + /** Generate a fresh keypair using a cryptographically secure random source. */ + public MLDSA65() { + this.privateKey = generatePrivateKey(); + this.publicKey = derivePublicKey(this.privateKey); + } + + /** Deterministically generate a keypair from a 32-byte seed (FIPS 204 ξ). */ + public MLDSA65(byte[] seed) { + if (seed == null || seed.length != SEED_LENGTH) { + throw new IllegalArgumentException("ML-DSA-65 seed length must be " + SEED_LENGTH); + } + this.privateKey = generatePrivateKeyFromSeed(seed); + this.publicKey = derivePublicKey(this.privateKey); + } + + /** + * Build a keypair-bound instance from an existing keypair without re-deriving the public key. + * The caller is responsible for ensuring that {@code publicKey} is the correct public key + * corresponding to {@code privateKey}; no consistency check is performed. + */ + public MLDSA65(byte[] privateKey, byte[] publicKey) { + validatePrivateKeyBytes(privateKey); + if (publicKey == null || publicKey.length != PUBLIC_KEY_LENGTH) { + throw new IllegalArgumentException( + "ML-DSA-65 public key length must be " + PUBLIC_KEY_LENGTH); + } + this.privateKey = privateKey.clone(); + this.publicKey = publicKey.clone(); + } + + /** Load an existing 4032-byte ML-DSA-65 private key; the public key is derived. */ + public static MLDSA65 fromPrivate(byte[] privateKey) { + validatePrivateKeyBytes(privateKey); + byte[] sk = privateKey.clone(); + return new MLDSA65(sk, derivePublicKey(sk)); + } + + @Override + public SignatureScheme getScheme() { + return SignatureScheme.ML_DSA_65; + } + + @Override + public int getPrivateKeyLength() { + return PRIVATE_KEY_LENGTH; + } + + @Override + public int getPublicKeyLength() { + return PUBLIC_KEY_LENGTH; + } + + @Override + public int getSignatureLength() { + return SIGNATURE_LENGTH; + } + + @Override + public byte[] getPrivateKey() { + return privateKey.clone(); + } + + @Override + public byte[] getPublicKey() { + return publicKey.clone(); + } + + /** 21-byte TRON address derived from the public key. */ + @Override + public byte[] getAddress() { + return Hash.sha3omit12(publicKey); + } + + @Override + public byte[] sign(byte[] message) { + return sign(privateKey, message); + } + + @Override + public boolean verify(byte[] message, byte[] signature) { + return verify(publicKey, message, signature); + } + + /** ML-DSA-65 produces fixed-length signatures; override the default upper-bound check. */ + @Override + public void validateSignature(byte[] signature) { + if (signature == null || signature.length != SIGNATURE_LENGTH) { + throw new IllegalArgumentException( + "invalid " + getScheme() + " signature length: " + + (signature == null ? "null" : signature.length) + + ", expected " + SIGNATURE_LENGTH); + } + } + + public static boolean verify(byte[] publicKey, byte[] message, byte[] signature) { + if (publicKey == null || publicKey.length != PUBLIC_KEY_LENGTH) { + throw new IllegalArgumentException( + "ML-DSA-65 public key length must be " + PUBLIC_KEY_LENGTH); + } + if (signature == null || signature.length != SIGNATURE_LENGTH) { + throw new IllegalArgumentException( + "ML-DSA-65 signature length must be " + SIGNATURE_LENGTH); + } + if (message == null) { + throw new IllegalArgumentException("message must not be null"); + } + MLDSAPublicKeyParameters pk = new MLDSAPublicKeyParameters( + MLDSAParameters.ml_dsa_65, publicKey); + MLDSASigner verifier = new MLDSASigner(); + verifier.init(false, pk); + verifier.update(message, 0, message.length); + return verifier.verifySignature(signature); + } + + public static byte[] sign(byte[] privateKey, byte[] message) { + validatePrivateKeyBytes(privateKey); + if (message == null) { + throw new IllegalArgumentException("message must not be null"); + } + MLDSAPrivateKeyParameters sk = new MLDSAPrivateKeyParameters( + MLDSAParameters.ml_dsa_65, privateKey); + MLDSASigner signer = new MLDSASigner(); + signer.init(true, sk); + signer.update(message, 0, message.length); + try { + return signer.generateSignature(); + } catch (Exception e) { + throw new IllegalStateException("ML-DSA-65 signing failed", e); + } + } + + /** Generate a fresh ML-DSA-65 private key; returns the NIST-encoded 4032-byte key. */ + public static byte[] generatePrivateKey() { + return generatePrivateKey(new SecureRandom()); + } + + /** Deterministically generate an ML-DSA-65 private key from a 32-byte seed. */ + public static byte[] generatePrivateKeyFromSeed(byte[] seed) { + if (seed == null || seed.length != SEED_LENGTH) { + throw new IllegalArgumentException( + "ML-DSA-65 seed length must be " + SEED_LENGTH); + } + return generatePrivateKey(new FixedSecureRandom(seed)); + } + + private static byte[] generatePrivateKey(SecureRandom random) { + MLDSAKeyPairGenerator generator = new MLDSAKeyPairGenerator(); + generator.init(new MLDSAKeyGenerationParameters(random, MLDSAParameters.ml_dsa_65)); + AsymmetricCipherKeyPair keyPair = generator.generateKeyPair(); + return ((MLDSAPrivateKeyParameters) keyPair.getPrivate()).getEncoded(); + } + + public static byte[] derivePublicKey(byte[] privateKey) { + validatePrivateKeyBytes(privateKey); + MLDSAPrivateKeyParameters sk = new MLDSAPrivateKeyParameters( + MLDSAParameters.ml_dsa_65, privateKey); + MLDSAPublicKeyParameters pk = sk.getPublicKeyParameters(); + return pk.getEncoded(); + } + + /** Derive a TRON 21-byte address from an ML-DSA-65 public key via {@code sha3omit12(pubKey)}. */ + public static byte[] computeAddress(byte[] publicKey) { + if (publicKey == null || publicKey.length != PUBLIC_KEY_LENGTH) { + throw new IllegalArgumentException( + "ML-DSA-65 public key length must be " + PUBLIC_KEY_LENGTH); + } + return Hash.sha3omit12(publicKey); + } + + private static void validatePrivateKeyBytes(byte[] privateKey) { + if (privateKey == null || privateKey.length != PRIVATE_KEY_LENGTH) { + throw new IllegalArgumentException( + "ML-DSA-65 private key length must be " + PRIVATE_KEY_LENGTH); + } + } +} diff --git a/crypto/src/main/java/org/tron/common/crypto/pqc/MerkleTree.java b/crypto/src/main/java/org/tron/common/crypto/pqc/MerkleTree.java new file mode 100644 index 00000000000..d1996dcb1e8 --- /dev/null +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/MerkleTree.java @@ -0,0 +1,174 @@ +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^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 = 20; + + 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)). 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); + } + 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 new file mode 100644 index 00000000000..b744dc0b4bc --- /dev/null +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/PqAuthDigest.java @@ -0,0 +1,113 @@ +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"; + 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() { + } + + /** + * 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(); + } + + /** + * 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), + (byte) ((v >>> 16) & 0xff), + (byte) ((v >>> 8) & 0xff), + (byte) (v & 0xff) + }; + } + + 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/PqSignature.java b/crypto/src/main/java/org/tron/common/crypto/pqc/PqSignature.java new file mode 100644 index 00000000000..6afd5e6fa97 --- /dev/null +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/PqSignature.java @@ -0,0 +1,70 @@ +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 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 == 0 || signature.length > getSignatureLength()) { + throw new IllegalArgumentException( + "invalid " + getScheme() + " signature length: " + + (signature == null ? "null" : signature.length) + + ", expected 1.." + getSignatureLength()); + } + } +} diff --git a/crypto/src/main/java/org/tron/common/crypto/pqc/PqSignatureRegistry.java b/crypto/src/main/java/org/tron/common/crypto/pqc/PqSignatureRegistry.java new file mode 100644 index 00000000000..52dc7e180bc --- /dev/null +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/PqSignatureRegistry.java @@ -0,0 +1,183 @@ +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 schemes keyed by + * {@link SignatureScheme}. Each entry binds a scheme to its public-key length, + * signature length, and stateless sign/verify/keygen operations. Legacy + * schemes (ECDSA secp256k1, SM2/SM3) are NOT registered — they flow through + * the existing {@code SignInterface} path. + */ +public final class PqSignatureRegistry { + + /** Stateless sign/verify/keygen dispatch bound to a single PQ scheme. */ + public interface SignatureOps { + byte[] sign(byte[] privateKey, byte[] message); + + boolean verify(byte[] publicKey, byte[] message, byte[] signature); + + PqSignature fromSeed(byte[] seed); + } + + private static final class SchemeInfo { + final int publicKeyLength; + final int signatureLength; + final SignatureOps ops; + + SchemeInfo(int publicKeyLength, int signatureLength, SignatureOps ops) { + this.publicKeyLength = publicKeyLength; + this.signatureLength = signatureLength; + this.ops = ops; + } + } + + private static final Map SCHEMES; + + static { + EnumMap m = new EnumMap<>(SignatureScheme.class); + m.put(SignatureScheme.ML_DSA_44, new SchemeInfo( + MLDSA44.PUBLIC_KEY_LENGTH, MLDSA44.SIGNATURE_LENGTH, 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, new SignatureOps() { + @Override + public byte[] sign(byte[] privateKey, byte[] message) { + return MLDSA65.sign(privateKey, message); + } + + @Override + public boolean verify(byte[] publicKey, byte[] message, byte[] signature) { + return MLDSA65.verify(publicKey, message, signature); + } + + @Override + public PqSignature fromSeed(byte[] seed) { + return new MLDSA65(seed); + } + })); + m.put(SignatureScheme.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); + } + })); + 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); + } + })); + 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); + } + + 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; + } + + /** + * 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 || scheme == SignatureScheme.EPHEMERAL_SECP256K1) { + return length > 0 && length <= info.signatureLength; + } + return length == info.signatureLength; + } + + public static byte[] sign(SignatureScheme scheme, byte[] privateKey, byte[] message) { + return require(scheme).ops.sign(privateKey, message); + } + + public static boolean verify( + SignatureScheme scheme, byte[] publicKey, byte[] message, byte[] signature) { + return require(scheme).ops.verify(publicKey, message, signature); + } + + public static PqSignature fromSeed(SignatureScheme scheme, byte[] seed) { + return require(scheme).ops.fromSeed(seed); + } + + private static SchemeInfo require(SignatureScheme scheme) { + SchemeInfo info = SCHEMES.get(scheme); + if (info == null) { + throw new IllegalArgumentException( + "no PqSignature registered for scheme: " + scheme); + } + return info; + } +} \ No newline at end of file diff --git a/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..c2dd2f5d3e3 --- /dev/null +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/SLHDSA.java @@ -0,0 +1,191 @@ +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); + } + + /** 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( + "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/main/java/org/tron/core/Wallet.java b/framework/src/main/java/org/tron/core/Wallet.java index 39e8f06c281..cd57bbeef42 100755 --- a/framework/src/main/java/org/tron/core/Wallet.java +++ b/framework/src/main/java/org/tron/core/Wallet.java @@ -1514,6 +1514,31 @@ public Protocol.ChainParameters getChainParameters() { .setValue(dbManager.getDynamicPropertiesStore().getAllowTvmOsaka()) .build()); + builder.addChainParameter(Protocol.ChainParameters.ChainParameter.newBuilder() + .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 83d7fd2c63d..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; @@ -78,6 +79,7 @@ import org.tron.p2p.dns.update.PublishConfig; import org.tron.p2p.utils.NetUtil; import org.tron.program.Version; +import org.tron.protos.Protocol.SignatureScheme; @Slf4j(topic = "app") @NoArgsConstructor @@ -1041,6 +1043,26 @@ public static void applyConfigParams( config.hasPath(ConfigKey.COMMITTEE_ALLOW_TVM_OSAKA) ? config .getInt(ConfigKey.COMMITTEE_ALLOW_TVM_OSAKA) : 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(); } @@ -1184,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()) { @@ -1220,6 +1250,37 @@ private static void initLocalWitnesses(Config config, CLIParameter cmd) { } } + // path 4: PQ seed configuration + if (config.hasPath(ConfigKey.LOCAL_WITNESS_SEED_PQ)) { + List pqSeeds = config.getStringList(ConfigKey.LOCAL_WITNESS_SEED_PQ); + if (!pqSeeds.isEmpty()) { + localWitnesses = new LocalWitnesses(); + 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 { + 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) { + 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..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 @@ -13,6 +13,8 @@ private ConfigKey() { public static final String LOCAL_WITNESS = "localwitness"; // private key public static final String LOCAL_WITNESS_ACCOUNT_ADDRESS = "localWitnessAccountAddress"; public static final String LOCAL_WITNESS_KEYSTORE = "localwitnesskeystore"; + public static final String LOCAL_WITNESS_SEED_PQ = "localwitness_seed_pq"; + public static final String LOCAL_WITNESS_SEED_PQ_SCHEME = "localwitness_seed_pq_scheme"; // crypto public static final String CRYPTO_ENGINE = "crypto.engine"; @@ -248,6 +250,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_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/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..69b6fe4f0f7 100644 --- a/framework/src/main/java/org/tron/core/consensus/ConsensusService.java +++ b/framework/src/main/java/org/tron/core/consensus/ConsensusService.java @@ -10,13 +10,17 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.tron.common.crypto.SignUtils; +import org.tron.common.crypto.pqc.PqSignature; +import org.tron.common.crypto.pqc.PqSignatureRegistry; import org.tron.common.parameter.CommonParameter; import org.tron.consensus.Consensus; import org.tron.consensus.base.Param; import org.tron.consensus.base.Param.Miner; import org.tron.core.capsule.WitnessCapsule; import org.tron.core.config.args.Args; +import org.tron.core.exception.TronError; import org.tron.core.store.WitnessStore; +import org.tron.protos.Protocol.SignatureScheme; @Slf4j(topic = "consensus") @Component @@ -46,6 +50,7 @@ public void start() { param.setAgreeNodeCount(parameter.getAgreeNodeCount()); List miners = new ArrayList<>(); List privateKeys = Args.getLocalWitnesses().getPrivateKeys(); + List pqSeeds = Args.getLocalWitnesses().getPqSeeds(); if (privateKeys.size() > 1) { for (String key : privateKeys) { byte[] privateKey = fromHexString(key); @@ -76,6 +81,29 @@ public void start() { Miner miner = param.new Miner(privateKey, ByteString.copyFrom(privateKeyAddress), ByteString.copyFrom(witnessAddress)); miners.add(miner); + } else if (pqSeeds.size() > 1) { + SignatureScheme scheme = Args.getLocalWitnesses().getPqScheme(); + requireSupportedPqScheme(scheme); + for (String seed : pqSeeds) { + byte[] seedBytes = fromHexString(seed); + PqSignature keypair = PqSignatureRegistry.fromSeed(scheme, seedBytes); + byte[] sk = keypair.getPrivateKey(); + byte[] pk = keypair.getPublicKey(); + byte[] pqAddress = keypair.getAddress(); + WitnessCapsule witnessCapsule = witnessStore.get(pqAddress); + if (null == witnessCapsule) { + logger.warn("Witness {} is not in witnessStore.", Hex.toHexString(pqAddress)); + } + ByteString pqAddressBs = ByteString.copyFrom(pqAddress); + Miner miner = param.new Miner(null, pqAddressBs, pqAddressBs); + miner.setPqPrivateKey(sk); + miner.setPqPublicKey(pk); + miners.add(miner); + logger.info("Add {} witness (from seed): {}, size: {}", + scheme, Hex.toHexString(pqAddress), miners.size()); + } + } else if (pqSeeds.size() == 1) { + miners.add(buildPqOnlyMinerFromSeed(param, pqSeeds.get(0))); } param.setMiners(miners); @@ -85,6 +113,38 @@ public void start() { logger.info("consensus service start success"); } + private Miner buildPqOnlyMinerFromSeed(Param param, String pqSeed) { + SignatureScheme scheme = Args.getLocalWitnesses().getPqScheme(); + requireSupportedPqScheme(scheme); + byte[] seedBytes = fromHexString(pqSeed); + PqSignature keypair = PqSignatureRegistry.fromSeed(scheme, seedBytes); + byte[] sk = keypair.getPrivateKey(); + byte[] pk = keypair.getPublicKey(); + byte[] pqAddress = keypair.getAddress(); + byte[] witnessAddress = Args.getLocalWitnesses().getWitnessAccountAddress(); + if (witnessAddress == null || witnessAddress.length == 0) { + witnessAddress = pqAddress; + } + WitnessCapsule witnessCapsule = witnessStore.get(witnessAddress); + if (null == witnessCapsule) { + logger.warn("Witness {} is not in witnessStore.", Hex.toHexString(witnessAddress)); + } + // In multi-signature mode, the address derived from the PQ key may differ from witnessAddress. + Miner miner = param.new Miner(null, ByteString.copyFrom(pqAddress), + ByteString.copyFrom(witnessAddress)); + miner.setPqPrivateKey(sk); + miner.setPqPublicKey(pk); + logger.info("Add {} witness (from seed): {}", scheme, Hex.toHexString(witnessAddress)); + return miner; + } + + private static void requireSupportedPqScheme(SignatureScheme scheme) { + if (!PqSignatureRegistry.contains(scheme)) { + throw new TronError("unsupported PQ witness scheme: " + scheme, + TronError.ErrCode.WITNESS_INIT); + } + } + public void stop() { logger.info("consensus service closed start."); consensus.stop(); diff --git a/framework/src/main/java/org/tron/core/consensus/ProposalService.java b/framework/src/main/java/org/tron/core/consensus/ProposalService.java index 1bec0c2bda3..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,6 +396,26 @@ public static boolean process(Manager manager, ProposalCapsule proposalCapsule) manager.getDynamicPropertiesStore().saveAllowTvmOsaka(entry.getValue()); break; } + 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: 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..6bd54c49168 100644 --- a/framework/src/main/java/org/tron/core/db/Manager.java +++ b/framework/src/main/java/org/tron/core/db/Manager.java @@ -54,6 +54,8 @@ import org.tron.common.args.GenesisBlock; import org.tron.common.bloom.Bloom; import org.tron.common.cron.CronExpression; +import org.tron.common.crypto.pqc.PqAuthDigest; +import org.tron.common.crypto.pqc.PqSignatureRegistry; import org.tron.common.es.ExecutorServiceManager; import org.tron.common.exit.ExitManager; import org.tron.common.logsfilter.EventPluginLoader; @@ -168,7 +170,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; @@ -1528,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); @@ -1738,7 +1750,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 +1766,55 @@ public BlockCapsule generateBlock(Miner miner, long blockTime, long timeout) { return capsule; } + private void signBlockCapsule(BlockCapsule blockCapsule, Miner miner) { + SignatureScheme scheme = resolveWitnessScheme(miner); + if (PqSignatureRegistry.contains(scheme)) { + signWitnessAuth(blockCapsule, miner, scheme); + } else { + blockCapsule.sign(miner.getPrivateKey()); + } + } + + private SignatureScheme resolveWitnessScheme(Miner miner) { + if (!chainBaseManager.getDynamicPropertiesStore().isAnyPqSchemeAllowed()) { + return SignatureScheme.UNKNOWN_SIG_SCHEME; + } + byte[] witnessAddress = miner.getWitnessAddress().toByteArray(); + AccountCapsule accountCapsule = chainBaseManager.getAccountStore().get(witnessAddress); + if (accountCapsule == null || !accountCapsule.getInstance().hasWitnessPermission()) { + return SignatureScheme.UNKNOWN_SIG_SCHEME; + } + Permission witnessPermission = accountCapsule.getInstance().getWitnessPermission(); + if (witnessPermission.getKeysCount() == 0) { + return SignatureScheme.UNKNOWN_SIG_SCHEME; + } + 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) { + byte[] witnessAddress = miner.getWitnessAddress().toByteArray(); + Permission witnessPermission = chainBaseManager.getAccountStore().get(witnessAddress) + .getInstance().getWitnessPermission(); + byte[] pqPrivateKey = miner.getPqPrivateKey(); + if (pqPrivateKey == null) { + throw new IllegalStateException( + "witness permission requires " + scheme + + " but local PQ private key is not configured"); + } + byte[] signerAddress = witnessPermission.getKeys(0).getAddress().toByteArray(); + byte[] digest = PqAuthDigest.block(blockCapsule.getRawHashBytes(), signerAddress); + byte[] signature = PqSignatureRegistry.sign(scheme, 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..7e5115e5cba 100644 --- a/framework/src/main/resources/config.conf +++ b/framework/src/main/resources/config.conf @@ -673,6 +673,21 @@ localwitness = [ # "localwitnesskeystore.json" # ] +# Scheme used to derive keys from localwitness_seed_pq. Defaults to ML_DSA_65. +# Allowed values: ML_DSA_44, ML_DSA_65, FN_DSA, SLH_DSA. EPHEMERAL_SECP256K1 +# is NOT a valid witness scheme (no deterministic seed path). +# localwitness_seed_pq_scheme = "ML_DSA_65" + +# 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" +# ] + block = { needSyncCheck = true maintenanceTimeInterval = 21600000 // 6 hours: 21600000(ms) @@ -760,6 +775,7 @@ committee = { # consensusLogicOptimization = 0 # allowOptimizedReturnValueOfChainId = 0 # allowTvmOsaka = 0 + # allowMlDsa = 0 } event.subscribe = { 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..6ab0e8ac284 --- /dev/null +++ b/framework/src/test/java/org/tron/common/crypto/pqc/EphemeralSecp256k1Test.java @@ -0,0 +1,323 @@ +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/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/common/crypto/pqc/MLDSA44Test.java b/framework/src/test/java/org/tron/common/crypto/pqc/MLDSA44Test.java new file mode 100644 index 00000000000..5b3a6bfd168 --- /dev/null +++ b/framework/src/test/java/org/tron/common/crypto/pqc/MLDSA44Test.java @@ -0,0 +1,228 @@ +package org.tron.common.crypto.pqc; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.security.SecureRandom; +import org.bouncycastle.crypto.AsymmetricCipherKeyPair; +import org.bouncycastle.pqc.crypto.mldsa.MLDSAKeyGenerationParameters; +import org.bouncycastle.pqc.crypto.mldsa.MLDSAKeyPairGenerator; +import org.bouncycastle.pqc.crypto.mldsa.MLDSAParameters; +import org.bouncycastle.pqc.crypto.mldsa.MLDSAPrivateKeyParameters; +import org.bouncycastle.pqc.crypto.mldsa.MLDSAPublicKeyParameters; +import org.bouncycastle.pqc.crypto.mldsa.MLDSASigner; +import org.junit.Before; +import org.junit.Test; +import org.tron.protos.Protocol.SignatureScheme; + +public class MLDSA44Test { + + private MLDSA44 keypair; + private MLDSAPublicKeyParameters pk; + private MLDSAPrivateKeyParameters sk; + + @Before + public void setUp() { + MLDSAKeyPairGenerator gen = new MLDSAKeyPairGenerator(); + gen.init(new MLDSAKeyGenerationParameters(new SecureRandom(), MLDSAParameters.ml_dsa_44)); + AsymmetricCipherKeyPair kp = gen.generateKeyPair(); + pk = (MLDSAPublicKeyParameters) kp.getPublic(); + sk = (MLDSAPrivateKeyParameters) kp.getPrivate(); + keypair = new MLDSA44(sk.getEncoded(), pk.getEncoded()); + } + + private static byte[] freshPrivateKey() { + MLDSAKeyPairGenerator gen = new MLDSAKeyPairGenerator(); + gen.init(new MLDSAKeyGenerationParameters(new SecureRandom(), MLDSAParameters.ml_dsa_44)); + AsymmetricCipherKeyPair kp = gen.generateKeyPair(); + return ((MLDSAPrivateKeyParameters) kp.getPrivate()).getEncoded(); + } + + private byte[] rawSign(byte[] message) { + MLDSASigner signer = new MLDSASigner(); + signer.init(true, sk); + signer.update(message, 0, message.length); + try { + return signer.generateSignature(); + } catch (Exception e) { + throw new AssertionError("failed to sign in test setup", e); + } + } + + @Test + public void schemeAndLengthsMatchFips204() { + assertEquals(SignatureScheme.ML_DSA_44, keypair.getScheme()); + assertEquals(1312, keypair.getPublicKeyLength()); + assertEquals(2420, keypair.getSignatureLength()); + assertEquals(1312, pk.getEncoded().length); + } + + @Test + public void privateKeyLengthMatchesFips204() { + byte[] skBytes = freshPrivateKey(); + assertEquals(MLDSA44.PRIVATE_KEY_LENGTH, skBytes.length); + } + + @Test + public void derivedPublicKeyLengthMatchesFips204() { + byte[] skBytes = freshPrivateKey(); + byte[] pkBytes = MLDSA44.derivePublicKey(skBytes); + assertEquals(MLDSA44.PUBLIC_KEY_LENGTH, pkBytes.length); + } + + @Test + public void signProducesVerifiableSignature() { + byte[] skBytes = freshPrivateKey(); + byte[] pkBytes = MLDSA44.derivePublicKey(skBytes); + byte[] message = "hello, ml-dsa-44".getBytes(); + + byte[] sig = MLDSA44.sign(skBytes, message); + assertEquals(MLDSA44.SIGNATURE_LENGTH, sig.length); + + assertTrue(MLDSA44.verify(pkBytes, message, sig)); + } + + @Test + public void roundTripSignVerifyWithTamperRejected() { + byte[] skBytes = freshPrivateKey(); + byte[] pkBytes = MLDSA44.derivePublicKey(skBytes); + byte[] message = "roundtrip".getBytes(); + byte[] sig = MLDSA44.sign(skBytes, message); + + assertTrue(MLDSA44.verify(pkBytes, message, sig)); + + byte[] tampered = sig.clone(); + tampered[0] ^= 0x01; + if (MLDSA44.verify(pkBytes, message, tampered)) { + fail("tampered signature should not verify"); + } + } + + @Test + public void deterministicPublicKeyDerivation() { + byte[] skBytes = freshPrivateKey(); + byte[] pk1 = MLDSA44.derivePublicKey(skBytes); + byte[] pk2 = MLDSA44.derivePublicKey(skBytes); + assertArrayEquals(pk1, pk2); + } + + @Test(expected = IllegalArgumentException.class) + public void rejectsShortPrivateKey() { + MLDSA44.sign(new byte[10], new byte[4]); + } + + @Test(expected = IllegalArgumentException.class) + public void rejectsNullMessage() { + byte[] skBytes = freshPrivateKey(); + MLDSA44.sign(skBytes, null); + } + + @Test + public void validSignatureVerifiesViaInstance() { + byte[] msg = "tron-pq-mldsa44".getBytes(); + byte[] sig = rawSign(msg); + assertEquals(2420, sig.length); + assertTrue(keypair.verify(msg, sig)); + } + + @Test + public void signatureBoundToMessage() { + byte[] msg = "hello".getBytes(); + byte[] sig = rawSign(msg); + byte[] tamperedMsg = "hellp".getBytes(); + assertFalse(keypair.verify(tamperedMsg, sig)); + } + + @Test + public void tamperedSignatureFailsVerification() { + byte[] msg = "payload".getBytes(); + byte[] sig = rawSign(msg); + sig[0] ^= 0x01; + assertFalse(keypair.verify(msg, sig)); + } + + @Test + public void wrongPublicKeyFailsVerification() { + byte[] msg = "payload".getBytes(); + byte[] sig = rawSign(msg); + MLDSAKeyPairGenerator gen = new MLDSAKeyPairGenerator(); + gen.init(new MLDSAKeyGenerationParameters(new SecureRandom(), MLDSAParameters.ml_dsa_44)); + MLDSAPublicKeyParameters otherPk = + (MLDSAPublicKeyParameters) gen.generateKeyPair().getPublic(); + assertFalse(MLDSA44.verify(otherPk.getEncoded(), msg, sig)); + } + + @Test + public void invalidPublicKeyLengthRejected() { + byte[] badPk = new byte[1311]; + byte[] msg = new byte[] {1}; + byte[] sig = new byte[2420]; + try { + MLDSA44.verify(badPk, msg, sig); + fail("short public key should be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("public key length")); + } + } + + @Test + public void invalidSignatureLengthRejected() { + byte[] badSig = new byte[2419]; + byte[] msg = new byte[] {1}; + try { + MLDSA44.verify(pk.getEncoded(), msg, badSig); + fail("short signature should be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("signature length")); + } + } + + @Test + public void nullMessageRejected() { + byte[] sig = new byte[2420]; + try { + MLDSA44.verify(pk.getEncoded(), null, sig); + fail("null message should be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("message")); + } + } + + @Test + public void emptyMessageVerifiesConsistently() { + byte[] msg = new byte[0]; + byte[] sig = rawSign(msg); + assertTrue(keypair.verify(msg, sig)); + } + + @Test + public void keypairBoundInstanceSignsAndVerifies() { + MLDSA44 signer = new MLDSA44(); + byte[] msg = "keypair-bound".getBytes(); + byte[] sig = signer.sign(msg); + assertEquals(MLDSA44.SIGNATURE_LENGTH, sig.length); + assertTrue(signer.verify(msg, sig)); + } + + @Test + public void fromSeedIsDeterministic() { + byte[] seed = new byte[32]; + for (int i = 0; i < seed.length; i++) { + seed[i] = (byte) i; + } + MLDSA44 a = new MLDSA44(seed); + MLDSA44 b = new MLDSA44(seed); + assertArrayEquals(a.getPublicKey(), b.getPublicKey()); + assertArrayEquals(a.getPrivateKey(), b.getPrivateKey()); + } + + @Test + public void computeAddressIs21Bytes() { + byte[] skBytes = freshPrivateKey(); + byte[] pkBytes = MLDSA44.derivePublicKey(skBytes); + assertEquals(21, MLDSA44.computeAddress(pkBytes).length); + } +} diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/MLDSA65Test.java b/framework/src/test/java/org/tron/common/crypto/pqc/MLDSA65Test.java new file mode 100644 index 00000000000..370fb6772e1 --- /dev/null +++ b/framework/src/test/java/org/tron/common/crypto/pqc/MLDSA65Test.java @@ -0,0 +1,178 @@ +package org.tron.common.crypto.pqc; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.security.SecureRandom; +import org.bouncycastle.crypto.AsymmetricCipherKeyPair; +import org.bouncycastle.pqc.crypto.mldsa.MLDSAKeyGenerationParameters; +import org.bouncycastle.pqc.crypto.mldsa.MLDSAKeyPairGenerator; +import org.bouncycastle.pqc.crypto.mldsa.MLDSAParameters; +import org.bouncycastle.pqc.crypto.mldsa.MLDSAPrivateKeyParameters; +import org.bouncycastle.pqc.crypto.mldsa.MLDSAPublicKeyParameters; +import org.bouncycastle.pqc.crypto.mldsa.MLDSASigner; +import org.junit.Before; +import org.junit.Test; +import org.tron.protos.Protocol.SignatureScheme; + +public class MLDSA65Test { + + private MLDSA65 keypair; + private MLDSAPublicKeyParameters pk; + private MLDSAPrivateKeyParameters sk; + + @Before + public void setUp() { + MLDSAKeyPairGenerator gen = new MLDSAKeyPairGenerator(); + gen.init(new MLDSAKeyGenerationParameters(new SecureRandom(), MLDSAParameters.ml_dsa_65)); + AsymmetricCipherKeyPair kp = gen.generateKeyPair(); + pk = (MLDSAPublicKeyParameters) kp.getPublic(); + sk = (MLDSAPrivateKeyParameters) kp.getPrivate(); + keypair = new MLDSA65(sk.getEncoded(), pk.getEncoded()); + } + + private byte[] rawSign(byte[] message) { + MLDSASigner signer = new MLDSASigner(); + signer.init(true, sk); + signer.update(message, 0, message.length); + try { + return signer.generateSignature(); + } catch (Exception e) { + throw new AssertionError("failed to sign in test setup", e); + } + } + + @Test + public void schemeAndLengthsMatchFips204() { + assertEquals(SignatureScheme.ML_DSA_65, keypair.getScheme()); + assertEquals(1952, keypair.getPublicKeyLength()); + assertEquals(3309, keypair.getSignatureLength()); + assertEquals(1952, pk.getEncoded().length); + } + + @Test + public void validSignatureVerifies() { + byte[] msg = "tron-pq-mldsa65".getBytes(); + byte[] sig = rawSign(msg); + assertEquals(3309, sig.length); + assertTrue(keypair.verify(msg, sig)); + } + + @Test + public void signatureBoundToMessage() { + byte[] msg = "block-header".getBytes(); + byte[] sig = rawSign(msg); + byte[] tamperedMsg = "block-footer".getBytes(); + assertFalse(keypair.verify(tamperedMsg, sig)); + } + + @Test + public void tamperedSignatureFailsVerification() { + byte[] msg = "payload".getBytes(); + byte[] sig = rawSign(msg); + sig[sig.length - 1] ^= 0x01; + assertFalse(keypair.verify(msg, sig)); + } + + @Test + public void wrongPublicKeyFailsVerification() { + byte[] msg = "payload".getBytes(); + byte[] sig = rawSign(msg); + MLDSAKeyPairGenerator gen = new MLDSAKeyPairGenerator(); + gen.init(new MLDSAKeyGenerationParameters(new SecureRandom(), MLDSAParameters.ml_dsa_65)); + MLDSAPublicKeyParameters otherPk = + (MLDSAPublicKeyParameters) gen.generateKeyPair().getPublic(); + assertFalse(MLDSA65.verify(otherPk.getEncoded(), msg, sig)); + } + + @Test + public void invalidPublicKeyLengthRejected() { + byte[] badPk = new byte[1951]; + byte[] msg = new byte[] {1}; + byte[] sig = new byte[3309]; + try { + MLDSA65.verify(badPk, msg, sig); + fail("short public key should be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("public key length")); + } + } + + @Test + public void invalidSignatureLengthRejected() { + byte[] badSig = new byte[3310]; + byte[] msg = new byte[] {1}; + try { + MLDSA65.verify(pk.getEncoded(), msg, badSig); + fail("wrong-length signature should be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("signature length")); + } + } + + @Test + public void nullMessageRejected() { + byte[] sig = new byte[3309]; + try { + MLDSA65.verify(pk.getEncoded(), null, sig); + fail("null message should be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("message")); + } + } + + @Test + public void emptyMessageVerifiesConsistently() { + byte[] msg = new byte[0]; + byte[] sig = rawSign(msg); + assertTrue(keypair.verify(msg, sig)); + } + + @Test + public void crossSchemeKeyFailsVerification() { + MLDSAKeyPairGenerator gen44 = new MLDSAKeyPairGenerator(); + gen44.init(new MLDSAKeyGenerationParameters(new SecureRandom(), MLDSAParameters.ml_dsa_44)); + MLDSAPublicKeyParameters pk44 = + (MLDSAPublicKeyParameters) gen44.generateKeyPair().getPublic(); + byte[] pk44Bytes = pk44.getEncoded(); + byte[] msg = new byte[] {1}; + byte[] sig = new byte[3309]; + try { + MLDSA65.verify(pk44Bytes, msg, sig); + fail("ML-DSA-44 key for ML-DSA-65 verifier should be rejected on length"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("public key length")); + } + } + + @Test + public void keypairBoundInstanceSignsAndVerifies() { + MLDSA65 signer = new MLDSA65(); + byte[] msg = "keypair-bound".getBytes(); + byte[] sig = signer.sign(msg); + assertEquals(MLDSA65.SIGNATURE_LENGTH, sig.length); + assertTrue(signer.verify(msg, sig)); + } + + @Test + public void fromSeedIsDeterministic() { + byte[] seed = new byte[32]; + for (int i = 0; i < seed.length; i++) { + seed[i] = (byte) i; + } + MLDSA65 a = new MLDSA65(seed); + MLDSA65 b = new MLDSA65(seed); + assertArrayEquals(a.getPublicKey(), b.getPublicKey()); + assertArrayEquals(a.getPrivateKey(), b.getPrivateKey()); + } + + @Test + public void computeAddressIs21Bytes() { + byte[] skBytes = MLDSA65.generatePrivateKey(); + byte[] pkBytes = MLDSA65.derivePublicKey(skBytes); + assertEquals(21, MLDSA65.computeAddress(pkBytes).length); + } +} diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/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 new file mode 100644 index 00000000000..a985f3f4b7b --- /dev/null +++ b/framework/src/test/java/org/tron/common/crypto/pqc/PqAuthDigestTest.java @@ -0,0 +1,208 @@ +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); + 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 + 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/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/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/common/crypto/pqc/SignatureSchemeBenchmarkTest.java b/framework/src/test/java/org/tron/common/crypto/pqc/SignatureSchemeBenchmarkTest.java new file mode 100644 index 00000000000..b8210017fbf --- /dev/null +++ b/framework/src/test/java/org/tron/common/crypto/pqc/SignatureSchemeBenchmarkTest.java @@ -0,0 +1,166 @@ +package org.tron.common.crypto.pqc; + +import java.security.SignatureException; +import java.util.Locale; +import org.junit.Test; +import org.tron.common.crypto.ECKey; +import org.tron.common.crypto.ECKey.ECDSASignature; +import org.tron.common.crypto.Hash; + +/** + * Micro-benchmark comparing key generation, signing and verification latency for + * secp256k1 ECDSA (ECKey), ML-DSA-44 and ML-DSA-65. Numbers are reported in + * microseconds (avg of {@link #ITERATIONS} iterations after {@link #WARMUP} warm-up rounds). + */ +public class SignatureSchemeBenchmarkTest { + + private static final int WARMUP = 20; + private static final int ITERATIONS = 500; + private static final byte[] MESSAGE = "tron-pq-benchmark-message".getBytes(); + private static final byte[] MESSAGE_HASH = Hash.sha3(MESSAGE); + + @Test + public void benchmarkAllSchemes() { + Result eckey = benchEcKey(); + Result mldsa44 = benchMlDsa44(); + Result mldsa65 = benchMlDsa65(); + + System.out.println(String.format(Locale.ROOT, + "=== Signature scheme benchmark (avg over %d iterations, warmup %d) ===", + ITERATIONS, WARMUP)); + System.out.println(String.format(Locale.ROOT, + "%-12s | %12s | %12s | %12s", + "scheme", "keygen (us)", "sign (us)", "verify (us)")); + System.out.println("-------------+--------------+--------------+--------------"); + printResult(eckey); + printResult(mldsa44); + printResult(mldsa65); + } + + private Result benchEcKey() { + for (int i = 0; i < WARMUP; i++) { + ECKey k = new ECKey(); + ECDSASignature s = k.sign(MESSAGE_HASH); + try { + ECKey.signatureToAddress(MESSAGE_HASH, s); + } catch (SignatureException e) { + throw new AssertionError(e); + } + } + + long keygenNs = 0; + ECKey[] keys = new ECKey[ITERATIONS]; + for (int i = 0; i < ITERATIONS; i++) { + long t0 = System.nanoTime(); + keys[i] = new ECKey(); + keygenNs += System.nanoTime() - t0; + } + + long signNs = 0; + ECDSASignature[] sigs = new ECDSASignature[ITERATIONS]; + for (int i = 0; i < ITERATIONS; i++) { + long t0 = System.nanoTime(); + sigs[i] = keys[i].sign(MESSAGE_HASH); + signNs += System.nanoTime() - t0; + } + + long verifyNs = 0; + for (int i = 0; i < ITERATIONS; i++) { + long t0 = System.nanoTime(); + try { + ECKey.signatureToAddress(MESSAGE_HASH, sigs[i]); + } catch (SignatureException e) { + throw new AssertionError(e); + } + verifyNs += System.nanoTime() - t0; + } + return new Result("ECKey(secp)", keygenNs, signNs, verifyNs); + } + + private Result benchMlDsa44() { + for (int i = 0; i < WARMUP; i++) { + MLDSA44 k = new MLDSA44(); + byte[] sig = k.sign(MESSAGE); + k.verify(MESSAGE, sig); + } + + long keygenNs = 0; + MLDSA44[] keys = new MLDSA44[ITERATIONS]; + for (int i = 0; i < ITERATIONS; i++) { + long t0 = System.nanoTime(); + keys[i] = new MLDSA44(); + keygenNs += System.nanoTime() - t0; + } + + long signNs = 0; + byte[][] sigs = new byte[ITERATIONS][]; + for (int i = 0; i < ITERATIONS; i++) { + long t0 = System.nanoTime(); + sigs[i] = keys[i].sign(MESSAGE); + signNs += System.nanoTime() - t0; + } + + long verifyNs = 0; + for (int i = 0; i < ITERATIONS; i++) { + long t0 = System.nanoTime(); + keys[i].verify(MESSAGE, sigs[i]); + verifyNs += System.nanoTime() - t0; + } + return new Result("ML-DSA-44", keygenNs, signNs, verifyNs); + } + + private Result benchMlDsa65() { + for (int i = 0; i < WARMUP; i++) { + MLDSA65 k = new MLDSA65(); + byte[] sig = k.sign(MESSAGE); + k.verify(MESSAGE, sig); + } + + long keygenNs = 0; + MLDSA65[] keys = new MLDSA65[ITERATIONS]; + for (int i = 0; i < ITERATIONS; i++) { + long t0 = System.nanoTime(); + keys[i] = new MLDSA65(); + keygenNs += System.nanoTime() - t0; + } + + long signNs = 0; + byte[][] sigs = new byte[ITERATIONS][]; + for (int i = 0; i < ITERATIONS; i++) { + long t0 = System.nanoTime(); + sigs[i] = keys[i].sign(MESSAGE); + signNs += System.nanoTime() - t0; + } + + long verifyNs = 0; + for (int i = 0; i < ITERATIONS; i++) { + long t0 = System.nanoTime(); + keys[i].verify(MESSAGE, sigs[i]); + verifyNs += System.nanoTime() - t0; + } + return new Result("ML-DSA-65", keygenNs, signNs, verifyNs); + } + + private static void printResult(Result r) { + System.out.println(String.format(Locale.ROOT, + "%-12s | %12.2f | %12.2f | %12.2f", + r.name, + r.keygenNs / 1_000.0 / ITERATIONS, + r.signNs / 1_000.0 / ITERATIONS, + r.verifyNs / 1_000.0 / ITERATIONS)); + } + + private static final class Result { + final String name; + final long keygenNs; + final long signNs; + final long verifyNs; + + Result(String name, long keygenNs, long signNs, long verifyNs) { + this.name = name; + this.keygenNs = keygenNs; + this.signNs = signNs; + this.verifyNs = verifyNs; + } + } +} diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/program/PqFullNode.java b/framework/src/test/java/org/tron/common/crypto/pqc/program/PqFullNode.java new file mode 100644 index 00000000000..2a71ef6f794 --- /dev/null +++ b/framework/src/test/java/org/tron/common/crypto/pqc/program/PqFullNode.java @@ -0,0 +1,118 @@ +package org.tron.common.crypto.pqc.program; + +import java.io.File; +import java.net.InetSocketAddress; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Collections; +import org.tron.common.application.Application; +import org.tron.common.application.ApplicationFactory; +import org.tron.common.application.TronApplicationContext; +import org.tron.common.crypto.pqc.MLDSA44; +import org.tron.common.utils.ByteArray; +import org.tron.core.ChainBaseManager; +import org.tron.core.config.DefaultConfig; +import org.tron.core.config.args.Args; +import org.tron.core.db.Manager; + +/** + * Demo fullnode that dials {@link PqcWitnessNode} via P2P and syncs PQ-signed blocks. + * + * Both nodes share the same deterministic PQ genesis pre-state (witness account with an + * ML-DSA-44 witness permission + demo user account with an ML-DSA-44 owner permission), + * installed via {@link 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 ────── + MLDSA44 witnessKp = new MLDSA44(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(MLDSA44.computeAddress(witnessPub))); + + // ── 2. Configure node (no -w: this is a pure fullnode) ──────────────── + File dbDir = Files.createTempDirectory("pqc-fullnode-").toFile(); + dbDir.deleteOnExit(); + + Args.setParam(new String[]{"--output-directory", dbDir.getAbsolutePath()}, + "config-test.conf"); + Args.getInstance().setRpcEnable(true); + Args.getInstance().setFullNodeHttpEnable(true); + Args.getInstance().setFullNodeHttpPort(HTTP_PORT); + Args.getInstance().setSolidityNodeHttpEnable(false); + Args.getInstance().setRpcPort(GRPC_PORT); + Args.getInstance().setNodeListenPort(P2P_PORT); + Args.getInstance().setNeedSyncCheck(false); + Args.getInstance().setMinEffectiveConnection(0); + Args.getInstance().genesisBlock.setWitnesses(new ArrayList<>()); + + // Point to the witness node as the only seed peer. + // Mutable list — startup appends persisted peers to it. + Args.getInstance().getSeedNode().setAddressList(new ArrayList<>( + Collections.singletonList(new InetSocketAddress(WITNESS_HOST, WITNESS_P2P_PORT)))); + + // ── 3. Start Spring context ─────────────────────────────────────────── + TronApplicationContext context = new TronApplicationContext(DefaultConfig.class); + Application app = ApplicationFactory.create(context); + Manager db = context.getBean(Manager.class); + ChainBaseManager chain = context.getBean(ChainBaseManager.class); + + // ── 4. Install matching PQ genesis pre-state ────────────────────────── + // Without this the incoming witness_auth would fail to validate because + // 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) ─ + app.startup(); + + System.out.println("\nFull node running, syncing from witness. Send Ctrl-C to stop.\n"); + + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + System.out.println("Shutting down..."); + context.close(); + Args.clearParam(); + })); + + Thread.currentThread().join(); + } +} diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/program/PqcClient.java b/framework/src/test/java/org/tron/common/crypto/pqc/program/PqcClient.java new file mode 100644 index 00000000000..79b8c8e4b89 --- /dev/null +++ b/framework/src/test/java/org/tron/common/crypto/pqc/program/PqcClient.java @@ -0,0 +1,147 @@ +package org.tron.common.crypto.pqc.program; + +import com.google.protobuf.Any; +import com.google.protobuf.ByteString; +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; +import java.nio.ByteBuffer; +import java.security.MessageDigest; +import java.util.Arrays; +import java.util.concurrent.TimeUnit; +import org.tron.api.GrpcAPI.EmptyMessage; +import org.tron.api.GrpcAPI.Return; +import org.tron.api.WalletGrpc; +import org.tron.api.WalletGrpc.WalletBlockingStub; +import org.tron.common.crypto.pqc.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; +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.program.PqcWitnessNode + * Terminal 2 — broadcast a PQC transaction: + * ./gradlew :framework:run -PmainClass=org.tron.common.crypto.pqc.program.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 { + // 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); + 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/program/PqcWitnessNode.java b/framework/src/test/java/org/tron/common/crypto/pqc/program/PqcWitnessNode.java new file mode 100644 index 00000000000..a222988b9c8 --- /dev/null +++ b/framework/src/test/java/org/tron/common/crypto/pqc/program/PqcWitnessNode.java @@ -0,0 +1,186 @@ +package org.tron.common.crypto.pqc.program; + +import com.google.protobuf.ByteString; +import java.io.File; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import org.tron.common.application.Application; +import org.tron.common.application.ApplicationFactory; +import org.tron.common.application.TronApplicationContext; +import org.tron.common.crypto.pqc.MLDSA44; +import org.tron.common.utils.ByteArray; +import org.tron.core.ChainBaseManager; +import org.tron.core.capsule.AccountCapsule; +import org.tron.core.capsule.WitnessCapsule; +import org.tron.core.config.DefaultConfig; +import org.tron.core.config.args.Args; +import org.tron.core.consensus.ConsensusService; +import org.tron.core.db.Manager; +import org.tron.protos.Protocol.Account; +import org.tron.protos.Protocol.AccountType; +import org.tron.protos.Protocol.Key; +import org.tron.protos.Protocol.Permission; +import org.tron.protos.Protocol.Permission.PermissionType; +import org.tron.protos.Protocol.SignatureScheme; + +/** + * Demo witness node with ML-DSA-44 block production. + * + * Starts an in-process TRON node configured with a PQC witness keypair and + * a user account that holds an ML-DSA-44 owner permission — ready to receive + * transactions from {@link 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.program.PqcWitnessNode + * Terminal 2 — broadcast a PQC transaction: + * ./gradlew :framework:run -PmainClass=org.tron.common.crypto.pqc.program.PqcClient + */ +public class PqcWitnessNode { + + /** 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); + + /** gRPC port the node listens on. */ + static final int GRPC_PORT = 50051; + + /** Full-node HTTP port. */ + static final int HTTP_PORT = 8090; + + /** P2P listen port (shared with PqFullNode so it can dial in as a seed peer). */ + static final int P2P_PORT = 18888; + + /** Fixed on-chain address for the demo user account. */ + static final byte[] USER_ADDR = + ByteArray.fromHexString("41abd4b9367799eaa3197fecb144eb71de1e049abc"); + + public static void main(String[] args) throws Exception { + // Force INFO level: logback-test.xml (on the test classpath) sets root=DEBUG + // which is far too noisy for a demo run. + ((ch.qos.logback.classic.Logger) org.slf4j.LoggerFactory + .getLogger(ch.qos.logback.classic.Logger.ROOT_LOGGER_NAME)) + .setLevel(ch.qos.logback.classic.Level.INFO); + + // ── 1. Derive deterministic keypairs ────────────────────────────────── + MLDSA44 witnessKp = new MLDSA44(WITNESS_SEED); + MLDSA44 userKp = new MLDSA44(USER_SEED); + + byte[] witnessPub = witnessKp.getPublicKey(); + byte[] witnessAddr = MLDSA44.computeAddress(witnessPub); + byte[] userPub = userKp.getPublicKey(); + byte[] signerAddr = MLDSA44.computeAddress(userPub); + + System.out.println("=== PQC Witness Node ==="); + System.out.println("Witness address (ML-DSA-44): " + ByteArray.toHexString(witnessAddr)); + System.out.println("User address: " + ByteArray.toHexString(USER_ADDR)); + System.out.println("User signer address: " + ByteArray.toHexString(signerAddr)); + System.out.println("gRPC port: " + GRPC_PORT); + System.out.println("HTTP port: " + HTTP_PORT); + System.out.println("P2P port: " + P2P_PORT); + + // ── 2. Configure node ───────────────────────────────────────────────── + File dbDir = Files.createTempDirectory("pqc-node-").toFile(); + dbDir.deleteOnExit(); + + Args.setParam(new String[]{"--output-directory", dbDir.getAbsolutePath(), "-w"}, + "config-test.conf"); + Args.getInstance().setRpcEnable(true); + Args.getInstance().setFullNodeHttpEnable(true); + Args.getInstance().setFullNodeHttpPort(HTTP_PORT); + Args.getInstance().setRpcPort(GRPC_PORT); + Args.getInstance().setNodeListenPort(P2P_PORT); + Args.getInstance().setNeedSyncCheck(false); + Args.getInstance().setMinEffectiveConnection(0); + Args.getInstance().genesisBlock.setWitnesses(new ArrayList<>()); + + // ── 3. Start Spring context ─────────────────────────────────────────── + TronApplicationContext context = new TronApplicationContext(DefaultConfig.class); + Application app = ApplicationFactory.create(context); + Manager db = context.getBean(Manager.class); + ChainBaseManager chain = context.getBean(ChainBaseManager.class); + + // ── 4. Install PQ genesis pre-state (shared with PqFullNode) ───────── + installPqGenesisState(db, chain, witnessPub, userPub); + + // ── 5. Start consensus (DposTask auto-produces blocks) ─────────────── + context.getBean(ConsensusService.class).start(); + + // ── 6. Start gRPC / P2P server ─────────────────────────────────────── + app.startup(); + + System.out.println("\nNode is running. Send Ctrl-C to stop."); + System.out.println("Run 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 = MLDSA44.computeAddress(witnessPub); + ByteString witnessAddrBs = ByteString.copyFrom(witnessAddr); + byte[] signerAddr = MLDSA44.computeAddress(userPub); + ByteString signerAddrBs = ByteString.copyFrom(signerAddr); + + // Activate ML-DSA on the local chain params. + db.getDynamicPropertiesStore().saveAllowMlDsa44(1L); + db.getDynamicPropertiesStore().saveAllowMultiSign(1L); + + // Witness account with ML-DSA-44 witness permission. + Permission witnessPerm = Permission.newBuilder() + .setType(PermissionType.Witness) + .setId(1).setPermissionName("witness").setThreshold(1) + .addKeys(Key.newBuilder() + .setAddress(witnessAddrBs).setWeight(1) + .setScheme(SignatureScheme.ML_DSA_44) + .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); + + // 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); + } + + private static byte[] filledSeed(int value) { + byte[] seed = new byte[32]; + Arrays.fill(seed, (byte) value); + return seed; + } +} diff --git a/framework/src/test/java/org/tron/common/runtime/vm/MlDsaPrecompileTest.java b/framework/src/test/java/org/tron/common/runtime/vm/MlDsaPrecompileTest.java new file mode 100644 index 00000000000..2b2172ee085 --- /dev/null +++ b/framework/src/test/java/org/tron/common/runtime/vm/MlDsaPrecompileTest.java @@ -0,0 +1,196 @@ +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.initAllowMlDsa44(1L); + VMConfig.initAllowMlDsa65(1L); + } + + @After + public void disableProposal() { + VMConfig.initAllowMlDsa44(0L); + VMConfig.initAllowMlDsa65(0L); + } + + @Test + public void switchOff_returnsNull() { + VMConfig.initAllowMlDsa44(0L); + VMConfig.initAllowMlDsa65(0L); + Assert.assertNull(PrecompiledContracts.getContractForAddress(MLDSA44_ADDR)); + Assert.assertNull(PrecompiledContracts.getContractForAddress(MLDSA65_ADDR)); + } + + @Test + public void switchOn_returnsContracts() { + Assert.assertNotNull(PrecompiledContracts.getContractForAddress(MLDSA44_ADDR)); + Assert.assertNotNull(PrecompiledContracts.getContractForAddress(MLDSA65_ADDR)); + } + + @Test + public void mldsa44_validSignature_returnsOne() { + MLDSA44 key = new MLDSA44(); + byte[] sig = key.sign(MESSAGE_HASH); + byte[] input = concat(MESSAGE_HASH, sig, key.getPublicKey()); + + PrecompiledContract pc = PrecompiledContracts.getContractForAddress(MLDSA44_ADDR); + Pair result = pc.execute(input); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ONE().getData(), result.getRight()); + Assert.assertEquals(4500, pc.getEnergyForData(input)); + } + + @Test + public void mldsa44_tamperedMessage_returnsZero() { + MLDSA44 key = new MLDSA44(); + byte[] sig = key.sign(MESSAGE_HASH); + byte[] tampered = MESSAGE_HASH.clone(); + tampered[0] ^= 0x01; + byte[] input = concat(tampered, sig, key.getPublicKey()); + + Pair result = + PrecompiledContracts.getContractForAddress(MLDSA44_ADDR).execute(input); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + @Test + public void mldsa44_tamperedSignature_returnsZero() { + MLDSA44 key = new MLDSA44(); + byte[] sig = key.sign(MESSAGE_HASH); + sig[0] ^= 0x01; + byte[] input = concat(MESSAGE_HASH, sig, key.getPublicKey()); + + Pair result = + PrecompiledContracts.getContractForAddress(MLDSA44_ADDR).execute(input); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + @Test + public void mldsa44_wrongPublicKey_returnsZero() { + MLDSA44 signer = new MLDSA44(); + MLDSA44 other = new MLDSA44(); + byte[] sig = signer.sign(MESSAGE_HASH); + byte[] input = concat(MESSAGE_HASH, sig, other.getPublicKey()); + + Pair result = + PrecompiledContracts.getContractForAddress(MLDSA44_ADDR).execute(input); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + @Test + public void mldsa44_shortInput_returnsZero() { + byte[] tooShort = new byte[100]; + Pair result = + PrecompiledContracts.getContractForAddress(MLDSA44_ADDR).execute(tooShort); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + @Test + public void mldsa44_nullInput_returnsZero() { + Pair result = + PrecompiledContracts.getContractForAddress(MLDSA44_ADDR).execute(null); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + @Test + public void mldsa65_validSignature_returnsOne() { + MLDSA65 key = new MLDSA65(); + byte[] sig = key.sign(MESSAGE_HASH); + byte[] input = concat(MESSAGE_HASH, sig, key.getPublicKey()); + + PrecompiledContract pc = PrecompiledContracts.getContractForAddress(MLDSA65_ADDR); + Pair result = pc.execute(input); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ONE().getData(), result.getRight()); + Assert.assertEquals(7000, pc.getEnergyForData(input)); + } + + @Test + public void mldsa65_tamperedSignature_returnsZero() { + MLDSA65 key = new MLDSA65(); + byte[] sig = key.sign(MESSAGE_HASH); + sig[sig.length - 1] ^= 0x01; + byte[] input = concat(MESSAGE_HASH, sig, key.getPublicKey()); + + Pair result = + PrecompiledContracts.getContractForAddress(MLDSA65_ADDR).execute(input); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + @Test + public void mldsa65_shortInput_returnsZero() { + Pair result = + PrecompiledContracts.getContractForAddress(MLDSA65_ADDR).execute(new byte[3000]); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + private static byte[] concat(byte[]... parts) { + int total = 0; + for (byte[] p : parts) { + total += p.length; + } + byte[] out = new byte[total]; + int off = 0; + for (byte[] p : parts) { + System.arraycopy(p, 0, out, off, p.length); + off += p.length; + } + return out; + } + + // Sanity check: the helper above and Arrays.copyOfRange behave consistently. + @Test + public void concatHelper_works() { + byte[] a = {1, 2}; + byte[] b = {3, 4, 5}; + byte[] c = concat(a, b); + Assert.assertArrayEquals(new byte[] {1, 2, 3, 4, 5}, c); + Assert.assertArrayEquals(a, Arrays.copyOfRange(c, 0, 2)); + } +} diff --git a/framework/src/test/java/org/tron/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..f4ad26537e0 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,561 @@ 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().saveAllowMlDsa44(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_44 = 0"); + } catch (ContractValidateException e) { + Assert.assertTrue(e.getMessage().contains("ML_DSA_44 is not activated")); + } + } + + @Test + public void legacyKeyWithNonEmptyPublicKeyRejected() { + dbManager.getDynamicPropertiesStore().saveAllowMlDsa44(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().saveAllowMlDsa44(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().saveAllowMlDsa44(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa65(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().saveAllowMlDsa44(1L); + ByteString address = ByteString.copyFrom(ByteArray.fromHexString(OWNER_ADDRESS)); + Permission owner = ownerPermissionWithKeys( + java.util.Collections.singletonList( + mlDsaKey(KEY_ADDRESS, SignatureScheme.ML_DSA_44, 1311, 1)), + 2); + Permission active = activePermissionWithKeys( + java.util.Collections.singletonList(legacyKey(KEY_ADDRESS1)), 2); + Any any = getContract(address, owner, null, + java.util.Collections.singletonList(active)); + + try { + actuatorFor(any).validate(); + fail("ML-DSA-44 wrong public_key length should be rejected"); + } catch (ContractValidateException e) { + Assert.assertTrue(e.getMessage().contains("public_key length")); + } + } + + @Test + public void witnessMlDsa44Accepted() throws ContractValidateException { + dbManager.getDynamicPropertiesStore().saveAllowMlDsa44(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa65(1L); + ByteString address = ByteString.copyFrom(ByteArray.fromHexString(WITNESS_ADDRESS)); + Permission owner = ownerPermissionWithKeys( + java.util.Collections.singletonList( + mlDsaKey(KEY_ADDRESS, SignatureScheme.ML_DSA_65, 1952, 1)), + 2); + Permission witness = witnessPermissionWithKey( + mlDsaKey(KEY_ADDRESS1, SignatureScheme.ML_DSA_44, 1312, 2)); + Permission active = activePermissionWithKeys( + java.util.Collections.singletonList(legacyKey(KEY_ADDRESS2)), 2); + Any any = getContract(address, owner, witness, + java.util.Collections.singletonList(active)); + + actuatorFor(any).validate(); + } + + @Test + public void duplicatePublicKeyRejected() { + dbManager.getDynamicPropertiesStore().saveAllowMlDsa44(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().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.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().saveAllowMlDsa44(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa65(1L); + ByteString address = ByteString.copyFrom(ByteArray.fromHexString(WITNESS_ADDRESS)); + Permission owner = ownerPermissionWithKeys( + java.util.Collections.singletonList( + mlDsaKey(KEY_ADDRESS, SignatureScheme.ML_DSA_65, 1952, 1)), + 2); + Permission witness = witnessPermissionWithKey( + mlDsaKey(KEY_ADDRESS1, SignatureScheme.ML_DSA_65, 1952, 2)); + Permission active = activePermissionWithKeys( + java.util.Collections.singletonList( + mlDsaKey(KEY_ADDRESS2, SignatureScheme.ML_DSA_65, 1952, 3)), + 2); + Any any = getContract(address, owner, witness, + java.util.Collections.singletonList(active)); + + Assert.assertTrue(actuatorFor(any).validate()); + } + + @Test + public void 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")); + } + } + + @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()); + } + + @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 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..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 @@ -772,4 +772,53 @@ public void blockVersionCheck() { } } } + + @Test + 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, 2)); + assertEquals( + "This value[" + name + "] is only allowed to be 0 or 1", thrown.getMessage()); + + thrown = assertThrows(ContractValidateException.class, + () -> 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=0 and value=1 should both be accepted: " + e.getMessage()); + } + } } 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..ca3c6ff0696 --- /dev/null +++ b/framework/src/test/java/org/tron/core/capsule/BlockCapsulePqTest.java @@ -0,0 +1,290 @@ +package org.tron.core.capsule; + +import com.google.protobuf.ByteString; +import org.junit.Assert; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.tron.common.BaseTest; +import org.tron.common.TestConstants; +import org.tron.common.crypto.ECKey; +import org.tron.common.crypto.pqc.MLDSA65; +import org.tron.common.crypto.pqc.PqAuthDigest; +import org.tron.common.crypto.pqc.SLHDSA; +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 MLDSA65 pqKeypair; + + @BeforeClass + public static void init() { + Args.setParam(new String[] {"-d", dbPath()}, TestConstants.TEST_CONF); + } + + @Before + public void setUp() { + witnessKey = new ECKey(); + witnessAddress = witnessKey.getAddress(); + pqKeypair = new MLDSA65(); + } + + private AccountCapsule buildWitnessAccount(SignatureScheme scheme) { + Key.Builder kb = Key.newBuilder() + .setAddress(ByteString.copyFrom(witnessAddress)) + .setWeight(1) + .setScheme(scheme); + if (scheme == SignatureScheme.ML_DSA_65) { + kb.setPublicKey(ByteString.copyFrom(pqKeypair.getPublicKey())); + } + Permission witnessPerm = Permission.newBuilder() + .setType(PermissionType.Witness) + .setId(1) + .setPermissionName("witness") + .setThreshold(1) + .addKeys(kb) + .build(); + Account account = Account.newBuilder() + .setAccountName(ByteString.copyFromUtf8("w")) + .setAddress(ByteString.copyFrom(witnessAddress)) + .setType(AccountType.Normal) + .setBalance(1_000_000_000L) + .setIsWitness(true) + .setWitnessPermission(witnessPerm) + .build(); + return new AccountCapsule(account); + } + + private BlockCapsule buildSignedBlock(byte[] parentHash) { + BlockCapsule block = new BlockCapsule( + 1L, + Sha256Hash.wrap(ByteString.copyFrom(parentHash)), + System.currentTimeMillis(), + ByteString.copyFrom(witnessAddress)); + block.sign(witnessKey.getPrivKeyBytes()); + return block; + } + + private BlockCapsule buildUnsignedBlock(byte[] parentHash) { + return new BlockCapsule( + 1L, + Sha256Hash.wrap(ByteString.copyFrom(parentHash)), + System.currentTimeMillis(), + ByteString.copyFrom(witnessAddress)); + } + + private byte[] signPq(byte[] message) { + return MLDSA65.sign(pqKeypair.getPrivateKey(), message); + } + + @Test + public void legacyValidateWithoutAuthWitnessAcceptedBeforeActivation() 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); + + 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().saveAllowMlDsa44(0L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa65(0L); + dbManager.getDynamicPropertiesStore().saveAllowSlhDsa(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().saveAllowMlDsa44(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa65(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().saveAllowMlDsa44(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa65(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().saveAllowMlDsa44(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa65(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().saveAllowMlDsa44(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa65(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().saveAllowMlDsa44(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa65(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().saveAllowMlDsa44(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa65(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 + 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); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa44(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa65(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..07ecd79d032 100644 --- a/framework/src/test/java/org/tron/core/capsule/TransactionCapsuleTest.java +++ b/framework/src/test/java/org/tron/core/capsule/TransactionCapsuleTest.java @@ -4,7 +4,14 @@ 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.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; @@ -12,15 +19,33 @@ import org.junit.Test; import org.tron.common.BaseTest; import org.tron.common.TestConstants; +import org.tron.common.crypto.ECKey; +import org.tron.common.crypto.pqc.FNDSA; +import org.tron.common.crypto.pqc.MLDSA44; +import org.tron.common.crypto.pqc.MLDSA65; +import org.tron.common.crypto.pqc.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; 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.EphemeralWitness; +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; +import org.tron.protos.contract.SmartContractOuterClass.TriggerSmartContract; @Slf4j public class TransactionCapsuleTest extends BaseTest { @@ -69,4 +94,761 @@ 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 byte[] sign(MLDSA44 kp, byte[] msg) { + return MLDSA44.sign(kp.getPrivateKey(), msg); + } + + private Transaction buildTransferTx(String ownerHex, int permissionId) { + TransferContract transfer = TransferContract.newBuilder() + .setOwnerAddress(ByteString.copyFrom(ByteArray.fromHexString(ownerHex))) + .setToAddress(ByteString.copyFrom(ByteArray.fromHexString(PQ_SIGNER_HEX))) + .setAmount(1L) + .build(); + Transaction.Contract c = Transaction.Contract.newBuilder() + .setType(ContractType.TransferContract) + .setParameter(Any.pack(transfer)) + .setPermissionId(permissionId) + .build(); + raw rawData = raw.newBuilder().addContract(c).build(); + return Transaction.newBuilder().setRawData(rawData).build(); + } + + private void putAccountWithPqPermission( + String ownerHex, byte[] pqPublicKey, SignatureScheme scheme) { + byte[] addr = ByteArray.fromHexString(ownerHex); + byte[] signerAddr = ByteArray.fromHexString(PQ_SIGNER_HEX); + Key pqKey = Key.newBuilder() + .setAddress(ByteString.copyFrom(signerAddr)) + .setWeight(1L) + .setScheme(scheme) + .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().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))) + .setSignature(ByteString.copyFrom(new byte[2420])) + .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("no PQ scheme is activated")); + } + } + + @Test + public void signatureAndAuthWitnessAreMutuallyExclusive() { + dbManager.getDynamicPropertiesStore().saveAllowMlDsa44(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[2420])) + .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().saveAllowMlDsa44(1L); + MLDSA44 kp = new MLDSA44(); + putAccountWithPqPermission(PQ_OWNER_HEX, kp.getPublicKey(), SignatureScheme.ML_DSA_44); + + Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0); + byte[] txid = Sha256Hash.of(true, tx.getRawData().toByteArray()).getBytes(); + byte[] signerAddr = ByteArray.fromHexString(PQ_SIGNER_HEX); + byte[] digest = PqAuthDigest.tx(txid, 0, signerAddr); + byte[] sig = sign(kp, 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().saveAllowMlDsa44(1L); + MLDSA44 kp = new MLDSA44(); + putAccountWithPqPermission(PQ_OWNER_HEX, kp.getPublicKey(), SignatureScheme.ML_DSA_44); + + Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0); + byte[] txid = Sha256Hash.of(true, tx.getRawData().toByteArray()).getBytes(); + byte[] signerAddr = ByteArray.fromHexString(PQ_SIGNER_HEX); + byte[] digest = PqAuthDigest.tx(txid, 0, signerAddr); + byte[] sig = sign(kp, 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().saveAllowMlDsa44(1L); + MLDSA44 kp = new MLDSA44(); + putAccountWithPqPermission(PQ_OWNER_HEX, kp.getPublicKey(), SignatureScheme.ML_DSA_44); + + Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0); + byte[] txid = Sha256Hash.of(true, tx.getRawData().toByteArray()).getBytes(); + byte[] signerAddr = ByteArray.fromHexString(PQ_SIGNER_HEX); + byte[] digest = PqAuthDigest.tx(txid, 0, signerAddr); + byte[] sig = sign(kp, 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().saveAllowMlDsa44(1L); + MLDSA44 kp = new MLDSA44(); + putAccountWithPqPermission(PQ_OWNER_HEX, kp.getPublicKey(), SignatureScheme.ML_DSA_44); + + Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0); + byte[] txid = Sha256Hash.of(true, tx.getRawData().toByteArray()).getBytes(); + byte[] otherSigner = ByteArray.fromHexString( + "41BCE23C7D683B889326F762DDA2223A861EDA2E5C"); + byte[] digest = PqAuthDigest.tx(txid, 0, otherSigner); + byte[] sig = sign(kp, 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")); + } + } + + /** + * 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().saveAllowMlDsa44(1L); + dbManager.getDynamicPropertiesStore().saveAllowMlDsa65(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())); + } + + @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")); + } + } + + @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 + } + } + + // --------------------- 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 diff --git a/framework/src/test/java/org/tron/core/exception/TronErrorTest.java b/framework/src/test/java/org/tron/core/exception/TronErrorTest.java index e965ae3fd60..9ca052a54e4 100644 --- a/framework/src/test/java/org/tron/core/exception/TronErrorTest.java +++ b/framework/src/test/java/org/tron/core/exception/TronErrorTest.java @@ -16,6 +16,7 @@ import com.typesafe.config.ConfigObject; import java.io.IOException; import java.lang.reflect.Field; +import java.nio.file.Files; import java.nio.file.Path; import java.util.Collections; import java.util.HashMap; @@ -109,9 +110,16 @@ public void LogLoadTest() throws IOException { } @Test - public void witnessInitTest() { + public void witnessInitTest() throws IOException { + // Inherit config-test.conf and override every witness-key source so that + // --witness has nothing to initialize from. + Path conf = temporaryFolder.newFile("no-witness.conf").toPath(); + String content = "include classpath(\"" + TestConstants.TEST_CONF + "\")\n" + + "localwitness = []\n" + + "localwitness_seed_pq = []\n"; + Files.write(conf, content.getBytes()); TronError thrown = assertThrows(TronError.class, () -> { - Args.setParam(new String[]{"--witness"}, TestConstants.TEST_CONF); + Args.setParam(new String[]{"--witness"}, conf.toString()); }); assertEquals(TronError.ErrCode.WITNESS_INIT, thrown.getErrCode()); } diff --git a/framework/src/test/java/org/tron/core/services/ProposalServiceTest.java b/framework/src/test/java/org/tron/core/services/ProposalServiceTest.java index 5732e6f1cde..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,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_44; import static org.tron.core.utils.ProposalUtil.ProposalType.CONSENSUS_LOGIC_OPTIMIZATION; import static org.tron.core.utils.ProposalUtil.ProposalType.ENERGY_FEE; import static org.tron.core.utils.ProposalUtil.ProposalType.PROPOSAL_EXPIRE_TIME; @@ -151,4 +152,20 @@ public void testProposalExpireTime() { Assert.assertEquals(MAX_PROPOSAL_EXPIRE_TIME - 3000, window); } + @Test + public void testProcessAllowMlDsa() { + dbManager.getDynamicPropertiesStore().saveAllowMlDsa44(0L); + Assert.assertFalse(dbManager.getDynamicPropertiesStore().allowMlDsa44()); + + Proposal proposal = Proposal.newBuilder() + .putParameters(ALLOW_ML_DSA_44.getCode(), 1L).build(); + ProposalCapsule proposalCapsule = new ProposalCapsule(proposal); + boolean result = ProposalService.process(dbManager, proposalCapsule); + Assert.assertTrue(result); + + Assert.assertEquals(1L, dbManager.getDynamicPropertiesStore().getAllowMlDsa44()); + Assert.assertTrue(dbManager.getDynamicPropertiesStore().allowMlDsa44()); + + dbManager.getDynamicPropertiesStore().saveAllowMlDsa44(0L); + } } \ No newline at end of file diff --git a/framework/src/test/java/org/tron/core/services/http/UtilTest.java b/framework/src/test/java/org/tron/core/services/http/UtilTest.java index 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/framework/src/test/resources/config-test.conf b/framework/src/test/resources/config-test.conf index 71e93f84db5..f920e10253f 100644 --- a/framework/src/test/resources/config-test.conf +++ b/framework/src/test/resources/config-test.conf @@ -348,12 +348,16 @@ genesis.block = { // and the localwitness is configured with the private key of the witnessPermissionAddress in the witness account. // When it is empty,the localwitness is configured with the private key of the witness account. -//localWitnessAccountAddress = +// localWitnessAccountAddress = localwitness = [ - ] +localwitness_seed_pq_scheme = "ML_DSA_44" +localwitness_seed_pq = [ + "0101010101010101010101010101010101010101010101010101010101010101" + ] + block = { needSyncCheck = true # first node : false, other : true } @@ -386,4 +390,4 @@ node.dynamicConfig.enable = true event.subscribe = { enable = false } -node.dynamicConfig.checkInterval = 0 \ No newline at end of file +node.dynamicConfig.checkInterval = 0 diff --git a/protocol/src/main/protos/core/Tron.proto b/protocol/src/main/protos/core/Tron.proto index 2b104b86d34..cede255ec06 100644 --- a/protocol/src/main/protos/core/Tron.proto +++ b/protocol/src/main/protos/core/Tron.proto @@ -17,6 +17,23 @@ 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; + 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, message AccountId { bytes name = 1; @@ -238,11 +255,50 @@ 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 { 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; +} + +// 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 { @@ -443,12 +499,21 @@ 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; // 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 +580,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