Skip to content

Test for lineage.depth overflow of the extended keys #1624

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 7 commits into from

Conversation

quapka
Copy link

@quapka quapka commented Mar 31, 2025

Hi, based on the discussion in #1623, I've decided to create a few test cases. I've manually created an extended private key with the depth set to 254 and then incremented it twice, checking that the first derivation passes and the second one fails (for various combinations of hd_private, hd_public and derive_private or derive_public).

I've split the tests into two commits due to, surprisingly, some variants failing. In particular, it seems to be possible to hit the overflow without and when deriving a public key of depth 256 from a private key of depth 255. Unfortunately, the tests are quite some copy-paste (I am happy to be transparent about this), but I could not see where the test (for the failing ones) would be incorrect.

@quapka
Copy link
Author

quapka commented Mar 31, 2025

If I read the output of the CI correctly, it runs all tests together as a single test as part of the installation in install-cmake.sh and thus the failure that I talked about is hidden :-/

@evoskuil
Copy link
Member

evoskuil commented Mar 31, 2025

@quapka
Copy link
Author

quapka commented Mar 31, 2025

https://github.com/libbitcoin/libbitcoin-system/actions/runs/14169924138/job/39725696803?pr=1624#step:10:958

cmake: https://github.com/libbitcoin/libbitcoin-system/actions/runs/14169924138/job/39725696825#step:23:1

My bad, thanks! The two failing tests are indeed the ones I was talking about. The offending lines is this and this one.

The idea of the tests was to hit the overflow when going from 255 xpriv to 256/0 xpub. This code.

Oh, wait a minute. Isn't the derive_private(index) return value left unchecked and simple the to_public() is what gets returned, which succeeds even for the empty key?

@evoskuil
Copy link
Member

No worries, in some of the CI outputs (CMake I think) you have to go to a different section to see the individual test failures.

@quapka
Copy link
Author

quapka commented Mar 31, 2025

Oh, wait a minute. Isn't the derive_private(index) return value left unchecked and simple the to_public() is what gets returned, which succeeds even for the empty key?

I am starting to believe that this is the case. The tests pass with the following patch:

diff --git a/src/wallet/keys/hd_private.cpp b/src/wallet/keys/hd_private.cpp
index fa9d7dc9c..0b4201c74 100644
--- a/src/wallet/keys/hd_private.cpp
+++ b/src/wallet/keys/hd_private.cpp
@@ -266,7 +266,12 @@ hd_private hd_private::derive_private(uint32_t index) const NOEXCEPT

 hd_public hd_private::derive_public(uint32_t index) const NOEXCEPT
 {
-    return derive_private(index).to_public();
+    const auto tmp = derive_private(index);
+    if (tmp) {
+        return tmp.to_public();
+    }
+
+    return {};
 }

 // Operators.

I am no C/C++, so the code is just something quickly stitched together. Wdyt?

@evoskuil
Copy link
Member

That makes sense, I'll give it a look in a bit.

@quapka
Copy link
Author

quapka commented Mar 31, 2025

If my suggestion is indeed the problem, I wonder what a proper fix should be. Maybe verifying this within to_public() method? Which makes me wonder, how the C/C++ API of HD keys work and what are possibly all the other places, where operation are done on invalid keys.

@evoskuil
Copy link
Member

I think people generally ignore secp invalidity and guard against deriving past the limit (since it's a guaranteed failure).

@quapka
Copy link
Author

quapka commented Mar 31, 2025

Do you also backport bug-fixes? I see the same thing in verson3.

@evoskuil
Copy link
Member

Sure, if they're simple.

@evoskuil
Copy link
Member

It's either:

hd_public hd_private::derive_public(uint32_t index) const NOEXCEPT
{
    const auto derived = derive_private(index);
    return derived ? derived.to_public() : hd_public{};
}

or:

hd_public hd_private::to_public() const NOEXCEPT
{
    if (!valid_)
        return {};
        ...
 }

as you suggested. I prefer the latter because it explicitly guards both scenarios which also benefits the case where the caller has not guarded a private key before conversion (as opposed to derivation) to public.

@evoskuil
Copy link
Member

Feel free to make the change into both repos.

quapka added 3 commits April 1, 2025 11:14
In case `hd_private` is invalid,  the `to_public()` derivation
should fail. The `hd_private` might be invalid from various reasons,
such as deserializing invalid key or derived, using `derive_private`
into an invalid one.

Finally, nitpick: `return {};` is enclosed in curly brackets to make the
if statement scope explicit.
@quapka
Copy link
Author

quapka commented Apr 1, 2025

hd_public hd_private::to_public() const NOEXCEPT
{
    if (!valid_)
        return {};
        ...
 }

Thanks for the code examples. I also like the second one more. I've added a couple more tests to also:

  • test explicitly for decoding null keys, both hd_private and hd_public

  • test to_public() called on invalid hd_private arising from other reasons, as you mentioned.

  • Fixed in master

  • Backport fix to version3

Copy link
Member

@evoskuil evoskuil left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you please squash these to a single commit?


if (!valid_) {
return {};
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avoid bracing for single line (also braces would be balanced).

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like to be explicit about the if scope, as I have seen a new code added (maybe under the influence of Python-like, whitespace aware languages), without adding the braces. Tests should hopefully catch that though.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@@ -128,4 +128,112 @@ BOOST_AUTO_TEST_CASE(hd_private__derive_public__long_seed__expected)
BOOST_REQUIRE_EQUAL(m0xH1yH2_pub.encoded(), "xpub6FnCn6nSzZAw5Tw7cgR9bi15UV96gLZhjDstkXXxvCLsUXBGXPdSnLFbdpq8p9HmGsApME5hQTZ3emM2rnY5agb9rXpVGyy3bdW6EEgAtqt");
}

BOOST_AUTO_TEST_CASE(hd_private__constructor__null_key_decodes_to_invalid__expected)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This:

hd_private__constructor__null_key_decodes_to_invalid__expected

would be better as:

hd_private__constructor__null_key__decodes_to_invalid

Others as well. Third part is the thing being tested and fourth part is the expectation.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, will improve.

BOOST_REQUIRE_EQUAL(xpub_255.lineage().depth, 255);
BOOST_REQUIRE(xpub_255);

// depth overflows uint from 255 to 0
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it's actually overflowing here, it should be the default (invalid/null) key, and the depth value for that happens to be zero.

@evoskuil
Copy link
Member

Moved to #1637

@evoskuil evoskuil closed this Apr 13, 2025
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.

2 participants