security: fix non-functional and replayable Ethereum signature check in external-delegate-token-master - #691
Open
NikkiAung wants to merge 2 commits into
Conversation
…in external-delegate-token-master Two independent bugs in `transfer_tokens`, the one instruction this example exists to demonstrate — which has zero test coverage, exactly why both survived. 1. `verify_ethereum_signature` sliced `&pubkey_bytes[1..]` before hashing, assuming a 0x04-prefixed 65-byte key. `solana_secp256k1_recover::Secp256k1Pubkey::to_bytes()` returns the bare 64-byte X||Y with no prefix (verified against the pinned crate source), so this hashed 63 bytes starting one byte into the X-coordinate. No genuine Ethereum signature could ever pass — the instruction was dead code. 2. `message: [u8; 32]` was a free-form caller-supplied value with no cryptographic tie to `amount`, `recipient_token_account`, or a nonce — a captured signature could be replayed for any amount, to any recipient, indefinitely. The two bugs masked each other: bug 1 made the instruction unreachable, so bug 2 was latent. Fixing only the obvious byte-slice typo would have produced a working but fully replayable signature check, so both are fixed together. The digest is now derived on-chain from a domain separator, program id, both token account keys, amount, and a new per-account nonce; `message` is no longer caller-supplied. Added the transfer_tokens test coverage that never existed: a valid-signature happy path, replay-after-nonce-advance, parameter-tampering, and wrong-signer rejection. Verified bug 1 reproduces against the unpatched code with a standalone script using a genuinely-derived and correctly-signed key, and confirmed all new tests fail against the pre-fix program and pass after. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Contributor
Greptile SummaryThis PR repairs Ethereum signature verification and makes transfer authorizations parameter-bound and single-use.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains. Important Files Changed
Reviews (2): Last reviewed commit: "style: match root prettier config in ext..." | Re-trigger Greptile |
…ests Root CI's prettier check uses @solana/prettier-config-solana via prettier 3.x (trailingComma: all), which differs from this project's own pinned prettier 2.x default. Whitespace only, no logic change. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Contributor
Author
|
Hey @dev-jodee — this is ready for review whenever you get a chance. CI is green and Greptile's automated pass found no blocking issues. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
tokens/external-delegate-token-mastergates a Solana token transfer on an Ethereum ECDSAsignature via
transfer_tokens. It has two independent bugs, and — the pattern is thesame as #690 — they mask each other, so fixing only the obvious one would make the other
immediately exploitable. Both survived because
transfer_tokens, the one instruction thisexample exists to demonstrate, had zero test coverage; the only tested transfer path was
authority_transfer, which does no Ethereum check at all.Bug 1 — the address derivation is cryptographically wrong (100% false-negative)
&pubkey_bytes[1..]assumesto_bytes()returns a0x04-prefixed 65-byte SEC1 key. Itdoesn't. Checked directly against the pinned crate's source
(
solana-secp256k1-recover = "2.0.0"):It's the bare 64-byte
X || Y, no prefix — matching Solana's own canonicalconstruct_eth_pubkeycookbook recipe, which hashes the full slice with no skip. So[1..]here hashes 63 bytes starting one byte into the X-coordinate. No genuine Ethereum signature
could ever pass this check —
transfer_tokenswas dead code for legitimate use. Verifiedempirically: a standalone script that registers a real, correctly-derived Ethereum address and
signs with the matching key still gets rejected with
InvalidSignatureagainst the unpatchedcode.
Bug 2 — the signature authorizes nothing (unbound, replayable)
message: [u8; 32]was a free-form, caller-supplied value with zero cryptographic tie toamount,recipient_token_account,user_account, or any nonce. The check only proved "thiskey signed some 32 bytes at some point," never "this key authorized this transfer." Fixing
only Bug 1 — the natural, obvious typo to spot — would have produced a working signature
check that's fully replayable: a captured
(message, signature)pair could be resubmitted forany amount, to any recipient, indefinitely.
Fix
Both bugs fixed together in
transfer_tokens:&pubkey_bytes[1..]→&pubkey_bytes[..].that determines where funds move plus a replay guard:
user_token_accountis bound explicitly (not just the recipient) since nothing constrains itto a canonical ATA. Added
UserAccount.nonce: u64, incremented withchecked_addafter asuccessful transfer (this repo's
overflow-checks = truerelease profile panics rather thanwraps on overflow, matching the convention security: fix swapped transfer amounts + broken invariant check in token-swap #690 established).
authority_transferis untouched — it's gated by a realhas_one = authorityEd25519 signercheck and is a legitimate, separate, lower-friction path for the account's Solana-side owner.
Test changes
Added the
transfer_tokenscoverage that never existed, using@noble/curves(secp256k1signing with recovery bit) and
@noble/hashes(keccak256) as devDependencies — both alreadyresolved elsewhere in this repo's dependency tree:
Note this changes
transfer_tokens's instruction args (dropsmessage) andUserAccount'saccount layout (+8 bytes for
nonce) — breaking for anything already deployed against the oldlayout, expected for an educational example fix.
Verification
anchor build,cargo check -p external-delegate-token-master,cargo fmt -p external-delegate-token-master,pnpm exec tsc --noEmit,prettier --checkall pass.pnpm testsuite: 7/7 passing.lib.rs, rebuilt, and confirmed Bug 1 reproducesagainst the unpatched code via a standalone script (real key, real signature, still rejected);
reapplied the patch and confirmed the full suite passes green.