Harden account key validation#3791
Conversation
b227e60 to
2c1f633
Compare
|
The latest Buf updates on your PR. Results from workflow Buf / buf (pull_request).
|
2c1f633 to
646d817
Compare
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #3791 +/- ##
==========================================
- Coverage 60.19% 59.23% -0.97%
==========================================
Files 2328 2236 -92
Lines 194502 184040 -10462
==========================================
- Hits 117088 109009 -8079
+ Misses 66862 65327 -1535
+ Partials 10552 9704 -848
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
646d817 to
ad38c3a
Compare
PR SummaryMedium Risk Overview Regression tests cover multisigs with wrong-length or invalid secp256k1 child keys for both entry points and assert the account pubkey stays unset when Reviewed by Cursor Bugbot for commit 8f0f81e. Bugbot is set up for automated code reviews on this repo. Configure here. |
There was a problem hiding this comment.
A focused, well-tested hardening change that validates account pubkey metadata (including nested multisig keys) in the SetPubKeyDecorator ante handler before persistence, with panic recovery correctly guarding against wrong-length key panics. No correctness or security blockers found; only minor observations.
Findings: 0 blocking | 3 non-blocking | 0 posted inline
Blockers
- None at the file/PR level.
Non-blocking
- Cursor second-opinion review (./cursor-review.md) was empty — that pass produced no output. Codex (./codex-review.md) reported no material issues, consistent with this review.
- The PR is labeled
app-hash-breaking: the new validation now rejects transactions that previously could persist malformed key metadata (e.g. a multisig with an invalid nested key), which is the intended hardening. This is not gated behind an upgrade constant, so it relies on release coordination to roll out uniformly. Worth confirming the rollout plan treats this as a consensus-affecting change, but per repo review guidelines Sei's version-by-version sync model makes this acceptable and not a correctness bug. - Minor: in
validatePubKeyNoPanic, thedefaultbranch'slen(pubKey.Address()) == 0check is largely defensive/redundant for well-known key types (Address() returns a fixed-length hash or panics on bad input, which is caught by the recover in validatePubKey). It's harmless, but the effective guard is the panic recovery rather than the length check.
There was a problem hiding this comment.
I didn't find any correctness issues, but this changes the SetPubKeyDecorator ante handler — code that runs on every transaction and is marked app-hash-breaking — so it's worth a human sign-off before merging.
What was reviewed: the new validatePubKey/validateMultisigPubKey gating (missing keys, zero/over threshold, recursive multisig members, empty Address()), the simulate-mode placeholder normalization, and the panic-to-error recovery. Also checked the ruled-out candidates (guard-ordering, dead-code Address() check, multisig nesting CPU cost) against the current diff.
Extended reasoning...
Overview
The PR adds pre-persistence validation of signer public keys in SetPubKeyDecorator.AnteHandle (sei-cosmos/x/auth/ante/sigverify.go), rejecting malformed keys (missing, empty Address(), multisig threshold of 0 or exceeding key count, malformed nested multisig members) before SetPubKey/SetAccount are called, and normalizes empty secp256k1 placeholder keys during simulation. It adds two new regression tests covering the rejection path and the simulation placeholder path.
Security risks
This is squarely auth/signature-verification code executed in the ante handler for every transaction. A logic error here could either (a) allow malformed key metadata to be persisted into account state, or (b) incorrectly reject valid transactions/simulations chain-wide. The PR is marked app-hash-breaking, meaning it can change consensus-visible behavior across nodes — exactly the kind of change where a subtle mismatch between validation logic and existing key types would be very costly to catch after the fact.
Level of scrutiny
The bug hunting system found no issues and its candidate findings (guard-ordering around the pre-existing pubkey check, the 'dead code' Address()==0 check, multisig nesting cost) were investigated and refuted. Independently, I confirmed the panic-recovery wrapper is sound, the multisig threshold/address checks look correct against the existing LegacyAminoPubKey/secp256k1/secp256r1/ed25519 Address() implementations, and the simulate-mode normalization only affects empty-key secp256k1 placeholders. Despite the clean result, this class of change (consensus-affecting, security-sensitive, touches every tx) warrants a human reviewer's sign-off per the repo's own risk profile, not an automatic approval.
Other factors
Test coverage for the new behavior looks reasonable (nested-multisig rejection, simulation placeholder acceptance), and CI (Buf, Codecov) is green. No outstanding reviewer comments need addressing.
| return validatePubKeyNoPanic(pubKey) | ||
| } | ||
|
|
||
| func validatePubKeyNoPanic(pubKey cryptotypes.PubKey) error { |
There was a problem hiding this comment.
We call this function exactly once; I would avoid over refactoring it and would inline it into validatePubKey.
| } | ||
| } | ||
|
|
||
| if len(pubKey.Address()) == 0 { |
There was a problem hiding this comment.
This would accept any byte slice of length 33. Can we harden the check? Address() just wraps the bytes into type.
ad38c3a to
b62b16f
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit b62b16f. Configure here.
There was a problem hiding this comment.
A small, well-targeted defensive fix that validates account key metadata (including nested multisig subkeys) before it is written to account state on the live cosmos ante path, with focused regression tests. No blocking issues; one consistency/coverage note carried over from Codex.
Findings: 0 blocking | 4 non-blocking | 1 posted inline
Blockers
- None at the file/PR level.
Non-blocking
- Consistency/coverage (from Codex, downgraded to non-blocking): the same
validatePubKeyguard is only added to the customlegacyabciante path (CosmosStatelessChecks/CheckPubKeys), not to the stockante.SetPubKeyDecoratorused by the BaseApp-registeredNewAnteHandler. That decorator still callspk.Address()/SetPubKeywithout the new validation. Impact is limited: the live CheckTx/DeliverTx consensus path runs throughlegacyabci(now covered), and the BaseApp handler is only reached viaSimulate, wherebaseapp.runTxrecovers panics (baseapp.go:904) into an error rather than halting. Still, consider sharing one validation helper across both paths, or adding a test that exercises the malformed nested key through the BaseApp ante/simulate path, to prevent future drift. - The PR is labeled
app-hash-breakingbut the new rejection is unconditional (no in-code upgrade-height gate). This is consistent with a coordinated release rollout and the repo review guidelines, so not flagged as a bug — just confirm the change ships as part of a coordinated upgrade so all validators begin rejecting these keys at the same height. cursor-review.mdis empty — the Cursor second-opinion pass produced no output.- 1 suggestion(s)/nit(s) flagged inline on specific lines.
| return validatePubKeyNoPanic(pubKey) | ||
| } | ||
|
|
||
| func validatePubKeyNoPanic(pubKey cryptotypes.PubKey) error { |
There was a problem hiding this comment.
[nit] Minor: the case nil arm only matches an untyped nil interface, not a typed nil (e.g. (*secp256k1.PubKey)(nil)), which would fall through to default and panic in pubKey.Address(). That's fine here because validatePubKey's recover() converts it into an ErrInvalidPubKey, but worth being aware of. No change required.
There was a problem hiding this comment.
Focused, well-tested defensive hardening that validates public-key metadata (including nested multisig keys, with panic recovery) before it is compared or persisted into account state in the DeliverTx/CheckTx ante path. No correctness or security issues found; only a minor error-message nit and a couple of process notes.
Findings: 0 blocking | 4 non-blocking | 1 posted inline
Blockers
- None at the file/PR level.
Non-blocking
- The Cursor second-opinion review (cursor-review.md) is empty — that pass produced no output. Codex reported "No material issues found," which matches my own assessment.
- Confirmation only (not a defect): both CheckPubKeys and CosmosStatelessChecks run in the DeliverTx consensus path and this change rejects transactions that were previously accepted (or panicked), consistent with the app-hash-breaking label. There is no in-code upgrade/height gate; per Sei's coordinated-release deployment model this is expected, but worth confirming the change ships in a coordinated release so validators switch behavior at the same height.
- Minor: the validatePubKey/validatePubKeyNoPanic split plus per-node recover means Address() is effectively invoked twice for valid keys (once in validation, once in the caller); negligible on the ante hot path but noting it. This also incidentally hardens the later CountSubKeys call, which is a nice side benefit.
- 1 suggestion(s)/nit(s) flagged inline on specific lines.
| return validateMultisigPubKey(pk) | ||
| default: | ||
| if len(pubKey.Address()) == 0 { | ||
| return sdkerrors.Wrapf(sdkerrors.ErrInvalidPubKey, "invalid public key type: %T", pubKey) |
There was a problem hiding this comment.
[nit] Nit: the error message says "invalid public key type" but this branch is actually reached when the key's Address() is empty, not because of its type. Consider a message like "public key has empty address" (keeping the %T for context) to make the failure clearer in logs.
There was a problem hiding this comment.
The PR adds recursive, panic-safe pubkey validation in the active app ante path (CosmosStatelessChecks and CheckPubKeys) plus regression tests for nested-multisig secp256k1 children, which is a solid, well-scoped hardening. The main gap is that the cryptographic decode check only covers secp256k1: ed25519/sr25519 keys fall through the default branch, so a correct-length but invalid-curve key still slips through — the same class of hole the PR is trying to close.
Findings: 0 blocking | 4 non-blocking | 1 posted inline
Blockers
- None at the file/PR level.
Non-blocking
- Test coverage is secp256k1-only. Add regression cases for a 32-byte non-canonical ed25519 and sr25519 child (both as top-level and as nested multisig children) so the intended hardening is asserted for every registered pubkey type, not just secp256k1.
- Cursor's second-opinion review file (cursor-review.md) was empty — that pass produced no output. Codex's single P1 finding is incorporated below.
- Minor: the
case nil:arm of the type switch is effectively dead for the two call sites (both guardpk == nilbeforehand) and the nil-interface case is also handled by the per-typepk == nilchecks; harmless but slightly redundant. - 1 suggestion(s)/nit(s) flagged inline on specific lines.
| } | ||
| return nil | ||
| default: | ||
| if len(pubKey.Address()) == 0 { |
There was a problem hiding this comment.
[suggestion] Asymmetric validation: only secp256k1 gets a cryptographic decode (btcec.ParsePubKey). ed25519 and sr25519 are both registered pubkey implementations (see sei-cosmos/crypto/codec/proto.go) and land in this default branch, where the only check is Address() being non-empty.
For those types Address() panics on a wrong-length key (caught by the recover), but a correct-length (32-byte) yet non-canonical / invalid-curve key returns a hash and passes. So a malformed ed25519/sr25519 key — e.g. an invalid child in a multisig whose threshold is met by other signers — still bypasses the hardening and can be persisted as inactive account metadata, which is exactly the class of issue this PR sets out to close (matching Codex's P1).
Consider validating each registered key type's encoding here (length + curve/point decode for ed25519 and sr25519), or documenting why leaving them at the Address()-only check is acceptable for Sei's account model.
There was a problem hiding this comment.
Beyond the inline finding, I checked whether running validatePubKey (with its per-key btcec.ParsePubKey calls) ahead of the TxSigLimit/gas-metering checks opens a CPU-DoS vector — it's bounded by the same tx-byte-size cap that already gates all pre-gas-metering stateless checks (ValidateBasic, pubkey/signer matching, etc.), so this PR doesn't introduce a new unbounded-cost path beyond what already existed.
Extended reasoning...
This PR is security-sensitive (auth ante / account-key validation), so it warrants human review regardless. Beyond the inline nit finding, the bug-hunting run also raised a candidate CPU-DoS concern: validatePubKey (including btcec.ParsePubKey per secp256k1 key, and recursion into nested multisig children) runs in CosmosStatelessChecks before any gas metering or TxSigLimit check. I traced this and it isn't a new regression introduced by this PR: the number of keys/nesting an attacker can pack into a request is bounded by the same tx-byte-size ceiling that already gates every other pre-gas stateless check in this path (ValidateBasic, GetPubKeys, address matching), and per-key ParsePubKey cost is small relative to that ceiling, so it doesn't add a meaningfully larger DoS surface than what already existed before this PR.
| default: | ||
| if len(pubKey.Address()) == 0 { | ||
| return sdkerrors.Wrapf(sdkerrors.ErrInvalidPubKey, "invalid public key type: %T", pubKey) | ||
| } | ||
|
|
||
| return nil | ||
| } |
There was a problem hiding this comment.
🟡 The validatePubKey default branch (app/ante/cosmos_checktx.go:293-299) only checks that pubKey.Address() is non-empty, but ed25519/sr25519's Address() implementations never validate the bytes decode to a real curve point — they only panic on wrong length and otherwise hash the raw bytes. This is the same class of gap the PR fixed for secp256k1 via btcec.ParsePubKey (and which seidroid[bot] flagged post-hoc in the timeline), but left open for the other two registered key types. A correctly-sized (32-byte) garbage ed25519/sr25519 key can still be embedded as a non-signing child of a threshold multisig and get persisted into account state.
Extended reasoning...
The bug: validatePubKey's default case (app/ante/cosmos_checktx.go:293-299) is the fallback validation path for every registered pubkey type that isn't *kmultisig.LegacyAminoPubKey or *secp256k1.PubKey. It only checks len(pubKey.Address()) == 0:
default:
if len(pubKey.Address()) == 0 {
return sdkerrors.Wrapf(sdkerrors.ErrInvalidPubKey, "invalid public key type: %T", pubKey)
}
return nilFor ed25519.PubKey, Address() (sei-cosmos/crypto/keys/ed25519/ed25519.go:162-170) panics only if len(pubKey.Key) != PubKeySize and otherwise returns sha256(Key)[:20] — no check that Key is a valid point on the Edwards curve. For sr25519.PubKey, Address() (sei-cosmos/crypto/keys/sr25519/pubkey.go:20-22) delegates to the underlying tendermint Key.Address(), which has the identical length-only behavior. SHA256 output is never all-zero-length for a non-empty input, so len(pubKey.Address()) == 0 is always false for a correctly-sized key — meaning no cryptographic validation ever happens for these two types, unlike the dedicated *secp256k1.PubKey branch which calls btcec.ParsePubKey to confirm the bytes decode to an actual curve point.
Trigger path: This is exploitable through the exact nested-multisig mechanism this PR's own regression tests target. validatePubKey's *kmultisig.LegacyAminoPubKey case recurses into each child via validatePubKey, so a LegacyAminoPubKey with threshold 1 and children [validSecp256k1Key, garbage32ByteEd25519Key] will have its garbage child pass the default branch untouched. ConsumeMultisignatureVerificationGas / actual signature verification only iterate over children whose bit is set in the multisig's BitArray, so the non-signing garbage child is never cryptographically checked at all. An attacker signs only with the valid child to satisfy the threshold; CheckPubKeys (app/ante/cosmos_checktx.go) then calls acc.SetPubKey(pk) and accountKeeper.SetAccount(ctx, acc), persisting the whole multisig — garbage child included — into account state.
Why existing code/tests don't catch it: The PR's new tests (TestCosmosStatelessChecksRejectsInvalidNestedMultisigKey, TestCheckPubKeysRejectsInvalidNestedMultisigKey) only exercise malformed secp256k1 children (wrong length, and correct-length-but-off-curve). Nothing exercises an ed25519 or sr25519 child, so the asymmetry between the hardened secp256k1 path and the untouched default path is untested. This is also the exact gap maintainer masih raised inline ("This would accept any byte slice of length 33 ... Can we harden the check?") and that seidroid[bot] explicitly flagged post-hoc in the timeline as unaddressed for ed25519/sr25519.
Step-by-step proof:
- Construct
garbage := &ed25519.PubKey{Key: bytes.Repeat([]byte{0xAB}, 32)}— 32 arbitrary bytes, not guaranteed to be a valid compressed Edwards point. garbage.Address()does not panic (len(Key) == 32 == PubKeySize) and returnssha256(Key)[:20], a non-empty 20-byte value.validatePubKey(garbage)hits thedefaultcase,len(garbage.Address()) == 0isfalse, so it returnsnil— the garbage key is "valid".- Build
multisigPubKey := kmultisig.NewLegacyAminoPubKey(1, []cryptotypes.PubKey{validSecp256k1Key, garbage}).validatePubKeyon the multisig recurses into both children;validSecp256k1Keypasses viabtcec.ParsePubKey,garbagepasses via the flawed default branch above; the multisig's ownAddress()is non-empty, so the whole thing validates. - Sign the tx with only the private key for
validSecp256k1Key, setting the bit for that child in the multisig'sBitArrayand leavinggarbage's bit unset. Threshold (1) is met. CheckSignatures→DefaultSigVerificationGasConsumer→ConsumeMultisignatureVerificationGasonly iterates over set bits, sogarbageis never passed to any signature-verification or curve-validation logic.CheckPubKeyspersists the multisig pubkey (includinggarbage) viaacc.SetPubKey+accountKeeper.SetAccount, directly contradicting the PR's stated goal of "keeping invalid account key metadata out of state."
Fix: Add real point/format validation for ed25519 and sr25519 in validatePubKey, mirroring the secp256k1 branch — e.g. decompress/validate the Edwards point for ed25519, and the corresponding sr25519 compressed-point check — rather than relying on Address() non-emptiness as a proxy for validity.
Severity: nit, not blocking. The persisted metadata is inert: a non-signing garbage child can never produce a valid signature on its own, so it grants no attacker capability and causes no panic or consensus divergence downstream (UpdateSigners's btcec.ParsePubKey on the multisig pubkey, and normal signature-verification flows, all handle it gracefully). It's a completeness gap in this PR's stated hardening goal — matching what seidroid[bot] already raised as a [suggestion] — not a concrete runtime failure.
🔬 also observed by seidroid[bot]

Summary
Validation
go test ./app -run 'Test(CosmosStatelessChecksRejectsInvalidNestedMultisigKey|CheckPubKeysRejectsInvalidNestedMultisigKey)' -count=1go test ./app/ante -count=1gofmt -s -l .goimports -l app/ante/cosmos_checktx.go app/cosmos_checktx_test.goNote:
goimports -l .reports existing generated files and nested local worktree files unrelated to this branch.