fix: enforce the base58 checksum and reject non-master extended keys (closes #210) #232
Reference in New Issue
Block a user
Delete Branch "fix/issue-210-xprv-validation"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #210 — both defects filed there. Each one produced the same user-visible disaster: an apparently successful import of a wallet that is not the user's, with no error.
Defect 1 — the base58 checksum was not enforced
HDNodeWallet.fromExtendedKeyskips checksum verification whenever the decoded payload is the usual 82 bytes, which is exactly the case the checksum exists to catch.isValidXprvwas a thin wrapper over it, so a mistyped key parsed into a different wallet and imported cleanly.Every extended key entering the app now goes through one helper,
parseExtendedKey, which parses with ethers and then requiresnode.extendedKey === key— ethers computes the checksum when it serializes, so the round trip reproduces a well-formed key byte for byte and any altered character shows up as a mismatch. The round trip also rejects non-canonical encodings of an otherwise valid payload, but it is not strictly stronger than a bare checksum comparison in every direction:HDNodeVoidWallet.extendedKeyhardcodes the mainnet version bytes0x0488B21E, so atpub(testnet version bytes, valid checksum) that the old code parsed and returned the CORRECT address for is now rejected. That case is unreachable in this app, and rejecting is the safe direction for an Ethereum wallet. It is not a testnet regression either —tprv,yprvandzprvwere already rejected before this change. No new crypto primitive is introduced, so the Crypto Policy needs no exception.isValidXprv,hdWalletFromXprv,getSignerForAddressandderiveAddressFromXpuball sit on it;fromExtendedKeynow has exactly one call site in the codebase.Defect 2 — non-master keys derived beneath themselves
hdWalletFromXprvandgetSignerForAddressderived the RELATIVE path44'/60'/0'/0. That is the BIP-44 Ethereum account path only from a depth-0 master key. Handed an account-level (depth-3) xprv, they derivedm/44'/60'/0'/44'/60'/0'/0beneath it. Measured for the vector phrase: the account-level key produced0xbBBAc7de23fF320B7E52a2385A41bFB6eB99D78Awhere the user's first address is0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266.Decision: reject depth > 0. The serialized extended-key format carries depth, parent fingerprint and child index — it does not carry the path the key sits at. A depth-3 key may be
m/44'/60'/0', orm/44'/0'/5', or anything else; nothing in the key says which. So there is no way to know which path components remain to be derived without asking the user, and guessing is the exact class of silent-wrong-wallet failure this issue is about. Accepting arbitrary-depth keys correctly means a user-supplied derivation path, which is a feature, not this bug fix.Both call sites now derive the absolute
BIP44_ETH_PATH(m/44'/60'/0'/0) from a key checked to be at depth 0, which also removes the duplicated relative-path literal that was the root cause. The import screen reports the two cases separately, because "check it for a typo" is the wrong advice for a key the user copied correctly:The xprv form's help text now states the same up front, rather than only on failure. Both messages are full sentences per the README Language & Labeling rules.
Does the xpub path share the holes?
isValidXpubin this codebase; the xpub entry point isderiveAddressFromXpub, used by the import flow's address scan. It calledfromExtendedKeydirectly and had the identical hole — 101 of the single-character typos of a valid xpub were accepted and returned addresses from a different tree. Fixed by the same helper; it now throws instead.deriveAddressFromXpubis called with the account-level (depth-4) xpub this app derives and stores. A depth check there would reject every legitimate wallet. The depth rule belongs only to the xprv import, where the app is the one deriving the account path.Failing first
New and unskipped tests run against the unmodified
src/(git stash push -- src/,make test) — 10 failures, all of them the new assertions:The four depth-predicate failures not quoted above (
a master key is a master key,an account-level key is not a master key,a derived xpub is not a master key,a mistyped key is not a master key either) all fail aswallet.isMasterExtendedKey is not a functionagainst the old code.So: 199 single-character typos of the BIP-32 vector 1 master key imported as wallets before this change, and 101 typos of a valid xpub returned addresses. After: 0 and 0.
Coverage added
rejects an extended key with a one-character typois unskipped and passes.isValidXprvandhdWalletFromXprv) and the xpub (deriveAddressFromXpub).isMasterExtendedKey,hdWalletFromXprvandgetSignerForAddress, whileisValidXprvstill reports it well-formed so it is provably the depth check doing the rejecting and not an encoding error.The #159 known-answer vectors and the
key derivation costblock intests/vault.test.jsare untouched.Verification
make checkgreen on this branch after rebasing onto currentnext: 11 test suites, 274 tests passed, 0 skipped, prettier clean, exit 0.FAIL —
needs-rebase. The change itself is clean; the only blocker is the base.Conflicts with
next. The branch is based onf455b0a;nextis now12acf4d, which inserted a #179 entry at the same point inTODO.md"Completed Steps".git rebase origin/nextconflicts inTODO.mdonly — all five source files apply cleanly. Acceptable: rebase, retaining both entries.Anomaly, not a defect. "Strictly stronger than a bare checksum comparison" is overstated in one direction: a
tpub(version0x043587cf, valid checksum) parsed under the old code and returned the correct address, and the round trip now rejects it, becauseHDNodeVoidWallet.extendedKeyhardcodes the mainnet0x0488B21E. Unreachable here — there is no xpub import UI, andderiveAddressFromXpubonly ever receives the app's own stored xpub — and rejecting is the safe direction for an Ethereum wallet, so no rework is required.tprv/yprv/zprvwere already rejected by ethers before this change.Probed independently on
1fba21c, all passing: 6327-mutant sweeps (every position, all 57 base58 alternates, against the PR's 4-character/199) — xprv 2784 accepted before / 0 now, xpub 1511 / 0, with 0 disagreements against an independent base58check verifier, so the round trip is equivalent to checksum verification across the entire single-substitution space; 400 random seeds x {xprv, xpub} x {master, derived} plus 10 path shapes includingm/2147483647'/0/2147483647'rejected 0 valid keys; the absolutem/44'/60'/0'/0and the old relative44'/60'/0'/0produce a byte-identical stored xpub and identical addresses at indices 0, 1, 2, 5 from a depth-0 master; non-canonical1-padded keys accepted before are rejected now; whitespace, empty, non-string, and short/long valid-checksum payloads are rejected both before and after.make checkon my own clone: 258 tests, 0 skipped, prettier clean, exit 0, 4.5s — not a cache hit.Noted, not failing: the two new error strings are covered by no test, so they are verified by reading only;
deriveAddressFromXpub's callers (src/shared/balances.js:235,src/popup/views/home.js:296) do not catch, though it could already throw before and only ever receives app-generated xpubs.Checked and clean: single commit titled
(closes #210), basenext, authoredclawbot, no attribution trailers, no Claude or Anthropic references, no forbidden crypto strings in the new source (the Crypto Policy claim holds),fromExtendedKeyreduced to one call site insrc/, the previously skipped one-character-typo test unskipped and passing, and the #159 vectors andtests/vault.test.jsuntouched.1fba21cd16to57e1fd0198