Skip to content

Reject the invalid extended keys of BIP32 test vector 5 - #440

Open
DMZ22 wants to merge 1 commit into
richardkiss:mainfrom
DMZ22:bip32-test-vector-5
Open

Reject the invalid extended keys of BIP32 test vector 5#440
DMZ22 wants to merge 1 commit into
richardkiss:mainfrom
DMZ22:bip32-test-vector-5

Conversation

@DMZ22

@DMZ22 DMZ22 commented Jul 24, 2026

Copy link
Copy Markdown

Closes #437.

I measured it before fixing it: of the 16 keys in BIP32 test vector 5 plus the two bitcoinfuzz strings from the issue body, 8 of 18 were accepted rather than rejected. They fall into exactly three rules, all of which were missing:

BIP32 test vector 5 case before after
pubkey version / prvkey mismatch accepted rejected
prvkey version / pubkey mismatch accepted rejected
zero depth with non-zero parent fingerprint (prv, pub) accepted ×2 rejected
zero depth with non-zero index (prv, pub) accepted ×2 rejected
bitcoinfuzz xprv9s21ZrQMAtJo7… (zero depth, child index 40000000) accepted rejected
bitcoinfuzz tpubD6NzVbkrYhZ4W… (tpub carrying private key data) accepted rejected
invalid pubkey/prvkey prefix 01 and 04, unknown version ×2, private key 0 and n, invalid pubkey 0200…07, invalid checksum already rejected rejected

0 of 18 are accepted now, and all of test vectors 1–4 still parse and round-trip.

Where the checks go

The prefix/payload agreement check is in hparse. That is the only place that knows which of _bip32_prv_prefix / _bip32_pub_prefix actually matched — deserialize cannot do it, because override_network calls it with the version bytes zeroed out. It returns None, matching the unknown-version and bad-checksum paths right above it. Being in hparse it covers bip49 and bip84 as well as bip32.

The depth invariants are in deserialize, since they are network-agnostic and this way they also cover callers that don't go through the parse API. They raise EncodingError, which is what this file already imports and what the neighbouring payload failures (sec_to_public_pair, the secret exponent range check) already do.

That does mean rejection is signalled two different ways depending on the case, but that is pre-existing — bad checksum returns None today while a bad SEC encoding raises. Happy to make the depth checks return None from hparse instead if you'd rather the parse API were uniform.

One thing worth a look

tests/cmds/test_cases/ku/bip84_ltc.txt was itself a test-vector-5 key. Its input decodes to version 04b24746 — Litecoin's BIP84 public prefix — with a payload beginning 00, i.e. private key data. A private key serialized with the public version bytes, which is precisely "pubkey version / prvkey mismatch". The fixture's own comment said "encoded as zpub" while the expected output reported "private_key": "yes".

I replaced it with the correctly serialized zprv of the same key. Every other field in the expected output is byte-identical, which confirms it is the same key material — the diff is the comment, the command, and the input field.

Verification

  • tests/btc/bip32_test.py, tests/parse_test.py, tests/key_validate_test.py: 23 passed before, and pass after.
  • Full suite: 8 failed / 1818 passed before, 8 failed / 1821 passed after — same failures (they are Windows/gpg environment issues in tests/cmds/), plus the three new tests.
  • Reverting only the two source files while keeping the new tests makes bip32_test.py go 9 passed → 2 failed, so the tests do guard the change.
  • New tests: the full test-vector-5 table, the two fuzzer strings, and a guard that well-formed master and derived keys still parse — the last one passes with or without the fix, deliberately, to catch over-rejection.
  • ruff format --check is clean on both files I touched that were clean before. ParseAPI.py is already unformatted on main, so I left the rest of it alone rather than reformat it in this PR.

Three BIP32 rules were not enforced when parsing an extended key, so 8 of
the 18 keys that must be rejected (16 from test vector 5 plus the two
bitcoinfuzz strings in the issue) were accepted instead:

- the serialization prefix says private or public, and the payload has to
  agree; the type was being taken from the payload alone, so an xpub
  carrying private key data parsed happily
- a key at depth zero is a master key, so its parent fingerprint must be
  zero
- a key at depth zero is nobody's child, so its child index must be zero

The prefix check goes in hparse, which is the only place that knows which
prefix matched; the depth checks go in deserialize, so they also cover
callers that do not come through the parse API.

This also corrects tests/cmds/test_cases/ku/bip84_ltc.txt, whose input was
itself a "pubkey version / prvkey mismatch" key: a Litecoin BIP84 private
key serialized with the zpub version bytes. It is replaced with the
correctly serialized zprv, which produces identical output.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Missing BIP32 Test Vector 5 Validation

1 participant