Skip to content

Harden account key validation#3791

Open
codchen wants to merge 3 commits into
mainfrom
codex/account-key-hardening
Open

Harden account key validation#3791
codchen wants to merge 3 commits 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 24, 2026, 3:35 AM

@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 50.00000% with 19 lines in your changes missing coverage. Please review.
✅ Project coverage is 59.23%. Comparing base (26d7d6c) to head (8f0f81e).
⚠️ Report is 3 commits behind head on main.

Files with missing lines Patch % Lines
app/ante/cosmos_checktx.go 50.00% 14 Missing and 5 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   184040   -10462     
==========================================
- Hits       117088   109009    -8079     
+ Misses      66862    65327    -1535     
+ Partials    10552     9704     -848     
Flag Coverage Δ
sei-chain-pr 52.37% <50.00%> (?)
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 Δ
sei-cosmos/x/auth/ante/sigverify.go 68.18% <ø> (ø)
app/ante/cosmos_checktx.go 38.30% <50.00%> (+1.22%) ⬆️

... 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-critical CheckTx/account pubkey handling; changes reject more txs but reduce risk of corrupt account keys.

Overview
Adds validatePubKey on the active CheckTx path (app/ante/cosmos_checktx.go) and runs it in CosmosStatelessChecks (before signer address checks) and CheckPubKeys (before SetPubKey / account write). Invalid or malformed keys—especially nested multisig children and secp256k1 (size + curve parse)—are rejected so bad pubkey metadata never lands in account state; panics during validation are turned into ErrInvalidPubKey.

Regression tests cover multisigs with wrong-length or invalid secp256k1 child keys for both entry points and assert the account pubkey stays unset when CheckPubKeys fails. A comment on SigVerificationDecorator in sei-cosmos/x/auth/ante/sigverify.go notes that normal Sei app ante logic lives under app/ante/.

Reviewed by Cursor Bugbot for commit 8f0f81e. 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.

Comment thread app/ante/cosmos_checktx.go Outdated

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

Comment thread app/ante/cosmos_checktx.go Outdated
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 thread app/ante/cosmos_checktx.go Outdated
@codchen
codchen marked this pull request as ready for review July 24, 2026 03:27

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

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.

Comment thread app/ante/cosmos_checktx.go Outdated
return validateMultisigPubKey(pk)
default:
if len(pubKey.Address()) == 0 {
return sdkerrors.Wrapf(sdkerrors.ErrInvalidPubKey, "invalid public key type: %T", pubKey)

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

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

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 guard pk == nil beforehand) and the nil-interface case is also handled by the per-type pk == nil checks; harmless but slightly redundant.
  • 1 suggestion(s)/nit(s) flagged inline on specific lines.

}
return nil
default:
if len(pubKey.Address()) == 0 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

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

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.

Comment on lines +293 to +299
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.

🟡 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 nil

For 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:

  1. Construct garbage := &ed25519.PubKey{Key: bytes.Repeat([]byte{0xAB}, 32)} — 32 arbitrary bytes, not guaranteed to be a valid compressed Edwards point.
  2. garbage.Address() does not panic (len(Key) == 32 == PubKeySize) and returns sha256(Key)[:20], a non-empty 20-byte value.
  3. validatePubKey(garbage) hits the default case, len(garbage.Address()) == 0 is false, so it returns nil — the garbage key is "valid".
  4. Build multisigPubKey := kmultisig.NewLegacyAminoPubKey(1, []cryptotypes.PubKey{validSecp256k1Key, garbage}). validatePubKey on the multisig recurses into both children; validSecp256k1Key passes via btcec.ParsePubKey, garbage passes via the flawed default branch above; the multisig's own Address() is non-empty, so the whole thing validates.
  5. Sign the tx with only the private key for validSecp256k1Key, setting the bit for that child in the multisig's BitArray and leaving garbage's bit unset. Threshold (1) is met.
  6. CheckSignaturesDefaultSigVerificationGasConsumerConsumeMultisignatureVerificationGas only iterates over set bits, so garbage is never passed to any signature-verification or curve-validation logic.
  7. CheckPubKeys persists the multisig pubkey (including garbage) via acc.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]

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