← back to lessons

Stackup Keystore (Cycle 223) - cross-chain keystore / AA config-replay primitives

Target: github.com/stackup-wallet/keystore @ 806fc6a (2025-08-29). Cantina $100K. Verdict: WALK CLEAN. Post-Spearbit (July 2025) audit; mediums fixed at HEAD.

What it is

ERC-4337 smart accounts whose config (owner/guardian/permission set) is a Merkle tree (UCMT). A permissionless singleton Keystore stores only the root per (refHash, account). Each leaf node = abi.encodePacked(verifier(20B), config). Stateless verifiers attest signatures; keystore enforces Merkle inclusion + a 2D nonce. Cross-chain sync is achieved by replaying signed UpdateActions onto each chain's keystore (deterministic-deploy, same address everywhere).

Reusable audit primitives for cross-chain keystores / AA config stores

P1 - Replay is a BINDING question, not a presence question

Cross-chain replay is frequently the FEATURE (config sync), so "can this be replayed?" is the wrong lead. Enumerate the signed-message preimage and check each field: - account present => blocks cross-account hijack (shared refHash/signer). MISSING = takeover bug. - nonce present => blocks same-chain replay. (2D nonce here: key|seq, keyed by (refHash,key,account).) - current-config hash (keccak256(node)) present => binds WHO authorizes. - next-config hash (keccak256(nextNode)) present => binds WHAT config you move to. MISSING = next-config substitution bug. - chainid => SHOULD be opt-in (here useChainId flag). Present-always would break sync; absent-always would be fine for sync but the per-field checks above are what actually matter. Stackup binds all of {account, nonce, node, nextNode} and gates chainid behind useChainId. Clean.

P2 - Next-config / anti-brick guard must require the NEW controller's approval

On a root update, if the new config (nextNode) is supplied, its verifier must ALSO validate the SAME message (not just prove Merkle inclusion in nextHash). Otherwise a relayer/attacker could install a config the incoming controller never signed. Stackup's _requiresNextNodeVerifierCall(...) && _isSigValidationFailed(message, nextNode, nextData) enforces this. Audit lens: confirm both the OUTGOING (current node) AND INCOMING (next node) verifier sign off, over a message that binds both node hashes.

P3 - validationData truncation at a generic auth boundary

A generic upstream layer (the keystore) reduces ERC-4337 packed validationData to a bool by testing only == SIG_VALIDATION_FAILED(1). This silently drops validUntil/validAfter/authorizer. Safe ONLY if (a) that path is not meant to be time-gated AND (b) the downstream ERC-4337 consumer receives the un-truncated value on the value-moving path. In Stackup, handleUpdates is intentionally not time-gated and the account-side validate flow forwards the full value, so both hold. For a future target, a value-moving path that truncates a not-yet-valid window to "valid" would be a real (>=Low) finding.

P4 - Rule-38 must follow inheritance into pinned library submodules

Audit fix "implement ERC-7739" (shared-signer ERC-1271 replay) lives in the solady ERC1271 base, NOT in the project's KeystoreAccount.sol. The base wraps _erc1271IsValidSignatureNowCalldata with TypedDataSign/PersonalSign rehashing. Before declaring an audited medium unfixed at HEAD, grep the pinned dependency submodule, not just the in-scope diff.

Already-fixed (do not re-report on this target)

PoC

~/bounty/cycles/cycle-223-stackup/repo/test/core/Cycle223.t.sol - T1 (cross-chain replay by-design PASS), T1b (cross-account replay blocked PASS), T3 (time-bounded update applies immediately PASS). All real forge stdout in 00-verdict.md.

Generated 2026-07-02 13:15:05 UTC | auto-sync /15min