Skip to content

Harden account key validation#3791

Draft
codchen wants to merge 1 commit into
mainfrom
codex/account-key-hardening
Draft

Harden account key validation#3791
codchen wants to merge 1 commit into
mainfrom
codex/account-key-hardening

Conversation

@codchen

@codchen codchen commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Add defensive validation for account key metadata before active ante handling persists it.
  • Keep invalid account key metadata from being written into account state.
  • Add regression coverage for invalid account key metadata in the active app ante path.

Validation

  • go test ./app -run 'Test(CosmosStatelessChecksRejectsInvalidNestedMultisigKey|CheckPubKeysRejectsInvalidNestedMultisigKey)' -count=1
  • go test ./app/ante -count=1
  • gofmt -s -l .
  • goimports -l app/ante/cosmos_checktx.go app/cosmos_checktx_test.go

Note: goimports -l . reports existing generated files and nested local worktree files unrelated to this branch.

@codchen
codchen force-pushed the codex/account-key-hardening branch from b227e60 to 2c1f633 Compare July 23, 2026 03:24
@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown

The latest Buf updates on your PR. Results from workflow Buf / buf (pull_request).

BuildFormatLintBreakingUpdated (UTC)
✅ passed✅ passed✅ passed✅ passedJul 23, 2026, 1:26 PM

@codchen
codchen force-pushed the codex/account-key-hardening branch from 2c1f633 to 646d817 Compare July 23, 2026 03:25
@codecov

codecov Bot commented Jul 23, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 61.76471% with 13 lines in your changes missing coverage. Please review.
✅ Project coverage is 59.23%. Comparing base (26d7d6c) to head (b62b16f).

Files with missing lines Patch % Lines
app/ante/cosmos_checktx.go 61.76% 9 Missing and 4 partials ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #3791      +/-   ##
==========================================
- Coverage   60.19%   59.23%   -0.97%     
==========================================
  Files        2328     2236      -92     
  Lines      194502   184036   -10466     
==========================================
- Hits       117088   109011    -8077     
+ Misses      66862    65322    -1540     
+ Partials    10552     9703     -849     
Flag Coverage Δ
sei-chain-pr 49.30% <61.76%> (?)
sei-db 70.41% <ø> (ø)
sei-db-state-db ?

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
app/ante/cosmos_checktx.go 39.19% <61.76%> (+2.10%) ⬆️

... and 92 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@codchen
codchen force-pushed the codex/account-key-hardening branch from 646d817 to ad38c3a Compare July 23, 2026 03:55
@codchen
codchen marked this pull request as ready for review July 23, 2026 11:43
@cursor

cursor Bot commented Jul 23, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Touches consensus-adjacent CheckTx ante logic for all Cosmos txs; behavior change may reject txs that previously slipped through, but reduces risk of bad pubkey state and panics.

Overview
Adds defensive public-key validation on the Cosmos CheckTx path so malformed signer keys are rejected before they can be written to account state or pass stateless checks.

New helpers (validatePubKey, recursive multisig checks, panic recovery) run in CosmosStatelessChecks (before pubkey/signer address comparison) and CheckPubKeys (before SetPubKey / SetAccount). Invalid keys—including nested multisig with a malformed child secp256k1 key—return ErrInvalidPubKey without persisting the pubkey.

Regression tests in app/cosmos_checktx_test.go cover rejection in stateless checks and in CheckPubKeys, and assert the account remains without a stored pubkey on failure.

Reviewed by Cursor Bugbot for commit b62b16f. Bugbot is set up for automated code reviews on this repo. Configure here.

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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, the default branch's len(pubKey.Address()) == 0 check 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.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

@masih
masih self-requested a review July 23, 2026 12:15
Comment thread sei-cosmos/x/auth/ante/sigverify.go Outdated
return validatePubKeyNoPanic(pubKey)
}

