diff --git a/actuator/src/main/java/org/tron/core/actuator/AccountPermissionUpdateActuator.java b/actuator/src/main/java/org/tron/core/actuator/AccountPermissionUpdateActuator.java index f2eafb20a5e..2fd5f75f8dd 100644 --- a/actuator/src/main/java/org/tron/core/actuator/AccountPermissionUpdateActuator.java +++ b/actuator/src/main/java/org/tron/core/actuator/AccountPermissionUpdateActuator.java @@ -95,13 +95,14 @@ private boolean checkPermission(Permission permission) throws ContractValidateEx long weightSum = 0; List addressList = permission.getKeysList() .stream() - .map(x -> x.getAddress()) + .map(Key::getAddress) .distinct() .collect(toList()); if (addressList.size() != permission.getKeysList().size()) { throw new ContractValidateException( "address should be distinct in permission " + permission.getType()); } + for (Key key : permission.getKeysList()) { if (!DecodeUtil.addressValid(key.getAddress().toByteArray())) { throw new ContractValidateException("key is not a validate address"); @@ -237,4 +238,5 @@ public ByteString getOwnerAddress() throws InvalidProtocolBufferException { public long calcFee() { return chainBaseManager.getDynamicPropertiesStore().getUpdateAccountPermissionFee(); } + } diff --git a/actuator/src/main/java/org/tron/core/utils/ProposalUtil.java b/actuator/src/main/java/org/tron/core/utils/ProposalUtil.java index cd42d7a9010..b9ac309ff2c 100644 --- a/actuator/src/main/java/org/tron/core/utils/ProposalUtil.java +++ b/actuator/src/main/java/org/tron/core/utils/ProposalUtil.java @@ -886,6 +886,17 @@ public static void validator(DynamicPropertiesStore dynamicPropertiesStore, } break; } + case ALLOW_FN_DSA_512: { + if (dynamicPropertiesStore.getAllowFnDsa512() == 1) { + throw new ContractValidateException( + "[ALLOW_FN_DSA_512] has been valid, no need to propose again"); + } + if (value != 1) { + throw new ContractValidateException( + "This value[ALLOW_FN_DSA_512] is only allowed to be 1"); + } + break; + } default: break; } @@ -971,7 +982,8 @@ public enum ProposalType { // current value, value range ALLOW_TVM_BLOB(89), // 0, 1 PROPOSAL_EXPIRE_TIME(92), // (0, 31536003000) ALLOW_TVM_SELFDESTRUCT_RESTRICTION(94), // 0, 1 - ALLOW_TVM_OSAKA(96); // 0, 1 + ALLOW_TVM_OSAKA(96), // 0, 1 + ALLOW_FN_DSA_512(100); // 0, 1 private long code; diff --git a/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java b/actuator/src/main/java/org/tron/core/vm/PrecompiledContracts.java index 634f7f2d3d1..a345ffff3b6 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.FNDSA; +import org.tron.common.crypto.pqc.PQSchemeRegistry; import org.tron.common.crypto.zksnark.BN128; import org.tron.common.crypto.zksnark.BN128Fp; import org.tron.common.crypto.zksnark.BN128G1; @@ -74,6 +76,7 @@ import org.tron.core.vm.utils.MUtil; import org.tron.core.vm.utils.VoteRewardUtil; import org.tron.protos.Protocol; +import org.tron.protos.Protocol.PQScheme; import org.tron.protos.Protocol.Permission; @Slf4j(topic = "VM") @@ -107,6 +110,11 @@ public class PrecompiledContracts { private static final EthRipemd160 ethRipemd160 = new EthRipemd160(); private static final Blake2F blake2F = new Blake2F(); + private static final VerifyFnDsa verifyFnDsa = new VerifyFnDsa(); + private static final ValidateMultiSignPQ validateMultiSignPQ = + new ValidateMultiSignPQ(); + private static final BatchValidateSignPQ batchValidateSignPQ = new BatchValidateSignPQ(); + // FreezeV2 PrecompileContracts private static final GetChainParameter getChainParameter = new GetChainParameter(); private static final AvailableUnfreezeV2Size availableUnfreezeV2Size = new AvailableUnfreezeV2Size(); @@ -200,6 +208,24 @@ public class PrecompiledContracts { private static final DataWord blake2FAddr = new DataWord( "0000000000000000000000000000000000000000000000000000000000020009"); + // EIP-8052 0x16: FN-DSA / Falcon-512 verify (FIPS-206 draft). Input layout: + // [msg 32B | sig_len 2B (big-endian) | sig sig_len B (1..752) | pk 896B]. + // Variable-length signature is prefixed with a 2-byte length field. + private static final DataWord verifyFnDsaAddr = new DataWord( + "0000000000000000000000000000000000000000000000000000000000000016"); + + // 0x17: algorithm-agnostic Permission multi-sign — accepts both ECDSA and + // Falcon-512 signatures against the same Permission.keys[] in one call, + // matching transaction-side §2.3.5 mixed-weight semantics. + private static final DataWord validateMultiSignPQAddr = new DataWord( + "0000000000000000000000000000000000000000000000000000000000000017"); + + // 0x18: batch independent Falcon-512 verify — bitmap of (sig, pk, addr) + // matches; mixed-algorithm contracts call 0x0A and 0x18 separately and OR + // the bitmaps client-side. + private static final DataWord batchValidateSignPqAddr = new DataWord( + "0000000000000000000000000000000000000000000000000000000000000018"); + public static PrecompiledContract getOptimizedContractForConstant(PrecompiledContract contract) { try { Constructor constructor = contract.getClass().getDeclaredConstructor(); @@ -282,6 +308,18 @@ public static PrecompiledContract getContractForAddress(DataWord address) { return blake2F; } + if (VMConfig.allowFnDsa512() && address.equals(verifyFnDsaAddr)) { + return verifyFnDsa; + } + + if (VMConfig.allowFnDsa512() && address.equals(validateMultiSignPQAddr)) { + return validateMultiSignPQ; + } + + if (VMConfig.allowFnDsa512() && address.equals(batchValidateSignPqAddr)) { + return batchValidateSignPQ; + } + if (VMConfig.allowTvmFreezeV2()) { if (address.equals(getChainParameterAddr)) { return getChainParameter; @@ -2221,4 +2259,363 @@ public Pair execute(byte[] data) { } } + /** + * Verifies a FN-DSA / Falcon-512 signature (FIPS-206 draft). EIP-8052 / TRON extension. + * + *

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

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

Returns a 32-byte word: 1 on valid signature, 0 otherwise. + * Malformed input (wrong lengths, out-of-range sig_len) returns 0 without error. + */ + public static class VerifyFnDsa extends PrecompiledContract { + + private static final int MSG_LEN = 32; + private static final int SIG_LEN_FIELD = 2; + private static final int PK_LEN = FNDSA.PUBLIC_KEY_LENGTH; + private static final int MAX_SIG_LEN = FNDSA.SIGNATURE_LENGTH; + private static final int MIN_INPUT_LEN = MSG_LEN + SIG_LEN_FIELD + 1 + PK_LEN; + + @Override + public long getEnergyForData(byte[] data) { + return 2500; + } + + @Override + public Pair execute(byte[] data) { + if (data == null || data.length < MIN_INPUT_LEN) { + return Pair.of(true, DataWord.ZERO().getData()); + } + try { + byte[] msg = copyOfRange(data, 0, MSG_LEN); + int sigLen = ((data[MSG_LEN] & 0xFF) << 8) | (data[MSG_LEN + 1] & 0xFF); + if (sigLen < 1 || sigLen > MAX_SIG_LEN) { + return Pair.of(true, DataWord.ZERO().getData()); + } + int pkOffset = MSG_LEN + SIG_LEN_FIELD + sigLen; + if (data.length < pkOffset + PK_LEN) { + return Pair.of(true, DataWord.ZERO().getData()); + } + byte[] sig = copyOfRange(data, MSG_LEN + SIG_LEN_FIELD, pkOffset); + byte[] pk = copyOfRange(data, pkOffset, pkOffset + PK_LEN); + boolean ok = FNDSA.verify(pk, msg, sig); + return Pair.of(true, ok ? DataWord.ONE().getData() : DataWord.ZERO().getData()); + } catch (Throwable t) { + return Pair.of(true, DataWord.ZERO().getData()); + } + } + } + + /** + * 0x17 ValidateMultiSign — algorithm-agnostic Permission multi-sign. + *

Mirrors 0x09 hash construction ({@code SHA-256(address ‖ permissionId(int4B) ‖ data)}) + * and threshold/dedup semantics, while accepting Falcon-512 entries alongside ECDSA + * against the same {@code Permission.keys[]}. The {@code data} field stays {@code bytes32} + * so the hash is bit-identical to 0x09. + * + *

ABI: + *

+   *   validateMultiSign(
+   *       address account,           // word[0]
+   *       uint256 permissionId,      // word[1]
+   *       bytes32 data,              // word[2]
+   *       bytes[] ecdsaSignatures,   // word[3] = offset; each entry 65 B
+   *       bytes[] pqSignatures,      // word[4] = offset; each entry 1..752 B
+   *       bytes[] pqPublicKeys       // word[5] = offset; each entry 896 B
+   *   ) returns (bool)
+   * 
+ * + *

{@code MAX_SIZE = 5} applies to the total signature count + * ({@code ecdsaCnt + pqCnt}). Energy is split: {@code ecdsaCnt × 1500 + pqCnt × 15000}. + */ + public static class ValidateMultiSignPQ extends PrecompiledContract { + + private static final int ECDSA_ENERGY_PER_SIGN = 1500; + private static final int PQ_ENERGY_PER_SIGN = 15000; + private static final int MAX_SIZE = 5; + private static final int PK_LEN = FNDSA.PUBLIC_KEY_LENGTH; + private static final int MAX_SIG_LEN = FNDSA.SIGNATURE_LENGTH; + + @Override + public long getEnergyForData(byte[] data) { + try { + DataWord[] words = DataWord.parseArray(data); + int ecdsaCnt = words[words[3].intValueSafe() / WORD_SIZE].intValueSafe(); + int pqCnt = words[words[4].intValueSafe() / WORD_SIZE].intValueSafe(); + return (long) ecdsaCnt * ECDSA_ENERGY_PER_SIGN + + (long) pqCnt * PQ_ENERGY_PER_SIGN; + } catch (Throwable t) { + return (long) MAX_SIZE * PQ_ENERGY_PER_SIGN; + } + } + + @Override + public Pair execute(byte[] rawData) { + try { + DataWord[] words = DataWord.parseArray(rawData); + byte[] address = words[0].toTronAddress(); + int permissionId = words[1].intValueSafe(); + byte[] data = words[2].getData(); + + byte[] combine = ByteUtil.merge(address, ByteArray.fromInt(permissionId), data); + byte[] hash = Sha256Hash.hash(CommonParameter + .getInstance().isECKeyCryptoEngine(), combine); + + int ecdsaArrayWord = words[3].intValueSafe() / WORD_SIZE; + int pqSigArrayWord = words[4].intValueSafe() / WORD_SIZE; + int pqPkArrayWord = words[5].intValueSafe() / WORD_SIZE; + + int ecdsaCnt = words[ecdsaArrayWord].intValueSafe(); + int pqSigCnt = words[pqSigArrayWord].intValueSafe(); + int pqPkCnt = words[pqPkArrayWord].intValueSafe(); + + if (pqSigCnt != pqPkCnt + || ecdsaCnt + pqSigCnt == 0 + || ecdsaCnt + pqSigCnt > MAX_SIZE) { + return Pair.of(true, DATA_FALSE); + } + + byte[][] ecdsaSigs = extractSigArray(words, ecdsaArrayWord, rawData); + byte[][] pqSigs = extractBytesArray(words, pqSigArrayWord, rawData); + byte[][] pqPks = extractBytesArray(words, pqPkArrayWord, rawData); + + AccountCapsule account = this.getDeposit().getAccount(address); + if (account == null) { + return Pair.of(true, DATA_FALSE); + } + Permission permission = account.getPermissionById(permissionId); + if (permission == null) { + return Pair.of(true, DATA_FALSE); + } + + long totalWeight = 0L; + List executedSignList = new ArrayList<>(); + + for (byte[] sign : ecdsaSigs) { + byte[] recoveredAddr = recoverAddrBySign(sign, hash); + byte[] dedupKey = merge(recoveredAddr, sign); + if (ByteArray.matrixContains(executedSignList, recoveredAddr)) { + if (ByteArray.matrixContains(executedSignList, dedupKey)) { + continue; + } + MUtil.checkCPUTime(); + } + long weight = TransactionCapsule.getWeight(permission, recoveredAddr); + if (weight == 0) { + return Pair.of(true, DATA_FALSE); + } + totalWeight += weight; + executedSignList.add(dedupKey); + executedSignList.add(recoveredAddr); + } + + for (int i = 0; i < pqSigs.length; i++) { + byte[] sig = pqSigs[i]; + byte[] pk = pqPks[i]; + if (pk == null || pk.length != PK_LEN + || sig == null || sig.length < 1 || sig.length > MAX_SIG_LEN) { + return Pair.of(true, DATA_FALSE); + } + byte[] derivedAddr; + try { + derivedAddr = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pk); + } catch (Throwable t) { + return Pair.of(true, DATA_FALSE); + } + // Falcon-512 signing is randomized: the same key can produce many distinct + // valid signatures for the same hash. Dedup must therefore key on the + // derived address alone, otherwise an attacker could replay one key into + // the threshold N times via N different signatures. + if (ByteArray.matrixContains(executedSignList, derivedAddr)) { + continue; + } + long weight = TransactionCapsule.getWeight(permission, derivedAddr); + if (weight == 0) { + return Pair.of(true, DATA_FALSE); + } + if (!FNDSA.verify(pk, hash, sig)) { + return Pair.of(true, DATA_FALSE); + } + totalWeight += weight; + executedSignList.add(derivedAddr); + } + + if (totalWeight >= permission.getThreshold()) { + return Pair.of(true, dataOne()); + } + } catch (Throwable t) { + if (t instanceof OutOfTimeException) { + throw t; + } + logger.info("ValidateMultiSign(0x17) error:{}", t.getMessage()); + } + return Pair.of(true, DATA_FALSE); + } + } + + /** + * 0x18 BatchValidateSignPQ — independent per-element Falcon-512 verify. + *

Returns a 256-bit bitmap (matching 0x0A) where bit {@code i} is set iff + * {@code derive(pk_i) == expectedAddr_i} AND {@code FNDSA.verify(pk_i, hash, sig_i)}. + * + *

ABI: + *

+   *   batchValidateSignPQ(
+   *       bytes32   hash,                  // word[0]
+   *       bytes[]   signatures,            // word[1] = offset; each 1..752 B
+   *       bytes[]   publicKeys,            // word[2] = offset; each 896 B
+   *       bytes32[] expectedAddresses      // word[3] = offset; 21-byte addr in low 21 bytes
+   *   ) returns (bytes32)
+   * 
+ * + *

Reuses the {@code BatchValidateSign.workers} pool when not in a constant + * call and enforces {@code getCPUTimeLeftInNanoSecond()} timeout. {@code MAX_SIZE = 16}. + * Energy is {@code cnt × 15000}. + */ + public static class BatchValidateSignPQ extends PrecompiledContract { + + private static final int ENERGY_PER_SIGN = 15000; + private static final int MAX_SIZE = 16; + private static final int PK_LEN = FNDSA.PUBLIC_KEY_LENGTH; + private static final int MAX_SIG_LEN = FNDSA.SIGNATURE_LENGTH; + + @Override + public long getEnergyForData(byte[] data) { + try { + DataWord[] words = DataWord.parseArray(data); + int cnt = words[words[1].intValueSafe() / WORD_SIZE].intValueSafe(); + return (long) cnt * ENERGY_PER_SIGN; + } catch (Throwable t) { + return (long) MAX_SIZE * ENERGY_PER_SIGN; + } + } + + @Override + public Pair execute(byte[] data) { + try { + return doExecute(data); + } catch (Throwable t) { + if (t instanceof OutOfTimeException) { + throw (OutOfTimeException) t; + } + if (t instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + return Pair.of(true, new byte[WORD_SIZE]); + } + } + + private Pair doExecute(byte[] data) + throws InterruptedException, ExecutionException { + DataWord[] words = DataWord.parseArray(data); + byte[] hash = words[0].getData(); + + int sigArrayWord = words[1].intValueSafe() / WORD_SIZE; + int pkArrayWord = words[2].intValueSafe() / WORD_SIZE; + int addrArrayWord = words[3].intValueSafe() / WORD_SIZE; + + int sigArraySize = words[sigArrayWord].intValueSafe(); + int pkArraySize = words[pkArrayWord].intValueSafe(); + int addrArraySize = words[addrArrayWord].intValueSafe(); + + if (sigArraySize > MAX_SIZE || pkArraySize > MAX_SIZE + || addrArraySize > MAX_SIZE + || sigArraySize != pkArraySize || sigArraySize != addrArraySize) { + return Pair.of(true, DATA_FALSE); + } + + byte[][] signatures = extractBytesArray(words, sigArrayWord, data); + byte[][] publicKeys = extractBytesArray(words, pkArrayWord, data); + byte[][] addresses = extractBytes32Array(words, addrArrayWord); + + int cnt = signatures.length; + if (cnt == 0) { + return Pair.of(true, DATA_FALSE); + } + + byte[] res = new byte[WORD_SIZE]; + if (isConstantCall()) { + for (int i = 0; i < cnt; i++) { + if (verifyOne(signatures[i], publicKeys[i], hash, addresses[i])) { + res[i] = 1; + } + } + } else { + CountDownLatch countDownLatch = new CountDownLatch(cnt); + List> futures = new ArrayList<>(cnt); + + for (int i = 0; i < cnt; i++) { + Future future = BatchValidateSign.workers.submit( + new PqVerifyTask(countDownLatch, hash, signatures[i], + publicKeys[i], addresses[i], i)); + futures.add(future); + } + + boolean withNoTimeout = countDownLatch + .await(getCPUTimeLeftInNanoSecond(), TimeUnit.NANOSECONDS); + + if (!withNoTimeout) { + logger.info("BatchValidateSignPQ timeout"); + throw Program.Exception.notEnoughTime("call BatchValidateSignPQ precompile method"); + } + + for (Future future : futures) { + PqVerifyResult r = future.get(); + if (r.success) { + res[r.nonce] = 1; + } + } + } + return Pair.of(true, res); + } + + private static boolean verifyOne(byte[] sig, byte[] pk, byte[] hash, + byte[] expectedAddr) { + if (pk == null || pk.length != PK_LEN + || sig == null || sig.length < 1 || sig.length > MAX_SIG_LEN) { + return false; + } + try { + byte[] derived = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pk); + if (!DataWord.equalAddressByteArray(derived, expectedAddr)) { + return false; + } + return FNDSA.verify(pk, hash, sig); + } catch (Throwable t) { + return false; + } + } + + @AllArgsConstructor + private static class PqVerifyTask implements Callable { + + private CountDownLatch countDownLatch; + private byte[] hash; + private byte[] signature; + private byte[] publicKey; + private byte[] expectedAddr; + private int nonce; + + @Override + public PqVerifyResult call() { + try { + return new PqVerifyResult( + verifyOne(signature, publicKey, hash, expectedAddr), nonce); + } finally { + countDownLatch.countDown(); + } + } + } + + @AllArgsConstructor + private static class PqVerifyResult { + + private boolean success; + private int nonce; + } + } + } diff --git a/actuator/src/main/java/org/tron/core/vm/config/ConfigLoader.java b/actuator/src/main/java/org/tron/core/vm/config/ConfigLoader.java index e099101912b..d5edf2b8ef8 100644 --- a/actuator/src/main/java/org/tron/core/vm/config/ConfigLoader.java +++ b/actuator/src/main/java/org/tron/core/vm/config/ConfigLoader.java @@ -46,6 +46,7 @@ public static void load(StoreFactory storeFactory) { VMConfig.initAllowTvmBlob(ds.getAllowTvmBlob()); VMConfig.initAllowTvmSelfdestructRestriction(ds.getAllowTvmSelfdestructRestriction()); VMConfig.initAllowTvmOsaka(ds.getAllowTvmOsaka()); + VMConfig.initAllowFnDsa512(ds.getAllowFnDsa512()); } } } 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..dc8f4f0c4be 100644 --- a/chainbase/src/main/java/org/tron/common/utils/LocalWitnesses.java +++ b/chainbase/src/main/java/org/tron/common/utils/LocalWitnesses.java @@ -18,14 +18,17 @@ import com.google.common.collect.Lists; import java.util.List; import lombok.Getter; +import lombok.Setter; import lombok.extern.slf4j.Slf4j; import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.StringUtils; import org.tron.common.crypto.ECKey; import org.tron.common.crypto.SignInterface; import org.tron.common.crypto.SignUtils; +import org.tron.common.crypto.pqc.PQSchemeRegistry; import org.tron.core.config.Parameter.ChainConstant; import org.tron.core.exception.TronError; +import org.tron.protos.Protocol.PQScheme; @Slf4j(topic = "app") public class LocalWitnesses { @@ -33,6 +36,40 @@ public class LocalWitnesses { @Getter private List privateKeys = Lists.newArrayList(); + /** + * Pre-derived PQ private keys in hex format, one per witness. The expected + * byte length depends on {@link #pqScheme}: 1280 bytes (2560 hex chars) for + * FN-DSA-512. Index-aligned with {@link #pqPublicKeys}. + * + *

Configured directly (rather than derived from a seed on the node) so + * the runtime path is not exposed to potential cross-platform floating-point + * non-determinism in BC's Falcon keygen — operators generate the keypair + * off-line and ship both halves to the node. + */ + @Getter + private List pqPrivateKeys = Lists.newArrayList(); + + /** + * PQ public keys in hex format, one per witness. The expected byte length + * depends on {@link #pqScheme}: 896 bytes (1792 hex chars) for FN-DSA-512. + * Index-aligned with {@link #pqPrivateKeys}. + */ + @Getter + private List pqPublicKeys = Lists.newArrayList(); + + /** PQ signature scheme used by the configured {@link #pqPrivateKeys}. */ + @Getter + private PQScheme pqScheme = PQScheme.FN_DSA_512; + + public void setPqScheme(PQScheme pqScheme) { + if (pqScheme == null || !PQSchemeRegistry.contains(pqScheme)) { + throw new TronError("unsupported PQ signature scheme: " + pqScheme, + TronError.ErrCode.WITNESS_INIT); + } + this.pqScheme = pqScheme; + } + + @Setter @Getter private byte[] witnessAccountAddress; @@ -95,6 +132,55 @@ public void addPrivateKeys(String privateKey) { this.privateKeys.add(privateKey); } + /** + * Pre-derived PQ keypairs (priv + pub) used as signing keys under + * {@link #pqScheme}. The two lists must be the same length and index-aligned; + * each entry must be a hex string whose byte length matches the scheme's + * required private/public key size. Callers must therefore set the scheme + * via {@link #setPqScheme(PQScheme)} before calling this method when + * targeting a non-default scheme. + */ + public void setPqKeypairs(final List pqPrivateKeys, + final List pqPublicKeys) { + if (CollectionUtils.isEmpty(pqPrivateKeys) + && CollectionUtils.isEmpty(pqPublicKeys)) { + return; + } + int privCount = pqPrivateKeys == null ? 0 : pqPrivateKeys.size(); + int pubCount = pqPublicKeys == null ? 0 : pqPublicKeys.size(); + if (privCount != pubCount) { + throw new TronError(String.format( + "PQ keypair list size mismatch: priv=%d, pub=%d", privCount, pubCount), + TronError.ErrCode.WITNESS_INIT); + } + int expectedPrivLen = PQSchemeRegistry.getPrivateKeyLength(pqScheme); + int expectedPubLen = PQSchemeRegistry.getPublicKeyLength(pqScheme); + for (int i = 0; i < privCount; i++) { + validatePqKey(pqPrivateKeys.get(i), expectedPrivLen, "PQ private key"); + validatePqKey(pqPublicKeys.get(i), expectedPubLen, "PQ public key"); + } + this.pqPrivateKeys = pqPrivateKeys; + this.pqPublicKeys = pqPublicKeys; + } + + private static void validatePqKey(String key, int expectedLen, String label) { + String hex = key; + // Match downstream ByteArray.fromHexString, which only strips lowercase "0x". + if (StringUtils.startsWith(hex, "0x")) { + hex = hex.substring(2); + } + int expectedHexLen = expectedLen * 2; + if (StringUtils.isBlank(hex) || hex.length() != expectedHexLen) { + throw new TronError(String.format("%s must be %d hex chars, actual: %d", + label, expectedHexLen, StringUtils.isBlank(hex) ? 0 : hex.length()), + TronError.ErrCode.WITNESS_INIT); + } + if (!StringUtil.isHexadecimal(hex)) { + throw new TronError(label + " must be hex string", + TronError.ErrCode.WITNESS_INIT); + } + } + //get the first one recently public String getPrivateKey() { if (CollectionUtils.isEmpty(privateKeys)) { diff --git a/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java b/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java index 01ff7fb5365..afb7299e0ea 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,7 @@ import org.tron.common.bloom.Bloom; import org.tron.common.crypto.SignInterface; import org.tron.common.crypto.SignUtils; +import org.tron.common.crypto.pqc.PQSchemeRegistry; import org.tron.common.parameter.CommonParameter; import org.tron.common.utils.ByteArray; import org.tron.common.utils.Sha256Hash; @@ -43,6 +44,10 @@ import org.tron.core.store.DynamicPropertiesStore; import org.tron.protos.Protocol.Block; import org.tron.protos.Protocol.BlockHeader; +import org.tron.protos.Protocol.Key; +import org.tron.protos.Protocol.PQScheme; +import org.tron.protos.Protocol.PQAuthSig; +import org.tron.protos.Protocol.Permission; import org.tron.protos.Protocol.Transaction; @Slf4j(topic = "capsule") @@ -173,6 +178,16 @@ public void sign(byte[] privateKey) { } + public void setPqAuthSig(PQAuthSig pqAuthSig) { + BlockHeader blockHeader = this.block.getBlockHeader().toBuilder() + .setPqAuthSig(pqAuthSig).build(); + this.block = this.block.toBuilder().setBlockHeader(blockHeader).build(); + } + + public byte[] getRawHashBytes() { + return getRawHash().getBytes(); + } + private Sha256Hash getRawHash() { return Sha256Hash.of(CommonParameter.getInstance().isECKeyCryptoEngine(), this.block.getBlockHeader().getRawData().toByteArray()); @@ -180,27 +195,104 @@ private Sha256Hash getRawHash() { public boolean validateSignature(DynamicPropertiesStore dynamicPropertiesStore, AccountStore accountStore) throws ValidateSignatureException { + BlockHeader header = block.getBlockHeader(); + boolean hasLegacy = !header.getWitnessSignature().isEmpty(); + PQAuthSig pqAuthSig = header.getPqAuthSig(); + boolean hasPq = pqAuthSig != null + && pqAuthSig.getSignature() != null + && !pqAuthSig.getSignature().isEmpty(); + + if (hasLegacy && hasPq) { + throw new ValidateSignatureException( + "witness_signature and pq_auth_sig are mutually exclusive"); + } + if (!hasLegacy && !hasPq) { + throw new ValidateSignatureException("missing witness signature"); + } + + byte[] witnessAccountAddress = header.getRawData().getWitnessAddress().toByteArray(); + if (hasPq) { + return validatePQSignature(dynamicPropertiesStore, accountStore, + witnessAccountAddress, pqAuthSig); + } + return validateLegacySignature(dynamicPropertiesStore, accountStore, witnessAccountAddress); + } + + private boolean validateLegacySignature(DynamicPropertiesStore dynamicPropertiesStore, + AccountStore accountStore, byte[] witnessAccountAddress) + throws ValidateSignatureException { 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()); } } + /** + * Verify a PQ-signed block header. V2 binds the signing key by deriving its + * 21-byte address from the in-band {@code public_key} and matching against + * the witness account's Witness Permission keys[]. + */ + private boolean validatePQSignature(DynamicPropertiesStore dynamicPropertiesStore, + AccountStore accountStore, byte[] witnessAccountAddress, PQAuthSig pqAuthSig) + throws ValidateSignatureException { + PQScheme scheme = pqAuthSig.getScheme(); + if (!PQSchemeRegistry.contains(scheme)) { + throw new ValidateSignatureException( + "pq_auth_sig scheme " + scheme + " is not registered"); + } + if (!dynamicPropertiesStore.isPqSchemeAllowed(scheme)) { + throw new ValidateSignatureException( + "pq_auth_sig scheme " + scheme + " is not activated"); + } + + AccountCapsule accountCapsule = accountStore.get(witnessAccountAddress); + Permission witnessPermission = null; + if (accountCapsule != null && accountCapsule.getInstance().hasWitnessPermission()) { + witnessPermission = accountCapsule.getInstance().getWitnessPermission(); + } + if (witnessPermission == null || witnessPermission.getKeysCount() == 0) { + throw new ValidateSignatureException( + "pq_auth_sig present but witness permission is not configured"); + } + + byte[] publicKey = pqAuthSig.getPublicKey().toByteArray(); + if (publicKey.length != PQSchemeRegistry.getPublicKeyLength(scheme)) { + throw new ValidateSignatureException( + "pq_auth_sig public key length mismatch for scheme " + scheme); + } + byte[] signature = pqAuthSig.getSignature().toByteArray(); + if (!PQSchemeRegistry.isValidSignatureLength(scheme, signature.length)) { + throw new ValidateSignatureException( + "pq_auth_sig signature length mismatch for scheme " + scheme); + } + + byte[] derivedAddr = PQSchemeRegistry.computeAddress(scheme, publicKey); + Key matched = null; + for (Key k : witnessPermission.getKeysList()) { + if (Arrays.equals(k.getAddress().toByteArray(), derivedAddr)) { + matched = k; + break; + } + } + if (matched == null) { + throw new ValidateSignatureException( + "pq_auth_sig public key does not match any witness permission key"); + } + + byte[] digest = getRawHash().getBytes(); + return PQSchemeRegistry.verify(scheme, publicKey, digest, signature); + } + public BlockId getBlockId() { if (blockId.equals(Sha256Hash.ZERO_HASH)) { blockId = @@ -308,7 +400,12 @@ public long getTimeStamp() { } public boolean hasWitnessSignature() { - return !getInstance().getBlockHeader().getWitnessSignature().isEmpty(); + BlockHeader header = getInstance().getBlockHeader(); + if (!header.getWitnessSignature().isEmpty()) { + return true; + } + PQAuthSig auth = header.getPqAuthSig(); + 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..528c3116761 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,9 @@ import org.tron.common.crypto.Rsv; import org.tron.common.crypto.SignInterface; import org.tron.common.crypto.SignUtils; +import org.tron.common.crypto.pqc.PQSchemeRegistry; import org.tron.common.es.ExecutorServiceManager; +import org.tron.common.math.StrictMathWrapper; import org.tron.common.overlay.message.Message; import org.tron.common.parameter.CommonParameter; import org.tron.common.utils.ByteArray; @@ -65,6 +67,8 @@ import org.tron.core.store.AccountStore; import org.tron.core.store.DynamicPropertiesStore; import org.tron.protos.Protocol.Key; +import org.tron.protos.Protocol.PQScheme; +import org.tron.protos.Protocol.PQAuthSig; import org.tron.protos.Protocol.Permission; import org.tron.protos.Protocol.Permission.PermissionType; import org.tron.protos.Protocol.Transaction; @@ -484,11 +488,25 @@ public static boolean validateSignature(Transaction transaction, throw new PermissionException("permission isn't exit"); } checkPermission(permissionId, permission, contract); - long weight = checkWeight(permission, transaction.getSignatureList(), hash, null); - if (weight >= permission.getThreshold()) { - return true; + + // Hybrid weight: ECDSA signatures and PQ witnesses share one threshold + // check. The two domains derive distinct addresses (Keccak vs SHA-256 + // tagged with 0x41), so a key entry contributes to at most one path. + java.util.Set signedAddresses = new java.util.HashSet<>(); + List approveList = new ArrayList<>(); + long weight = checkWeight(permission, transaction.getSignatureList(), hash, approveList); + signedAddresses.addAll(approveList); + + if (transaction.getPqAuthSigCount() > 0) { + try { + weight = StrictMathWrapper.addExact(weight, + validatePQSignature(transaction, permission, signedAddresses, + dynamicPropertiesStore)); + } catch (ArithmeticException e) { + throw new PermissionException("weight overflow"); + } } - return false; + return weight >= permission.getThreshold(); } public void resetResult() { @@ -637,12 +655,20 @@ public boolean validatePubSignature(AccountStore accountStore, DynamicPropertiesStore dynamicPropertiesStore) throws ValidateSignatureException { if (!isVerified) { - if (this.transaction.getSignatureCount() <= 0 - || this.transaction.getRawData().getContractCount() <= 0) { - throw new ValidateSignatureException("miss sig or contract"); + int legacyCount = this.transaction.getSignatureCount(); + int pqCount = this.transaction.getPqAuthSigCount(); + + if (pqCount > 0 && !dynamicPropertiesStore.isAnyPqSchemeAllowed()) { + throw new ValidateSignatureException( + "pq_auth_sig not allowed: no post-quantum scheme is activated"); + } + if (legacyCount == 0 && pqCount == 0) { + throw new ValidateSignatureException("miss sig"); } - if (this.transaction.getSignatureCount() > dynamicPropertiesStore - .getTotalSignNum()) { + if (this.transaction.getRawData().getContractCount() <= 0) { + throw new ValidateSignatureException("miss contract"); + } + if (legacyCount + pqCount > dynamicPropertiesStore.getTotalSignNum()) { throw new ValidateSignatureException("too many signatures"); } @@ -662,6 +688,78 @@ public boolean validatePubSignature(AccountStore accountStore, return true; } + /** + * Verify {@code transaction.pq_auth_sig[]} entries against {@code permission} + * and return the combined weight contributed by valid PQ witnesses. + * + *

V2 four-step verification per witness: + *

    + *
  1. Resolve the permission context (caller passes {@code permission}).
  2. + *
  3. Derive the 21-byte address from {@code witness.public_key} via the + * scheme's fingerprint hash.
  4. + *
  5. Match against {@code permission.keys[].address}; reject duplicates + * and addresses already counted by the legacy ECDSA path.
  6. + *
  7. Verify the signature over {@code txid} directly; the + * {@code permission_id} is already bound by {@code txid} since it is + * part of {@code raw_data}.
  8. + *
+ */ + static long validatePQSignature(Transaction transaction, Permission permission, + java.util.Set signedAddresses, + DynamicPropertiesStore dynamicPropertiesStore) + throws PermissionException { + byte[] digest = computeRawHash(transaction).getBytes(); + + long weight = 0L; + for (PQAuthSig witness : transaction.getPqAuthSigList()) { + PQScheme scheme = witness.getScheme(); + if (!PQSchemeRegistry.contains(scheme)) { + throw new PermissionException("unsupported pq scheme: " + scheme); + } + if (!dynamicPropertiesStore.isPqSchemeAllowed(scheme)) { + throw new PermissionException(scheme + " is not activated"); + } + byte[] pk = witness.getPublicKey().toByteArray(); + byte[] sig = witness.getSignature().toByteArray(); + if (pk.length != PQSchemeRegistry.getPublicKeyLength(scheme) + || !PQSchemeRegistry.isValidSignatureLength(scheme, sig.length)) { + throw new PermissionException("public key or signature length mismatch"); + } + byte[] derivedAddr = PQSchemeRegistry.computeAddress(scheme, pk); + ByteString addrBs = ByteString.copyFrom(derivedAddr); + if (!signedAddresses.add(addrBs)) { + throw new PermissionException( + encode58Check(derivedAddr) + " has signed twice!"); + } + Key matched = null; + for (Key k : permission.getKeysList()) { + if (k.getAddress().equals(addrBs)) { + matched = k; + break; + } + } + if (matched == null) { + throw new PermissionException( + "pq_auth_sig public key derives to " + encode58Check(derivedAddr) + + " but it is not contained of permission."); + } + if (!PQSchemeRegistry.verify(scheme, pk, digest, sig)) { + throw new PermissionException("pq sig invalid"); + } + try { + weight = StrictMathWrapper.addExact(weight, matched.getWeight()); + } catch (ArithmeticException e) { + throw new PermissionException("weight overflow"); + } + } + return weight; + } + + private static Sha256Hash computeRawHash(Transaction transaction) { + return Sha256Hash.of(CommonParameter.getInstance().isECKeyCryptoEngine(), + transaction.getRawData().toByteArray()); + } + /** * validate signature */ diff --git a/chainbase/src/main/java/org/tron/core/db/BandwidthProcessor.java b/chainbase/src/main/java/org/tron/core/db/BandwidthProcessor.java index 2488686bfb0..ed15ac85b44 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().getPqAuthSigCount() > 0) { + long pqAuthSigBytes = 0L; + for (org.tron.protos.Protocol.PQAuthSig aw + : trx.getInstance().getPqAuthSigList()) { + pqAuthSigBytes += aw.getSerializedSize(); + } + sigOverhead += pqAuthSigBytes; + } long createAccountBytesSize = trx.getInstance().toBuilder().clearRet() - .build().getSerializedSize() - (signatureCount * PER_SIGN_LENGTH); + .build().getSerializedSize() - sigOverhead; if (createAccountBytesSize > maxCreateAccountTxSize) { throw new TooBigTransactionException(String.format( "Too big new account transaction, TxId %s, the size is %d bytes, maxTxSize %d", diff --git a/chainbase/src/main/java/org/tron/core/store/DynamicPropertiesStore.java b/chainbase/src/main/java/org/tron/core/store/DynamicPropertiesStore.java index e0adb0d444a..68728b0f150 100644 --- a/chainbase/src/main/java/org/tron/core/store/DynamicPropertiesStore.java +++ b/chainbase/src/main/java/org/tron/core/store/DynamicPropertiesStore.java @@ -21,6 +21,7 @@ import org.tron.common.utils.Sha256Hash; import org.tron.core.capsule.BytesCapsule; import org.tron.core.config.Parameter.ChainConstant; +import org.tron.protos.Protocol.PQScheme; import org.tron.core.db.TronStoreWithRevoking; import org.tron.core.exception.BadItemException; import org.tron.core.exception.ItemNotFoundException; @@ -240,6 +241,8 @@ public class DynamicPropertiesStore extends TronStoreWithRevoking private static final byte[] ALLOW_TVM_OSAKA = "ALLOW_TVM_OSAKA".getBytes(); + private static final byte[] ALLOW_FN_DSA_512 = "ALLOW_FN_DSA_512".getBytes(); + @Autowired private DynamicPropertiesStore(@Value("properties") String dbName) { super(dbName); @@ -2993,6 +2996,43 @@ public void saveAllowTvmOsaka(long value) { this.put(ALLOW_TVM_OSAKA, new BytesCapsule(ByteArray.fromLong(value))); } + public long getAllowFnDsa512() { + return Optional.ofNullable(getUnchecked(ALLOW_FN_DSA_512)) + .map(BytesCapsule::getData) + .map(ByteArray::toLong) + .orElse(CommonParameter.getInstance().getAllowFnDsa512()); + } + + public void saveAllowFnDsa512(long value) { + this.put(ALLOW_FN_DSA_512, new BytesCapsule(ByteArray.fromLong(value))); + } + + public boolean allowFnDsa512() { + return getAllowFnDsa512() == 1L; + } + + /** Returns true iff at least one post-quantum signature scheme is currently activated. */ + public boolean isAnyPqSchemeAllowed() { + return allowFnDsa512(); + } + + /** + * Per-scheme governance check. V2 launches with FN-DSA-512 only. Future schemes will + * each get their own flag. + */ + public boolean isPqSchemeAllowed(PQScheme scheme) { + if (scheme == null) { + return false; + } + switch (scheme) { + case UNKNOWN_PQ_SCHEME: // proto3 default → Falcon-512 (see PQSchemeRegistry#resolve) + case FN_DSA_512: + return allowFnDsa512(); + default: + return false; + } + } + private static class DynamicResourceProperties { private static final byte[] ONE_DAY_NET_LIMIT = "ONE_DAY_NET_LIMIT".getBytes(); diff --git a/common/src/main/java/org/tron/common/parameter/CommonParameter.java b/common/src/main/java/org/tron/common/parameter/CommonParameter.java index a73158a718a..887a26db213 100644 --- a/common/src/main/java/org/tron/common/parameter/CommonParameter.java +++ b/common/src/main/java/org/tron/common/parameter/CommonParameter.java @@ -637,6 +637,10 @@ public class CommonParameter { @Setter public long allowTvmOsaka; + @Getter + @Setter + public long allowFnDsa512; + 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..370a442fc06 100644 --- a/common/src/main/java/org/tron/core/Constant.java +++ b/common/src/main/java/org/tron/core/Constant.java @@ -60,6 +60,14 @@ public class Constant { // Crypto engine public static final String ECKey_ENGINE = "ECKey"; + // Post-quantum (FIPS 206 draft) FN-DSA / Falcon-512 signature constants. + // Falcon signatures are variable-length; SIGNATURE_MAX_LENGTH is the protocol-level + // upper bound, not an exact length. + public static final int FN_DSA_PUBLIC_KEY_LENGTH = 896; + public static final int FN_DSA_SIGNATURE_MAX_LENGTH = 752; + public static final String PQ_TX_AUTH_DOMAIN = "TRON_TX_AUTH_V1"; + public static final String PQ_BLOCK_AUTH_DOMAIN = "TRON_BLOCK_AUTH_V1"; + // Network public static final String LOCAL_HOST = "127.0.0.1"; diff --git a/common/src/main/java/org/tron/core/vm/config/VMConfig.java b/common/src/main/java/org/tron/core/vm/config/VMConfig.java index 1a7f0c058a4..743e6d184d7 100644 --- a/common/src/main/java/org/tron/core/vm/config/VMConfig.java +++ b/common/src/main/java/org/tron/core/vm/config/VMConfig.java @@ -63,6 +63,8 @@ public class VMConfig { private static boolean ALLOW_TVM_OSAKA = false; + private static boolean ALLOW_FN_DSA_512 = false; + private VMConfig() { } @@ -178,6 +180,10 @@ public static void initAllowTvmOsaka(long allow) { ALLOW_TVM_OSAKA = allow == 1; } + public static void initAllowFnDsa512(long allow) { + ALLOW_FN_DSA_512 = allow == 1; + } + public static boolean getEnergyLimitHardFork() { return CommonParameter.ENERGY_LIMIT_HARD_FORK; } @@ -281,4 +287,8 @@ public static boolean allowTvmSelfdestructRestriction() { public static boolean allowTvmOsaka() { return ALLOW_TVM_OSAKA; } + + public static boolean allowFnDsa512() { + return ALLOW_FN_DSA_512; + } } 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..a2692cf4c55 100644 --- a/consensus/src/main/java/org/tron/consensus/base/Param.java +++ b/consensus/src/main/java/org/tron/consensus/base/Param.java @@ -5,6 +5,7 @@ import lombok.Getter; import lombok.Setter; import org.tron.common.args.GenesisBlock; +import org.tron.protos.Protocol.PQScheme; public class Param { @@ -67,11 +68,35 @@ public class Miner { @Setter private ByteString witnessAddress; + private byte[] pqPrivateKey; + + private byte[] pqPublicKey; + + @Getter + @Setter + private PQScheme pqScheme; + public Miner(byte[] privateKey, ByteString privateKeyAddress, ByteString witnessAddress) { this.privateKey = privateKey; this.privateKeyAddress = privateKeyAddress; this.witnessAddress = witnessAddress; } + + public byte[] getPQPrivateKey() { + return pqPrivateKey == null ? null : pqPrivateKey.clone(); + } + + public void setPQPrivateKey(byte[] pqPrivateKey) { + this.pqPrivateKey = pqPrivateKey == null ? null : pqPrivateKey.clone(); + } + + public byte[] getPQPublicKey() { + return pqPublicKey == null ? null : pqPublicKey.clone(); + } + + public void setPQPublicKey(byte[] pqPublicKey) { + this.pqPublicKey = pqPublicKey == null ? null : pqPublicKey.clone(); + } } public Miner getMiner() { diff --git a/crypto/src/main/java/org/tron/common/crypto/pqc/FNDSA.java b/crypto/src/main/java/org/tron/common/crypto/pqc/FNDSA.java new file mode 100644 index 00000000000..3a9e22316c5 --- /dev/null +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/FNDSA.java @@ -0,0 +1,259 @@ +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.protos.Protocol.PQScheme; + +/** + * FIPS 206 (draft) FN-DSA / Falcon-512 keypair-bound signer/verifier. Instance + * methods sign/verify with the bound keypair, static {@link #sign(byte[], byte[])} + * / {@link #verify} provide stateless entry points used by + * {@link PQSchemeRegistry}. + * + *

Falcon signatures are variable-length: {@link #SIGNATURE_LENGTH} + * is the protocol-level upper bound, not an exact length. The + * {@link PQSignature#validateSignature} default treats this as + * {@code <= SIGNATURE_LENGTH}. 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; + /** + * Extended private key encoding {@code f ‖ g ‖ F ‖ h}: the standard BC private key + * (1280 B) with the 896-byte public key {@code h} appended. Lets the holder recover + * the address without re-running keygen, since BC currently has no public API for + * deriving {@code h} from {@code (f, g)} alone (see bcgit/bc-java#2297). + */ + public static final int PRIVATE_KEY_WITH_PUBLIC_KEY_LENGTH = + PRIVATE_KEY_LENGTH + PUBLIC_KEY_LENGTH; + /** 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) { + if (privateKey == null || privateKey.length != PRIVATE_KEY_LENGTH) { + throw new IllegalArgumentException( + "FN-DSA private key length must be " + PRIVATE_KEY_LENGTH); + } + if (publicKey == null || publicKey.length != PUBLIC_KEY_LENGTH) { + throw new IllegalArgumentException( + "FN-DSA public key length must be " + PUBLIC_KEY_LENGTH); + } + this.privateKey = privateKey.clone(); + this.publicKey = publicKey.clone(); + } + + /** + * Builds an instance from the extended private key encoding {@code f ‖ g ‖ F ‖ h} + * ({@link #PRIVATE_KEY_WITH_PUBLIC_KEY_LENGTH} bytes), as produced by + * {@link #getPrivateKeyWithPublicKey()}. Provided as a static factory rather + * than an additional {@code FNDSA(byte[])} constructor because Java cannot + * overload {@link #FNDSA(byte[]) the seed constructor} on length alone. + */ + public static FNDSA fromPrivateKeyWithPublicKey(byte[] extendedPrivateKey) { + if (extendedPrivateKey == null + || extendedPrivateKey.length != PRIVATE_KEY_WITH_PUBLIC_KEY_LENGTH) { + throw new IllegalArgumentException( + "FN-DSA extended private key length must be " + + PRIVATE_KEY_WITH_PUBLIC_KEY_LENGTH); + } + byte[] sk = new byte[PRIVATE_KEY_LENGTH]; + byte[] pk = new byte[PUBLIC_KEY_LENGTH]; + System.arraycopy(extendedPrivateKey, 0, sk, 0, PRIVATE_KEY_LENGTH); + System.arraycopy(extendedPrivateKey, PRIVATE_KEY_LENGTH, pk, 0, PUBLIC_KEY_LENGTH); + return new FNDSA(sk, pk); + } + + @Override + public PQScheme getScheme() { + return PQScheme.FN_DSA_512; + } + + @Override + public int getPrivateKeyLength() { + return PRIVATE_KEY_LENGTH; + } + + @Override + public int getPublicKeyLength() { + return PUBLIC_KEY_LENGTH; + } + + /** Returns the protocol-level signature length upper bound (signatures are variable-length). */ + @Override + public int getSignatureLength() { + return SIGNATURE_LENGTH; + } + + @Override + public byte[] getPrivateKey() { + return privateKey.clone(); + } + + /** + * Returns the private key with the 896-byte public key {@code h} appended: + * {@code f ‖ g ‖ F ‖ h} (total {@link #PRIVATE_KEY_WITH_PUBLIC_KEY_LENGTH} bytes). + * Use this format on disk / in config when the consumer needs to recover the + * address from the private key alone — neither BC's encoded private key nor + * the 48-byte keygen seed (without re-running keygen) suffice today. + */ + public byte[] getPrivateKeyWithPublicKey() { + byte[] out = new byte[PRIVATE_KEY_WITH_PUBLIC_KEY_LENGTH]; + System.arraycopy(privateKey, 0, out, 0, PRIVATE_KEY_LENGTH); + System.arraycopy(publicKey, 0, out, PRIVATE_KEY_LENGTH, PUBLIC_KEY_LENGTH); + return out; + } + + @Override + public byte[] getPublicKey() { + return publicKey.clone(); + } + + @Override + public byte[] getAddress() { + return PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, publicKey); + } + + @Override + public byte[] sign(byte[] message) { + return sign(privateKey, message); + } + + @Override + public boolean verify(byte[] message, byte[] signature) { + return verify(publicKey, message, signature); + } + + public static boolean verify(byte[] publicKey, byte[] message, byte[] signature) { + if (publicKey == null || publicKey.length != PUBLIC_KEY_LENGTH) { + throw new IllegalArgumentException( + "FN-DSA public key length must be " + PUBLIC_KEY_LENGTH); + } + if (signature == null || signature.length == 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; + } + } + + /** + * Signs {@code message} using either the bare private key + * ({@link #PRIVATE_KEY_LENGTH} bytes, {@code f ‖ g ‖ F}) or the extended form + * ({@link #PRIVATE_KEY_WITH_PUBLIC_KEY_LENGTH} bytes, {@code f ‖ g ‖ F ‖ h}). + * The trailing {@code h} segment is ignored — only {@code (f, g, F)} feed BC's signer. + */ + 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); + } + } + + /** + * Recovers the public key when the input is in the extended form + * {@code f ‖ g ‖ F ‖ h} ({@link #PRIVATE_KEY_WITH_PUBLIC_KEY_LENGTH} bytes). + * Throws {@link UnsupportedOperationException} for the bare {@code f ‖ g ‖ F} + * form: BouncyCastle currently has no public API to compute {@code h = g · f⁻¹} + * mod q, so callers must persist {@code h} alongside the private key (use + * {@link #getPrivateKeyWithPublicKey()}) or re-run keygen from a stored seed. + * See bcgit/bc-java#2297. + */ + public static byte[] derivePublicKey(byte[] privateKey) { + if (privateKey != null && privateKey.length == PRIVATE_KEY_WITH_PUBLIC_KEY_LENGTH) { + byte[] pk = new byte[PUBLIC_KEY_LENGTH]; + System.arraycopy(privateKey, PRIVATE_KEY_LENGTH, pk, 0, PUBLIC_KEY_LENGTH); + return pk; + } + throw new UnsupportedOperationException( + "FN-DSA public key cannot be derived from the bare encoded private key; " + + "supply the extended form (f ‖ g ‖ F ‖ h) or both halves to the " + + "(privateKey, publicKey) constructor"); + } + + public static byte[] computeAddress(byte[] publicKey) { + return PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, publicKey); + } + + private static AsymmetricCipherKeyPair generateKeyPair(SecureRandom random) { + FalconKeyPairGenerator generator = new FalconKeyPairGenerator(); + generator.init(new FalconKeyGenerationParameters(random, PARAMS)); + return generator.generateKeyPair(); + } + + private static void validatePrivateKeyBytes(byte[] privateKey) { + if (privateKey == null + || (privateKey.length != PRIVATE_KEY_LENGTH + && privateKey.length != PRIVATE_KEY_WITH_PUBLIC_KEY_LENGTH)) { + throw new IllegalArgumentException( + "FN-DSA private key length must be " + PRIVATE_KEY_LENGTH + + " or " + PRIVATE_KEY_WITH_PUBLIC_KEY_LENGTH); + } + } +} diff --git a/crypto/src/main/java/org/tron/common/crypto/pqc/PQSchemeRegistry.java b/crypto/src/main/java/org/tron/common/crypto/pqc/PQSchemeRegistry.java new file mode 100644 index 00000000000..b3965ac4cb8 --- /dev/null +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/PQSchemeRegistry.java @@ -0,0 +1,223 @@ +package org.tron.common.crypto.pqc; + +import java.util.Collections; +import java.util.EnumMap; +import java.util.Map; +import org.tron.common.crypto.Hash; +import org.tron.protos.Protocol.PQScheme; + +/** + * Static dispatch table for post-quantum signature schemes keyed by + * {@link PQScheme}. Each entry binds a scheme to its public-key length, + * signature length, seed length, fingerprint hash function, and stateless + * sign/verify/keygen operations. Legacy ECDSA secp256k1 / SM2 schemes are NOT + * registered — they flow through the existing {@code SignInterface} path. + * + *

Address binding (V2). A PQ-derived TRON address is + * {@code 0x41 ‖ deriveHash(scheme, public_key)[12..32]}, matching the ECDSA + * flow's {@code 0x41 ‖ Keccak-256(public_key)[12..32]} so PQ and ECDSA + * addresses share the same derivation shape. The hash function is scheme- + * specific (see {@link #deriveHash}); {@code FN_DSA_512} uses Keccak-256. + * + *

Wire-format default. {@code UNKNOWN_PQ_SCHEME = 0} is the proto3 + * default (reserved for the {@code UNKNOWN_} API-evolution slot); on the wire + * it is interpreted as {@code FN_DSA_512} so V2-launch witnesses pay zero + * bytes for the scheme tag. All public methods normalize via + * {@link #resolve(PQScheme)} before dispatch. + */ +public final class PQSchemeRegistry { + + /** Stateless sign/verify/keygen dispatch bound to a single PQ scheme. */ + public interface SignatureOps { + byte[] sign(byte[] privateKey, byte[] message); + + boolean verify(byte[] publicKey, byte[] message, byte[] signature); + + PQSignature fromSeed(byte[] seed); + + PQSignature fromKeypair(byte[] privateKey, byte[] publicKey); + } + + /** + * Fingerprint hash used to derive a 21-byte TRON address from a PQ public key. + * V2 first launch uses Keccak-256 for FN_DSA_512 to match the ECDSA address + * derivation; later schemes may bind to a different hash if the PQ scheme has + * its own canonical fingerprint. + */ + public interface FingerprintHash { + /** Returns the full digest of {@code data} (no truncation). */ + byte[] digest(byte[] data); + } + + private static final FingerprintHash KECCAK_256 = Hash::sha3; + + private static final class SchemeInfo { + final int privateKeyLength; + final int publicKeyLength; + final int signatureLength; + final int seedLength; + final FingerprintHash hash; + final SignatureOps ops; + + SchemeInfo(int privateKeyLength, int publicKeyLength, int signatureLength, + int seedLength, FingerprintHash hash, SignatureOps ops) { + this.privateKeyLength = privateKeyLength; + this.publicKeyLength = publicKeyLength; + this.signatureLength = signatureLength; + this.seedLength = seedLength; + this.hash = hash; + this.ops = ops; + } + } + + private static final Map SCHEMES; + + static { + EnumMap m = new EnumMap<>(PQScheme.class); + m.put(PQScheme.FN_DSA_512, new SchemeInfo( + FNDSA.PRIVATE_KEY_LENGTH, FNDSA.PUBLIC_KEY_LENGTH, + FNDSA.SIGNATURE_LENGTH, FNDSA.SEED_LENGTH, + KECCAK_256, + 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); + } + + @Override + public PQSignature fromKeypair(byte[] privateKey, byte[] publicKey) { + return new FNDSA(privateKey, publicKey); + } + })); + SCHEMES = Collections.unmodifiableMap(m); + } + + private PQSchemeRegistry() { + } + + /** + * Map a wire-format {@link PQScheme} to its registered scheme. The proto3 + * default {@code UNKNOWN_PQ_SCHEME} is normalized to {@code FN_DSA_512} so + * V2-launch witnesses that omit the scheme tag are decoded as Falcon-512. + * {@code null} and {@code UNRECOGNIZED} pass through unchanged so the + * caller-side {@code contains}/{@code require} checks reject them. + */ + public static PQScheme resolve(PQScheme scheme) { + if (scheme == PQScheme.UNKNOWN_PQ_SCHEME) { + return PQScheme.FN_DSA_512; + } + return scheme; + } + + public static boolean contains(PQScheme scheme) { + PQScheme resolved = resolve(scheme); + return resolved != null && SCHEMES.containsKey(resolved); + } + + public static int getPrivateKeyLength(PQScheme scheme) { + return require(scheme).privateKeyLength; + } + + public static int getPublicKeyLength(PQScheme scheme) { + return require(scheme).publicKeyLength; + } + + public static int getSignatureLength(PQScheme scheme) { + return require(scheme).signatureLength; + } + + public static int getSeedLength(PQScheme scheme) { + return require(scheme).seedLength; + } + + /** + * Per-scheme signature-length predicate. Fixed-length schemes require exact + * equality with {@link #getSignatureLength(PQScheme)}; variable-length + * schemes ({@code FN_DSA_512}) treat that value as an upper bound and accept + * any {@code 1..max}. + */ + public static boolean isValidSignatureLength(PQScheme scheme, int length) { + PQScheme resolved = resolve(scheme); + SchemeInfo info = require(resolved); + if (resolved == PQScheme.FN_DSA_512) { + return length > 0 && length <= info.signatureLength; + } + return length == info.signatureLength; + } + + public static byte[] sign(PQScheme scheme, byte[] privateKey, byte[] message) { + return require(scheme).ops.sign(privateKey, message); + } + + public static boolean verify( + PQScheme scheme, byte[] publicKey, byte[] message, byte[] signature) { + return require(scheme).ops.verify(publicKey, message, signature); + } + + public static PQSignature fromSeed(PQScheme scheme, byte[] seed) { + return require(scheme).ops.fromSeed(seed); + } + + /** + * Build a keypair-bound {@link PQSignature} from already-derived private and + * public key bytes. Used by the witness-config path when the operator has + * pre-computed the keypair off-line and wants to bypass on-node keygen. + * Validates {@code privateKey} and {@code publicKey} lengths against the + * scheme; cryptographic consistency between the two halves is the caller's + * responsibility. + */ + public static PQSignature fromKeypair( + PQScheme scheme, byte[] privateKey, byte[] publicKey) { + return require(scheme).ops.fromKeypair(privateKey, publicKey); + } + + /** + * Scheme-dispatched fingerprint hash of a PQ public key. Returns the full + * digest; callers truncate to 20 bytes when deriving the address suffix. + */ + public static byte[] deriveHash(PQScheme scheme, byte[] publicKey) { + SchemeInfo info = require(scheme); + if (publicKey == null || publicKey.length != info.publicKeyLength) { + throw new IllegalArgumentException( + "invalid public key length for " + scheme + ": " + + (publicKey == null ? -1 : publicKey.length)); + } + return info.hash.digest(publicKey); + } + + /** + * Derive the 21-byte TRON address from a PQ public key as + * {@code 0x41 ‖ deriveHash(scheme, public_key)[12..32]} — the rightmost 20 + * bytes of the digest, matching the ECDSA address derivation slice. + */ + public static byte[] computeAddress(PQScheme scheme, byte[] publicKey) { + byte[] h = deriveHash(scheme, publicKey); + byte[] addr = new byte[21]; + addr[0] = 0x41; + System.arraycopy(h, h.length - 20, addr, 1, 20); + return addr; + } + + private static SchemeInfo require(PQScheme scheme) { + if (scheme == null) { + throw new IllegalArgumentException("scheme must not be null"); + } + PQScheme resolved = resolve(scheme); + SchemeInfo info = SCHEMES.get(resolved); + if (info == null) { + throw new IllegalArgumentException( + "no PQSignature registered for scheme: " + scheme); + } + return info; + } +} diff --git a/crypto/src/main/java/org/tron/common/crypto/pqc/PQSignature.java b/crypto/src/main/java/org/tron/common/crypto/pqc/PQSignature.java new file mode 100644 index 00000000000..dbba2683ef0 --- /dev/null +++ b/crypto/src/main/java/org/tron/common/crypto/pqc/PQSignature.java @@ -0,0 +1,72 @@ +package org.tron.common.crypto.pqc; + +import org.tron.protos.Protocol.PQScheme; + +/** + * Post-quantum signature scheme facade bound to a keypair. Instance methods + * (sign/verify/getAddress/getPublicKey/getPrivateKey) operate on the held + * keypair. Stateless dispatch by {@link PQScheme} is provided by + * {@link PQSchemeRegistry}. + */ +public interface PQSignature { + + PQScheme getScheme(); + + int getPrivateKeyLength(); + + int getPublicKeyLength(); + + int getSignatureLength(); + + byte[] getPrivateKey(); + + byte[] getPublicKey(); + + /** + * 21-byte TRON address derived from the held public key as + * {@code 0x41 ‖ deriveHash(scheme, public_key)[12..32]} (see + * {@link PQSchemeRegistry#computeAddress}). + */ + byte[] getAddress(); + + /** Sign {@code message} with the held private key; returns the raw signature. */ + byte[] sign(byte[] message); + + /** + * Verify {@code signature} over {@code message} against the held public key. + * + * @return true iff the signature is cryptographically valid for the bound keypair + */ + boolean verify(byte[] message, byte[] signature); + + default void validatePrivateKey(byte[] privateKey) { + if (privateKey == null || privateKey.length != getPrivateKeyLength()) { + throw new IllegalArgumentException( + "invalid " + getScheme() + " private key length: " + + (privateKey == null ? "null" : privateKey.length) + + ", expected " + getPrivateKeyLength()); + } + } + + default void validatePublicKey(byte[] publicKey) { + if (publicKey == null || publicKey.length != getPublicKeyLength()) { + throw new IllegalArgumentException( + "invalid " + getScheme() + " public key length: " + + (publicKey == null ? "null" : publicKey.length) + + ", expected " + getPublicKeyLength()); + } + } + + /** + * Default upper-bound check, sufficient for variable-length schemes (FN_DSA_512). + * Fixed-length schemes override this with strict equality. + */ + default void validateSignature(byte[] signature) { + if (signature == null || signature.length == 0 || signature.length > getSignatureLength()) { + throw new IllegalArgumentException( + "invalid " + getScheme() + " signature length: " + + (signature == null ? "null" : signature.length) + + ", expected 1.." + getSignatureLength()); + } + } +} diff --git a/framework/src/main/java/org/tron/core/Wallet.java b/framework/src/main/java/org/tron/core/Wallet.java index 39e8f06c281..8c5d4040c9e 100755 --- a/framework/src/main/java/org/tron/core/Wallet.java +++ b/framework/src/main/java/org/tron/core/Wallet.java @@ -1514,6 +1514,11 @@ public Protocol.ChainParameters getChainParameters() { .setValue(dbManager.getDynamicPropertiesStore().getAllowTvmOsaka()) .build()); + builder.addChainParameter(Protocol.ChainParameters.ChainParameter.newBuilder() + .setKey("getAllowFnDsa512") + .setValue(dbManager.getDynamicPropertiesStore().getAllowFnDsa512()) + .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..422d9bae07c 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; @@ -56,6 +57,7 @@ import org.tron.common.args.Witness; import org.tron.common.config.DbBackupConfig; import org.tron.common.cron.CronExpression; +import org.tron.common.crypto.pqc.PQSchemeRegistry; import org.tron.common.logsfilter.EventPluginConfig; import org.tron.common.logsfilter.FilterQuery; import org.tron.common.logsfilter.TriggerConfig; @@ -78,6 +80,7 @@ import org.tron.p2p.dns.update.PublishConfig; import org.tron.p2p.utils.NetUtil; import org.tron.program.Version; +import org.tron.protos.Protocol.PQScheme; @Slf4j(topic = "app") @NoArgsConstructor @@ -1041,6 +1044,10 @@ public static void applyConfigParams( config.hasPath(ConfigKey.COMMITTEE_ALLOW_TVM_OSAKA) ? config .getInt(ConfigKey.COMMITTEE_ALLOW_TVM_OSAKA) : 0; + PARAMETER.allowFnDsa512 = + config.hasPath(ConfigKey.COMMITTEE_ALLOW_FN_DSA_512) ? config + .getInt(ConfigKey.COMMITTEE_ALLOW_FN_DSA_512) : 0; + logConfig(); } @@ -1184,6 +1191,9 @@ private static void applyCLIParams(CLIParameter cmd, JCommander jc) { } } + private static final EnumSet WITNESS_PQ_SCHEMES = EnumSet.of( + PQScheme.FN_DSA_512); + private static void initLocalWitnesses(Config config, CLIParameter cmd) { // not a witness node, skip if (!PARAMETER.isWitness()) { @@ -1220,6 +1230,59 @@ private static void initLocalWitnesses(Config config, CLIParameter cmd) { } } + // path 4: PQ pre-derived keypair configuration + if (config.hasPath(ConfigKey.LOCAL_WITNESS_PQ_KEYS)) { + List pqEntries = config.getStringList(ConfigKey.LOCAL_WITNESS_PQ_KEYS); + if (!pqEntries.isEmpty()) { + localWitnesses = new LocalWitnesses(); + // Scheme must be applied before keypairs — key-length validation depends on it. + if (config.hasPath(ConfigKey.LOCAL_WITNESS_PQ_SCHEME)) { + String schemeName = config.getString(ConfigKey.LOCAL_WITNESS_PQ_SCHEME); + try { + PQScheme scheme = PQScheme.valueOf(schemeName); + if (!WITNESS_PQ_SCHEMES.contains(scheme)) { + throw new TronError("invalid " + ConfigKey.LOCAL_WITNESS_PQ_SCHEME + + ": " + schemeName + "; valid values: " + WITNESS_PQ_SCHEMES, + TronError.ErrCode.WITNESS_INIT); + } + localWitnesses.setPqScheme(scheme); + } catch (IllegalArgumentException e) { + throw new TronError("invalid " + ConfigKey.LOCAL_WITNESS_PQ_SCHEME + + ": " + schemeName, TronError.ErrCode.WITNESS_INIT); + } + } + // Each entry is the extended private key f‖g‖F‖h (priv ‖ pub) hex, + // sized (privLen + pubLen) bytes for the active scheme. We split here + // so downstream consumers (ConsensusService, LocalWitnesses) keep the + // same priv/pub split they already use — derivePublicKey(priv) replaces + // the previous explicit `pub` config field. + PQScheme scheme = localWitnesses.getPqScheme(); + int privHexLen = PQSchemeRegistry.getPrivateKeyLength(scheme) * 2; + int extHexLen = privHexLen + PQSchemeRegistry.getPublicKeyLength(scheme) * 2; + List pqPrivateKeys = new ArrayList<>(pqEntries.size()); + List pqPublicKeys = new ArrayList<>(pqEntries.size()); + for (int i = 0; i < pqEntries.size(); i++) { + String hex = pqEntries.get(i); + String stripped = hex != null && hex.startsWith("0x") ? hex.substring(2) : hex; + if (stripped == null || stripped.length() != extHexLen) { + throw new TronError(String.format( + "%s[%d] must be %d hex chars (extended priv‖pub for %s), actual: %d", + ConfigKey.LOCAL_WITNESS_PQ_KEYS, i, extHexLen, scheme, + stripped == null ? 0 : stripped.length()), + TronError.ErrCode.WITNESS_INIT); + } + pqPrivateKeys.add(stripped.substring(0, privHexLen)); + pqPublicKeys.add(stripped.substring(privHexLen)); + } + localWitnesses.setPqKeypairs(pqPrivateKeys, pqPublicKeys); + byte[] address = WitnessInitializer.resolvePqAuthSigAddress(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..955910c1ef7 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_PQ_KEYS = "localwitness_pq_keys"; + public static final String LOCAL_WITNESS_PQ_SCHEME = "localwitness_pq_scheme"; // crypto public static final String CRYPTO_ENGINE = "crypto.engine"; @@ -248,6 +250,7 @@ private ConfigKey() { public static final String COMMITTEE_ALLOW_TVM_BLOB = "committee.allowTvmBlob"; public static final String COMMITTEE_PROPOSAL_EXPIRE_TIME = "committee.proposalExpireTime"; public static final String COMMITTEE_ALLOW_TVM_OSAKA = "committee.allowTvmOsaka"; + public static final String COMMITTEE_ALLOW_FN_DSA_512 = "committee.allowFnDsa512"; 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..919904f5313 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[] resolvePqAuthSigAddress(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..5f54b62b955 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.PQSchemeRegistry; +import org.tron.common.crypto.pqc.PQSignature; import org.tron.common.parameter.CommonParameter; import org.tron.consensus.Consensus; import org.tron.consensus.base.Param; import org.tron.consensus.base.Param.Miner; import org.tron.core.capsule.WitnessCapsule; import org.tron.core.config.args.Args; +import org.tron.core.exception.TronError; import org.tron.core.store.WitnessStore; +import org.tron.protos.Protocol.PQScheme; @Slf4j(topic = "consensus") @Component @@ -46,6 +50,14 @@ public void start() { param.setAgreeNodeCount(parameter.getAgreeNodeCount()); List miners = new ArrayList<>(); List privateKeys = Args.getLocalWitnesses().getPrivateKeys(); + List pqPrivateKeys = Args.getLocalWitnesses().getPqPrivateKeys(); + List pqPublicKeys = Args.getLocalWitnesses().getPqPublicKeys(); + if (pqPublicKeys.size() != pqPrivateKeys.size()) { + throw new TronError( + "localwitness_pq_keys size mismatch: " + pqPrivateKeys.size() + + " private vs " + pqPublicKeys.size() + " public", + TronError.ErrCode.WITNESS_INIT); + } if (privateKeys.size() > 1) { for (String key : privateKeys) { byte[] privateKey = fromHexString(key); @@ -76,6 +88,31 @@ public void start() { Miner miner = param.new Miner(privateKey, ByteString.copyFrom(privateKeyAddress), ByteString.copyFrom(witnessAddress)); miners.add(miner); + } else if (pqPrivateKeys.size() > 1) { + PQScheme scheme = Args.getLocalWitnesses().getPqScheme(); + requireSupportedPqScheme(scheme); + for (int i = 0; i < pqPrivateKeys.size(); i++) { + byte[] privBytes = fromHexString(pqPrivateKeys.get(i)); + byte[] pubBytes = fromHexString(pqPublicKeys.get(i)); + PQSignature keypair = PQSchemeRegistry.fromKeypair(scheme, privBytes, pubBytes); + 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); + miner.setPqScheme(scheme); + miners.add(miner); + logger.info("Add {} witness (from configured keypair): {}, size: {}", + scheme, Hex.toHexString(pqAddress), miners.size()); + } + } else if (pqPrivateKeys.size() == 1) { + miners.add(buildPQOnlyMinerFromKeypair(param, pqPrivateKeys.get(0), pqPublicKeys.get(0))); } param.setMiners(miners); @@ -85,6 +122,42 @@ public void start() { logger.info("consensus service start success"); } + private Miner buildPQOnlyMinerFromKeypair(Param param, String pqPrivateKey, + String pqPublicKey) { + PQScheme scheme = Args.getLocalWitnesses().getPqScheme(); + requireSupportedPqScheme(scheme); + byte[] privBytes = fromHexString(pqPrivateKey); + byte[] pubBytes = fromHexString(pqPublicKey); + PQSignature keypair = PQSchemeRegistry.fromKeypair(scheme, privBytes, pubBytes); + 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); + miner.setPqScheme(scheme); + logger.info("Add {} witness (from configured keypair): {}", + scheme, Hex.toHexString(witnessAddress)); + return miner; + } + + private static void requireSupportedPqScheme(PQScheme scheme) { + if (!PQSchemeRegistry.contains(scheme)) { + throw new TronError("unsupported PQ witness scheme: " + scheme, + TronError.ErrCode.WITNESS_INIT); + } + } + public void stop() { logger.info("consensus service closed start."); consensus.stop(); diff --git a/framework/src/main/java/org/tron/core/consensus/ProposalService.java b/framework/src/main/java/org/tron/core/consensus/ProposalService.java index 1bec0c2bda3..9dff86cfa61 100644 --- a/framework/src/main/java/org/tron/core/consensus/ProposalService.java +++ b/framework/src/main/java/org/tron/core/consensus/ProposalService.java @@ -396,6 +396,10 @@ public static boolean process(Manager manager, ProposalCapsule proposalCapsule) manager.getDynamicPropertiesStore().saveAllowTvmOsaka(entry.getValue()); break; } + case ALLOW_FN_DSA_512: { + manager.getDynamicPropertiesStore().saveAllowFnDsa512(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..b09f91ff338 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,7 @@ import org.tron.common.args.GenesisBlock; import org.tron.common.bloom.Bloom; import org.tron.common.cron.CronExpression; +import org.tron.common.crypto.pqc.PQSchemeRegistry; import org.tron.common.es.ExecutorServiceManager; import org.tron.common.exit.ExitManager; import org.tron.common.logsfilter.EventPluginLoader; @@ -168,6 +169,8 @@ import org.tron.core.utils.TransactionRegister; import org.tron.protos.Protocol; import org.tron.protos.Protocol.AccountType; +import org.tron.protos.Protocol.PQAuthSig; +import org.tron.protos.Protocol.PQScheme; import org.tron.protos.Protocol.Permission; import org.tron.protos.Protocol.Transaction; import org.tron.protos.Protocol.Transaction.Contract; @@ -1738,7 +1741,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 +1757,60 @@ public BlockCapsule generateBlock(Miner miner, long blockTime, long timeout) { return capsule; } + private void signBlockCapsule(BlockCapsule blockCapsule, Miner miner) { + PQScheme scheme = resolveWitnessScheme(miner); + if (scheme != null && PQSchemeRegistry.contains(scheme)) { + signWitnessAuth(blockCapsule, miner, scheme); + } else { + blockCapsule.sign(miner.getPrivateKey()); + } + } + + private PQScheme resolveWitnessScheme(Miner miner) { + if (!chainBaseManager.getDynamicPropertiesStore().isAnyPqSchemeAllowed()) { + return null; + } + PQScheme scheme = miner.getPqScheme(); + if (scheme == null || !PQSchemeRegistry.contains(scheme)) { + return null; + } + if (!chainBaseManager.getDynamicPropertiesStore().isPqSchemeAllowed(scheme)) { + return null; + } + byte[] witnessAddress = miner.getWitnessAddress().toByteArray(); + AccountCapsule accountCapsule = chainBaseManager.getAccountStore().get(witnessAddress); + if (accountCapsule == null || !accountCapsule.getInstance().hasWitnessPermission()) { + return null; + } + Permission witnessPermission = accountCapsule.getInstance().getWitnessPermission(); + if (witnessPermission.getKeysCount() == 0) { + return null; + } + return scheme; + } + + private void signWitnessAuth(BlockCapsule blockCapsule, Miner miner, PQScheme scheme) { + byte[] pqPrivateKey = miner.getPQPrivateKey(); + byte[] pqPublicKey = miner.getPQPublicKey(); + if (pqPrivateKey == null || pqPublicKey == null) { + throw new IllegalStateException( + "witness permission requires " + scheme + + " but local PQ key material is not configured"); + } + byte[] digest = blockCapsule.getRawHashBytes(); + byte[] signature = PQSchemeRegistry.sign(scheme, pqPrivateKey, digest); + PQAuthSig.Builder builder = PQAuthSig.newBuilder() + .setPublicKey(ByteString.copyFrom(pqPublicKey)) + .setSignature(ByteString.copyFrom(signature)); + // FN_DSA_512 is the launch scheme: leave scheme at the proto3 default + // (UNKNOWN_PQ_SCHEME) and rely on PQSchemeRegistry.resolve() on the read + // path so the tag costs zero wire bytes per block. + if (scheme != PQScheme.FN_DSA_512) { + builder.setScheme(scheme); + } + blockCapsule.setPqAuthSig(builder.build()); + } + private void filterOwnerAddress(TransactionCapsule transactionCapsule, Set result) { byte[] owner = transactionCapsule.getOwnerAddress(); String ownerAddress = ByteArray.toHexString(owner); diff --git a/framework/src/main/resources/config.conf b/framework/src/main/resources/config.conf index 369924074bc..60cb7451d1e 100644 --- a/framework/src/main/resources/config.conf +++ b/framework/src/main/resources/config.conf @@ -673,6 +673,23 @@ localwitness = [ # "localwitnesskeystore.json" # ] +# Scheme used by localwitness_pq_keys. Defaults to FN_DSA_512. +# V2 first launch only allows FN_DSA_512 (Falcon-512, FIPS 206 draft). +# localwitness_pq_scheme = "FN_DSA_512" + +# Post-quantum witness signing keypairs, hex-encoded. Each entry is the +# extended private key f‖g‖F‖h (priv ‖ pub) as one hex string. For FN_DSA_512 +# the total is 2176 bytes (4352 hex chars): 1280 B Falcon-512 private key +# (f‖g‖F) followed by the 896 B public key h. Operators MUST generate the +# keypair off-line on a single platform and distribute the extended key; +# on-node keygen is intentionally bypassed because BouncyCastle's Falcon +# FFT/FPR code paths are not declared strictfp and could in theory diverge +# across JVMs/architectures. Used only after the ALLOW_FN_DSA_512 proposal +# is active and the witness Permission has been upgraded to FN_DSA_512. +# localwitness_pq_keys = [ +# "<4352 hex chars>" +# ] + block = { needSyncCheck = true maintenanceTimeInterval = 21600000 // 6 hours: 21600000(ms) @@ -760,6 +777,7 @@ committee = { # consensusLogicOptimization = 0 # allowOptimizedReturnValueOfChainId = 0 # allowTvmOsaka = 0 + # allowMlDsa = 0 } event.subscribe = { diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/FNDSAKatTest.java b/framework/src/test/java/org/tron/common/crypto/pqc/FNDSAKatTest.java new file mode 100644 index 00000000000..1c6656d8f38 --- /dev/null +++ b/framework/src/test/java/org/tron/common/crypto/pqc/FNDSAKatTest.java @@ -0,0 +1,246 @@ +package org.tron.common.crypto.pqc; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +import java.security.MessageDigest; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; +import org.junit.Test; +import org.tron.common.crypto.Hash; +import org.tron.protos.Protocol.PQScheme; + +/** + * Known-Answer Tests (KAT) for FN-DSA / Falcon-512. + * + *

Five seed vectors covering boundary patterns (incrementing, all-zero, + * all-ones, all-{@code 0xAA}, descending) lock in the deterministic + * seed → keypair derivation pinned by BouncyCastle 1.79's + * {@code FalconKeyPairGenerator}. Reference {@code pk}/{@code sk} digests and + * the V2 fingerprint address are captured from this same codebase / BC 1.79; + * the role of the test is regression detection — any change in seeding, + * encoding, or fingerprint derivation lights up. + * + *

Falcon signing is randomized so signature bytes cannot be pinned. Sign / + * verify is exercised per-vector and cross-vector to confirm signatures only + * verify under their own key. + */ +public class FNDSAKatTest { + + private static final class KatVector { + final String label; + final byte[] seed; + final String pkSha256; + final String skSha256; + + KatVector(String label, byte[] seed, String pkSha256, String skSha256) { + this.label = label; + this.seed = seed; + this.pkSha256 = pkSha256; + this.skSha256 = skSha256; + } + } + + private static byte[] seedIncrementing() { + byte[] s = new byte[FNDSA.SEED_LENGTH]; + for (int i = 0; i < s.length; i++) { + s[i] = (byte) i; + } + return s; + } + + private static byte[] seedDescending() { + byte[] s = new byte[FNDSA.SEED_LENGTH]; + for (int i = 0; i < s.length; i++) { + s[i] = (byte) (FNDSA.SEED_LENGTH - 1 - i); + } + return s; + } + + private static byte[] seedFilled(int b) { + byte[] s = new byte[FNDSA.SEED_LENGTH]; + Arrays.fill(s, (byte) b); + return s; + } + + private static final KatVector[] VECTORS = { + new KatVector("incrementing", seedIncrementing(), + "1cc09837c6931f9c5988e59ad0acd4e8bc5f13e274573d0edb444822cd4afc90", + "960a83b03e1a8a075002be97f7a92959a2b60c91184cabac06172d8821c32d6a"), + new KatVector("all_zero", seedFilled(0x00), + "708a446d675ee40027562aa2f853b9de0d9c876a08187133bb227c6d372aa1f2", + "fb05b4c139c8fd08b9ae3ecf3da9cc375623aeef38b20ecdb5bbd8c7c02e7324"), + new KatVector("all_ff", seedFilled(0xff), + "4744e8d541a208ae10f62f5175c6eda7b695f3fd32b2145a38f8b16665a350b0", + "e9adaa331dd9dc8d5881578e25bee75050105d7885bc7eac4e5e7f7fbba5612d"), + new KatVector("all_aa", seedFilled(0xaa), + "0894fd3551559bf8dbfd2ca828081c4f6998a16d65e63c595cf24178a2f952d3", + "b2c4678087cba90219fb590bf618a88eb663db96c1ad9c572ff86d38e8d78e1f"), + new KatVector("descending", seedDescending(), + "d2191201811bf061040a012d1799dcdacb055e844d99164e0ddc45c71007d829", + "dce0af30c51875158f3ea7c24b4ced289f49ce6123148994dc2a79548e678c2f"), + }; + + private static byte[] sha256(byte[] in) { + try { + return MessageDigest.getInstance("SHA-256").digest(in); + } catch (Exception e) { + throw new AssertionError("SHA-256 unavailable", e); + } + } + + private static String hex(byte[] b) { + StringBuilder sb = new StringBuilder(b.length * 2); + for (byte x : b) { + sb.append(String.format("%02x", x)); + } + return sb.toString(); + } + + @Test + public void allVectorsDeriveExpectedPublicAndPrivateKey() { + for (KatVector v : VECTORS) { + FNDSA k = new FNDSA(v.seed); + assertEquals(v.label + ": pk length", + FNDSA.PUBLIC_KEY_LENGTH, k.getPublicKey().length); + assertEquals(v.label + ": sk length", + FNDSA.PRIVATE_KEY_LENGTH, k.getPrivateKey().length); + assertEquals(v.label + ": pk SHA-256 must match KAT vector", + v.pkSha256, hex(sha256(k.getPublicKey()))); + assertEquals(v.label + ": sk SHA-256 must match KAT vector", + v.skSha256, hex(sha256(k.getPrivateKey()))); + } + } + + @Test + public void allVectorsDeriveExpectedAddress() { + for (KatVector v : VECTORS) { + FNDSA k = new FNDSA(v.seed); + byte[] addr = k.getAddress(); + assertEquals(v.label + ": address length", + 21, addr.length); + + byte[] viaRegistry = + PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, k.getPublicKey()); + assertArrayEquals(v.label + ": registry dispatch must match instance", + addr, viaRegistry); + } + } + + @Test + public void addressIsExactly0x41PlusKeccak256RightmostBytesOfPublicKey() { + for (KatVector v : VECTORS) { + FNDSA k = new FNDSA(v.seed); + byte[] pk = k.getPublicKey(); + byte[] hash = Hash.sha3(pk); + byte[] expected = new byte[21]; + expected[0] = 0x41; + System.arraycopy(hash, hash.length - 20, expected, 1, 20); + assertArrayEquals(v.label + ": address must be 0x41 ‖ Keccak-256(pk)[12..32]", + expected, k.getAddress()); + } + } + + @Test + public void allVectorsAreReproducibleAcrossInstances() { + for (KatVector v : VECTORS) { + FNDSA a = new FNDSA(v.seed); + FNDSA b = new FNDSA(v.seed); + assertArrayEquals(v.label + ": pk reproducible", a.getPublicKey(), b.getPublicKey()); + assertArrayEquals(v.label + ": sk reproducible", a.getPrivateKey(), b.getPrivateKey()); + assertArrayEquals(v.label + ": addr reproducible", a.getAddress(), b.getAddress()); + } + } + + @Test + public void distinctSeedsProduceDistinctKeysAndAddresses() { + Set pkDigests = new HashSet<>(); + Set skDigests = new HashSet<>(); + Set addresses = new HashSet<>(); + for (KatVector v : VECTORS) { + pkDigests.add(v.pkSha256); + skDigests.add(v.skSha256); + addresses.add(hex(new FNDSA(v.seed).getAddress())); + } + assertEquals("KAT pk digests must be pairwise distinct", + VECTORS.length, pkDigests.size()); + assertEquals("KAT sk digests must be pairwise distinct", + VECTORS.length, skDigests.size()); + assertEquals("KAT addresses must be pairwise distinct", + VECTORS.length, addresses.size()); + } + + @Test + public void signaturesFromKatKeysVerifyUnderTheirOwnPublicKey() { + byte[][] messages = { + new byte[0], + "x".getBytes(), + "tron-fn-dsa-kat-message".getBytes(), + new byte[1024], + }; + for (KatVector v : VECTORS) { + FNDSA k = new FNDSA(v.seed); + for (byte[] msg : messages) { + byte[] sig = k.sign(msg); + assertTrue(v.label + ": signature must be non-empty", + sig.length > 0); + assertTrue(v.label + ": signature must respect 752-byte upper bound", + sig.length <= FNDSA.SIGNATURE_LENGTH); + assertTrue(v.label + ": signature must verify under its own pk", + FNDSA.verify(k.getPublicKey(), msg, sig)); + assertTrue(v.label + ": registry verify must accept own signature", + PQSchemeRegistry.verify( + PQScheme.FN_DSA_512, k.getPublicKey(), msg, sig)); + } + } + } + + @Test + public void signatureFromVectorAFailsUnderVectorBPublicKey() { + byte[] msg = "tron-fn-dsa-kat-cross".getBytes(); + FNDSA[] keys = new FNDSA[VECTORS.length]; + byte[][] sigs = new byte[VECTORS.length][]; + for (int i = 0; i < VECTORS.length; i++) { + keys[i] = new FNDSA(VECTORS[i].seed); + sigs[i] = keys[i].sign(msg); + } + for (int i = 0; i < VECTORS.length; i++) { + for (int j = 0; j < VECTORS.length; j++) { + if (i == j) { + assertTrue(VECTORS[i].label + ": self-verify must succeed", + FNDSA.verify(keys[i].getPublicKey(), msg, sigs[i])); + } else { + assertFalse("signature from " + VECTORS[i].label + + " must NOT verify under " + VECTORS[j].label, + FNDSA.verify(keys[j].getPublicKey(), msg, sigs[i])); + } + } + } + } + + @Test + public void distinctSeedsAtRuntimeAlsoProduceDistinctRuntimePublicKeys() { + // Belt-and-braces: the sanity check above only compared hard-coded digests. + // Re-derive at runtime and confirm they're still pairwise distinct. + byte[][] pks = new byte[VECTORS.length][]; + for (int i = 0; i < VECTORS.length; i++) { + pks[i] = new FNDSA(VECTORS[i].seed).getPublicKey(); + } + for (int i = 0; i < VECTORS.length; i++) { + for (int j = i + 1; j < VECTORS.length; j++) { + assertFalse( + VECTORS[i].label + " and " + VECTORS[j].label + + " produced identical pk bytes", + Arrays.equals(pks[i], pks[j])); + assertNotEquals( + VECTORS[i].label + " and " + VECTORS[j].label + + " produced identical pk digests", + hex(sha256(pks[i])), hex(sha256(pks[j]))); + } + } + } +} diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/FNDSATest.java b/framework/src/test/java/org/tron/common/crypto/pqc/FNDSATest.java new file mode 100644 index 00000000000..6298b1d251b --- /dev/null +++ b/framework/src/test/java/org/tron/common/crypto/pqc/FNDSATest.java @@ -0,0 +1,443 @@ +package org.tron.common.crypto.pqc; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.security.SecureRandom; +import org.bouncycastle.crypto.AsymmetricCipherKeyPair; +import org.bouncycastle.crypto.params.ParametersWithRandom; +import org.bouncycastle.pqc.crypto.falcon.FalconKeyGenerationParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconKeyPairGenerator; +import org.bouncycastle.pqc.crypto.falcon.FalconParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconPrivateKeyParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconPublicKeyParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconSigner; +import org.junit.Before; +import org.junit.Test; +import org.tron.protos.Protocol.PQScheme; + +public class 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(PQScheme.FN_DSA_512, 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(PQSchemeRegistry.verify( + PQScheme.FN_DSA_512, pk.getH(), msg, sigDirect)); + byte[] sigViaRegistry = PQSchemeRegistry.sign( + PQScheme.FN_DSA_512, sk.getEncoded(), msg); + assertTrue(FNDSA.verify(pk.getH(), msg, sigViaRegistry)); + assertEquals(FNDSA.PUBLIC_KEY_LENGTH, + PQSchemeRegistry.getPublicKeyLength(PQScheme.FN_DSA_512)); + assertEquals(FNDSA.SIGNATURE_LENGTH, + PQSchemeRegistry.getSignatureLength(PQScheme.FN_DSA_512)); + } + + @Test + public void registryIsValidSignatureLengthRespectsUpperBound() { + assertTrue(PQSchemeRegistry.isValidSignatureLength(PQScheme.FN_DSA_512, 1)); + assertTrue(PQSchemeRegistry.isValidSignatureLength( + PQScheme.FN_DSA_512, FNDSA.SIGNATURE_LENGTH)); + assertFalse(PQSchemeRegistry.isValidSignatureLength(PQScheme.FN_DSA_512, 0)); + assertFalse(PQSchemeRegistry.isValidSignatureLength( + PQScheme.FN_DSA_512, FNDSA.SIGNATURE_LENGTH + 1)); + } + + @Test + public void registryComputeAddressMatchesDirect() { + assertArrayEquals( + FNDSA.computeAddress(pk.getH()), + PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pk.getH())); + } + + @Test(expected = IllegalArgumentException.class) + public void seedConstructorRejectsNull() { + new FNDSA((byte[]) null); + } + + @Test + public void keypairConstructorRejectsNullPrivateKey() { + try { + new FNDSA(null, pk.getH()); + fail("null private key must be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("private key length")); + } + } + + @Test + public void keypairConstructorRejectsWrongPrivateKeyLength() { + try { + new FNDSA(new byte[FNDSA.PRIVATE_KEY_LENGTH - 1], pk.getH()); + fail("short private key must be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("private key length")); + } + } + + @Test + public void keypairConstructorRejectsNullPublicKey() { + try { + new FNDSA(sk.getEncoded(), null); + fail("null public key must be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("public key length")); + } + } + + @Test + public void keypairConstructorRejectsWrongPublicKeyLength() { + try { + new FNDSA(sk.getEncoded(), new byte[FNDSA.PUBLIC_KEY_LENGTH + 1]); + fail("over-long public key must be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("public key length")); + } + } + + @Test + public void extendedPrivateKeyRoundTripsThroughFromAndGetters() { + byte[] extended = keypair.getPrivateKeyWithPublicKey(); + assertEquals(FNDSA.PRIVATE_KEY_WITH_PUBLIC_KEY_LENGTH, extended.length); + FNDSA restored = FNDSA.fromPrivateKeyWithPublicKey(extended); + assertArrayEquals(keypair.getPrivateKey(), restored.getPrivateKey()); + assertArrayEquals(keypair.getPublicKey(), restored.getPublicKey()); + // The recovered keypair must produce verifiable signatures and recover its address. + byte[] msg = "extended-key-roundtrip".getBytes(); + byte[] sig = restored.sign(msg); + assertTrue(restored.verify(msg, sig)); + assertArrayEquals(keypair.getAddress(), restored.getAddress()); + } + + @Test(expected = IllegalArgumentException.class) + public void fromExtendedPrivateKeyRejectsNull() { + FNDSA.fromPrivateKeyWithPublicKey(null); + } + + @Test(expected = IllegalArgumentException.class) + public void fromExtendedPrivateKeyRejectsWrongLength() { + FNDSA.fromPrivateKeyWithPublicKey(new byte[FNDSA.PRIVATE_KEY_LENGTH]); + } + + @Test + public void derivePublicKeyFromExtendedFormReturnsAppendedPublicKey() { + byte[] extended = keypair.getPrivateKeyWithPublicKey(); + byte[] derived = FNDSA.derivePublicKey(extended); + assertArrayEquals(keypair.getPublicKey(), derived); + } + + @Test(expected = UnsupportedOperationException.class) + public void derivePublicKeyRejectsNull() { + FNDSA.derivePublicKey(null); + } + + @Test + public void staticSignAcceptsExtendedPrivateKey() { + byte[] extended = keypair.getPrivateKeyWithPublicKey(); + byte[] msg = "static-sign-extended".getBytes(); + byte[] sig = FNDSA.sign(extended, msg); + assertTrue(sig.length > 0 && sig.length <= FNDSA.SIGNATURE_LENGTH); + assertTrue(FNDSA.verify(pk.getH(), msg, sig)); + } + + @Test + public void staticSignRejectsNullPrivateKey() { + try { + FNDSA.sign(null, new byte[] {1}); + fail("null private key must be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("private key length")); + } + } + + @Test + public void staticSignRejectsWrongPrivateKeyLength() { + try { + FNDSA.sign(new byte[FNDSA.PRIVATE_KEY_LENGTH - 1], new byte[] {1}); + fail("short private key must be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("private key length")); + } + } + + @Test + public void staticSignRejectsNullMessage() { + try { + FNDSA.sign(sk.getEncoded(), null); + fail("null message must be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("message")); + } + } + + @Test + public void staticVerifyRejectsNullPublicKey() { + try { + FNDSA.verify(null, new byte[] {1}, new byte[16]); + fail("null public key must be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("public key length")); + } + } + + @Test + public void unknownPqSchemeResolvesToFnDsa512() { + assertEquals(PQScheme.FN_DSA_512, + PQSchemeRegistry.resolve(PQScheme.UNKNOWN_PQ_SCHEME)); + assertTrue(PQSchemeRegistry.contains(PQScheme.UNKNOWN_PQ_SCHEME)); + assertEquals(FNDSA.PUBLIC_KEY_LENGTH, + PQSchemeRegistry.getPublicKeyLength(PQScheme.UNKNOWN_PQ_SCHEME)); + assertEquals(FNDSA.SIGNATURE_LENGTH, + PQSchemeRegistry.getSignatureLength(PQScheme.UNKNOWN_PQ_SCHEME)); + assertTrue(PQSchemeRegistry.isValidSignatureLength( + PQScheme.UNKNOWN_PQ_SCHEME, FNDSA.SIGNATURE_LENGTH)); + assertArrayEquals( + FNDSA.computeAddress(pk.getH()), + PQSchemeRegistry.computeAddress(PQScheme.UNKNOWN_PQ_SCHEME, pk.getH())); + + byte[] msg = "unknown-resolves-to-falcon".getBytes(); + byte[] sig = PQSchemeRegistry.sign( + PQScheme.UNKNOWN_PQ_SCHEME, sk.getEncoded(), msg); + assertTrue(PQSchemeRegistry.verify( + PQScheme.UNKNOWN_PQ_SCHEME, pk.getH(), msg, sig)); + assertTrue(FNDSA.verify(pk.getH(), msg, sig)); + } +} diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/PQSchemeRegistryTest.java b/framework/src/test/java/org/tron/common/crypto/pqc/PQSchemeRegistryTest.java new file mode 100644 index 00000000000..203d4625ca8 --- /dev/null +++ b/framework/src/test/java/org/tron/common/crypto/pqc/PQSchemeRegistryTest.java @@ -0,0 +1,130 @@ +package org.tron.common.crypto.pqc; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.util.Arrays; +import org.junit.Test; +import org.tron.protos.Protocol.PQScheme; + +/** + * Covers the static dispatch helpers of {@link PQSchemeRegistry} and the + * defensive paths exercised by callers passing {@code null}, {@code UNRECOGNIZED} + * or wrong-shaped public keys. + */ +public class PQSchemeRegistryTest { + + @Test + public void containsRejectsNullScheme() { + assertFalse(PQSchemeRegistry.contains(null)); + } + + @Test + public void containsRejectsUnrecognized() { + assertFalse(PQSchemeRegistry.contains(PQScheme.UNRECOGNIZED)); + } + + @Test + public void containsAcceptsRegisteredScheme() { + assertTrue(PQSchemeRegistry.contains(PQScheme.FN_DSA_512)); + } + + @Test + public void getSeedLengthReturnsRegisteredValue() { + assertEquals(FNDSA.SEED_LENGTH, + PQSchemeRegistry.getSeedLength(PQScheme.FN_DSA_512)); + // UNKNOWN_PQ_SCHEME normalizes to FN_DSA_512. + assertEquals(FNDSA.SEED_LENGTH, + PQSchemeRegistry.getSeedLength(PQScheme.UNKNOWN_PQ_SCHEME)); + } + + @Test + public void getPrivateKeyLengthReturnsRegisteredValue() { + assertEquals(FNDSA.PRIVATE_KEY_LENGTH, + PQSchemeRegistry.getPrivateKeyLength(PQScheme.FN_DSA_512)); + } + + @Test + public void fromSeedDispatchesToFalcon() { + byte[] seed = new byte[FNDSA.SEED_LENGTH]; + Arrays.fill(seed, (byte) 0x07); + PQSignature sig = PQSchemeRegistry.fromSeed(PQScheme.FN_DSA_512, seed); + assertNotNull(sig); + assertEquals(PQScheme.FN_DSA_512, sig.getScheme()); + // Same seed must yield deterministic keypair across direct and dispatched paths. + FNDSA direct = new FNDSA(seed); + assertArrayEquals(direct.getPublicKey(), sig.getPublicKey()); + assertArrayEquals(direct.getPrivateKey(), sig.getPrivateKey()); + } + + @Test + public void fromKeypairDispatchesAndPreservesAddress() { + byte[] seed = new byte[FNDSA.SEED_LENGTH]; + Arrays.fill(seed, (byte) 0x09); + FNDSA src = new FNDSA(seed); + PQSignature sig = PQSchemeRegistry.fromKeypair( + PQScheme.FN_DSA_512, src.getPrivateKey(), src.getPublicKey()); + assertArrayEquals(src.getAddress(), sig.getAddress()); + byte[] msg = "from-keypair".getBytes(); + byte[] s = sig.sign(msg); + assertTrue(sig.verify(msg, s)); + } + + @Test + public void deriveHashRejectsNullPublicKey() { + try { + PQSchemeRegistry.deriveHash(PQScheme.FN_DSA_512, null); + fail("null public key must be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("public key length")); + } + } + + @Test + public void deriveHashRejectsWrongLengthPublicKey() { + try { + PQSchemeRegistry.deriveHash( + PQScheme.FN_DSA_512, new byte[FNDSA.PUBLIC_KEY_LENGTH - 1]); + fail("short public key must be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("public key length")); + } + } + + @Test + public void requireRejectsNullScheme() { + try { + PQSchemeRegistry.getPublicKeyLength(null); + fail("null scheme must be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("scheme")); + } + } + + @Test + public void requireRejectsUnrecognizedScheme() { + try { + PQSchemeRegistry.getPublicKeyLength(PQScheme.UNRECOGNIZED); + fail("UNRECOGNIZED scheme must be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("PQSignature registered")); + } + } + + @Test + public void resolvePassesThroughNonDefaultSchemes() { + assertEquals(PQScheme.FN_DSA_512, + PQSchemeRegistry.resolve(PQScheme.FN_DSA_512)); + // null should pass through so contains/require can decide. + assertTrue(PQSchemeRegistry.resolve(null) == null); + } + + @Test + public void isValidSignatureLengthRejectsZero() { + assertFalse(PQSchemeRegistry.isValidSignatureLength(PQScheme.FN_DSA_512, 0)); + } +} diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/PQSignatureDefaultsTest.java b/framework/src/test/java/org/tron/common/crypto/pqc/PQSignatureDefaultsTest.java new file mode 100644 index 00000000000..748885e998f --- /dev/null +++ b/framework/src/test/java/org/tron/common/crypto/pqc/PQSignatureDefaultsTest.java @@ -0,0 +1,132 @@ +package org.tron.common.crypto.pqc; + +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import org.junit.Before; +import org.junit.Test; +import org.tron.protos.Protocol.PQScheme; + +/** + * Drives the {@link PQSignature} default validator branches (null and + * length-mismatch) via a minimal in-test implementation. {@link FNDSA} + * exposes these defaults but the cryptographic instances exercise mostly the + * happy paths; the explicit fixture here forces the error legs. + */ +public class PQSignatureDefaultsTest { + + private PQSignature stub; + + @Before + public void setUp() { + stub = new PQSignature() { + @Override + public PQScheme getScheme() { + return PQScheme.FN_DSA_512; + } + + @Override + public int getPrivateKeyLength() { + return 16; + } + + @Override + public int getPublicKeyLength() { + return 8; + } + + @Override + public int getSignatureLength() { + return 32; + } + + @Override + public byte[] getPrivateKey() { + return new byte[getPrivateKeyLength()]; + } + + @Override + public byte[] getPublicKey() { + return new byte[getPublicKeyLength()]; + } + + @Override + public byte[] getAddress() { + return new byte[21]; + } + + @Override + public byte[] sign(byte[] message) { + return new byte[1]; + } + + @Override + public boolean verify(byte[] message, byte[] signature) { + return false; + } + }; + } + + @Test + public void validatePrivateKeyRejectsNull() { + try { + stub.validatePrivateKey(null); + fail("null private key must be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("private key length")); + assertTrue(expected.getMessage().contains("null")); + } + } + + @Test + public void validatePrivateKeyRejectsWrongLength() { + try { + stub.validatePrivateKey(new byte[stub.getPrivateKeyLength() - 1]); + fail("short private key must be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("private key length")); + } + } + + @Test + public void validatePrivateKeyAcceptsExactLength() { + stub.validatePrivateKey(new byte[stub.getPrivateKeyLength()]); + } + + @Test + public void validatePublicKeyRejectsNull() { + try { + stub.validatePublicKey(null); + fail("null public key must be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("public key length")); + assertTrue(expected.getMessage().contains("null")); + } + } + + @Test + public void validatePublicKeyRejectsWrongLength() { + try { + stub.validatePublicKey(new byte[stub.getPublicKeyLength() + 1]); + fail("over-long public key must be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("public key length")); + } + } + + @Test + public void validatePublicKeyAcceptsExactLength() { + stub.validatePublicKey(new byte[stub.getPublicKeyLength()]); + } + + @Test + public void validateSignatureRejectsNull() { + try { + stub.validateSignature(null); + fail("null signature must be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("signature length")); + assertTrue(expected.getMessage().contains("null")); + } + } +} diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/SignatureSchemeBenchmarkTest.java b/framework/src/test/java/org/tron/common/crypto/pqc/SignatureSchemeBenchmarkTest.java new file mode 100644 index 00000000000..f7fdb8d7876 --- /dev/null +++ b/framework/src/test/java/org/tron/common/crypto/pqc/SignatureSchemeBenchmarkTest.java @@ -0,0 +1,132 @@ +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) and FN-DSA / Falcon-512. Numbers are reported + * in microseconds (avg of {@link #ITERATIONS} iterations after {@link #WARMUP} warm-up rounds). + */ +public class SignatureSchemeBenchmarkTest { + + private static final int WARMUP = 20; + private static final int ITERATIONS = 500; + private static final byte[] MESSAGE = "tron-pq-benchmark-message".getBytes(); + private static final byte[] MESSAGE_HASH = Hash.sha3(MESSAGE); + + @Test + public void benchmarkAllSchemes() { + Result eckey = benchEcKey(); + Result fndsa = benchFnDsa(); + + System.out.println(String.format(Locale.ROOT, + "=== Signature scheme benchmark (avg over %d iterations, warmup %d) ===", + ITERATIONS, WARMUP)); + System.out.println(String.format(Locale.ROOT, + "%-12s | %12s | %12s | %12s", + "scheme", "keygen (us)", "sign (us)", "verify (us)")); + System.out.println("-------------+--------------+--------------+--------------"); + printResult(eckey); + printResult(fndsa); + } + + private Result benchEcKey() { + for (int i = 0; i < WARMUP; i++) { + ECKey k = new ECKey(); + ECDSASignature s = k.sign(MESSAGE_HASH); + try { + ECKey.signatureToAddress(MESSAGE_HASH, s); + } catch (SignatureException e) { + throw new AssertionError(e); + } + } + + long keygenNs = 0; + ECKey[] keys = new ECKey[ITERATIONS]; + for (int i = 0; i < ITERATIONS; i++) { + long t0 = System.nanoTime(); + keys[i] = new ECKey(); + keygenNs += System.nanoTime() - t0; + } + + long signNs = 0; + ECDSASignature[] sigs = new ECDSASignature[ITERATIONS]; + for (int i = 0; i < ITERATIONS; i++) { + long t0 = System.nanoTime(); + sigs[i] = keys[i].sign(MESSAGE_HASH); + signNs += System.nanoTime() - t0; + } + + long verifyNs = 0; + for (int i = 0; i < ITERATIONS; i++) { + long t0 = System.nanoTime(); + try { + ECKey.signatureToAddress(MESSAGE_HASH, sigs[i]); + } catch (SignatureException e) { + throw new AssertionError(e); + } + verifyNs += System.nanoTime() - t0; + } + return new Result("ECKey(secp)", keygenNs, signNs, verifyNs); + } + + private Result benchFnDsa() { + for (int i = 0; i < WARMUP; i++) { + FNDSA k = new FNDSA(); + byte[] sig = k.sign(MESSAGE); + k.verify(MESSAGE, sig); + } + + long keygenNs = 0; + FNDSA[] keys = new FNDSA[ITERATIONS]; + for (int i = 0; i < ITERATIONS; i++) { + long t0 = System.nanoTime(); + keys[i] = new FNDSA(); + keygenNs += System.nanoTime() - t0; + } + + long signNs = 0; + byte[][] sigs = new byte[ITERATIONS][]; + for (int i = 0; i < ITERATIONS; i++) { + long t0 = System.nanoTime(); + sigs[i] = keys[i].sign(MESSAGE); + signNs += System.nanoTime() - t0; + } + + long verifyNs = 0; + for (int i = 0; i < ITERATIONS; i++) { + long t0 = System.nanoTime(); + keys[i].verify(MESSAGE, sigs[i]); + verifyNs += System.nanoTime() - t0; + } + return new Result("FN-DSA-512", keygenNs, signNs, verifyNs); + } + + private static void printResult(Result r) { + System.out.println(String.format(Locale.ROOT, + "%-12s | %12.2f | %12.2f | %12.2f", + r.name, + r.keygenNs / 1_000.0 / ITERATIONS, + r.signNs / 1_000.0 / ITERATIONS, + r.verifyNs / 1_000.0 / ITERATIONS)); + } + + private static final class Result { + final String name; + final long keygenNs; + final long signNs; + final long verifyNs; + + Result(String name, long keygenNs, long signNs, long verifyNs) { + this.name = name; + this.keygenNs = keygenNs; + this.signNs = signNs; + this.verifyNs = verifyNs; + } + } +} diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/program/PQClient.java b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQClient.java new file mode 100644 index 00000000000..6522611946c --- /dev/null +++ b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQClient.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.FNDSA; +import org.tron.common.utils.ByteArray; +import org.tron.protos.Protocol.Block; +import org.tron.protos.Protocol.PQAuthSig; +import org.tron.protos.Protocol.Transaction; +import org.tron.protos.Protocol.Transaction.Contract.ContractType; +import org.tron.protos.contract.BalanceContract.TransferContract; + +/** + * Demo client that connects to {@link PQWitnessNode} and broadcasts an FN-DSA-512 + * signed transfer transaction. + * + * The keypair is derived from the same fixed seed used by PQWitnessNode, so no + * out-of-band key exchange is needed. + * + * Usage: + * Terminal 1 — start the witness node first: + * ./gradlew :framework:run -PmainClass=org.tron.common.crypto.pqc.program.PQWitnessNode + * Terminal 2 — broadcast a PQC transaction: + * ./gradlew :framework:run -PmainClass=org.tron.common.crypto.pqc.program.PQClient + * + * Optional JVM args: + * -Dpqc.host=localhost (default: localhost) + * -Dpqc.port=50051 (default: 50051) + */ +public class PQClient { + + private static final String HOST = + System.getProperty("pqc.host", "localhost"); + private static final int PORT = + Integer.parseInt(System.getProperty("pqc.port", "50051")); + + /** Recipient of the demo transfer. */ + private static final byte[] TO_ADDR = + ByteArray.fromHexString("41f522cc20ca18b636bdd93b4fb15ea84cc2b4e001"); + + public static void main(String[] args) throws Exception { + // Force INFO level: logback-test.xml (on the test classpath) sets root=DEBUG + // which is far too noisy for a demo run. + ((ch.qos.logback.classic.Logger) org.slf4j.LoggerFactory + .getLogger(ch.qos.logback.classic.Logger.ROOT_LOGGER_NAME)) + .setLevel(ch.qos.logback.classic.Level.INFO); + + // ── 1. Derive user keypair from same fixed seed as PQWitnessNode ───── + byte[] userSeed = new byte[FNDSA.SEED_LENGTH]; + Arrays.fill(userSeed, (byte) 0x02); + FNDSA userKp = new FNDSA(userSeed); + + byte[] userPub = userKp.getPublicKey(); + byte[] userPriv = userKp.getPrivateKey(); + byte[] signerAddr = FNDSA.computeAddress(userPub); + byte[] ownerAddr = PQWitnessNode.USER_ADDR; + + System.out.println("=== PQC Client ==="); + System.out.println("Connecting to " + HOST + ":" + PORT); + System.out.println("Owner address: " + ByteArray.toHexString(ownerAddr)); + System.out.println("Signer address: " + ByteArray.toHexString(signerAddr)); + + // ── 2. Connect via gRPC ─────────────────────────────────────────────── + ManagedChannel channel = ManagedChannelBuilder + .forAddress(HOST, PORT) + .usePlaintext() + .build(); + WalletBlockingStub stub = WalletGrpc.newBlockingStub(channel) + .withDeadlineAfter(10, TimeUnit.SECONDS); + + try { + // ── 3. Fetch reference block for TaPoS ─────────────────────────── + Block head = stub.getNowBlock(EmptyMessage.getDefaultInstance()); + byte[] headerRaw = head.getBlockHeader().getRawData().toByteArray(); + long refNum = head.getBlockHeader().getRawData().getNumber(); + byte[] blockHash = sha256(headerRaw); + + System.out.println("Reference block: #" + refNum + + " hash=" + ByteArray.toHexString(Arrays.copyOfRange(blockHash, 0, 8)) + "..."); + + // ── 4. Build the transfer transaction ───────────────────────────── + Transaction.raw rawData = Transaction.raw.newBuilder() + .addContract(Transaction.Contract.newBuilder() + .setType(ContractType.TransferContract) + .setParameter(Any.pack(TransferContract.newBuilder() + .setOwnerAddress(ByteString.copyFrom(ownerAddr)) + .setToAddress(ByteString.copyFrom(TO_ADDR)) + .setAmount(1_000_000L) // 1 TRX + .build())) + .setPermissionId(0)) + // TaPoS fields + .setRefBlockHash(ByteString.copyFrom(Arrays.copyOfRange(blockHash, 8, 16))) + .setRefBlockBytes(ByteString.copyFrom(longToBytes(refNum), 6, 2)) + .setExpiration(System.currentTimeMillis() + 60_000L) + .build(); + + Transaction tx = Transaction.newBuilder().setRawData(rawData).build(); + + // ── 5. Sign with FN-DSA-512 pq_auth_sig ───────────────────────────── + byte[] txId = sha256(rawData.toByteArray()); + byte[] sig = FNDSA.sign(userPriv, txId); + + // FN_DSA_512 is the launch scheme → leave scheme at proto3 default and + // let PQSchemeRegistry.resolve() normalize it on the verifier side. + Transaction signedTx = tx.toBuilder() + .addPqAuthSig(PQAuthSig.newBuilder() + .setPublicKey(ByteString.copyFrom(userPub)) + .setSignature(ByteString.copyFrom(sig))) + .build(); + + System.out.println("TX id: " + ByteArray.toHexString(txId)); + + // ── 6. Broadcast ────────────────────────────────────────────────── + Return result = stub.broadcastTransaction(signedTx); + System.out.println("Broadcast result: " + result.getCode() + + " — " + result.getMessage().toStringUtf8()); + + if (result.getResult()) { + System.out.println("SUCCESS: PQC-signed transaction accepted by the node."); + } else { + System.out.println("REJECTED: " + result.getCode()); + } + + } finally { + channel.shutdown(); + channel.awaitTermination(5, TimeUnit.SECONDS); + } + } + + private static byte[] sha256(byte[] data) throws Exception { + return MessageDigest.getInstance("SHA-256").digest(data); + } + + private static byte[] longToBytes(long value) { + return ByteBuffer.allocate(8).putLong(value).array(); + } +} diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/program/PQFullNode.java b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQFullNode.java new file mode 100644 index 00000000000..7f628f84b70 --- /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.FNDSA; +import org.tron.common.utils.ByteArray; +import org.tron.core.ChainBaseManager; +import org.tron.core.config.DefaultConfig; +import org.tron.core.config.args.Args; +import org.tron.core.db.Manager; + +/** + * Demo fullnode that dials {@link PQWitnessNode} via P2P and syncs PQ-signed blocks. + * + * Both nodes share the same deterministic PQ genesis pre-state (witness account with an + * FN-DSA-512 witness permission + demo user account with an FN-DSA-512 owner permission), + * installed via {@link PQWitnessNode#installPQGenesisState}. Once the witness produces + * a block it is broadcast over P2P; this node validates {@code BlockHeader.pq_auth_sig} + * against the same on-chain public key and applies the block. + * + * Usage: + * Terminal 1 — start the witness node first: + * ./gradlew :framework:run -PmainClass=org.tron.common.crypto.pqc.program.PQWitnessNode + * Terminal 2 — start a fullnode that syncs from it: + * ./gradlew :framework:run -PmainClass=org.tron.common.crypto.pqc.program.PQFullNode + * + * Optional JVM args: + * -Dpqc.witness.host=127.0.0.1 (default: 127.0.0.1) + * -Dpqc.witness.p2p.port=18888 (default: PQWitnessNode.P2P_PORT) + */ +public class PQFullNode { + + /** gRPC port (different from PQWitnessNode so both can run on one host). */ + static final int GRPC_PORT = 50052; + /** Full-node HTTP port (different from PQWitnessNode). */ + static final int HTTP_PORT = 8091; + /** P2P listen port (different from PQWitnessNode). */ + static final int P2P_PORT = 18889; + + private static final String WITNESS_HOST = + System.getProperty("pqc.witness.host", "127.0.0.1"); + private static final int WITNESS_P2P_PORT = Integer.parseInt( + System.getProperty("pqc.witness.p2p.port", String.valueOf(PQWitnessNode.P2P_PORT))); + + public static void main(String[] args) throws Exception { + // Force INFO level: logback-test.xml (on the test classpath) sets root=DEBUG + // which is far too noisy for a demo run. + ((ch.qos.logback.classic.Logger) org.slf4j.LoggerFactory + .getLogger(ch.qos.logback.classic.Logger.ROOT_LOGGER_NAME)) + .setLevel(ch.qos.logback.classic.Level.INFO); + + // ── 1. Derive the same deterministic keys used by PQWitnessNode ────── + FNDSA witnessKp = new FNDSA(PQWitnessNode.WITNESS_SEED); + FNDSA userKp = new FNDSA(PQWitnessNode.USER_SEED); + + byte[] witnessPub = witnessKp.getPublicKey(); + byte[] userPub = userKp.getPublicKey(); + + System.out.println("=== PQC Full Node ==="); + System.out.println("Peer (witness): " + WITNESS_HOST + ":" + WITNESS_P2P_PORT); + System.out.println("gRPC port: " + GRPC_PORT); + System.out.println("HTTP port: " + HTTP_PORT); + System.out.println("P2P port: " + P2P_PORT); + System.out.println("Witness address (expected): " + + ByteArray.toHexString(FNDSA.computeAddress(witnessPub))); + + // ── 2. Configure node (no -w: this is a pure fullnode) ──────────────── + File dbDir = Files.createTempDirectory("pqc-fullnode-").toFile(); + dbDir.deleteOnExit(); + + Args.setParam(new String[]{"--output-directory", dbDir.getAbsolutePath()}, + "config-test.conf"); + Args.getInstance().setRpcEnable(true); + Args.getInstance().setFullNodeHttpEnable(true); + Args.getInstance().setFullNodeHttpPort(HTTP_PORT); + Args.getInstance().setSolidityNodeHttpEnable(false); + Args.getInstance().setRpcPort(GRPC_PORT); + Args.getInstance().setNodeListenPort(P2P_PORT); + Args.getInstance().setNeedSyncCheck(false); + Args.getInstance().setMinEffectiveConnection(0); + Args.getInstance().genesisBlock.setWitnesses(new ArrayList<>()); + + // Point to the witness node as the only seed peer. + // Mutable list — startup appends persisted peers to it. + Args.getInstance().getSeedNode().setAddressList(new ArrayList<>( + Collections.singletonList(new InetSocketAddress(WITNESS_HOST, WITNESS_P2P_PORT)))); + + // ── 3. Start Spring context ─────────────────────────────────────────── + TronApplicationContext context = new TronApplicationContext(DefaultConfig.class); + Application app = ApplicationFactory.create(context); + Manager db = context.getBean(Manager.class); + ChainBaseManager chain = context.getBean(ChainBaseManager.class); + + // ── 4. Install matching PQ genesis pre-state ────────────────────────── + // Without this the incoming pq_auth_sig would fail to validate because + // this node wouldn't know the witness's FN-DSA-512 public key. + PQWitnessNode.installPQGenesisState(db, chain, witnessPub, userPub); + + // ── 5. Start P2P + gRPC (no ConsensusService.start — we don't produce) ─ + app.startup(); + + System.out.println("\nFull node running, syncing from witness. Send Ctrl-C to stop.\n"); + + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + System.out.println("Shutting down..."); + context.close(); + Args.clearParam(); + })); + + Thread.currentThread().join(); + } +} diff --git a/framework/src/test/java/org/tron/common/crypto/pqc/program/PQWitnessNode.java b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQWitnessNode.java new file mode 100644 index 00000000000..f677a44cf01 --- /dev/null +++ b/framework/src/test/java/org/tron/common/crypto/pqc/program/PQWitnessNode.java @@ -0,0 +1,204 @@ +package org.tron.common.crypto.pqc.program; + +import com.google.protobuf.ByteString; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import org.bouncycastle.util.encoders.Hex; +import org.tron.common.application.Application; +import org.tron.common.application.ApplicationFactory; +import org.tron.common.application.TronApplicationContext; +import org.tron.common.crypto.pqc.FNDSA; +import org.tron.common.crypto.pqc.PQSchemeRegistry; +import org.tron.common.utils.ByteArray; +import org.tron.core.ChainBaseManager; +import org.tron.core.capsule.AccountCapsule; +import org.tron.core.capsule.WitnessCapsule; +import org.tron.core.config.DefaultConfig; +import org.tron.core.config.args.Args; +import org.tron.core.consensus.ConsensusService; +import org.tron.core.db.Manager; +import org.tron.protos.Protocol.Account; +import org.tron.protos.Protocol.AccountType; +import org.tron.protos.Protocol.Key; +import org.tron.protos.Protocol.PQScheme; +import org.tron.protos.Protocol.Permission; +import org.tron.protos.Protocol.Permission.PermissionType; + +/** + * Demo witness node with FN-DSA-512 block production. + * + * Starts an in-process TRON node configured with a PQC witness keypair and + * a user account that holds an FN-DSA-512 owner permission — ready to receive + * transactions from {@link PQClient}. + * + * Keypairs are derived from fixed seeds so PQClient can derive matching keys + * without any out-of-band coordination. + * + * Usage: + * Terminal 1 — start this node: + * ./gradlew :framework:run -PmainClass=org.tron.common.crypto.pqc.program.PQWitnessNode + * Terminal 2 — broadcast a PQC transaction: + * ./gradlew :framework:run -PmainClass=org.tron.common.crypto.pqc.program.PQClient + */ +public class PQWitnessNode { + + /** Fixed seed for the FN-DSA-512 witness keypair (shared with PQClient for derivation). */ + static final byte[] WITNESS_SEED = filledSeed(0x01); + /** Fixed seed for the FN-DSA-512 user keypair (shared with PQClient for derivation). */ + static final byte[] USER_SEED = filledSeed(0x02); + + /** gRPC port the node listens on. */ + static final int GRPC_PORT = 50051; + + /** Full-node HTTP port. */ + static final int HTTP_PORT = 8090; + + /** P2P listen port (shared with PQFullNode so it can dial in as a seed peer). */ + static final int P2P_PORT = 18888; + + /** Fixed on-chain address for the demo user account. */ + static final byte[] USER_ADDR = + ByteArray.fromHexString("41abd4b9367799eaa3197fecb144eb71de1e049abc"); + + public static void main(String[] args) throws Exception { + // Force INFO level: logback-test.xml (on the test classpath) sets root=DEBUG + // which is far too noisy for a demo run. + ((ch.qos.logback.classic.Logger) org.slf4j.LoggerFactory + .getLogger(ch.qos.logback.classic.Logger.ROOT_LOGGER_NAME)) + .setLevel(ch.qos.logback.classic.Level.INFO); + + // ── 1. Derive deterministic keypairs ────────────────────────────────── + FNDSA witnessKp = new FNDSA(WITNESS_SEED); + FNDSA userKp = new FNDSA(USER_SEED); + + byte[] witnessPub = witnessKp.getPublicKey(); + byte[] witnessAddr = FNDSA.computeAddress(witnessPub); + byte[] userPub = userKp.getPublicKey(); + byte[] signerAddr = FNDSA.computeAddress(userPub); + + System.out.println("=== PQC Witness Node ==="); + System.out.println("Witness address (FN-DSA-512): " + 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(); + + // Inject the witness keypair via a temp HOCON config that includes + // config-test.conf and overrides localwitness_pq_keys with the extended + // priv‖pub hex derived from WITNESS_SEED (matches what PQClient derives). + Path conf = writeWitnessConfig(witnessKp); + + Args.setParam(new String[]{"--output-directory", dbDir.getAbsolutePath(), "-w"}, + conf.toString()); + Args.getInstance().setRpcEnable(true); + Args.getInstance().setFullNodeHttpEnable(true); + Args.getInstance().setFullNodeHttpPort(HTTP_PORT); + Args.getInstance().setRpcPort(GRPC_PORT); + Args.getInstance().setNodeListenPort(P2P_PORT); + Args.getInstance().setNeedSyncCheck(false); + Args.getInstance().setMinEffectiveConnection(0); + Args.getInstance().genesisBlock.setWitnesses(new ArrayList<>()); + + // ── 3. Start Spring context ─────────────────────────────────────────── + TronApplicationContext context = new TronApplicationContext(DefaultConfig.class); + Application app = ApplicationFactory.create(context); + Manager db = context.getBean(Manager.class); + ChainBaseManager chain = context.getBean(ChainBaseManager.class); + + // ── 4. Install PQ genesis pre-state (shared with PQFullNode) ───────── + installPQGenesisState(db, chain, witnessPub, userPub); + + // ── 5. Start consensus (DposTask auto-produces blocks) ─────────────── + context.getBean(ConsensusService.class).start(); + + // ── 6. Start gRPC / P2P server ─────────────────────────────────────── + app.startup(); + + System.out.println("\nNode is running. Send Ctrl-C to stop."); + System.out.println("Run PQClient or PQFullNode in another terminal.\n"); + + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + System.out.println("Shutting down..."); + context.close(); + Args.clearParam(); + })); + + Thread.currentThread().join(); // block until Ctrl-C + } + + /** + * Apply the PQ-specific pre-state that must exist on every node participating + * in the demo network. Both PQWitnessNode and PQFullNode call this so their + * genesis state matches before the first PQ block is produced / received. + */ + static void installPQGenesisState(Manager db, ChainBaseManager chain, + byte[] witnessPub, byte[] userPub) { + byte[] witnessAddr = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, witnessPub); + ByteString witnessAddrBs = ByteString.copyFrom(witnessAddr); + byte[] signerAddr = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, userPub); + ByteString signerAddrBs = ByteString.copyFrom(signerAddr); + + // Activate FN-DSA on the local chain params. + db.getDynamicPropertiesStore().saveAllowFnDsa512(1L); + db.getDynamicPropertiesStore().saveAllowMultiSign(1L); + + // Witness account with FN-DSA-512 witness permission. Address-as-fingerprint + // binds the public key in-band; no separate pq_key field is stored. + Permission witnessPerm = Permission.newBuilder() + .setType(PermissionType.Witness) + .setId(1).setPermissionName("witness").setThreshold(1) + .addKeys(Key.newBuilder() + .setAddress(witnessAddrBs).setWeight(1)) + .build(); + db.getAccountStore().put(witnessAddr, new AccountCapsule(Account.newBuilder() + .setAddress(witnessAddrBs).setType(AccountType.Normal) + .setBalance(1_000_000_000L).setIsWitness(true) + .setWitnessPermission(witnessPerm).build())); + + // The witness must be in the witness store BEFORE consensus starts so that + // DposService.start() includes it in the active-witness schedule. + chain.getWitnessStore().put(witnessAddr, new WitnessCapsule(witnessAddrBs)); + chain.getWitnessScheduleStore().saveActiveWitnesses(new ArrayList<>()); + chain.addWitness(witnessAddrBs); + + // User account with FN-DSA-512 owner permission. + Permission userOwnerPerm = Permission.newBuilder() + .setType(PermissionType.Owner).setPermissionName("owner").setThreshold(1) + .addKeys(Key.newBuilder() + .setAddress(signerAddrBs).setWeight(1)) + .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[FNDSA.SEED_LENGTH]; + Arrays.fill(seed, (byte) value); + return seed; + } + + private static Path writeWitnessConfig(FNDSA witnessKp) throws java.io.IOException { + Path conf = Files.createTempFile("pqc-witness-", ".conf"); + conf.toFile().deleteOnExit(); + String body = "include classpath(\"config-test.conf\")\n" + + "localwitness_pq_scheme = \"FN_DSA_512\"\n" + + "localwitness_pq_keys = [\n" + + " \"" + Hex.toHexString(witnessKp.getPrivateKeyWithPublicKey()) + "\"\n" + + "]\n"; + Files.write(conf, body.getBytes(StandardCharsets.UTF_8)); + return conf; + } +} diff --git a/framework/src/test/java/org/tron/common/runtime/vm/BatchValidateSignPQTest.java b/framework/src/test/java/org/tron/common/runtime/vm/BatchValidateSignPQTest.java new file mode 100644 index 00000000000..aae387ad2b7 --- /dev/null +++ b/framework/src/test/java/org/tron/common/runtime/vm/BatchValidateSignPQTest.java @@ -0,0 +1,346 @@ +package org.tron.common.runtime.vm; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.tuple.Pair; +import org.bouncycastle.util.encoders.Hex; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.tron.common.crypto.pqc.FNDSA; +import org.tron.common.crypto.pqc.PQSchemeRegistry; +import org.tron.common.utils.client.utils.AbiUtil; +import org.tron.core.vm.PrecompiledContracts; +import org.tron.core.vm.PrecompiledContracts.BatchValidateSignPQ; +import org.tron.core.vm.PrecompiledContracts.PrecompiledContract; +import org.tron.core.vm.config.VMConfig; +import org.tron.protos.Protocol.PQScheme; + +/** + * Unit tests for the 0x18 batch independent Falcon-512 verify precompile. + * Returns a 256-bit bitmap where bit i is set iff + * {@code derive(pk_i) == expectedAddr_i && FNDSA.verify(pk_i, hash, sig_i)}. + * Stateless — no chain DB. + */ +@Slf4j +public class BatchValidateSignPQTest { + + private static final DataWord ADDR_0X18 = new DataWord( + "0000000000000000000000000000000000000000000000000000000000000018"); + + private static final String METHOD_SIGN = + "batchvalidatesignpq(bytes32,bytes[],bytes[],bytes32[])"; + + private static final byte[] HASH; + + static { + HASH = new byte[32]; + for (int i = 0; i < 32; i++) { + HASH[i] = (byte) (i + 1); + } + } + + private final BatchValidateSignPQ contract = new BatchValidateSignPQ(); + + @Before + public void enableProposal() { + VMConfig.initAllowFnDsa512(1L); + } + + @After + public void disableProposal() { + VMConfig.initAllowFnDsa512(0L); + } + + @Test + public void switchOff_returnsNull() { + VMConfig.initAllowFnDsa512(0L); + Assert.assertNull(PrecompiledContracts.getContractForAddress(ADDR_0X18)); + } + + @Test + public void switchOn_returnsContract() { + PrecompiledContract pc = PrecompiledContracts.getContractForAddress(ADDR_0X18); + Assert.assertNotNull(pc); + Assert.assertTrue(pc instanceof BatchValidateSignPQ); + } + + @Test + public void constantCall_allValid_setsAllBits() { + contract.setConstantCall(true); + int n = 4; + List sigs = new ArrayList<>(n); + List pks = new ArrayList<>(n); + List addrs = new ArrayList<>(n); + for (int i = 0; i < n; i++) { + FNDSA k = new FNDSA(); + sigs.add(Hex.toHexString(k.sign(HASH))); + pks.add(Hex.toHexString(k.getPublicKey())); + addrs.add(addrAsBytes32Hex(k.getPublicKey())); + } + byte[] res = run(HASH, sigs, pks, addrs).getRight(); + for (int i = 0; i < n; i++) { + Assert.assertEquals("bit " + i, 1, res[i]); + } + for (int i = n; i < 32; i++) { + Assert.assertEquals("padding bit " + i, 0, res[i]); + } + } + + @Test + public void constantCall_mismatchedAddress_clearsBit() { + contract.setConstantCall(true); + FNDSA k1 = new FNDSA(); + FNDSA k2 = new FNDSA(); + List sigs = Arrays.asList( + Hex.toHexString(k1.sign(HASH)), + Hex.toHexString(k2.sign(HASH))); + List pks = Arrays.asList( + Hex.toHexString(k1.getPublicKey()), + Hex.toHexString(k2.getPublicKey())); + // entry 1's address is wrong + List addrs = Arrays.asList( + addrAsBytes32Hex(k1.getPublicKey()), + addrAsBytes32Hex(k1.getPublicKey())); + + byte[] res = run(HASH, sigs, pks, addrs).getRight(); + Assert.assertEquals(1, res[0]); + Assert.assertEquals(0, res[1]); + } + + @Test + public void constantCall_tamperedSignature_clearsBit() { + contract.setConstantCall(true); + FNDSA k = new FNDSA(); + byte[] sig = k.sign(HASH); + sig[0] ^= 0x01; + List sigs = Collections1(Hex.toHexString(sig)); + List pks = Collections1(Hex.toHexString(k.getPublicKey())); + List addrs = Collections1(addrAsBytes32Hex(k.getPublicKey())); + + byte[] res = run(HASH, sigs, pks, addrs).getRight(); + Assert.assertEquals(0, res[0]); + } + + @Test + public void constantCall_wrongPkLength_clearsBit() { + contract.setConstantCall(true); + FNDSA k = new FNDSA(); + byte[] truncatedPk = Arrays.copyOf(k.getPublicKey(), k.getPublicKey().length - 1); + List sigs = Collections1(Hex.toHexString(k.sign(HASH))); + List pks = Collections1(Hex.toHexString(truncatedPk)); + List addrs = Collections1(addrAsBytes32Hex(k.getPublicKey())); + + byte[] res = run(HASH, sigs, pks, addrs).getRight(); + Assert.assertEquals(0, res[0]); + } + + @Test + public void asyncPath_allValid_setsAllBits() { + contract.setConstantCall(false); + contract.setVmShouldEndInUs(System.nanoTime() / 1000 + 5_000_000L); + int n = 4; + List sigs = new ArrayList<>(n); + List pks = new ArrayList<>(n); + List addrs = new ArrayList<>(n); + for (int i = 0; i < n; i++) { + FNDSA k = new FNDSA(); + sigs.add(Hex.toHexString(k.sign(HASH))); + pks.add(Hex.toHexString(k.getPublicKey())); + addrs.add(addrAsBytes32Hex(k.getPublicKey())); + } + byte[] res = run(HASH, sigs, pks, addrs).getRight(); + for (int i = 0; i < n; i++) { + Assert.assertEquals("bit " + i, 1, res[i]); + } + } + + @Test + public void mismatchedArrayLengths_returnsZero() { + contract.setConstantCall(true); + FNDSA k = new FNDSA(); + List sigs = Collections1(Hex.toHexString(k.sign(HASH))); + List pks = Arrays.asList( + Hex.toHexString(k.getPublicKey()), Hex.toHexString(k.getPublicKey())); + List addrs = Collections1(addrAsBytes32Hex(k.getPublicKey())); + + byte[] res = run(HASH, sigs, pks, addrs).getRight(); + Assert.assertArrayEquals(new byte[32], res); + } + + @Test + public void overMaxSize_returnsZero() { + contract.setConstantCall(true); + contract.setVmShouldEndInUs(System.nanoTime() / 1000 + 30_000_000L); + int n = 17; + List sigs = new ArrayList<>(n); + List pks = new ArrayList<>(n); + List addrs = new ArrayList<>(n); + for (int i = 0; i < n; i++) { + FNDSA k = new FNDSA(); + sigs.add(Hex.toHexString(k.sign(HASH))); + pks.add(Hex.toHexString(k.getPublicKey())); + addrs.add(addrAsBytes32Hex(k.getPublicKey())); + } + byte[] res = run(HASH, sigs, pks, addrs).getRight(); + Assert.assertArrayEquals(new byte[32], res); + } + + @Test + public void energyScalesWithCount() { + contract.setConstantCall(true); + int n = 3; + List sigs = new ArrayList<>(n); + List pks = new ArrayList<>(n); + List addrs = new ArrayList<>(n); + for (int i = 0; i < n; i++) { + FNDSA k = new FNDSA(); + sigs.add(Hex.toHexString(k.sign(HASH))); + pks.add(Hex.toHexString(k.getPublicKey())); + addrs.add(addrAsBytes32Hex(k.getPublicKey())); + } + byte[] input = encode(HASH, sigs, pks, addrs); + Assert.assertEquals(3L * 15000L, contract.getEnergyForData(input)); + } + + @Test + public void emptyArrays_returnsAllZero() { + contract.setConstantCall(true); + byte[] res = run(HASH, new ArrayList<>(), new ArrayList<>(), new ArrayList<>()).getRight(); + Assert.assertArrayEquals(new byte[32], res); + } + + @Test + public void differentHash_clearsAllBits() { + contract.setConstantCall(true); + int n = 3; + List sigs = new ArrayList<>(n); + List pks = new ArrayList<>(n); + List addrs = new ArrayList<>(n); + for (int i = 0; i < n; i++) { + FNDSA k = new FNDSA(); + // Sign HASH... + sigs.add(Hex.toHexString(k.sign(HASH))); + pks.add(Hex.toHexString(k.getPublicKey())); + addrs.add(addrAsBytes32Hex(k.getPublicKey())); + } + // ...but verify against a different hash. + byte[] otherHash = new byte[32]; + Arrays.fill(otherHash, (byte) 0xAA); + + byte[] res = run(otherHash, sigs, pks, addrs).getRight(); + Assert.assertArrayEquals(new byte[32], res); + } + + @Test + public void atMaxSize16_setsAllBits() { + contract.setConstantCall(true); + int n = 16; + List sigs = new ArrayList<>(n); + List pks = new ArrayList<>(n); + List addrs = new ArrayList<>(n); + for (int i = 0; i < n; i++) { + FNDSA k = new FNDSA(); + sigs.add(Hex.toHexString(k.sign(HASH))); + pks.add(Hex.toHexString(k.getPublicKey())); + addrs.add(addrAsBytes32Hex(k.getPublicKey())); + } + contract.setVmShouldEndInUs(System.nanoTime() / 1000 + 30_000_000L); + byte[] res = run(HASH, sigs, pks, addrs).getRight(); + for (int i = 0; i < n; i++) { + Assert.assertEquals("bit " + i, 1, res[i]); + } + for (int i = n; i < 32; i++) { + Assert.assertEquals("padding bit " + i, 0, res[i]); + } + } + + @Test + public void asyncPath_mixedValidInvalid() { + contract.setConstantCall(false); + contract.setVmShouldEndInUs(System.nanoTime() / 1000 + 10_000_000L); + int n = 4; + List sigs = new ArrayList<>(n); + List pks = new ArrayList<>(n); + List addrs = new ArrayList<>(n); + for (int i = 0; i < n; i++) { + FNDSA k = new FNDSA(); + byte[] sig = k.sign(HASH); + // Tamper entries 1 and 3. + if (i == 1 || i == 3) { + sig[0] ^= 0x01; + } + sigs.add(Hex.toHexString(sig)); + pks.add(Hex.toHexString(k.getPublicKey())); + addrs.add(addrAsBytes32Hex(k.getPublicKey())); + } + byte[] res = run(HASH, sigs, pks, addrs).getRight(); + Assert.assertEquals(1, res[0]); + Assert.assertEquals(0, res[1]); + Assert.assertEquals(1, res[2]); + Assert.assertEquals(0, res[3]); + } + + @Test + public void sigTooLong_clearsBit() { + contract.setConstantCall(true); + FNDSA k = new FNDSA(); + byte[] oversized = new byte[800]; + Arrays.fill(oversized, (byte) 0x99); + List sigs = Collections1(Hex.toHexString(oversized)); + List pks = Collections1(Hex.toHexString(k.getPublicKey())); + List addrs = Collections1(addrAsBytes32Hex(k.getPublicKey())); + + byte[] res = run(HASH, sigs, pks, addrs).getRight(); + Assert.assertEquals(0, res[0]); + } + + // -------- helpers -------- + + private Pair run(byte[] hash, List sigs, + List pks, List addrs) { + byte[] input = encode(hash, sigs, pks, addrs); + contract.setVmShouldEndInUs(System.nanoTime() / 1000 + 5_000_000L); + Pair ret = contract.execute(input); + logger.info("0x18 bitmap: {}", Hex.toHexString(ret.getRight())); + return ret; + } + + private byte[] encode(byte[] hash, List sigs, List pks, List addrs) { + List parameters = Arrays.asList( + "0x" + Hex.toHexString(hash), + toHexList(sigs), + toHexList(pks), + toHexList(addrs)); + return Hex.decode(AbiUtil.parseParameters(METHOD_SIGN, parameters)); + } + + private static List toHexList(List hexes) { + List out = new ArrayList<>(hexes.size()); + for (String h : hexes) { + out.add(h.startsWith("0x") ? h : ("0x" + h)); + } + return out; + } + + private static List Collections1(String s) { + List l = new ArrayList<>(1); + l.add(s); + return l; + } + + /** + * Build a bytes32 hex string whose low 21 bytes hold the derived TRON address + * (high 11 bytes left zero). Matches {@code DataWord.equalAddressByteArray}'s + * "compare last 20 bytes" semantics. + */ + private static String addrAsBytes32Hex(byte[] pk) { + byte[] addr21 = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pk); + byte[] padded = new byte[32]; + System.arraycopy(addr21, 0, padded, 32 - addr21.length, addr21.length); + return "0x" + Hex.toHexString(padded); + } +} diff --git a/framework/src/test/java/org/tron/common/runtime/vm/FnDsaPrecompileTest.java b/framework/src/test/java/org/tron/common/runtime/vm/FnDsaPrecompileTest.java new file mode 100644 index 00000000000..4e21409e2ce --- /dev/null +++ b/framework/src/test/java/org/tron/common/runtime/vm/FnDsaPrecompileTest.java @@ -0,0 +1,186 @@ +package org.tron.common.runtime.vm; + +import org.apache.commons.lang3.tuple.Pair; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.tron.common.crypto.pqc.FNDSA; +import org.tron.core.vm.PrecompiledContracts; +import org.tron.core.vm.PrecompiledContracts.PrecompiledContract; +import org.tron.core.vm.config.VMConfig; + +/** + * Unit tests for the FN-DSA / Falcon-512 (0x16) verify precompile (EIP-8052 / TRON extension). + * Input layout: [msg 32B | sig_len 2B | sig sig_len B | pk 896B]. Stateless — no chain DB. + */ +public class FnDsaPrecompileTest { + + private static final DataWord FNDSA_ADDR = new DataWord( + "0000000000000000000000000000000000000000000000000000000000000016"); + + private static final byte[] MESSAGE_HASH = new byte[32]; + + static { + for (int i = 0; i < 32; i++) { + MESSAGE_HASH[i] = (byte) i; + } + } + + @Before + public void enableProposal() { + VMConfig.initAllowFnDsa512(1L); + } + + @After + public void disableProposal() { + VMConfig.initAllowFnDsa512(0L); + } + + @Test + public void switchOff_returnsNull() { + VMConfig.initAllowFnDsa512(0L); + Assert.assertNull(PrecompiledContracts.getContractForAddress(FNDSA_ADDR)); + } + + @Test + public void switchOn_returnsContract() { + Assert.assertNotNull(PrecompiledContracts.getContractForAddress(FNDSA_ADDR)); + } + + @Test + public void validSignature_returnsOne() { + FNDSA key = new FNDSA(); + byte[] sig = key.sign(MESSAGE_HASH); + byte[] input = buildInput(MESSAGE_HASH, sig, key.getPublicKey()); + + PrecompiledContract pc = PrecompiledContracts.getContractForAddress(FNDSA_ADDR); + Pair result = pc.execute(input); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ONE().getData(), result.getRight()); + Assert.assertEquals(2500, pc.getEnergyForData(input)); + } + + @Test + public void tamperedMessage_returnsZero() { + FNDSA key = new FNDSA(); + byte[] sig = key.sign(MESSAGE_HASH); + byte[] tampered = MESSAGE_HASH.clone(); + tampered[0] ^= 0x01; + byte[] input = buildInput(tampered, sig, key.getPublicKey()); + + Pair result = + PrecompiledContracts.getContractForAddress(FNDSA_ADDR).execute(input); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + @Test + public void tamperedSignature_returnsZero() { + FNDSA key = new FNDSA(); + byte[] sig = key.sign(MESSAGE_HASH); + sig[0] ^= 0x01; + byte[] input = buildInput(MESSAGE_HASH, sig, key.getPublicKey()); + + Pair result = + PrecompiledContracts.getContractForAddress(FNDSA_ADDR).execute(input); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + @Test + public void wrongPublicKey_returnsZero() { + FNDSA signer = new FNDSA(); + FNDSA other = new FNDSA(); + byte[] sig = signer.sign(MESSAGE_HASH); + byte[] input = buildInput(MESSAGE_HASH, sig, other.getPublicKey()); + + Pair result = + PrecompiledContracts.getContractForAddress(FNDSA_ADDR).execute(input); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + @Test + public void nullInput_returnsZero() { + Pair result = + PrecompiledContracts.getContractForAddress(FNDSA_ADDR).execute(null); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + @Test + public void shortInput_returnsZero() { + Pair result = + PrecompiledContracts.getContractForAddress(FNDSA_ADDR).execute(new byte[100]); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + @Test + public void zeroSigLen_returnsZero() { + FNDSA key = new FNDSA(); + byte[] pk = key.getPublicKey(); + // sig_len = 0 is invalid (must be >= 1) + // input must be >= MIN_INPUT_LEN (931 = 32 + 2 + 1 + 896) to reach the sigLen check + byte[] input = new byte[32 + 2 + pk.length + 1]; + System.arraycopy(MESSAGE_HASH, 0, input, 0, 32); + // sig_len bytes = 0x00 0x00 → sigLen = 0 + System.arraycopy(pk, 0, input, 34, pk.length); + + Pair result = + PrecompiledContracts.getContractForAddress(FNDSA_ADDR).execute(input); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + @Test + public void oversizedSigLen_returnsZero() { + // sig_len = 753, which exceeds FNDSA.SIGNATURE_LENGTH (752) + byte[] input = new byte[32 + 2 + 753 + FNDSA.PUBLIC_KEY_LENGTH]; + input[32] = 0x02; // high byte + input[33] = (byte) 0xF1; // low byte → 0x02F1 = 753 + Pair result = + PrecompiledContracts.getContractForAddress(FNDSA_ADDR).execute(input); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + @Test + public void sigLenLargerThanActualData_returnsZero() { + FNDSA key = new FNDSA(); + byte[] sig = key.sign(MESSAGE_HASH); + // claim sig is 100 bytes longer than it is + byte[] input = buildInput(MESSAGE_HASH, sig, key.getPublicKey()); + // corrupt sig_len field to claim a larger sig + int claimedLen = sig.length + 100; + input[32] = (byte) ((claimedLen >> 8) & 0xFF); + input[33] = (byte) (claimedLen & 0xFF); + + Pair result = + PrecompiledContracts.getContractForAddress(FNDSA_ADDR).execute(input); + + Assert.assertTrue(result.getLeft()); + Assert.assertArrayEquals(DataWord.ZERO().getData(), result.getRight()); + } + + /** Encodes input as [msg 32B | sig_len 2B | sig | pk]. */ + private static byte[] buildInput(byte[] msg, byte[] sig, byte[] pk) { + int sigLen = sig.length; + byte[] out = new byte[32 + 2 + sigLen + pk.length]; + System.arraycopy(msg, 0, out, 0, 32); + out[32] = (byte) ((sigLen >> 8) & 0xFF); + out[33] = (byte) (sigLen & 0xFF); + System.arraycopy(sig, 0, out, 34, sigLen); + System.arraycopy(pk, 0, out, 34 + sigLen, pk.length); + return out; + } +} diff --git a/framework/src/test/java/org/tron/common/runtime/vm/ValidateMultiSignPQTest.java b/framework/src/test/java/org/tron/common/runtime/vm/ValidateMultiSignPQTest.java new file mode 100644 index 00000000000..37a7cd7aa02 --- /dev/null +++ b/framework/src/test/java/org/tron/common/runtime/vm/ValidateMultiSignPQTest.java @@ -0,0 +1,464 @@ +package org.tron.common.runtime.vm; + +import com.google.protobuf.ByteString; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.tuple.Pair; +import org.bouncycastle.util.encoders.Hex; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.tron.common.BaseTest; +import org.tron.common.TestConstants; +import org.tron.common.crypto.ECKey; +import org.tron.common.crypto.pqc.FNDSA; +import org.tron.common.crypto.pqc.PQSchemeRegistry; +import org.tron.common.parameter.CommonParameter; +import org.tron.common.utils.ByteArray; +import org.tron.common.utils.ByteUtil; +import org.tron.common.utils.Sha256Hash; +import org.tron.common.utils.StringUtil; +import org.tron.common.utils.client.utils.AbiUtil; +import org.tron.core.capsule.AccountCapsule; +import org.tron.core.config.args.Args; +import org.tron.core.store.StoreFactory; +import org.tron.core.vm.PrecompiledContracts; +import org.tron.core.vm.PrecompiledContracts.PrecompiledContract; +import org.tron.core.vm.PrecompiledContracts.ValidateMultiSignPQ; +import org.tron.core.vm.config.VMConfig; +import org.tron.core.vm.repository.Repository; +import org.tron.core.vm.repository.RepositoryImpl; +import org.tron.protos.Protocol; +import org.tron.protos.Protocol.PQScheme; + +/** + * Unit tests for the 0x17 algorithm-agnostic Permission multi-sign precompile. + * Mirrors 0x09 hash construction and threshold semantics, while supporting + * Falcon-512 entries alongside ECDSA against the same Permission.keys[]. + */ +@Slf4j +public class ValidateMultiSignPQTest extends BaseTest { + + private static final DataWord ADDR_0X17 = new DataWord( + "0000000000000000000000000000000000000000000000000000000000000017"); + + private static final String METHOD_SIGN = + "validatemultisign(address,uint256,bytes32,bytes[],bytes[],bytes[])"; + + private static final byte[] longData; + + static { + Args.setParam(new String[]{"--output-directory", dbPath(), "--debug"}, TestConstants.TEST_CONF); + longData = new byte[1000]; + Arrays.fill(longData, (byte) 7); + } + + private final ValidateMultiSignPQ contract = new ValidateMultiSignPQ(); + + @Before + public void before() { + dbManager.getDynamicPropertiesStore().saveAllowMultiSign(1); + dbManager.getDynamicPropertiesStore().saveTotalSignNum(5); + VMConfig.initAllowFnDsa512(1L); + } + + @Test + public void switchOff_returnsNull() { + VMConfig.initAllowFnDsa512(0L); + Assert.assertNull(PrecompiledContracts.getContractForAddress(ADDR_0X17)); + } + + @Test + public void switchOn_returnsContract() { + PrecompiledContract pc = PrecompiledContracts.getContractForAddress(ADDR_0X17); + Assert.assertNotNull(pc); + Assert.assertTrue(pc instanceof ValidateMultiSignPQ); + } + + @Test + public void unknownAccount_returnsZero() { + ECKey owner = new ECKey(); + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + List ecdsaSigs = Collections.singletonList( + Hex.toHexString(new ECKey().sign(toSign).toByteArray())); + Assert.assertArrayEquals(DataWord.ZERO().getData(), + runContract(owner.getAddress(), 2, data, ecdsaSigs, + Collections.emptyList(), Collections.emptyList()).getRight()); + } + + @Test + public void pureEcdsaThresholdReached_returnsOne() { + ECKey k1 = new ECKey(); + ECKey k2 = new ECKey(); + ECKey owner = new ECKey(); + setupPermission(owner, Arrays.asList(k1.getAddress(), k2.getAddress()), + Arrays.asList(1, 1), 2, Collections.emptyList(), Collections.emptyList()); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + List ecdsaSigs = Arrays.asList( + Hex.toHexString(k1.sign(toSign).toByteArray()), + Hex.toHexString(k2.sign(toSign).toByteArray())); + + Assert.assertArrayEquals(DataWord.ONE().getData(), + runContract(owner.getAddress(), 2, data, ecdsaSigs, + Collections.emptyList(), Collections.emptyList()).getRight()); + } + + @Test + public void purePqThresholdReached_returnsOne() { + FNDSA pq1 = new FNDSA(); + FNDSA pq2 = new FNDSA(); + ECKey owner = new ECKey(); + byte[] addr1 = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pq1.getPublicKey()); + byte[] addr2 = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pq2.getPublicKey()); + setupPermission(owner, Collections.emptyList(), Collections.emptyList(), + 2, Arrays.asList(addr1, addr2), Arrays.asList(1, 1)); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + + List pqSigs = Arrays.asList( + Hex.toHexString(pq1.sign(toSign)), + Hex.toHexString(pq2.sign(toSign))); + List pqPks = Arrays.asList( + Hex.toHexString(pq1.getPublicKey()), + Hex.toHexString(pq2.getPublicKey())); + + Assert.assertArrayEquals(DataWord.ONE().getData(), + runContract(owner.getAddress(), 2, data, + Collections.emptyList(), pqSigs, pqPks).getRight()); + } + + @Test + public void mixedEcdsaAndPq_returnsOne() { + ECKey k1 = new ECKey(); + FNDSA pq1 = new FNDSA(); + ECKey owner = new ECKey(); + byte[] pqAddr = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pq1.getPublicKey()); + setupPermission(owner, Collections.singletonList(k1.getAddress()), Collections.singletonList(1), + 2, Collections.singletonList(pqAddr), Collections.singletonList(1)); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + + List ecdsaSigs = Collections.singletonList( + Hex.toHexString(k1.sign(toSign).toByteArray())); + List pqSigs = Collections.singletonList(Hex.toHexString(pq1.sign(toSign))); + List pqPks = Collections.singletonList(Hex.toHexString(pq1.getPublicKey())); + + Assert.assertArrayEquals(DataWord.ONE().getData(), + runContract(owner.getAddress(), 2, data, ecdsaSigs, pqSigs, pqPks).getRight()); + } + + @Test + public void pqSignatureForgery_returnsZero() { + FNDSA pq1 = new FNDSA(); + ECKey owner = new ECKey(); + byte[] pqAddr = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pq1.getPublicKey()); + setupPermission(owner, Collections.emptyList(), Collections.emptyList(), + 1, Collections.singletonList(pqAddr), Collections.singletonList(1)); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + byte[] forgedSig = pq1.sign(toSign); + forgedSig[10] ^= 0x01; + + List pqSigs = Collections.singletonList(Hex.toHexString(forgedSig)); + List pqPks = Collections.singletonList(Hex.toHexString(pq1.getPublicKey())); + + Assert.assertArrayEquals(DataWord.ZERO().getData(), + runContract(owner.getAddress(), 2, data, + Collections.emptyList(), pqSigs, pqPks).getRight()); + } + + @Test + public void wrongPqPublicKeyLength_returnsZero() { + FNDSA pq1 = new FNDSA(); + ECKey owner = new ECKey(); + byte[] pqAddr = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pq1.getPublicKey()); + setupPermission(owner, Collections.emptyList(), Collections.emptyList(), + 1, Collections.singletonList(pqAddr), Collections.singletonList(1)); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + byte[] truncatedPk = Arrays.copyOf(pq1.getPublicKey(), pq1.getPublicKey().length - 1); + + List pqSigs = Collections.singletonList(Hex.toHexString(pq1.sign(toSign))); + List pqPks = Collections.singletonList(Hex.toHexString(truncatedPk)); + + Assert.assertArrayEquals(DataWord.ZERO().getData(), + runContract(owner.getAddress(), 2, data, + Collections.emptyList(), pqSigs, pqPks).getRight()); + } + + @Test + public void mismatchedPqArrayLengths_returnsZero() { + FNDSA pq1 = new FNDSA(); + FNDSA pq2 = new FNDSA(); + ECKey owner = new ECKey(); + byte[] addr1 = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pq1.getPublicKey()); + setupPermission(owner, Collections.emptyList(), Collections.emptyList(), + 1, Collections.singletonList(addr1), Collections.singletonList(1)); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + + List pqSigs = Arrays.asList( + Hex.toHexString(pq1.sign(toSign)), + Hex.toHexString(pq2.sign(toSign))); + List pqPks = Collections.singletonList(Hex.toHexString(pq1.getPublicKey())); + + Assert.assertArrayEquals(DataWord.ZERO().getData(), + runContract(owner.getAddress(), 2, data, + Collections.emptyList(), pqSigs, pqPks).getRight()); + } + + @Test + public void totalCountOverMaxSize_returnsZero() { + ECKey owner = new ECKey(); + List ecdsaAddrs = new ArrayList<>(); + List ecdsaWeights = new ArrayList<>(); + List ecdsaKeys = new ArrayList<>(); + for (int i = 0; i < 6; i++) { + ECKey k = new ECKey(); + ecdsaKeys.add(k); + ecdsaAddrs.add(k.getAddress()); + ecdsaWeights.add(1); + } + setupPermission(owner, ecdsaAddrs, ecdsaWeights, 6, + Collections.emptyList(), Collections.emptyList()); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + List ecdsaSigs = new ArrayList<>(); + for (ECKey k : ecdsaKeys) { + ecdsaSigs.add(Hex.toHexString(k.sign(toSign).toByteArray())); + } + + Assert.assertArrayEquals(DataWord.ZERO().getData(), + runContract(owner.getAddress(), 2, data, ecdsaSigs, + Collections.emptyList(), Collections.emptyList()).getRight()); + } + + @Test + public void duplicatePqSig_doesNotDoubleCount() { + FNDSA pq1 = new FNDSA(); + ECKey owner = new ECKey(); + byte[] pqAddr = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pq1.getPublicKey()); + setupPermission(owner, Collections.emptyList(), Collections.emptyList(), + 2, Collections.singletonList(pqAddr), Collections.singletonList(1)); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + byte[] sig = pq1.sign(toSign); + + List pqSigs = Arrays.asList(Hex.toHexString(sig), Hex.toHexString(sig)); + List pqPks = Arrays.asList( + Hex.toHexString(pq1.getPublicKey()), Hex.toHexString(pq1.getPublicKey())); + + Assert.assertArrayEquals(DataWord.ZERO().getData(), + runContract(owner.getAddress(), 2, data, + Collections.emptyList(), pqSigs, pqPks).getRight()); + } + + @Test + public void energyChargesEcdsaAndPqSeparately() { + FNDSA pq1 = new FNDSA(); + ECKey k1 = new ECKey(); + ECKey owner = new ECKey(); + byte[] pqAddr = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pq1.getPublicKey()); + setupPermission(owner, Collections.singletonList(k1.getAddress()), Collections.singletonList(1), + 2, Collections.singletonList(pqAddr), Collections.singletonList(1)); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + List ecdsaSigs = Collections.singletonList( + Hex.toHexString(k1.sign(toSign).toByteArray())); + List pqSigs = Collections.singletonList(Hex.toHexString(pq1.sign(toSign))); + List pqPks = Collections.singletonList(Hex.toHexString(pq1.getPublicKey())); + + byte[] input = encodeInput(owner.getAddress(), 2, data, ecdsaSigs, pqSigs, pqPks); + // 1 ECDSA × 1500 + 1 PQ × 15000 = 16500 + Assert.assertEquals(16500L, contract.getEnergyForData(input)); + } + + @Test + public void thresholdNotReached_returnsZero() { + ECKey k1 = new ECKey(); + ECKey k2 = new ECKey(); + ECKey owner = new ECKey(); + setupPermission(owner, Arrays.asList(k1.getAddress(), k2.getAddress()), + Arrays.asList(1, 1), 2, Collections.emptyList(), Collections.emptyList()); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + // Only one valid signature; threshold is 2. + List ecdsaSigs = Collections.singletonList( + Hex.toHexString(k1.sign(toSign).toByteArray())); + + Assert.assertArrayEquals(DataWord.ZERO().getData(), + runContract(owner.getAddress(), 2, data, ecdsaSigs, + Collections.emptyList(), Collections.emptyList()).getRight()); + } + + @Test + public void pqKeyNotInPermission_returnsZero() { + FNDSA inPerm = new FNDSA(); + FNDSA outsider = new FNDSA(); + ECKey owner = new ECKey(); + byte[] inAddr = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, inPerm.getPublicKey()); + setupPermission(owner, Collections.emptyList(), Collections.emptyList(), + 1, Collections.singletonList(inAddr), Collections.singletonList(1)); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + + // Outsider produces a perfectly valid Falcon signature, but its derived + // address is not in Permission.keys[] → weight 0 → not counted. + List pqSigs = Collections.singletonList(Hex.toHexString(outsider.sign(toSign))); + List pqPks = Collections.singletonList(Hex.toHexString(outsider.getPublicKey())); + + Assert.assertArrayEquals(DataWord.ZERO().getData(), + runContract(owner.getAddress(), 2, data, + Collections.emptyList(), pqSigs, pqPks).getRight()); + } + + @Test + public void pqSigTooLong_returnsZero() { + FNDSA pq1 = new FNDSA(); + ECKey owner = new ECKey(); + byte[] pqAddr = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pq1.getPublicKey()); + setupPermission(owner, Collections.emptyList(), Collections.emptyList(), + 1, Collections.singletonList(pqAddr), Collections.singletonList(1)); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + + // Pad sig past the 752-byte cap. + byte[] oversized = new byte[800]; + Arrays.fill(oversized, (byte) 0x42); + List pqSigs = Collections.singletonList(Hex.toHexString(oversized)); + List pqPks = Collections.singletonList(Hex.toHexString(pq1.getPublicKey())); + + Assert.assertArrayEquals(DataWord.ZERO().getData(), + runContract(owner.getAddress(), 2, data, + Collections.emptyList(), pqSigs, pqPks).getRight()); + } + + @Test + public void bothArraysEmpty_returnsZero() { + ECKey k1 = new ECKey(); + ECKey owner = new ECKey(); + setupPermission(owner, Collections.singletonList(k1.getAddress()), + Collections.singletonList(1), 1, + Collections.emptyList(), Collections.emptyList()); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + + Assert.assertArrayEquals(DataWord.ZERO().getData(), + runContract(owner.getAddress(), 2, data, + Collections.emptyList(), Collections.emptyList(), + Collections.emptyList()).getRight()); + } + + @Test + public void mixedFailingPqAborts_returnsZero() { + // Mirrors 0x09 semantics: a verify failure on any submitted entry aborts + // the whole call with DATA_FALSE — even if other entries would alone meet + // threshold. Verifies 0x17 does not silently skip a forged PQ signature. + ECKey k1 = new ECKey(); + ECKey k2 = new ECKey(); + FNDSA pq1 = new FNDSA(); + ECKey owner = new ECKey(); + byte[] pqAddr = PQSchemeRegistry.computeAddress(PQScheme.FN_DSA_512, pq1.getPublicKey()); + setupPermission(owner, + Arrays.asList(k1.getAddress(), k2.getAddress()), Arrays.asList(1, 1), + 2, Collections.singletonList(pqAddr), Collections.singletonList(1)); + + byte[] data = Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), longData); + byte[] toSign = computeHash(owner.getAddress(), 2, data); + + List ecdsaSigs = Arrays.asList( + Hex.toHexString(k1.sign(toSign).toByteArray()), + Hex.toHexString(k2.sign(toSign).toByteArray())); + byte[] forged = pq1.sign(toSign); + forged[0] ^= 0x55; + List pqSigs = Collections.singletonList(Hex.toHexString(forged)); + List pqPks = Collections.singletonList(Hex.toHexString(pq1.getPublicKey())); + + Assert.assertArrayEquals(DataWord.ZERO().getData(), + runContract(owner.getAddress(), 2, data, ecdsaSigs, pqSigs, pqPks).getRight()); + } + + // -------- helpers -------- + + private void setupPermission(ECKey owner, + List ecdsaKeyAddrs, List ecdsaWeights, + int threshold, + List pqKeyAddrs, List pqWeights) { + AccountCapsule account = new AccountCapsule(ByteString.copyFrom(owner.getAddress()), + Protocol.AccountType.Normal, System.currentTimeMillis(), true, + dbManager.getDynamicPropertiesStore()); + + Protocol.Permission.Builder perm = Protocol.Permission.newBuilder() + .setType(Protocol.Permission.PermissionType.Active) + .setId(2) + .setPermissionName("active") + .setThreshold(threshold) + .setOperations(ByteString.copyFrom(ByteArray.fromHexString( + "0000000000000000000000000000000000000000000000000000000000000000"))); + for (int i = 0; i < ecdsaKeyAddrs.size(); i++) { + perm.addKeys(Protocol.Key.newBuilder() + .setAddress(ByteString.copyFrom(ecdsaKeyAddrs.get(i))) + .setWeight(ecdsaWeights.get(i)).build()); + } + for (int i = 0; i < pqKeyAddrs.size(); i++) { + perm.addKeys(Protocol.Key.newBuilder() + .setAddress(ByteString.copyFrom(pqKeyAddrs.get(i))) + .setWeight(pqWeights.get(i)).build()); + } + account.updatePermissions(account.getPermissionById(0), null, + Collections.singletonList(perm.build())); + dbManager.getAccountStore().put(owner.getAddress(), account); + } + + private byte[] computeHash(byte[] address, int permissionId, byte[] data) { + byte[] combined = ByteUtil.merge(address, ByteArray.fromInt(permissionId), data); + return Sha256Hash.hash(CommonParameter.getInstance().isECKeyCryptoEngine(), combined); + } + + private byte[] encodeInput(byte[] ownerAddr, int permissionId, byte[] data, + List ecdsaSigs, List pqSigs, List pqPks) { + List parameters = Arrays.asList( + StringUtil.encode58Check(ownerAddr), + permissionId, + "0x" + Hex.toHexString(data), + toHexList(ecdsaSigs), + toHexList(pqSigs), + toHexList(pqPks)); + return Hex.decode(AbiUtil.parseParameters(METHOD_SIGN, parameters)); + } + + private Pair runContract(byte[] ownerAddr, int permissionId, byte[] data, + List ecdsaSigs, List pqSigs, + List pqPks) { + byte[] input = encodeInput(ownerAddr, permissionId, data, ecdsaSigs, pqSigs, pqPks); + Repository deposit = RepositoryImpl.createRoot(StoreFactory.getInstance()); + contract.setRepository(deposit); + Pair ret = contract.execute(input); + logger.info("0x17 result: {}", Hex.toHexString(ret.getRight())); + return ret; + } + + private static List toHexList(List hexes) { + List out = new ArrayList<>(hexes.size()); + for (String h : hexes) { + out.add(h.startsWith("0x") ? h : ("0x" + h)); + } + return out; + } +} diff --git a/framework/src/test/java/org/tron/common/utils/LocalWitnessesTest.java b/framework/src/test/java/org/tron/common/utils/LocalWitnessesTest.java new file mode 100644 index 00000000000..22e99ef4571 --- /dev/null +++ b/framework/src/test/java/org/tron/common/utils/LocalWitnessesTest.java @@ -0,0 +1,152 @@ +package org.tron.common.utils; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.util.Arrays; +import java.util.Collections; +import org.bouncycastle.util.encoders.Hex; +import org.junit.BeforeClass; +import org.junit.Test; +import org.tron.common.crypto.pqc.FNDSA; +import org.tron.core.exception.TronError; +import org.tron.protos.Protocol.PQScheme; + +public class LocalWitnessesTest { + + // Real Falcon-512 keypair generated once per test class. We exercise the + // (priv, pub) keypair config path with bytes that satisfy the BC ops, so the + // tests below never hit cross-platform FFT determinism concerns. + private static String priv; + private static String pub; + private static String priv2; + private static String pub2; + + @BeforeClass + public static void generateKeypairs() { + FNDSA k1 = new FNDSA(); + FNDSA k2 = new FNDSA(); + priv = Hex.toHexString(k1.getPrivateKey()); + pub = Hex.toHexString(k1.getPublicKey()); + priv2 = Hex.toHexString(k2.getPrivateKey()); + pub2 = Hex.toHexString(k2.getPublicKey()); + } + + @Test + public void fnDsa512AcceptsValidKeypair() { + LocalWitnesses lw = new LocalWitnesses(); + lw.setPqKeypairs(Collections.singletonList(priv), Collections.singletonList(pub)); + assertEquals(PQScheme.FN_DSA_512, lw.getPqScheme()); + assertEquals(1, lw.getPqPrivateKeys().size()); + assertEquals(1, lw.getPqPublicKeys().size()); + } + + @Test + public void fnDsa512AcceptsMultipleKeypairs() { + LocalWitnesses lw = new LocalWitnesses(); + lw.setPqKeypairs(Arrays.asList(priv, priv2), Arrays.asList(pub, pub2)); + assertEquals(2, lw.getPqPrivateKeys().size()); + assertEquals(2, lw.getPqPublicKeys().size()); + } + + @Test + public void mismatchedListSizesRejected() { + LocalWitnesses lw = new LocalWitnesses(); + TronError err = assertThrows(TronError.class, + () -> lw.setPqKeypairs(Arrays.asList(priv, priv2), Collections.singletonList(pub))); + assertEquals(TronError.ErrCode.WITNESS_INIT, err.getErrCode()); + assertTrue(err.getMessage().contains("size mismatch")); + } + + @Test + public void wrongLengthPrivateKeyRejected() { + LocalWitnesses lw = new LocalWitnesses(); + String shortPriv = priv.substring(2); + TronError err = assertThrows(TronError.class, + () -> lw.setPqKeypairs(Collections.singletonList(shortPriv), + Collections.singletonList(pub))); + assertEquals(TronError.ErrCode.WITNESS_INIT, err.getErrCode()); + assertTrue(err.getMessage().contains("PQ private key")); + // FN-DSA-512 private key is 1280 bytes = 2560 hex chars. + assertTrue(err.getMessage().contains("2560")); + } + + @Test + public void wrongLengthPublicKeyRejected() { + LocalWitnesses lw = new LocalWitnesses(); + String shortPub = pub.substring(2); + TronError err = assertThrows(TronError.class, + () -> lw.setPqKeypairs(Collections.singletonList(priv), + Collections.singletonList(shortPub))); + assertEquals(TronError.ErrCode.WITNESS_INIT, err.getErrCode()); + assertTrue(err.getMessage().contains("PQ public key")); + // FN-DSA-512 public key is 896 bytes = 1792 hex chars. + assertTrue(err.getMessage().contains("1792")); + } + + @Test + public void nonHexPrivateKeyRejected() { + LocalWitnesses lw = new LocalWitnesses(); + String badPriv = "zz" + priv.substring(2); + TronError err = assertThrows(TronError.class, + () -> lw.setPqKeypairs(Collections.singletonList(badPriv), + Collections.singletonList(pub))); + assertEquals(TronError.ErrCode.WITNESS_INIT, err.getErrCode()); + assertTrue(err.getMessage().contains("hex")); + } + + @Test + public void unsupportedSchemeRejected() { + LocalWitnesses lw = new LocalWitnesses(); + TronError err = assertThrows(TronError.class, + () -> lw.setPqScheme(PQScheme.UNRECOGNIZED)); + assertEquals(TronError.ErrCode.WITNESS_INIT, err.getErrCode()); + assertTrue(err.getMessage().contains("unsupported PQ signature scheme")); + } + + @Test + public void nullSchemeRejected() { + LocalWitnesses lw = new LocalWitnesses(); + TronError err = assertThrows(TronError.class, () -> lw.setPqScheme(null)); + assertEquals(TronError.ErrCode.WITNESS_INIT, err.getErrCode()); + assertTrue(err.getMessage().contains("unsupported PQ signature scheme")); + } + + @Test + public void supportedSchemeAccepted() { + LocalWitnesses lw = new LocalWitnesses(); + lw.setPqScheme(PQScheme.FN_DSA_512); + assertEquals(PQScheme.FN_DSA_512, lw.getPqScheme()); + } + + @Test + public void emptyKeypairsAreNoop() { + LocalWitnesses lw = new LocalWitnesses(); + lw.setPqKeypairs(Collections.emptyList(), Collections.emptyList()); + lw.setPqKeypairs(null, null); + assertEquals(0, lw.getPqPrivateKeys().size()); + assertEquals(0, lw.getPqPublicKeys().size()); + } + + @Test + public void zeroXPrefixedHexAccepted() { + // validatePqKey strips a leading "0x" before measuring the length, so + // hex strings with the prefix must be accepted. + LocalWitnesses lw = new LocalWitnesses(); + lw.setPqKeypairs( + Collections.singletonList("0x" + priv), + Collections.singletonList("0x" + pub)); + assertEquals(1, lw.getPqPrivateKeys().size()); + } + + @Test + public void blankKeyRejected() { + LocalWitnesses lw = new LocalWitnesses(); + TronError err = assertThrows(TronError.class, + () -> lw.setPqKeypairs(Collections.singletonList(""), + Collections.singletonList(pub))); + assertEquals(TronError.ErrCode.WITNESS_INIT, err.getErrCode()); + assertTrue(err.getMessage().contains("PQ private key")); + } +} diff --git a/framework/src/test/java/org/tron/core/BandwidthProcessorTest.java b/framework/src/test/java/org/tron/core/BandwidthProcessorTest.java index cf652af3650..f6fede77523 100755 --- a/framework/src/test/java/org/tron/core/BandwidthProcessorTest.java +++ b/framework/src/test/java/org/tron/core/BandwidthProcessorTest.java @@ -881,4 +881,120 @@ public void testCalculateGlobalNetLimit() { .calculateGlobalNetLimitV2(accountCapsule.getAllFrozenBalanceForBandwidth()); Assert.assertTrue(netLimitV2 > 0); } + + @Test + public void pqPQAuthWitnessBytesSubtractedInCreateAccountCap() throws Exception { + chainBaseManager.getDynamicPropertiesStore().saveLatestBlockHeaderTimestamp(1526647838000L); + chainBaseManager.getDynamicPropertiesStore().saveTotalNetWeight(10_000_000L); + + AccountCapsule ownerCapsule = new AccountCapsule( + ByteString.copyFromUtf8("owner"), + ByteString.copyFrom(ByteArray.fromHexString(OWNER_ADDRESS)), + AccountType.Normal, + chainBaseManager.getDynamicPropertiesStore().getAssetIssueFee()); + ownerCapsule.setBalance(10_000_000L); + long expireTime = DateTime.now().getMillis() + 6 * 86_400_000; + ownerCapsule.setFrozenForBandwidth(2_000_000L, expireTime); + chainBaseManager.getAccountStore().put(ownerCapsule.getAddress().toByteArray(), ownerCapsule); + + chainBaseManager.getAccountStore().delete(ByteArray.fromHexString(TO_ADDRESS)); + + TransferContract contract = TransferContract.newBuilder() + .setOwnerAddress(ByteString.copyFrom(ByteArray.fromHexString(OWNER_ADDRESS))) + .setToAddress(ByteString.copyFrom(ByteArray.fromHexString(TO_ADDRESS))) + .setAmount(100L) + .build(); + + byte[] fakeSig = new byte[752]; + byte[] fakePub = new byte[897]; + Protocol.PQAuthSig pqAuthSig = Protocol.PQAuthSig.newBuilder() + .setScheme(Protocol.PQScheme.FN_DSA_512) + .setPublicKey(ByteString.copyFrom(fakePub)) + .setSignature(ByteString.copyFrom(fakeSig)) + .build(); + + TransactionCapsule baseTrx = new TransactionCapsule(contract, + chainBaseManager.getAccountStore()); + Transaction withAuth = baseTrx.getInstance().toBuilder() + .addPqAuthSig(pqAuthSig) + .build(); + TransactionCapsule trx = new TransactionCapsule(withAuth); + TransactionTrace trace = new TransactionTrace(trx, StoreFactory.getInstance(), + new RuntimeImpl()); + + long cap = chainBaseManager.getDynamicPropertiesStore().getMaxCreateAccountTxSize(); + long rawSize = trx.getInstance().toBuilder().clearRet().build().getSerializedSize(); + Assert.assertTrue("test precondition: raw tx must exceed cap with pq_auth_sig", + rawSize > cap); + + BandwidthProcessor processor = new BandwidthProcessor(chainBaseManager); + try { + processor.consume(trx, trace); + } catch (TooBigTransactionException e) { + Assert.fail("PQ pq_auth_sig bytes should be deducted from create-account cap check"); + } finally { + chainBaseManager.getAccountStore().delete(ByteArray.fromHexString(OWNER_ADDRESS)); + chainBaseManager.getAccountStore().delete(ByteArray.fromHexString(TO_ADDRESS)); + } + } + + @Test + public void pqPQAuthWitnessCountedInBandwidthUsage() throws Exception { + chainBaseManager.getDynamicPropertiesStore().saveLatestBlockHeaderTimestamp(1526647838000L); + chainBaseManager.getDynamicPropertiesStore().saveTotalNetWeight(10_000_000L); + + AccountCapsule ownerCapsule = new AccountCapsule( + ByteString.copyFromUtf8("owner"), + ByteString.copyFrom(ByteArray.fromHexString(OWNER_ADDRESS)), + AccountType.Normal, + chainBaseManager.getDynamicPropertiesStore().getAssetIssueFee()); + ownerCapsule.setBalance(10_000_000L); + long expireTime = DateTime.now().getMillis() + 6 * 86_400_000; + ownerCapsule.setFrozenForBandwidth(2_000_000L, expireTime); + chainBaseManager.getAccountStore().put(ownerCapsule.getAddress().toByteArray(), ownerCapsule); + + AccountCapsule toAddressCapsule = new AccountCapsule( + ByteString.copyFromUtf8("to"), + ByteString.copyFrom(ByteArray.fromHexString(TO_ADDRESS)), + AccountType.Normal, + 0L); + chainBaseManager.getAccountStore().put(toAddressCapsule.getAddress().toByteArray(), + toAddressCapsule); + + TransferContract contract = TransferContract.newBuilder() + .setOwnerAddress(ByteString.copyFrom(ByteArray.fromHexString(OWNER_ADDRESS))) + .setToAddress(ByteString.copyFrom(ByteArray.fromHexString(TO_ADDRESS))) + .setAmount(100L) + .build(); + + byte[] fakeSig = new byte[752]; + byte[] fakePub = new byte[897]; + Protocol.PQAuthSig pqAuthSig = Protocol.PQAuthSig.newBuilder() + .setScheme(Protocol.PQScheme.FN_DSA_512) + .setPublicKey(ByteString.copyFrom(fakePub)) + .setSignature(ByteString.copyFrom(fakeSig)) + .build(); + + TransactionCapsule baseTrx = new TransactionCapsule(contract, + chainBaseManager.getAccountStore()); + Transaction withAuth = baseTrx.getInstance().toBuilder() + .addPqAuthSig(pqAuthSig) + .build(); + TransactionCapsule trx = new TransactionCapsule(withAuth); + TransactionTrace trace = new TransactionTrace(trx, StoreFactory.getInstance(), + new RuntimeImpl()); + + long expectedBytes = trx.getInstance().toBuilder().clearRet().build().getSerializedSize() + + (chainBaseManager.getDynamicPropertiesStore().supportVM() + ? Constant.MAX_RESULT_SIZE_IN_TX : 0); + + BandwidthProcessor processor = new BandwidthProcessor(chainBaseManager); + try { + processor.consume(trx, trace); + Assert.assertEquals(expectedBytes, trace.getReceipt().getNetUsage()); + } finally { + chainBaseManager.getAccountStore().delete(ByteArray.fromHexString(OWNER_ADDRESS)); + chainBaseManager.getAccountStore().delete(ByteArray.fromHexString(TO_ADDRESS)); + } + } } diff --git a/framework/src/test/java/org/tron/core/actuator/AccountPermissionUpdateActuatorTest.java b/framework/src/test/java/org/tron/core/actuator/AccountPermissionUpdateActuatorTest.java index 250f7b9dc01..1fa2ef9d16f 100644 --- a/framework/src/test/java/org/tron/core/actuator/AccountPermissionUpdateActuatorTest.java +++ b/framework/src/test/java/org/tron/core/actuator/AccountPermissionUpdateActuatorTest.java @@ -1019,4 +1019,4 @@ public void checkActiveDefaultOperations() { } -} \ No newline at end of file +} diff --git a/framework/src/test/java/org/tron/core/actuator/CreateAccountActuatorTest.java b/framework/src/test/java/org/tron/core/actuator/CreateAccountActuatorTest.java index 4cb8e639089..6c1923a9ce4 100755 --- a/framework/src/test/java/org/tron/core/actuator/CreateAccountActuatorTest.java +++ b/framework/src/test/java/org/tron/core/actuator/CreateAccountActuatorTest.java @@ -220,6 +220,10 @@ public void commonErrorCheck() { } + // PQ-native account creation is deferred per V2 scope: AccountCreateContract.pq_key + // has been removed (reserved 4) and CreateAccountActuator no longer carries any PQ + // validation logic. Tests for that path were dropped along with the field. + private void processAndCheckInvalid(CreateAccountActuator actuator, TransactionResultCapsule ret, String failMsg, String expectedMsg) { diff --git a/framework/src/test/java/org/tron/core/actuator/ShieldedTransferActuatorTest.java b/framework/src/test/java/org/tron/core/actuator/ShieldedTransferActuatorTest.java index faec4c74039..3b431b324db 100755 --- a/framework/src/test/java/org/tron/core/actuator/ShieldedTransferActuatorTest.java +++ b/framework/src/test/java/org/tron/core/actuator/ShieldedTransferActuatorTest.java @@ -275,7 +275,7 @@ public void publicAddressToPublicAddressNoPublicSign() { Assert.assertTrue(dbManager.pushTransaction(transactionCap)); } catch (ValidateSignatureException e) { Assert.assertTrue(e instanceof ValidateSignatureException); - Assert.assertEquals("miss sig or contract", e.getMessage()); + Assert.assertEquals("miss sig", e.getMessage()); } catch (Exception e) { Assert.assertTrue(false); } diff --git a/framework/src/test/java/org/tron/core/actuator/utils/ProposalUtilTest.java b/framework/src/test/java/org/tron/core/actuator/utils/ProposalUtilTest.java index f8d8e6bdd9d..4328766ccf1 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,30 @@ public void blockVersionCheck() { } } } + + @Test + public void validateAllowFnDsa512() { + long code = ProposalType.ALLOW_FN_DSA_512.getCode(); + + ContractValidateException thrown = assertThrows(ContractValidateException.class, + () -> ProposalUtil.validator(dynamicPropertiesStore, forkUtils, code, 0)); + assertEquals("This value[ALLOW_FN_DSA_512] is only allowed to be 1", thrown.getMessage()); + + thrown = assertThrows(ContractValidateException.class, + () -> ProposalUtil.validator(dynamicPropertiesStore, forkUtils, code, 2)); + assertEquals("This value[ALLOW_FN_DSA_512] is only allowed to be 1", thrown.getMessage()); + + try { + ProposalUtil.validator(dynamicPropertiesStore, forkUtils, code, 1); + } catch (ContractValidateException e) { + Assert.fail("value=1 should be accepted: " + e.getMessage()); + } + + dynamicPropertiesStore.saveAllowFnDsa512(1L); + thrown = assertThrows(ContractValidateException.class, + () -> ProposalUtil.validator(dynamicPropertiesStore, forkUtils, code, 1)); + assertEquals("[ALLOW_FN_DSA_512] has been valid, no need to propose again", + thrown.getMessage()); + dynamicPropertiesStore.saveAllowFnDsa512(0L); + } } diff --git a/framework/src/test/java/org/tron/core/capsule/BlockCapsulePQTest.java b/framework/src/test/java/org/tron/core/capsule/BlockCapsulePQTest.java new file mode 100644 index 00000000000..f224652046e --- /dev/null +++ b/framework/src/test/java/org/tron/core/capsule/BlockCapsulePQTest.java @@ -0,0 +1,194 @@ +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.FNDSA; +import org.tron.common.crypto.pqc.PQSchemeRegistry; +import org.tron.common.utils.Sha256Hash; +import org.tron.core.config.args.Args; +import org.tron.core.exception.ValidateSignatureException; +import org.tron.protos.Protocol.Account; +import org.tron.protos.Protocol.AccountType; +import org.tron.protos.Protocol.Key; +import org.tron.protos.Protocol.PQAuthSig; +import org.tron.protos.Protocol.PQScheme; +import org.tron.protos.Protocol.Permission; +import org.tron.protos.Protocol.Permission.PermissionType; + +public class BlockCapsulePQTest extends BaseTest { + + private ECKey witnessKey; + private byte[] witnessAddress; + private FNDSA pqKeypair; + private byte[] pqAddress; + + @BeforeClass + public static void init() { + Args.setParam(new String[] {"-d", dbPath()}, TestConstants.TEST_CONF); + } + + @Before + public void setUp() { + witnessKey = new ECKey(); + witnessAddress = witnessKey.getAddress(); + pqKeypair = new FNDSA(); + pqAddress = PQSchemeRegistry.computeAddress( + PQScheme.FN_DSA_512, pqKeypair.getPublicKey()); + } + + /** + * Build a witness account whose witness permission key is bound to the + * given address. For PQ scenarios, pass {@link #pqAddress}; for legacy ECDSA + * scenarios, pass {@link #witnessAddress}. + */ + private AccountCapsule buildWitnessAccount(byte[] keyAddress) { + Key kb = Key.newBuilder() + .setAddress(ByteString.copyFrom(keyAddress)) + .setWeight(1) + .build(); + Permission witnessPerm = Permission.newBuilder() + .setType(PermissionType.Witness) + .setId(1) + .setPermissionName("witness") + .setThreshold(1) + .addKeys(kb) + .build(); + Account account = Account.newBuilder() + .setAccountName(ByteString.copyFromUtf8("w")) + .setAddress(ByteString.copyFrom(witnessAddress)) + .setType(AccountType.Normal) + .setBalance(1_000_000_000L) + .setIsWitness(true) + .setWitnessPermission(witnessPerm) + .build(); + return new AccountCapsule(account); + } + + private BlockCapsule buildSignedBlock(byte[] parentHash) { + BlockCapsule block = new BlockCapsule( + 1L, + Sha256Hash.wrap(ByteString.copyFrom(parentHash)), + System.currentTimeMillis(), + ByteString.copyFrom(witnessAddress)); + block.sign(witnessKey.getPrivKeyBytes()); + return block; + } + + private BlockCapsule buildUnsignedBlock(byte[] parentHash) { + return new BlockCapsule( + 1L, + Sha256Hash.wrap(ByteString.copyFrom(parentHash)), + System.currentTimeMillis(), + ByteString.copyFrom(witnessAddress)); + } + + private byte[] signPQ(byte[] message) { + return FNDSA.sign(pqKeypair.getPrivateKey(), message); + } + + private PQAuthSig buildPQAuthSig(byte[] signature) { + return PQAuthSig.newBuilder() + .setScheme(PQScheme.FN_DSA_512) + .setPublicKey(ByteString.copyFrom(pqKeypair.getPublicKey())) + .setSignature(ByteString.copyFrom(signature)) + .build(); + } + + @Test + public void legacyValidateWithoutPQAuthSigAcceptedBeforeActivation() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowMultiSign(1L); + dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(0L); + AccountCapsule witness = buildWitnessAccount(witnessAddress); + dbManager.getAccountStore().put(witnessAddress, witness); + + byte[] parentHash = new byte[32]; + BlockCapsule block = buildSignedBlock(parentHash); + Assert.assertTrue(block.validateSignature( + dbManager.getDynamicPropertiesStore(), dbManager.getAccountStore())); + } + + @Test(expected = ValidateSignatureException.class) + public void pqAuthSigBeforeActivationRejected() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowMultiSign(1L); + dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(0L); + AccountCapsule witness = buildWitnessAccount(pqAddress); + dbManager.getAccountStore().put(witnessAddress, witness); + + byte[] parentHash = new byte[32]; + BlockCapsule block = buildUnsignedBlock(parentHash); + byte[] digest = block.getRawHashBytes(); + block.setPqAuthSig(buildPQAuthSig(signPQ(digest))); + block.validateSignature( + dbManager.getDynamicPropertiesStore(), dbManager.getAccountStore()); + } + + @Test(expected = ValidateSignatureException.class) + public void bothLegacyAndPQAuthSigRejected() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowMultiSign(1L); + dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(1L); + AccountCapsule witness = buildWitnessAccount(pqAddress); + dbManager.getAccountStore().put(witnessAddress, witness); + + byte[] parentHash = new byte[32]; + BlockCapsule block = buildSignedBlock(parentHash); + byte[] digest = block.getRawHashBytes(); + block.setPqAuthSig(buildPQAuthSig(signPQ(digest))); + block.validateSignature( + dbManager.getDynamicPropertiesStore(), dbManager.getAccountStore()); + } + + @Test + public void pqOnlyAccepted() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowMultiSign(1L); + dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(1L); + AccountCapsule witness = buildWitnessAccount(pqAddress); + dbManager.getAccountStore().put(witnessAddress, witness); + + byte[] parentHash = new byte[32]; + BlockCapsule block = buildUnsignedBlock(parentHash); + byte[] digest = block.getRawHashBytes(); + block.setPqAuthSig(buildPQAuthSig(signPQ(digest))); + Assert.assertTrue(block.validateSignature( + dbManager.getDynamicPropertiesStore(), dbManager.getAccountStore())); + } + + @Test + public void tamperedPQAuthSigFails() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowMultiSign(1L); + dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(1L); + AccountCapsule witness = buildWitnessAccount(pqAddress); + dbManager.getAccountStore().put(witnessAddress, witness); + + byte[] parentHash = new byte[32]; + BlockCapsule block = buildUnsignedBlock(parentHash); + byte[] digest = block.getRawHashBytes(); + byte[] pqSig = signPQ(digest); + pqSig[pqSig.length - 1] ^= 0x01; + block.setPqAuthSig(buildPQAuthSig(pqSig)); + Assert.assertFalse(block.validateSignature( + dbManager.getDynamicPropertiesStore(), dbManager.getAccountStore())); + } + + @Test(expected = ValidateSignatureException.class) + public void signerNotInWitnessPermissionRejected() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowMultiSign(1L); + dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(1L); + // Witness permission key bound to a different address (the legacy ECDSA + // address), so the PQ signer's derived address won't match. + AccountCapsule witness = buildWitnessAccount(witnessAddress); + dbManager.getAccountStore().put(witnessAddress, witness); + + byte[] parentHash = new byte[32]; + BlockCapsule block = buildUnsignedBlock(parentHash); + byte[] digest = block.getRawHashBytes(); + block.setPqAuthSig(buildPQAuthSig(signPQ(digest))); + block.validateSignature( + dbManager.getDynamicPropertiesStore(), dbManager.getAccountStore()); + } +} 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..5c6499cf488 100644 --- a/framework/src/test/java/org/tron/core/capsule/TransactionCapsuleTest.java +++ b/framework/src/test/java/org/tron/core/capsule/TransactionCapsuleTest.java @@ -4,6 +4,7 @@ import static org.tron.protos.Protocol.Transaction.Result.contractResult.PRECOMPILED_CONTRACT; import static org.tron.protos.Protocol.Transaction.Result.contractResult.SUCCESS; +import com.google.protobuf.Any; import com.google.protobuf.ByteString; import lombok.extern.slf4j.Slf4j; import org.junit.Assert; @@ -12,15 +13,28 @@ 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.PQSchemeRegistry; +import org.tron.common.utils.ByteArray; +import org.tron.common.utils.Sha256Hash; import org.tron.common.utils.StringUtil; import org.tron.core.Wallet; import org.tron.core.config.args.Args; +import org.tron.core.exception.ValidateSignatureException; import org.tron.protos.Protocol.AccountType; +import org.tron.protos.Protocol.Key; +import org.tron.protos.Protocol.PQAuthSig; +import org.tron.protos.Protocol.PQScheme; +import org.tron.protos.Protocol.Permission; +import org.tron.protos.Protocol.Permission.PermissionType; import org.tron.protos.Protocol.Transaction; import org.tron.protos.Protocol.Transaction.Contract.ContractType; import org.tron.protos.Protocol.Transaction.Result; import org.tron.protos.Protocol.Transaction.Result.contractResult; import org.tron.protos.Protocol.Transaction.raw; +import org.tron.protos.contract.BalanceContract.TransferContract; +import org.tron.protos.contract.SmartContractOuterClass.TriggerSmartContract; @Slf4j public class TransactionCapsuleTest extends BaseTest { @@ -69,4 +83,465 @@ public void testRemoveRedundantRet() { Assert.assertEquals(1, transactionCapsule.getInstance().getRetCount()); Assert.assertEquals(SUCCESS, transactionCapsule.getInstance().getRet(0).getContractRet()); } -} \ No newline at end of file + + // --------------------- FN-DSA pq_auth_sig verification (V2) --------------------- + + private static final String PQ_OWNER_HEX = + "41abd4b9367799eaa3197fecb144eb71de1e049abc"; + private static final String PQ_TO_HEX = + "41548794500882809695a8a687866e76d4271a1abc"; + + private Transaction buildTransferTx(String ownerHex, int permissionId) { + TransferContract transfer = TransferContract.newBuilder() + .setOwnerAddress(ByteString.copyFrom(ByteArray.fromHexString(ownerHex))) + .setToAddress(ByteString.copyFrom(ByteArray.fromHexString(PQ_TO_HEX))) + .setAmount(1L) + .build(); + Transaction.Contract c = Transaction.Contract.newBuilder() + .setType(ContractType.TransferContract) + .setParameter(Any.pack(transfer)) + .setPermissionId(permissionId) + .build(); + raw rawData = raw.newBuilder().addContract(c).build(); + return Transaction.newBuilder().setRawData(rawData).build(); + } + + /** + * V2: bind the PQ public key to the permission via address-as-fingerprint. + * The signer address is derived from the public key by the scheme's + * fingerprint hash (see {@link PQSchemeRegistry#computeAddress}). + */ + private void putAccountWithPQPermission( + String ownerHex, byte[] pqPublicKey, PQScheme scheme) { + byte[] addr = ByteArray.fromHexString(ownerHex); + byte[] signerAddr = PQSchemeRegistry.computeAddress(scheme, pqPublicKey); + Key pqKey = Key.newBuilder() + .setAddress(ByteString.copyFrom(signerAddr)) + .setWeight(1L) + .build(); + Permission owner = Permission.newBuilder() + .setType(PermissionType.Owner) + .setPermissionName("owner") + .setThreshold(1) + .addKeys(pqKey) + .build(); + AccountCapsule acc = new AccountCapsule(ByteString.copyFrom(addr), + ByteString.copyFromUtf8("pqowner"), AccountType.Normal); + acc.updatePermissions(owner, null, java.util.Collections.emptyList()); + dbManager.getAccountStore().put(addr, acc); + } + + @Test + public void pqAuthSigBeforeActivationRejected() { + dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(0L); + Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0).toBuilder() + .addPqAuthSig(PQAuthSig.newBuilder() + .setScheme(PQScheme.FN_DSA_512) + .setPublicKey(ByteString.copyFrom(new byte[FNDSA.PUBLIC_KEY_LENGTH])) + .setSignature(ByteString.copyFrom(new byte[FNDSA.SIGNATURE_LENGTH])) + .build()) + .build(); + TransactionCapsule cap = new TransactionCapsule(tx); + try { + cap.validatePubSignature(dbManager.getAccountStore(), + dbManager.getDynamicPropertiesStore()); + Assert.fail("should reject pq_auth_sig before activation"); + } catch (ValidateSignatureException e) { + Assert.assertTrue(e.getMessage().contains("no post-quantum scheme is activated")); + } + } + + @Test + public void validPQAuthSigAccepted() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(1L); + FNDSA kp = new FNDSA(); + putAccountWithPQPermission(PQ_OWNER_HEX, kp.getPublicKey(), PQScheme.FN_DSA_512); + + Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0); + byte[] txid = Sha256Hash.of(true, tx.getRawData().toByteArray()).getBytes(); + + byte[] sig = FNDSA.sign(kp.getPrivateKey(), txid); + + Transaction signed = tx.toBuilder() + .addPqAuthSig(PQAuthSig.newBuilder() + .setScheme(PQScheme.FN_DSA_512) + .setPublicKey(ByteString.copyFrom(kp.getPublicKey())) + .setSignature(ByteString.copyFrom(sig)) + .build()) + .build(); + TransactionCapsule cap = new TransactionCapsule(signed); + Assert.assertTrue(cap.validatePubSignature(dbManager.getAccountStore(), + dbManager.getDynamicPropertiesStore())); + } + + @Test + public void duplicateSignerRejected() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(1L); + FNDSA kp = new FNDSA(); + putAccountWithPQPermission(PQ_OWNER_HEX, kp.getPublicKey(), PQScheme.FN_DSA_512); + + Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0); + byte[] txid = Sha256Hash.of(true, tx.getRawData().toByteArray()).getBytes(); + + byte[] sig = FNDSA.sign(kp.getPrivateKey(), txid); + PQAuthSig w = PQAuthSig.newBuilder() + .setScheme(PQScheme.FN_DSA_512) + .setPublicKey(ByteString.copyFrom(kp.getPublicKey())) + .setSignature(ByteString.copyFrom(sig)) + .build(); + Transaction signed = tx.toBuilder().addPqAuthSig(w).addPqAuthSig(w).build(); + + TransactionCapsule cap = new TransactionCapsule(signed); + try { + cap.validatePubSignature(dbManager.getAccountStore(), + dbManager.getDynamicPropertiesStore()); + Assert.fail("duplicate signer should be rejected"); + } catch (ValidateSignatureException e) { + Assert.assertTrue(e.getMessage().contains("has signed twice")); + } + } + + @Test + public void tamperedPQAuthSigRejected() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(1L); + FNDSA kp = new FNDSA(); + putAccountWithPQPermission(PQ_OWNER_HEX, kp.getPublicKey(), PQScheme.FN_DSA_512); + + Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0); + byte[] txid = Sha256Hash.of(true, tx.getRawData().toByteArray()).getBytes(); + + byte[] sig = FNDSA.sign(kp.getPrivateKey(), txid); + sig[0] ^= 0x01; + + Transaction signed = tx.toBuilder() + .addPqAuthSig(PQAuthSig.newBuilder() + .setScheme(PQScheme.FN_DSA_512) + .setPublicKey(ByteString.copyFrom(kp.getPublicKey())) + .setSignature(ByteString.copyFrom(sig)) + .build()) + .build(); + TransactionCapsule cap = new TransactionCapsule(signed); + try { + cap.validatePubSignature(dbManager.getAccountStore(), + dbManager.getDynamicPropertiesStore()); + Assert.fail("tampered signature should be rejected"); + } catch (ValidateSignatureException e) { + Assert.assertTrue(e.getMessage().contains("pq sig invalid")); + } + } + + @Test + public void signerNotInPermissionRejected() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(1L); + FNDSA known = new FNDSA(); + putAccountWithPQPermission(PQ_OWNER_HEX, known.getPublicKey(), PQScheme.FN_DSA_512); + + // Sign with a *different* keypair → derived address is not in the permission. + FNDSA stranger = new FNDSA(); + Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0); + byte[] txid = Sha256Hash.of(true, tx.getRawData().toByteArray()).getBytes(); + + byte[] sig = FNDSA.sign(stranger.getPrivateKey(), txid); + + Transaction signed = tx.toBuilder() + .addPqAuthSig(PQAuthSig.newBuilder() + .setScheme(PQScheme.FN_DSA_512) + .setPublicKey(ByteString.copyFrom(stranger.getPublicKey())) + .setSignature(ByteString.copyFrom(sig)) + .build()) + .build(); + TransactionCapsule cap = new TransactionCapsule(signed); + try { + cap.validatePubSignature(dbManager.getAccountStore(), + dbManager.getDynamicPropertiesStore()); + Assert.fail("signer outside permission should be rejected"); + } catch (ValidateSignatureException e) { + Assert.assertTrue(e.getMessage().contains("not contained of permission")); + } + } + + /** + * TRC20 transfer(address,uint256) call data: 4-byte selector + 32-byte address + 32-byte amount. + */ + private Transaction buildTrc20TransferTx(String ownerHex, int permissionId) { + byte[] selector = ByteArray.fromHexString("a9059cbb"); + byte[] toAddrPadded = new byte[32]; + byte[] toRaw = ByteArray.fromHexString(PQ_TO_HEX.substring(2)); // strip "41" + System.arraycopy(toRaw, 0, toAddrPadded, 12, 20); + byte[] amountPadded = new byte[32]; + amountPadded[31] = (byte) 100; // 100 tokens + byte[] callData = new byte[selector.length + toAddrPadded.length + amountPadded.length]; + System.arraycopy(selector, 0, callData, 0, 4); + System.arraycopy(toAddrPadded, 0, callData, 4, 32); + System.arraycopy(amountPadded, 0, callData, 36, 32); + + byte[] contractAddr = ByteArray.fromHexString("41a614f803b6fd780986a42c78ec9c7f77e6ded13c"); + TriggerSmartContract trigger = TriggerSmartContract.newBuilder() + .setOwnerAddress(ByteString.copyFrom(ByteArray.fromHexString(ownerHex))) + .setContractAddress(ByteString.copyFrom(contractAddr)) + .setData(ByteString.copyFrom(callData)) + .build(); + Transaction.Contract c = Transaction.Contract.newBuilder() + .setType(ContractType.TriggerSmartContract) + .setParameter(Any.pack(trigger)) + .setPermissionId(permissionId) + .build(); + raw rawData = raw.newBuilder() + .addContract(c) + .setFeeLimit(150_000_000L) + .build(); + return Transaction.newBuilder().setRawData(rawData).build(); + } + + /** + * Returns [serializedSize, packSize, maxTxPerBlock] rows ordered by signature size: + * ECKey, FN-DSA-512. + */ + private long[][] measureSizes(Transaction baseTx) { + final long blockLimit = 2_000_000L; + + // ECKey (ECDSA): 65-byte signature in `signature` field + ECKey ecKey = new ECKey(); + TransactionCapsule ecCap = new TransactionCapsule(baseTx); + ecCap.sign(ecKey.getPrivKeyBytes()); + long ecSerial = ecCap.getInstance().toByteArray().length; + long ecPack = ecCap.computeTrxSizeForBlockMessage(); + + // FN-DSA-512: variable-length signature (<= 752 bytes) + 897-byte public key + FNDSA kpFn = new FNDSA(); + byte[] txidFn = Sha256Hash.of(true, baseTx.getRawData().toByteArray()).getBytes(); + byte[] sigFn = FNDSA.sign(kpFn.getPrivateKey(), txidFn); + Transaction txFn = baseTx.toBuilder() + .addPqAuthSig(PQAuthSig.newBuilder() + .setScheme(PQScheme.FN_DSA_512) + .setPublicKey(ByteString.copyFrom(kpFn.getPublicKey())) + .setSignature(ByteString.copyFrom(sigFn)) + .build()) + .build(); + TransactionCapsule capFn = new TransactionCapsule(txFn); + long dFnSerial = txFn.toByteArray().length; + long dFnPack = capFn.computeTrxSizeForBlockMessage(); + + return new long[][]{ + {ecSerial, ecPack, blockLimit / ecPack}, + {dFnSerial, dFnPack, blockLimit / dFnPack}, + }; + } + + @Test + public void transactionSizeComparisonByScheme() { + long[][] trx = measureSizes(buildTransferTx(PQ_OWNER_HEX, 0)); + long[][] trc20 = measureSizes(buildTrc20TransferTx(PQ_OWNER_HEX, 0)); + + String[] labels = {"ECKey (ECDSA)", "FN-DSA-512"}; + System.out.println("=== TRX transfer ==="); + for (int i = 0; i < labels.length; i++) { + System.out.printf(" %s: serial=%d B pack=%d B maxTx/block=%d%n", + labels[i], trx[i][0], trx[i][1], trx[i][2]); + } + System.out.println("=== TRC20 transfer ==="); + for (int i = 0; i < labels.length; i++) { + System.out.printf(" %s: serial=%d B pack=%d B maxTx/block=%d%n", + labels[i], trc20[i][0], trc20[i][1], trc20[i][2]); + } + + // FN-DSA-512 envelope is larger than ECKey, so it fits fewer txs per block. + Assert.assertTrue(trx[1][0] > trx[0][0]); + Assert.assertTrue(trc20[1][0] > trc20[0][0]); + Assert.assertTrue(trx[1][2] < trx[0][2]); + Assert.assertTrue(trc20[1][2] < trc20[0][2]); + } + + @Test + public void pqAuthSigWrongPublicKeyLengthRejected() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(1L); + FNDSA kp = new FNDSA(); + putAccountWithPQPermission(PQ_OWNER_HEX, kp.getPublicKey(), PQScheme.FN_DSA_512); + + Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0); + byte[] txid = Sha256Hash.of(true, tx.getRawData().toByteArray()).getBytes(); + byte[] sig = FNDSA.sign(kp.getPrivateKey(), txid); + + // Truncate public key by one byte to force the length-mismatch branch. + byte[] shortPub = new byte[FNDSA.PUBLIC_KEY_LENGTH - 1]; + System.arraycopy(kp.getPublicKey(), 0, shortPub, 0, shortPub.length); + + Transaction signed = tx.toBuilder() + .addPqAuthSig(PQAuthSig.newBuilder() + .setScheme(PQScheme.FN_DSA_512) + .setPublicKey(ByteString.copyFrom(shortPub)) + .setSignature(ByteString.copyFrom(sig)) + .build()) + .build(); + TransactionCapsule cap = new TransactionCapsule(signed); + try { + cap.validatePubSignature(dbManager.getAccountStore(), + dbManager.getDynamicPropertiesStore()); + Assert.fail("wrong public key length must be rejected"); + } catch (ValidateSignatureException e) { + Assert.assertTrue(e.getMessage().contains("public key or signature length mismatch")); + } + } + + @Test + public void pqAuthSigWrongSignatureLengthRejected() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(1L); + FNDSA kp = new FNDSA(); + putAccountWithPQPermission(PQ_OWNER_HEX, kp.getPublicKey(), PQScheme.FN_DSA_512); + + Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0); + + // Empty signature is not a valid FN-DSA-512 length, hits the same branch. + Transaction signed = tx.toBuilder() + .addPqAuthSig(PQAuthSig.newBuilder() + .setScheme(PQScheme.FN_DSA_512) + .setPublicKey(ByteString.copyFrom(kp.getPublicKey())) + .setSignature(ByteString.EMPTY) + .build()) + .build(); + TransactionCapsule cap = new TransactionCapsule(signed); + try { + cap.validatePubSignature(dbManager.getAccountStore(), + dbManager.getDynamicPropertiesStore()); + Assert.fail("wrong signature length must be rejected"); + } catch (ValidateSignatureException e) { + Assert.assertTrue(e.getMessage().contains("public key or signature length mismatch")); + } + } + + @Test + public void pqAuthSigUnsupportedSchemeRejected() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(1L); + FNDSA kp = new FNDSA(); + putAccountWithPQPermission(PQ_OWNER_HEX, kp.getPublicKey(), PQScheme.FN_DSA_512); + + Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0); + byte[] txid = Sha256Hash.of(true, tx.getRawData().toByteArray()).getBytes(); + byte[] sig = FNDSA.sign(kp.getPrivateKey(), txid); + + // setSchemeValue(99) sets an unknown numeric tag; reading back yields + // PQScheme.UNRECOGNIZED, which PQSchemeRegistry.contains() rejects. + Transaction signed = tx.toBuilder() + .addPqAuthSig(PQAuthSig.newBuilder() + .setSchemeValue(99) + .setPublicKey(ByteString.copyFrom(kp.getPublicKey())) + .setSignature(ByteString.copyFrom(sig)) + .build()) + .build(); + TransactionCapsule cap = new TransactionCapsule(signed); + try { + cap.validatePubSignature(dbManager.getAccountStore(), + dbManager.getDynamicPropertiesStore()); + Assert.fail("unsupported scheme must be rejected"); + } catch (ValidateSignatureException e) { + Assert.assertTrue(e.getMessage().contains("unsupported pq scheme")); + } + } + + @Test + public void validatePubSignatureRejectsMissingSig() { + Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0); + TransactionCapsule cap = new TransactionCapsule(tx); + try { + cap.validatePubSignature(dbManager.getAccountStore(), + dbManager.getDynamicPropertiesStore()); + Assert.fail("transaction with no signatures must be rejected"); + } catch (ValidateSignatureException e) { + Assert.assertTrue(e.getMessage().contains("miss sig")); + } + } + + @Test + public void validatePubSignatureRejectsMissingContract() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(1L); + FNDSA kp = new FNDSA(); + byte[] sig = FNDSA.sign(kp.getPrivateKey(), new byte[32]); + + // No contracts in raw_data, but a pq_auth_sig is attached so we get past + // the "miss sig" guard and into the "miss contract" branch. + Transaction tx = Transaction.newBuilder() + .setRawData(raw.newBuilder().build()) + .addPqAuthSig(PQAuthSig.newBuilder() + .setScheme(PQScheme.FN_DSA_512) + .setPublicKey(ByteString.copyFrom(kp.getPublicKey())) + .setSignature(ByteString.copyFrom(sig)) + .build()) + .build(); + TransactionCapsule cap = new TransactionCapsule(tx); + try { + cap.validatePubSignature(dbManager.getAccountStore(), + dbManager.getDynamicPropertiesStore()); + Assert.fail("transaction with no contracts must be rejected"); + } catch (ValidateSignatureException e) { + Assert.assertTrue(e.getMessage().contains("miss contract")); + } + } + + @Test + public void validatePubSignatureRejectsTooManySignatures() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(1L); + int original = dbManager.getDynamicPropertiesStore().getTotalSignNum(); + try { + dbManager.getDynamicPropertiesStore().saveTotalSignNum(1); + FNDSA a = new FNDSA(); + FNDSA b = new FNDSA(); + putAccountWithPQPermission(PQ_OWNER_HEX, a.getPublicKey(), PQScheme.FN_DSA_512); + + Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0); + byte[] txid = Sha256Hash.of(true, tx.getRawData().toByteArray()).getBytes(); + byte[] sigA = FNDSA.sign(a.getPrivateKey(), txid); + byte[] sigB = FNDSA.sign(b.getPrivateKey(), txid); + + Transaction signed = tx.toBuilder() + .addPqAuthSig(PQAuthSig.newBuilder() + .setScheme(PQScheme.FN_DSA_512) + .setPublicKey(ByteString.copyFrom(a.getPublicKey())) + .setSignature(ByteString.copyFrom(sigA)) + .build()) + .addPqAuthSig(PQAuthSig.newBuilder() + .setScheme(PQScheme.FN_DSA_512) + .setPublicKey(ByteString.copyFrom(b.getPublicKey())) + .setSignature(ByteString.copyFrom(sigB)) + .build()) + .build(); + TransactionCapsule cap = new TransactionCapsule(signed); + try { + cap.validatePubSignature(dbManager.getAccountStore(), + dbManager.getDynamicPropertiesStore()); + Assert.fail("more sigs than totalSignNum must be rejected"); + } catch (ValidateSignatureException e) { + Assert.assertTrue(e.getMessage().contains("too many signatures")); + } + } finally { + dbManager.getDynamicPropertiesStore().saveTotalSignNum(original); + } + } + + @Test + public void fnDsaPQAuthSigRejectedWhenNotActivated() throws Exception { + dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(0L); + FNDSA kp = new FNDSA(); + putAccountWithPQPermission(PQ_OWNER_HEX, kp.getPublicKey(), PQScheme.FN_DSA_512); + + Transaction tx = buildTransferTx(PQ_OWNER_HEX, 0); + byte[] txid = Sha256Hash.of(true, tx.getRawData().toByteArray()).getBytes(); + + byte[] sig = FNDSA.sign(kp.getPrivateKey(), txid); + + Transaction signed = tx.toBuilder() + .addPqAuthSig(PQAuthSig.newBuilder() + .setScheme(PQScheme.FN_DSA_512) + .setPublicKey(ByteString.copyFrom(kp.getPublicKey())) + .setSignature(ByteString.copyFrom(sig)) + .build()) + .build(); + TransactionCapsule cap = new TransactionCapsule(signed); + try { + cap.validatePubSignature(dbManager.getAccountStore(), + dbManager.getDynamicPropertiesStore()); + Assert.fail("FN-DSA must be rejected when ALLOW_FN_DSA_512 is 0"); + } catch (ValidateSignatureException expected) { + Assert.assertTrue(expected.getMessage().contains("no post-quantum scheme is activated")); + } + } +} diff --git a/framework/src/test/java/org/tron/core/exception/TronErrorTest.java b/framework/src/test/java/org/tron/core/exception/TronErrorTest.java index e965ae3fd60..2a6eb950768 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_pq_keys = []\n"; + Files.write(conf, content.getBytes()); TronError thrown = assertThrows(TronError.class, () -> { - Args.setParam(new String[]{"--witness"}, TestConstants.TEST_CONF); + Args.setParam(new String[]{"--witness"}, conf.toString()); }); assertEquals(TronError.ErrCode.WITNESS_INIT, thrown.getErrCode()); } diff --git a/framework/src/test/java/org/tron/core/services/ProposalServiceTest.java b/framework/src/test/java/org/tron/core/services/ProposalServiceTest.java index 5732e6f1cde..070a49bdd85 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_FN_DSA_512; 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 testProcessAllowFnDsa512() { + dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(0L); + Assert.assertFalse(dbManager.getDynamicPropertiesStore().allowFnDsa512()); + + Proposal proposal = Proposal.newBuilder() + .putParameters(ALLOW_FN_DSA_512.getCode(), 1L).build(); + ProposalCapsule proposalCapsule = new ProposalCapsule(proposal); + boolean result = ProposalService.process(dbManager, proposalCapsule); + Assert.assertTrue(result); + + Assert.assertEquals(1L, dbManager.getDynamicPropertiesStore().getAllowFnDsa512()); + Assert.assertTrue(dbManager.getDynamicPropertiesStore().allowFnDsa512()); + + dbManager.getDynamicPropertiesStore().saveAllowFnDsa512(0L); + } } \ 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..7777d5cb4dc 100644 --- a/framework/src/test/java/org/tron/core/services/http/UtilTest.java +++ b/framework/src/test/java/org/tron/core/services/http/UtilTest.java @@ -15,6 +15,8 @@ import org.tron.core.config.args.Args; import org.tron.core.utils.TransactionUtil; import org.tron.protos.Protocol; +import org.tron.protos.Protocol.PQAuthSig; +import org.tron.protos.Protocol.PQScheme; import org.tron.protos.Protocol.Transaction; public class UtilTest extends BaseTest { @@ -166,4 +168,41 @@ public void testPackTransaction() { TransactionSignWeight txSignWeight = transactionUtil.getTransactionSignWeight(transaction); Assert.assertNotNull(txSignWeight); } + + @Test + public void roundtripPQAuthSigJson() throws Exception { + byte[] sig = new byte[752]; + byte[] pubKey = new byte[897]; + for (int i = 0; i < sig.length; i++) { + sig[i] = (byte) (i & 0xff); + } + for (int i = 0; i < pubKey.length; i++) { + pubKey[i] = (byte) ((i * 7) & 0xff); + } + PQAuthSig pqAuthSig = PQAuthSig.newBuilder() + .setScheme(PQScheme.FN_DSA_512) + .setPublicKey(ByteString.copyFrom(pubKey)) + .setSignature(ByteString.copyFrom(sig)) + .build(); + Transaction original = Transaction.newBuilder() + .setRawData(Transaction.raw.newBuilder().setTimestamp(1L).build()) + .addPqAuthSig(pqAuthSig) + .build(); + + String json = Util.printTransactionToJSON(original, false).toJSONString(); + Assert.assertTrue("JSON output should contain pq_auth_sig field", + json.contains("pq_auth_sig")); + + Transaction.Builder rebuilt = Transaction.newBuilder(); + JsonFormat.merge(json, rebuilt, false); + Transaction decoded = rebuilt.build(); + + Assert.assertEquals(1, decoded.getPqAuthSigCount()); + Assert.assertEquals(pqAuthSig.getScheme(), + decoded.getPqAuthSig(0).getScheme()); + Assert.assertEquals(pqAuthSig.getPublicKey(), + decoded.getPqAuthSig(0).getPublicKey()); + Assert.assertEquals(pqAuthSig.getSignature(), + decoded.getPqAuthSig(0).getSignature()); + } } diff --git a/framework/src/test/resources/config-test.conf b/framework/src/test/resources/config-test.conf index 71e93f84db5..6e745a38c53 100644 --- a/framework/src/test/resources/config-test.conf +++ b/framework/src/test/resources/config-test.conf @@ -348,10 +348,13 @@ genesis.block = { // and the localwitness is configured with the private key of the witnessPermissionAddress in the witness account. // When it is empty,the localwitness is configured with the private key of the witness account. -//localWitnessAccountAddress = +// localWitnessAccountAddress = localwitness = [ +] +localwitness_pq_scheme = "FN_DSA_512" +localwitness_pq_keys = [ ] block = { @@ -386,4 +389,4 @@ node.dynamicConfig.enable = true event.subscribe = { enable = false } -node.dynamicConfig.checkInterval = 0 \ No newline at end of file +node.dynamicConfig.checkInterval = 0 diff --git a/protocol/src/main/protos/core/Tron.proto b/protocol/src/main/protos/core/Tron.proto index 2b104b86d34..3e7593f7bc5 100644 --- a/protocol/src/main/protos/core/Tron.proto +++ b/protocol/src/main/protos/core/Tron.proto @@ -17,6 +17,18 @@ enum AccountType { Contract = 2; } +// Post-quantum signature scheme identifier used by PQAuthSig. +// UNKNOWN_PQ_SCHEME = 0 is the proto3 default and is reserved per the +// java-tron API evolution standard (issue #6515) so unset / unrecognized +// values are detectable by JSON consumers; it MUST never be registered +// in PQSchemeRegistry. FN_DSA_512 = 1 is the V2 launch scheme. +// New schemes are reserved for future activation (see PQSchemeRegistry). +enum PQScheme { + UNKNOWN_PQ_SCHEME = 0; + FN_DSA_512 = 1; + reserved 2 to 15; +} + // AccountId, (name, address) use name, (null, address) use address, (name, null) use name, message AccountId { bytes name = 1; @@ -242,7 +254,17 @@ message Account { message Key { bytes address = 1; - int64 weight = 2; + int64 weight = 2; +} + +// Per-signer post-quantum authentication witness for a transaction or block. +// The signing public key is carried in-band; node verifies binding via +// derived_addr = 0x41 ‖ deriveHash(scheme, public_key)[0:20] +// and matches against Permission.keys[].address. +message PQAuthSig { + PQScheme scheme = 1; + bytes public_key = 2; + bytes signature = 3; } message DelegatedResource { @@ -449,6 +471,12 @@ message Transaction { // only support size = 1, repeated list here for muti-sig extension repeated bytes signature = 2; repeated Result ret = 5; + // Post-quantum authentication signatures. Each entry binds a signing + // public key to its derived address and the corresponding signature. + // ECDSA signatures (`signature` above) and PQAuthSig entries may co-exist + // on multi-sig transactions, contributing weight independently to the + // permission's threshold. + repeated PQAuthSig pq_auth_sig = 6; } message TransactionInfo { @@ -515,6 +543,12 @@ message BlockHeader { } raw raw_data = 1; bytes witness_signature = 2; + // Post-quantum block signature. Exactly one of {witness_signature, + // pq_auth_sig} SHALL be present per block: SRs with an ECDSA-only Witness + // Permission set witness_signature; SRs whose Witness Permission carries a + // PQ-derived Key set pq_auth_sig instead. The verifier dispatches by which + // field is populated. + PQAuthSig pq_auth_sig = 3; } // block