Repository navigation
fix: Correct the SLIP-39 share value and PBKDF2 salt encodings - #78
Merged
Merged
Conversation
azuchi
approved these changes
Sep 5, 2026
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
Two encoding defects in
Bitcoin::SLIP39make a share unrecoverable or non-interoperable. Both were found and fixed in tapyrusrb, whose SLIP-39 implementation this one is identical to, and both reproduce onmasterhere.0x00byte produces a mnemonic this library cannot decode. 4 out of 900 shares (0.44%) generated bySSS.setup_shares(group_threshold: 1, groups: [[2, 3]])failed to round trip.They live in different files and are reviewed most easily as the two commits of this branch.
Details
Leading zero bytes of the share value
Share#to_wordssucceeds, but feeding the result back intoShare.from_wordsraisesArgumentError: Invalid mnemonic length.A backup written to paper is silently unrecoverable.Two places in
lib/bitcoin/slip39/share.rbderive the length of the share value from its integer representation, which discards leading zero bytes.build_word_indicescomputed the bit length withInteger#bit_length:For a 32-byte value starting with
0x00this yields 248 instead of 256, so the mnemonic is one word short of what the specification requires.from_wordsconverted the recovered bits back to hex withInteger#to_even_length_hex, which only pads to a whole byte and cannot restore a leading00. Even with the writing side fixed, the recovered value would be shorter than the original andSSS.recover_secretwould reject it at the "all share values must have the same length" check.Identifier of the PBKDF2 salt
SLIP-39 defines the PBKDF2 salt as
"shamir" || identifier, where the identifier is always encoded as two bytes in big endian order.SSS.get_saltbuilt it withInteger#itb, which uses the minimum number of bytes and therefore emits a single byte whenever the identifier is below 256.7368616d697205(7 bytes)7368616d69720005(8 bytes)7368616d69720100(8 bytes)7368616d69720100(8 bytes)Identifiers come from
SecureRandom.random_number(32_767). Encryption and decryption used the same wrong salt, so such a backup could still be recovered by this library, but not by any other SLIP-39 implementation. Recovering it elsewhere yields a different master secret with no error, because the Feistel construction has no integrity check over the passphrase or the salt.SSS.get_saltnow encodes the identifier with[id].pack('n'), which always produces two bytes. Shares that this library has already generated with an identifier below 256 were encrypted with the shorter salt, and the mismatch surfaces as a silently different master secret rather than as an error.SSS.recover_secrettherefore takes alegacy_saltkeyword argument. When it is true, the salt is built the old way and such a backup can still be recovered. The option is deliberately absent fromsetup_shares: new shares are always generated according to the specification. The holder of an affected backup can recover it both ways and compare the addresses derived from each result to tell which one is theirs.None of the vectors in
spec/fixtures/slip39/vectors.jsonhas an identifier below 256; the smallest is 282. The defect was therefore invisible to the test suite.Changes
lib/bitcoin/slip39/share.rbbuild_word_indicesderives the bit length from the byte size of the value (value.htb.bytesize * 8).% RADIX_BITSso that a value whose bit length is already a multiple of 10 does not gain a spurious all-zero word.build_word_indicesrejects a value which is not an even number of bytes.from_wordsderives the padding length from the number of value words modulo 16, which only matches the padding written here for an even number of bytes; an odd one used to be read back as a different value instead of being rejected.from_wordspads the recovered hex to the exact length implied by the bit count instead of merely rounding up to a whole byte.lib/bitcoin/slip39/sss.rbget_salttakes alegacyflag and encodes the identifier with two bytes unless it is set.decrypttakes alegacy_saltargument and passes it toget_salt.recover_secrettakes alegacy_salt:keyword argument, defaulting to false, and passes it todecrypt.spec/fixtures/slip39/vectors_short_identifier.jsonshamir-mnemonic0.3.0, with the extendable backup flag off, an iteration exponent of 0 and the passphraseTREZOR. Recovering them proves interoperability for the range this library used to get wrong.spec/bitcoin/slip39_spec.rblegacy_salt: true, and asserts that recovering it without the option gives a different result.All ten new examples fail on
masterand pass with this change.Impact
Bitcoin::SLIP39::Share#to_words,Share.from_words,SSS.recover_secret,SSS.decryptandSSS.get_salt.0x00are encoded exactly as before, and backups with an identifier of 256 or above use an unchanged salt. All examples inspec/fixtures/slip39/vectors.jsonstill pass.0x00were already undecodable and remain so. Such a backup has to be regenerated.legacy_salt: truefrom now on. Recovering one without the option returns a wrong secret and raises nothing. This has to be stated in the release notes.How to verify
57 examples, 0 failures.
To confirm the first defect on
master:To confirm the second, recover any vector in
spec/fixtures/slip39/vectors_short_identifier.jsononmaster. The returned secret differs from the expected one and no exception is raised.