func validatePubKeyNoPanic(pubKey cryptotypes.PubKey) error {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We call this function exactly once; I would avoid over refactoring it and would inline it into validatePubKey.

Comment thread sei-cosmos/x/auth/ante/sigverify.go Outdated
}
}

if len(pubKey.Address()) == 0 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This would accept any byte slice of length 33. Can we harden the check? Address() just wraps the bytes into type.

@codchen
codchen force-pushed the codex/account-key-hardening branch from ad38c3a to b62b16f Compare July 23, 2026 13:23
@codchen
codchen marked this pull request as draft July 23, 2026 13:24

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ 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.

return sdkerrors.Wrapf(sdkerrors.ErrInvalidPubKey, "invalid public key type: %T", pubKey)
}
return nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Weak pubkey byte validation

Medium Severity

validatePubKeyNoPanic treats a non-empty Address() as proof the key is valid. For secp256k1, Address() only checks length 33 and hashes the bytes, so any 33-byte slice passes—including keys that are not valid curve points. That lets invalid nested key metadata reach account state when other multisig signers satisfy the threshold, which undercuts the goal of keeping invalid account key metadata out of state. The same file already uses btcec.ParsePubKey elsewhere for real secp256k1 checks.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b62b16f. Configure here.

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 validatePubKey guard is only added to the custom legacyabci ante path (CosmosStatelessChecks/CheckPubKeys), not to the stock ante.SetPubKeyDecorator used by the BaseApp-registered NewAnteHandler. That decorator still calls pk.Address()/SetPubKey without the new validation. Impact is limited: the live CheckTx/DeliverTx consensus path runs through legacyabci (now covered), and the BaseApp handler is only reached via Simulate, where baseapp.runTx recovers 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-breaking but 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.md is 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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[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.

Comment on lines +257 to +269
func validatePubKeyNoPanic(pubKey cryptotypes.PubKey) error {
switch pk := pubKey.(type) {
case nil:
return sdkerrors.Wrap(sdkerrors.ErrInvalidPubKey, "missing public key")
case *kmultisig.LegacyAminoPubKey:
return validateMultisigPubKey(pk)
default:
if len(pubKey.Address()) == 0 {
return sdkerrors.Wrapf(sdkerrors.ErrInvalidPubKey, "invalid public key type: %T", pubKey)
}
return nil
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 validatePubKeyNoPanic's default case only checks that pubKey.Address() is non-empty, but for secp256k1.PubKey, Address() just hashes the raw bytes and only panics on wrong length — it never checks the bytes are a valid point on the curve. So any 33-byte garbage slice (e.g. as a multisig child key) passes validation and can be persisted into account state via CheckPubKeys' SetAccount before signature verification runs, which is exactly the gap maintainer masih flagged inline ("This would accept any byte slice of length 33... Can we harden the check?") and the PR's own test only covers a wrong-length (34-byte) key, not this correctly-sized case.

Extended reasoning...

The bug: validatePubKeyNoPanic's default branch (app/ante/cosmos_checktx.go:257-269) is meant to be the hardened validity check for all non-multisig pubkey types, but it only asserts len(pubKey.Address()) == 0. For secp256k1.PubKey, Address() (sei-cosmos/crypto/keys/secp256k1/secp256k1.go:149-158) is:

func (pubKey *PubKey) Address() crypto.Address {
	if len(pubKey.Key) != PubKeySize {
		panic("length of pubkey is incorrect")
	}
	sha := sha256.Sum256(pubKey.Key)
	hasherRIPEMD160 := ripemd160.New()
	hasherRIPEMD160.Write(sha[:])
	return crypto.Address(hasherRIPEMD160.Sum(nil))
}

It panics only when len(Key) != 33, and otherwise blindly hashes the raw bytes — it never calls anything like btcec.ParsePubKey to confirm the bytes decode to an actual point on the secp256k1 curve. RIPEMD160(SHA256(x)) is never empty for any non-empty input, so for any 33-byte slice, len(pubKey.Address()) == 0 is false and validatePubKeyNoPanic returns nil (valid).

Trigger path: This matters most for the nested-multisig path this PR explicitly targets. validateMultisigPubKey recurses into each child key via validatePubKeyvalidatePubKeyNoPanic. A LegacyAminoPubKey with threshold 1 and children [validKey, garbage33ByteKey] will have its garbage child pass the default branch, satisfy the threshold/count checks, and the outer multisig's own Address() is also non-empty, so the whole multisig object validates. An attacker who signs only with the valid child key satisfies the threshold, so CheckSignatures passes, and CheckPubKeys (cosmos_checktx.go:419-440) then calls acc.SetPubKey(pk) and accountKeeper.SetAccount(ctx, acc), persisting the multisig pubkey — including the garbage, off-curve child — into account state before/regardless of any check on that child ever succeeding cryptographically.

Why the PR's own regression test misses it: buildNestedMultisigTx in app/cosmos_checktx_test.go constructs malformedPubKey := &secp256k1.PubKey{Key: bytes.Repeat([]byte{1}, secp256k1.PubKeySize+1)} — i.e. 34 bytes, one byte too long. That trips the len(Key) != PubKeySize panic inside Address(), which validatePubKey's recover() wrapper turns into an ErrInvalidPubKey. The test therefore only proves the wrong-length case is caught; it does not exercise a correctly-sized (33-byte) but off-curve key, which is the case the maintainer's inline comment was explicitly about ("This would accept any byte slice of length 33... Address() just wraps the bytes into type. Can we harden the check?").

Step-by-step proof:

  1. Take any 33 arbitrary bytes, e.g. key := bytes.Repeat([]byte{0x02}, 33) — note 0x02 as the first byte mimics a compressed-point prefix but the remaining bytes are not guaranteed to correspond to a valid x-coordinate on the curve; even fully random 33-byte inputs work, since curve validity is never checked.
  2. pk := &secp256k1.PubKey{Key: key}. pk.Address() does not panic (len(key) == 33) and returns RIPEMD160(SHA256(key)), a non-empty 20-byte value.
  3. validatePubKeyNoPanic(pk) hits the default case, checks len(pk.Address()) == 0 → false → returns nil. The garbage key is "valid."
  4. Embed pk as a child of a threshold-1 LegacyAminoPubKey alongside one real key. validateMultisigPubKey validates each child via validatePubKey; the garbage child returns nil as shown above, threshold (1) ≤ len(pubKeys) (2), and the multisig's own Address() is non-empty → the whole multisig passes validatePubKeyNoPanic.
  5. Sign the tx with only the real child key (satisfies threshold 1). CosmosStatelessChecks and CheckSignatures both pass.
  6. CheckPubKeys calls acc.SetPubKey(multisigPubKey) (which stores the Any-packed multisig, garbage child included) and accountKeeper.SetAccount(ctx, acc), persisting it into state — the exact "invalid account key metadata written into account state" this PR says it exists to prevent.

Impact: The garbage child key itself cannot be used to produce a valid signature (it isn't a real keypair), so this is not a full auth bypass — the specific tx used to plant it must still be validly signed by the threshold of legitimate children. But the PR is billed as a hardening/defensive change (app-hash-breaking label, consensus-adjacent ante logic) whose stated purpose is exactly to stop malformed key metadata from reaching account state, and a maintainer explicitly asked for this to be hardened before merge. As written, secp256k1 (and similarly ed25519, whose Address() also just hashes fixed-length bytes without curve validation) garbage keys of the correct byte length still get through and get persisted.

Fix: For the secp256k1.PubKey case specifically, validate curve membership before accepting the key, e.g. by attempting btcec.ParsePubKey(pk.Key) (already imported and used elsewhere in this file, in UpdateSigners) and rejecting on error, rather than relying solely on Address() producing a non-empty result.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants