fix(crypto): strengthen signature length validation#6776
Conversation
6185c26 to
3fa9b53
Compare
2a687f2 to
ac7889a
Compare
5897571 to
15bc00f
Compare
| for (ByteString sig : signedTransaction.getSignatureList()) { | ||
| if (!SignUtils.isValidLength(sig.size(), true)) { | ||
| String info = "Signature size is " + sig.size(); | ||
| logger.warn("Broadcast transaction {} has failed, {}.", txID, info); | ||
| return builder.setResult(false).setCode(response_code.SIGERROR) |
There was a problem hiding this comment.
[DISCUSS] Now, if a node upgrades before VERSION_4_8_2 is activated, the networking layer will immediately and unconditionally reject oversized-signature transactions, while the consensus layer will only start rejecting them after VERSION_4_8_2 is activated. Was this inconsistency between the networking-layer and consensus-layer rejection conditions intentional by design?
There was a problem hiding this comment.
Yes — this is intentional. The asymmetry follows from what each path actually sees:
- Consensus / block / PBFT validation must be able to replay history. Some historical on-chain signatures carry trailing padding within
[66, 68]bytes that ECDSA recovery silently ignores. If we unconditionally enforced[65, 68]on these paths, a node that upgraded before activation would reject otherwise-valid historical blocks and stall. That's why the upper bound is gated byForkController.signatureMaxSizeChecked()(i.e.VERSION_4_8_2fork stats), so the network reaches the new rule together. - Admission paths (
Wallet.broadcastTransaction,TransactionsMsgHandler.check,RelayService.checkHelloMessage) only see freshly-produced signatures from clients and peers — never historical chain state. A client emitting a 69-byte signature today is misbehaving regardless of fork state, so there is no replay risk in rejecting it at the edge.
Net effect on an upgraded-but-not-yet-activated node: it stops accepting freshly-broadcast oversized signatures, but still replays historical blocks correctly. This is documented in the "Compatibility" section of the PR description; happy to fold a shorter version into the code comment near the admission check if that would make the intent more discoverable.
…ength_limit # Conflicts: # framework/src/test/java/org/tron/core/capsule/BlockCapsuleTest.java
Summary
ChainConstant.MIN_SIGNATURE_SIZE(65) andChainConstant.MAX_SIGNATURE_SIZE(68), and centralize the predicate inSignUtils.isValidLength(int size, boolean checkMaxSignatureSize).ForkController.signatureMaxSizeChecked(), which passes only afterVERSION_4_8_2fork stats meet the required threshold.Wallet.broadcastTransaction,TransactionsMsgHandler.check,RelayService.checkHelloMessage).Wallet.getTransactionApprovedListon a lower-bound-only check, so historical transactions with padded signatures remain inspectable.Design Notes
Why use
[65, 68]instead of strict65?After the fork gate is active, the accepted signature-length window is
[65, 68]bytes rather than strict== 65.This is a deliberate compatibility trade-off:
r || s || v).MAX_SIGNATURE_SIZE = 68closes the previously unbounded encoding window while remaining compatible with historical data already on chain.== 65once the compatibility window is no longer needed.Because the bounds are centralized in
ChainConstantand the predicate is centralized inSignUtils.isValidLength, tightening the rule later will be straightforward.Why gate consensus paths with
ForkController.signatureMaxSizeChecked()?Consensus and block-validation paths must be able to replay history. The upper bound on these paths is enforced only after
VERSION_4_8_2passes through the existing fork-stats mechanism:SignUtils.isValidLength(size, false)enforces only the lower bound.SignUtils.isValidLength(size, true)enforces both the lower and upper bounds.ForkController.signatureMaxSizeChecked().ForkController.signatureMaxSizeChecked()delegates toForkController.instance().pass(ForkBlockVersionEnum.VERSION_4_8_2).Wallet.getTransactionApprovedListintentionally passesfalseso historical padded signatures remain readable even after the fork is active.Why enforce both bounds unconditionally on admission paths?
Admission paths see freshly created signatures, not historical chain state, so there is no need to keep a permissive window for replay:
Wallet.broadcastTransaction- local RPC entry; clients producing oversized signatures are misbehaving regardless of fork state.TransactionsMsgHandler.check- P2P transaction ingress; an oversized signature here cannot come from already-on-chain data.RelayService.checkHelloMessage- peer handshake signature, generated on demand from a fresh timestamp.Enforcing
[65, 68]unconditionally on these paths blocks malformed traffic at the edge without affecting historical chain replay.Compatibility
This PR tightens validation behavior, but it does not apply the new upper bound uniformly to every path:
VERSION_4_8_2passes, consensus/block/PBFT validation paths enforce only the lower bound (>= 65).VERSION_4_8_2passes, consensus/block/PBFT validation paths enforce both bounds ([65, 68]).Wallet.broadcastTransaction,TransactionsMsgHandler.check,RelayService.checkHelloMessage) enforce both bounds ([65, 68]) unconditionally.Wallet.getTransactionApprovedListremains intentionally more permissive and continues to skip the upper bound so historical transactions can still be inspected.This means admission policy can be stricter than pre-fork consensus replay. That is intentional because admission only handles fresh inbound data, while consensus replay must preserve historical compatibility.
Breaking Changes
The following public method signatures changed:
TransactionCapsule.checkWeight(Permission, List<ByteString>, byte[], List<ByteString>)(Permission, List<ByteString>, byte[], List<ByteString>, boolean)All in-tree callers were updated.
External JVM consumers of the
chainbaseartifact, such as SDKs, signing services, and third-party tools, must update these call sites. Existing callers will fail at compile time rather than silently misbehave at runtime.Main Code Changes
common/.../config/Parameter.javaMIN_SIGNATURE_SIZE,MAX_SIGNATURE_SIZE, andVERSION_4_8_2/BLOCK_VERSION = 35crypto/.../SignUtils.javaisValidLength(int, boolean)chainbase/.../ForkController.javasignatureMaxSizeChecked()as theVERSION_4_8_2fork gatechainbase/.../DynamicPropertiesStore.javaALLOW_TVM_OSAKA-basedsignatureMaxSizeChecked()helperchainbase/.../TransactionCapsule.javacheckWeight;validateSignatureuses the fork gate, whileaddSignvalidates existing signatures with an unconditional max-size checkchainbase/.../BlockCapsule.javavalidateSignatureframework/.../Wallet.javabroadcastTransaction; keep the read-only approved-list path lower-bound-onlyframework/.../TransactionsMsgHandler.javacheck()for the P2P transaction ingress pathframework/.../RelayService.javacheckHelloMessageframework/.../PbftDataSyncHandler.javaValidPbftSignTaskframework/.../PbftMsgHandler.javaconsensus/.../PbftBaseMessage.javaanalyzeSignatureconsensus/.../PbftMessageHandle.javaactuator/.../TransactionUtil.javacheckWeightusage lower-bound-onlyTest Coverage Added or Updated
ForkControllerTestForkController.signatureMaxSizeChecked()uses realVERSION_4_8_2fork stats: false below threshold, true once 80% stats pass.TransactionCapsuleTestBlockCapsuleTestTransactionExpireTestgetTransactionApprovedListafter Osaka.TransactionUtilTestgetTransactionSignWeight.WalletMockTestbroadcastTransactionadmission path; accept[65, 68].TransactionsMsgHandlerTest[65, 68].RelayServiceTestPbftDataSyncHandlerTestPbftMsgHandlerTestVerification
./gradlew framework:test --tests org.tron.core.ForkControllerTest./gradlew framework:test --tests org.tron.core.capsule.BlockCapsuleTest --tests org.tron.core.capsule.TransactionCapsuleTest --tests org.tron.core.net.messagehandler.PbftDataSyncHandlerTest --tests org.tron.core.net.messagehandler.PbftMsgHandlerTest./gradlew framework:checkstyleMain framework:checkstyleTestgit diff --check