Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
02af25c
feat: add ML-DSA post-quantum signature support
Federico2014 Apr 22, 2026
9ca04fb
refactor(crypto): consolidate ML-DSA PQC signer/verifier into unified…
Federico2014 Apr 24, 2026
c5a7bac
chore(crypto): extend pq demo with fullnode sync
Federico2014 Apr 24, 2026
a08de98
feat(crypto): configurable PQ witness scheme
Federico2014 Apr 26, 2026
51af6b6
fix(crypto): pq witness init hardening and test fixes
Federico2014 Apr 26, 2026
dc59e65
feat(crypto): per-scheme PQ activation flags and proposal governance
Federico2014 Apr 27, 2026
d793a84
feat(crypto): add SLH-DSA-SHA2-128s post-quantum signature support
Federico2014 Apr 27, 2026
0c5d4c9
feat(crypto): add FN-DSA / Falcon-512 post-quantum signature support
Federico2014 Apr 27, 2026
15c1742
feat(protocol): add Ephemeral secp256k1 wire format
Federico2014 Apr 27, 2026
7531eae
feat(crypto): add Ephemeral secp256k1 PQ scheme
Federico2014 Apr 27, 2026
8de44b4
feat(actuator): permission-update validation for Ephemeral secp256k1
Federico2014 Apr 27, 2026
3476710
feat(chainbase): ephemeral secp256k1 replay-protection state
Federico2014 Apr 27, 2026
b94123e
test(crypto): add ML-DSA vs ECKey signature scheme benchmark
Federico2014 Apr 27, 2026
a802fb0
test(chainbase): ephemeral replay-protection scenarios
Federico2014 Apr 27, 2026
22b0341
feat(vm): add ML-DSA verify precompiles at 0x12 and 0x14
Federico2014 Apr 27, 2026
fe5ce69
refactor(crypto): tighten merkle tree max depth to 20
Federico2014 Apr 27, 2026
775404d
chore(framework): tighten localwitness pq seed scheme validation
Federico2014 Apr 27, 2026
d00d184
Merge branch 'feat/add-pqc-signature' into feat/add-additional-pq-sch…
Federico2014 Apr 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<ByteString> 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");
Expand Down Expand Up @@ -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) {
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: expectedPublicKeyLength duplicates PqSignatureRegistry.getPublicKeyLength; use the registry as the single source of truth. If a new scheme is added to the registry but not to this switch, valid keys will be rejected. Replace the manual switch with a PqSignatureRegistry.contains + getPublicKeyLength call.

(Based on your team's feedback about treating the registry as the single source of truth for allowed schemes.)

View Feedback

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At actuator/src/main/java/org/tron/core/actuator/AccountPermissionUpdateActuator.java, line 312:

<comment>`expectedPublicKeyLength` duplicates `PqSignatureRegistry.getPublicKeyLength`; use the registry as the single source of truth. If a new scheme is added to the registry but not to this switch, valid keys will be rejected. Replace the manual switch with a `PqSignatureRegistry.contains` + `getPublicKeyLength` call.

(Based on your team's feedback about treating the registry as the single source of truth for allowed schemes.) </comment>

<file context>
@@ -237,4 +261,68 @@ public ByteString getOwnerAddress() throws InvalidProtocolBufferException {
+    }
+  }
+
+  private static int expectedPublicKeyLength(SignatureScheme scheme) {
+    switch (scheme) {
+      case ML_DSA_44:
</file context>
Fix with Cubic

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;
}
}
}
42 changes: 41 additions & 1 deletion actuator/src/main/java/org/tron/core/utils/ProposalUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -886,6 +886,41 @@ public static void validator(DynamicPropertiesStore dynamicPropertiesStore,
}
break;
}
case ALLOW_ML_DSA_44: {
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Add fork-version gating for the new PQ proposal IDs; they currently bypass activation checks used by other recently added governance params.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At actuator/src/main/java/org/tron/core/utils/ProposalUtil.java, line 889:

<comment>Add fork-version gating for the new PQ proposal IDs; they currently bypass activation checks used by other recently added governance params.</comment>

<file context>
@@ -886,6 +886,41 @@ public static void validator(DynamicPropertiesStore dynamicPropertiesStore,
         }
         break;
       }
+      case ALLOW_ML_DSA_44: {
+        if (value != 0 && value != 1) {
+          throw new ContractValidateException(
</file context>
Fix with Cubic

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;
}
Expand Down Expand Up @@ -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;

Expand Down
89 changes: 89 additions & 0 deletions actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -2221,4 +2242,72 @@ public Pair<Boolean, byte[]> 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<Boolean, byte[]> 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<Boolean, byte[]> 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());
}
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}
}
Expand Down
50 changes: 50 additions & 0 deletions chainbase/src/main/java/org/tron/common/utils/LocalWitnesses.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,42 @@
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 {

@Getter
private List<String> privateKeys = Lists.newArrayList();

/** ML-DSA seed values in hex format (64 hex chars = 32 bytes). */
@Getter
private List<String> 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;

Expand Down Expand Up @@ -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<String> 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;
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Seed length validation is hardcoded to MLDSA65.SEED_LENGTH (32 bytes) but FN-DSA and SLH-DSA require 48-byte seeds. A witness configured with pqScheme = FN_DSA or SLH_DSA will fail to start because its valid 96-hex-char seed gets rejected here. The validation should derive the expected length from the configured pqScheme (or accept a scheme parameter).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At chainbase/src/main/java/org/tron/common/utils/LocalWitnesses.java, line 136:

<comment>Seed length validation is hardcoded to `MLDSA65.SEED_LENGTH` (32 bytes) but FN-DSA and SLH-DSA require 48-byte seeds. A witness configured with `pqScheme = FN_DSA` or `SLH_DSA` will fail to start because its valid 96-hex-char seed gets rejected here. The validation should derive the expected length from the configured `pqScheme` (or accept a scheme parameter).</comment>

<file context>
@@ -95,6 +116,35 @@ public void addPrivateKeys(String privateKey) {
+    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",
</file context>
Fix with Cubic

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)) {
Expand Down
Loading
Loading