Fix: pass an explicit byte width where a fixed-length value is required - #6
Open
RexStarBSV wants to merge 2 commits into
Open
RexStarBSV wants to merge 2 commits into
RexStarBSV wants to merge 2 commits into
Conversation
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.
CONTRIBUTING.md asks that a change be discussed in an issue first. I have written this up as if it were that issue, so everything needed to judge it is here; if you would rather start with an issue and leave the code for later, say so and I will open one and close this.
Summary
integer_to_bytes(data)called withoutbytes_numtakes its width from the value:That is the documented behaviour and it is right for the flags, magics and prefixes that make up most of its callers. But twelve call sites in
bip38/bip38.pyuse it to build values that must be exactly 16 or 32 bytes. When such a value happens to begin with0x00it comes back 15 or 31 bytes, and the caller's length assumption breaks. Each affected value has a 1 in 256 chance of starting with0x00.The symptom depends on where the short value lands:
ValueError: plaintext block must be 16 bytesencrypt(),create_new_encrypted_wif()— the short block reaches AESSecp256k1Error: Invalid private key bytesdecrypt()— the recovered key is 31 bytesPassphraseError: Incorrect passphrasedecrypt(),confirm_code()— the short value is silently mis-sliced, and nothing notices until the address comparison at the endThe third row is the one I would draw attention to: the passphrase is correct, and the library says it is not.
The non-EC
decrypt()case is worth separate mention because it is not transient. The trigger there is the private key's own first byte being0x00, which is a property of the key, so an affected key encrypts without complaint and then cannot be decrypted again — not with a retry, not with any passphrase.Reproduction
Public API only, fixed inputs, correct passphrase in every case, library defaults. All five inputs are vectors this PR adds to
tests/data/values.json.On 1.4.1 this prints
5 of 5 cases failed. With this change it prints0 of 5 cases failed.In cases 2, 4 and 5 the ciphertext is what 1.4.1 itself emits — I generated each with the unpatched library and compared byte for byte. Those are cases where the library writes output it cannot read back.
How often
Each fixed-width value has a 1 in 256 chance of beginning with
0x00, so for a call that buildskof them the failure rate is1 - (255/256)^k. Countingkper entry point gives:kencrypt()0x0142decrypt()0x0142create_new_encrypted_wif()0x0143confirm_code()0x0143decrypt()0x0143The measured column is a single 10,000-sample run per path, so the sampling error is visible — a second 30,000-sample run of
encrypt()gave 1 in 152, and the two runs pooled give 1 in 134 against the predicted 1 in 128. The model is the reliable number; the samples are there to show it holds. Sampling used deliberately weak scrypt (N=16, r=8, p=1) to make the sample sizes affordable; the trigger is the leading byte of a derived value, which is uniform regardless of the scrypt cost parameters.The twelve call sites
Line numbers as of v1.4.1. Each needs a width the value cannot supply:
encryptcreate_new_encrypted_wifseed_b[:16] ^ derived_half_1create_new_encrypted_wif(encrypted_half_1[8:] + seed_b[16:]) ^ derived_half_2create_new_encrypted_wifpoint_bconfirm_codepoint_bdecryptdecryptencrypted_half_1[8:] + seed_b[16:]decryptseed_b[:16]decryptpass_factor * factor_bmod the curve orderThere are 53 calls to
integer_to_bytesin the repository. Twelve are changed here. Of the remaining 41, four already pass an explicit width and one is in a test; the rest take their value from a module constant (MAGIC_*, the0x0142/0x0143prefixes,CONFIRMATION_CODE_PREFIX, the flag bytes,COMPRESSED_PRIVATE_KEY_PREFIX) or from a per-coin prefix —wif_prefixinwif.py,address_prefixinp2pkh_address.py.The per-coin prefixes are worth a note, because the obvious worry there is a prefix of zero — and there is one. Enumerating all 155 cryptocurrencies: the 67 distinct
wif_prefixvalues are each a single byte, and of the 65 distinctaddress_prefixvalues, seven are0x00, Bitcoin mainnet among them. Those sites are nonetheless correct, becauseinteger_to_bytescomputes its width as(data.bit_length() if data > 0 else 1), and thatelse 1returns exactly one byte for zero. So the prefix paths are unaffected and I have left them alone rather than widen this PR.Why the existing tests pass
This is the part I would most like to flag, because it is why a fix without new vectors could regress silently.
All nine test vectors published in BIP-0038 pass on the unpatched code. I ran them: 9 of 9 green against 1.4.1. Not one of them has a private key whose first byte is
0x00, so not one can reach this. The same is true of the repository's own data — the twelvedecryptvectors intests/data/values.jsoncarry twelve private keys, and the smallest leading byte among them is0x09.So the specification does not supply a vector that can catch this, and the suite as it stands cannot either. New vectors are not optional here; they are the only thing standing between this fix and a silent regression later.
What changed
bip38/bip38.py— twelve lines, each gaining an explicit width argument, e.g.No signature changes, no new names, no new dependency, nothing added to
utils.py. This matches whatpoint.pyalready does at its three calls, andbip38.pyat line 147.I did consider changing the default in
integer_to_bytesinstead, since one line would be smaller than twelve. It does not work: the required width is not a function of the value —0x0142must be 2 bytes, a magic 8, a flag 1, an AES block 16, a private key 32 — and no rule over the integer alone separates those. Making the default a fixed 32 fails 7 of the 14 existing tests. The helper is behaving as documented. What is missing is at the twelve call sites, which need a specific width and never ask for one.tests/data/values.json— twelve vectors, one per fixed call site, appended to the four existing lists and separated by a blank line in the same way thedecryptlist already groups its entries. Twoencrypt, fourcreate_new_encrypted_wif, twoconfirm_code, fourdecrypt. They reuse the intermediate passphrase and passphrases already in the file, so the only thing that varies is the key or seed. No test code changed — the existing loops pick them up.Dropped onto unpatched 1.4.1, these vectors fail 4 of the 5 BIP38 tests (
test_bip38_encrypt,test_create_new_encrypted_wif,test_confirm_code,test_bip38_decrypt);test_intermediate_codepasses, correctly, sinceintermediate_code()has no affected call site.Backward compatibility
Nothing that worked before changes. I captured 31,715 operations from unpatched 1.4.1 and replayed the identical inputs against the patched build:
Every encrypted WIF, confirmation code, intermediate code, address and recovered seed is unchanged. No previously readable ciphertext becomes unreadable; the patch only affects inputs on which 1.4.1 raised.
One behaviour on the failure path does change, and the table above cannot show it because it only counts successes. Given an incorrect passphrase, 1.4.1 raises
Secp256k1Errorrather thanPassphraseErrorin roughly 1 case in 208 — whenever the garbage it reconstructs happens to begin with a zero byte. After this change that path raisesPassphraseErrorconsistently, which is what the docstring already promises. I mention it in case anyone is catching the narrower exception. Overflow is not a new risk either: every fixed site XORs operands already at the stated width, and line 653 is reduced modulo the curve order, which is below 2^256, soto_bytescannot overflow.That the recovered keys are the right ones is provable without trusting me
A BIP38 key commits to its own plaintext: bytes 3..7 of the payload hold the first four bytes of
SHA256dover the address of the key inside it. It is the same commitmentdecrypt()alreadychecks before returning. Both leading-zero vectors this PR adds satisfy it, so the keys 1.4.1
cannot recover are demonstrably the keys those ciphertexts were made from:
Both recovered secrets begin
0x00—0007a6de…and0096a5ad…— which is exactly the case1.4.1 cannot return. Anyone can check the match with a base58 decoder and two SHA-256 calls; it
does not depend on this patch, on my tooling, or on my word.
Verification
coverage run -m pytest— 14 passed.One cost worth naming: the added vectors take the suite from 37 to 53 scrypt evaluations at the spec's default parameters, which is the dominant term in the run time. Measured on an idle machine,
pytestgoes from 9.9s to 14.1s. If you would rather have fewer, the fourcreate_new_encrypted_wifvectors are the cheapest to keep (they use the intermediate code and so skip the expensive KDF entirely) and the twoconfirm_codevectors are the most expensive at three evaluations each. I am happy to trim to whatever set you prefer.Notes
master, based on4d3c721.CHANGELOG.mdorbip38/info.py, since both look release-scoped and yours to write. If it helps, an entry under Fix Bugs: might read: Pass an explicit byte length where a fixed-width value is required, so values beginning with0x00are no longer built one byte short.