From 7745331468e0abdde9c83e3e9c3d981c37f9623a Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 11 Aug 2026 21:56:09 -0500 Subject: [PATCH] feat: add DIP-14 256-bit child key derivation DIP-14 extends BIP32 child key derivation to 256-bit child indexes with an explicit hardened flag, replacing the high-bit convention. It underpins the DIP-13 Platform key hierarchy and DIP-15 friendship-derived address spaces, where child indexes are 256-bit identity hashes. Add DIP14Hash, CKey::Derive256, and CPubKey::Derive256. Indexes below 2^32 use the DIP-14 BIP32 compatibility behavior: the hardened flag is folded into the high bit on the private side, while public derivation rejects indexes with that bit set. Invalid index sizes are rejected at the API boundary. Cover the published DIP-14 vectors, compatibility and public/private consistency, malformed input lengths, and randomized 32-bit-boundary, general 256-bit, hardened, and tweak-add paths in the key fuzz target. Co-Authored-By: Claude Fable 5 --- src/Makefile.test.include | 1 + src/hash.cpp | 5 + src/hash.h | 5 + src/key.cpp | 28 ++++ src/key.h | 5 + src/pubkey.cpp | 28 ++++ src/pubkey.h | 5 + src/test/dip14_tests.cpp | 205 ++++++++++++++++++++++++++++++ src/test/fuzz/key.cpp | 43 +++++++ test/util/data/non-backported.txt | 1 + 10 files changed, 326 insertions(+) create mode 100644 src/test/dip14_tests.cpp diff --git a/src/Makefile.test.include b/src/Makefile.test.include index 22dea93afea6..df2773669860 100644 --- a/src/Makefile.test.include +++ b/src/Makefile.test.include @@ -106,6 +106,7 @@ BITCOIN_TESTS =\ test/cuckoocache_tests.cpp \ test/denialofservice_tests.cpp \ test/dip0020opcodes_tests.cpp \ + test/dip14_tests.cpp \ test/descriptor_tests.cpp \ test/dynamic_activation_thresholds_tests.cpp \ test/evo_assetlocks_tests.cpp \ diff --git a/src/hash.cpp b/src/hash.cpp index 0544468bfd94..bbb652f597b0 100644 --- a/src/hash.cpp +++ b/src/hash.cpp @@ -75,6 +75,11 @@ void BIP32Hash(const ChainCode &chainCode, unsigned int nChild, unsigned char he CHMAC_SHA512(chainCode.begin(), chainCode.size()).Write(&header, 1).Write(data, 32).Write(num, 4).Finalize(output); } +void DIP14Hash(const ChainCode& chainCode, const unsigned char nChild[32], unsigned char header, const unsigned char data[32], unsigned char output[64]) +{ + CHMAC_SHA512(chainCode.begin(), chainCode.size()).Write(&header, 1).Write(data, 32).Write(nChild, 32).Finalize(output); +} + uint256 SHA256Uint256(const uint256& input) { uint256 result; diff --git a/src/hash.h b/src/hash.h index 8db12e811cd4..bba188207066 100644 --- a/src/hash.h +++ b/src/hash.h @@ -242,6 +242,11 @@ unsigned int MurmurHash3(unsigned int nHashSeed, Span vData void BIP32Hash(const ChainCode &chainCode, unsigned int nChild, unsigned char header, const unsigned char data[32], unsigned char output[64]); +/** DIP-14 child key derivation HMAC: like BIP32Hash but with a 256-bit child + * index, serialized big-endian (ser256). Used for Dash Platform (DashPay) + * derivation paths. */ +void DIP14Hash(const ChainCode& chainCode, const unsigned char nChild[32], unsigned char header, const unsigned char data[32], unsigned char output[64]); + /** Return a HashWriter primed for tagged hashes (as specified in BIP 340). * * The returned object will have SHA256(tag) written to it twice (= 64 bytes). diff --git a/src/key.cpp b/src/key.cpp index 84a068d71454..9cad42d288e9 100644 --- a/src/key.cpp +++ b/src/key.cpp @@ -13,6 +13,8 @@ #include #include +#include + static secp256k1_context* secp256k1_context_sign = nullptr; /** These functions are taken from the libsecp256k1 distribution and are very ugly. */ @@ -305,6 +307,32 @@ bool CKey::Derive(CKey& keyChild, ChainCode &ccChild, unsigned int nChild, const return ret; } +bool CKey::Derive256(CKey& keyChild, ChainCode& ccChild, Span nChild, bool hardened, const ChainCode& cc) const { + assert(IsValid()); + assert(IsCompressed()); + if (nChild.size() != 32) return false; + // DIP-14 compatibility mode: indexes below 2^32 derive exactly as BIP32, + // with the hardened flag folded into the high bit of the 32-bit index. + if (std::all_of(nChild.begin(), nChild.begin() + 28, [](unsigned char c) { return c == 0; })) { + uint32_t child32 = ReadBE32(nChild.data() + 28); + return Derive(keyChild, ccChild, child32 | (hardened ? 0x80000000u : 0), cc); + } + std::vector> vout(64); + if (!hardened) { + CPubKey pubkey = GetPubKey(); + assert(pubkey.size() == CPubKey::COMPRESSED_SIZE); + DIP14Hash(cc, nChild.data(), *pubkey.begin(), pubkey.begin() + 1, vout.data()); + } else { + assert(size() == 32); + DIP14Hash(cc, nChild.data(), 0, begin(), vout.data()); + } + memcpy(ccChild.begin(), vout.data() + 32, 32); + keyChild.Set(begin(), begin() + 32, true); + bool ret = secp256k1_ec_seckey_tweak_add(secp256k1_context_sign, (unsigned char*)keyChild.begin(), vout.data()); + if (!ret) keyChild.ClearKeyData(); + return ret; +} + EllSwiftPubKey CKey::EllSwiftCreate(Span ent32) const { assert(keydata); diff --git a/src/key.h b/src/key.h index c4c713c02f24..25c4277dd328 100644 --- a/src/key.h +++ b/src/key.h @@ -157,6 +157,11 @@ class CKey //! Derive BIP32 child key. [[nodiscard]] bool Derive(CKey& keyChild, ChainCode &ccChild, unsigned int nChild, const ChainCode& cc) const; + //! Derive DIP-14 child key with a 256-bit index (32 bytes, big-endian). + //! Indexes below 2^32 fall back to BIP32 derivation for compatibility + //! (the hardened flag is then folded into the 32-bit index). + [[nodiscard]] bool Derive256(CKey& keyChild, ChainCode& ccChild, Span nChild, bool hardened, const ChainCode& cc) const; + /** * Verify thoroughly whether a private key and a public key match. * This is done using a different mechanism than just regenerating it. diff --git a/src/pubkey.cpp b/src/pubkey.cpp index 8de5143dbb0b..a69aa2fd1d7e 100644 --- a/src/pubkey.cpp +++ b/src/pubkey.cpp @@ -258,6 +258,34 @@ bool CPubKey::Derive(CPubKey& pubkeyChild, ChainCode &ccChild, unsigned int nChi return true; } +bool CPubKey::Derive256(CPubKey& pubkeyChild, ChainCode& ccChild, Span nChild, const ChainCode& cc) const { + assert(IsValid()); + if (nChild.size() != 32) return false; + assert(size() == COMPRESSED_SIZE); + // DIP-14 compatibility mode: indexes below 2^32 derive exactly as BIP32. + // A hardened (high bit set) 32-bit index cannot be derived from a pubkey. + if (std::all_of(nChild.begin(), nChild.begin() + 28, [](unsigned char c) { return c == 0; })) { + uint32_t child32 = ReadBE32(nChild.data() + 28); + if (child32 >> 31) return false; + return Derive(pubkeyChild, ccChild, child32, cc); + } + unsigned char out[64]; + DIP14Hash(cc, nChild.data(), *begin(), begin() + 1, out); + memcpy(ccChild.begin(), out + 32, 32); + secp256k1_pubkey pubkey; + if (!secp256k1_ec_pubkey_parse(secp256k1_context_static, &pubkey, vch, size())) { + return false; + } + if (!secp256k1_ec_pubkey_tweak_add(secp256k1_context_static, &pubkey, out)) { + return false; + } + unsigned char pub[COMPRESSED_SIZE]; + size_t publen = COMPRESSED_SIZE; + secp256k1_ec_pubkey_serialize(secp256k1_context_static, pub, &publen, &pubkey, SECP256K1_EC_COMPRESSED); + pubkeyChild.Set(pub, pub + publen); + return true; +} + EllSwiftPubKey::EllSwiftPubKey(Span ellswift) noexcept { assert(ellswift.size() == SIZE); diff --git a/src/pubkey.h b/src/pubkey.h index 5de7fa9519a8..a696821e27a7 100644 --- a/src/pubkey.h +++ b/src/pubkey.h @@ -215,6 +215,11 @@ class CPubKey //! Derive BIP32 child pubkey. [[nodiscard]] bool Derive(CPubKey& pubkeyChild, ChainCode &ccChild, unsigned int nChild, const ChainCode& cc) const; + + //! Derive DIP-14 (non-hardened) child pubkey with a 256-bit index + //! (32 bytes, big-endian). Indexes below 2^32 fall back to BIP32 + //! derivation for compatibility; a hardened 32-bit index fails. + [[nodiscard]] bool Derive256(CPubKey& pubkeyChild, ChainCode& ccChild, Span nChild, const ChainCode& cc) const; }; /** An ElligatorSwift-encoded public key. */ diff --git a/src/test/dip14_tests.cpp b/src/test/dip14_tests.cpp new file mode 100644 index 000000000000..545c0fc1fe78 --- /dev/null +++ b/src/test/dip14_tests.cpp @@ -0,0 +1,205 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include + +BOOST_FIXTURE_TEST_SUITE(dip14_tests, BasicTestingSetup) + +namespace { +//! One step of a DIP-14 derivation path: a 256-bit child index (big-endian) +//! plus hardened flag. +struct PathElement { + std::array index{}; + bool hardened{false}; +}; + +PathElement Elem256(const std::string& hex, bool hardened) +{ + const std::vector v{ParseHex(hex)}; + BOOST_REQUIRE_EQUAL(v.size(), 32U); + PathElement e; + std::copy(v.begin(), v.end(), e.index.begin()); + e.hardened = hardened; + return e; +} + +PathElement Elem32(uint32_t index, bool hardened) +{ + PathElement e; + WriteBE32(e.index.data() + 28, index); + e.hardened = hardened; + return e; +} + +//! Master key from the seed shared by all DIP-14 test vectors (dashpay/dips +//! dip-0014.md), from mnemonic "birth kingdom trash renew flavor utility +//! donkey gasp regular alert pave layer". +CExtKey MasterKey() +{ + const std::vector seed{ParseHex( + "b16d3782e714da7c55a397d5f19104cfed7ffa8036ac514509bbb50807f8ac59" + "8eeb26f0797bd8cc221a6cbff2168d90a5e9ee025a5bd977977b9eccd97894bb")}; + CExtKey master; + master.SetSeed(MakeByteSpan(seed)); + return master; +} + +void DerivePath(const std::vector& path, CKey& key_out, ChainCode& cc_out) +{ + const CExtKey master{MasterKey()}; + key_out = master.key; + cc_out = master.chaincode; + for (const PathElement& e : path) { + CKey child; + ChainCode cc_child; + BOOST_REQUIRE(key_out.Derive256(child, cc_child, e.index, e.hardened, cc_out)); + key_out = child; + cc_out = cc_child; + } +} + +std::string DerivedKeyHex(const std::vector& path) +{ + CKey key; + ChainCode cc; + DerivePath(path, key, cc); + return HexStr(Span{key.begin(), key.size()}); +} +} // namespace + +// DIP-14 test vector 1: m//'//0 +BOOST_AUTO_TEST_CASE(dip14_vector_1) +{ + const std::vector path{ + Elem256("775d3854c910b7dee436869c4724bed2fe0784e198b8a39f02bbb49d8ebcfc3b", false), + Elem256("f537439f36d04a15474ff7423e4b904a14373fafb37a41db74c84f1dbb5c89a6", true), + Elem256("4c4592ca670c983fc43397dfd21a6f427fac9b4ac53cb4dcdc6522ec51e81e79", false), + Elem32(0, false), + }; + BOOST_CHECK_EQUAL(DerivedKeyHex(path), "e8781fdef72862968cd9a4d2df34edaf9dcc5b17629ec505f0d2d1a8ed6f9f09"); +} + +// DIP-14 test vector 2: m/9'/5'/15'/0'/'/'/0 (DIP-15 shape) +BOOST_AUTO_TEST_CASE(dip14_vector_2) +{ + const std::vector path{ + Elem32(9, true), + Elem32(5, true), + Elem32(15, true), + Elem32(0, true), + Elem256("555d3854c910b7dee436869c4724bed2fe0784e198b8a39f02bbb49d8ebcfc3a", true), + Elem256("a137439f36d04a15474ff7423e4b904a14373fafb37a41db74c84f1dbb5c89b5", true), + Elem32(0, false), + }; + BOOST_CHECK_EQUAL(DerivedKeyHex(path), "fac40790776d171ee1db90899b5eb2df2f7d2aaf35ad56f07ffb8ed2c57f8e60"); +} + +// DIP-14 test vector 3: m/ (single 256-bit non-hardened step) +BOOST_AUTO_TEST_CASE(dip14_vector_3) +{ + const std::vector path{ + Elem256("775d3854c910b7dee436869c4724bed2fe0784e198b8a39f02bbb49d8ebcfc3b", false), + }; + BOOST_CHECK_EQUAL(DerivedKeyHex(path), "f6a95ae75ea8362d9478932f71b262b3d981918fe030316686a475dea4889938"); +} + +// DIP-14 test vector 4: m//' +BOOST_AUTO_TEST_CASE(dip14_vector_4) +{ + const std::vector path{ + Elem256("775d3854c910b7dee436869c4724bed2fe0784e198b8a39f02bbb49d8ebcfc3b", false), + Elem256("f537439f36d04a15474ff7423e4b904a14373fafb37a41db74c84f1dbb5c89a6", true), + }; + BOOST_CHECK_EQUAL(DerivedKeyHex(path), "b898ad92d3a0698bc3117d3777d82676673816ce52f4fc2f1263a2f676825f90"); +} + +//! Indexes below 2^32 must derive exactly as BIP32, so a DIP-14 path mixing +//! 32-bit and 256-bit steps stays compatible with existing BIP32 code. +BOOST_AUTO_TEST_CASE(dip14_bip32_compatibility) +{ + const CExtKey master{MasterKey()}; + + CKey child_bip32, child_dip14; + ChainCode cc_bip32, cc_dip14; + BOOST_REQUIRE(master.key.Derive(child_bip32, cc_bip32, 5, master.chaincode)); + BOOST_REQUIRE(master.key.Derive256(child_dip14, cc_dip14, Elem32(5, false).index, false, master.chaincode)); + BOOST_CHECK(child_bip32.GetPrivKey() == child_dip14.GetPrivKey()); + BOOST_CHECK(cc_bip32 == cc_dip14); + + BOOST_REQUIRE(master.key.Derive(child_bip32, cc_bip32, 5 | 0x80000000u, master.chaincode)); + BOOST_REQUIRE(master.key.Derive256(child_dip14, cc_dip14, Elem32(5, false).index, true, master.chaincode)); + BOOST_CHECK(child_bip32.GetPrivKey() == child_dip14.GetPrivKey()); + BOOST_CHECK(cc_bip32 == cc_dip14); + + const CPubKey parent_pub{master.key.GetPubKey()}; + CPubKey pub_bip32, pub_dip14; + BOOST_REQUIRE(parent_pub.Derive(pub_bip32, cc_bip32, 5, master.chaincode)); + BOOST_REQUIRE(parent_pub.Derive256(pub_dip14, cc_dip14, Elem32(5, false).index, master.chaincode)); + BOOST_CHECK(pub_bip32 == pub_dip14); + BOOST_CHECK(cc_bip32 == cc_dip14); +} + +//! Non-hardened 256-bit public derivation must match private derivation +//! (this is what lets a contact derive our friendship addresses from an +//! exported xpub), and hardened derivation must be rejected on the public +//! side. +BOOST_AUTO_TEST_CASE(dip14_public_derivation_matches) +{ + CKey parent_key; + ChainCode parent_cc; + DerivePath({Elem32(9, true), Elem32(1, true), Elem32(15, true), Elem32(0, true)}, parent_key, parent_cc); + + const PathElement id_a{Elem256("555d3854c910b7dee436869c4724bed2fe0784e198b8a39f02bbb49d8ebcfc3a", false)}; + const PathElement id_b{Elem256("a137439f36d04a15474ff7423e4b904a14373fafb37a41db74c84f1dbb5c89b5", false)}; + + // Private side: parent/idA/idB + CKey mid_key, leaf_key; + ChainCode mid_cc, leaf_cc; + BOOST_REQUIRE(parent_key.Derive256(mid_key, mid_cc, id_a.index, false, parent_cc)); + BOOST_REQUIRE(mid_key.Derive256(leaf_key, leaf_cc, id_b.index, false, mid_cc)); + + // Public side: neuter parent, then derive idA/idB + const CPubKey parent_pub{parent_key.GetPubKey()}; + CPubKey mid_pub, leaf_pub; + ChainCode mid_pub_cc, leaf_pub_cc; + BOOST_REQUIRE(parent_pub.Derive256(mid_pub, mid_pub_cc, id_a.index, parent_cc)); + BOOST_REQUIRE(mid_pub.Derive256(leaf_pub, leaf_pub_cc, id_b.index, mid_pub_cc)); + + BOOST_CHECK(leaf_pub == leaf_key.GetPubKey()); + BOOST_CHECK(leaf_pub_cc == leaf_cc); + + // A hardened 32-bit index (high bit set) cannot be derived from a pubkey. + CPubKey unused; + ChainCode unused_cc; + BOOST_CHECK(!parent_pub.Derive256(unused, unused_cc, Elem32(0x80000000u, false).index, parent_cc)); +} + +BOOST_AUTO_TEST_CASE(dip14_rejects_invalid_index_sizes) +{ + const CExtKey master{MasterKey()}; + const CPubKey parent_pub{master.key.GetPubKey()}; + const std::array short_index{}; + const std::array long_index{}; + CKey child_key; + CPubKey child_pubkey; + ChainCode child_cc; + + BOOST_CHECK(!master.key.Derive256(child_key, child_cc, short_index, false, master.chaincode)); + BOOST_CHECK(!master.key.Derive256(child_key, child_cc, long_index, false, master.chaincode)); + BOOST_CHECK(!parent_pub.Derive256(child_pubkey, child_cc, short_index, master.chaincode)); + BOOST_CHECK(!parent_pub.Derive256(child_pubkey, child_cc, long_index, master.chaincode)); +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/src/test/fuzz/key.cpp b/src/test/fuzz/key.cpp index 08521b822aa9..46c2c9b069b6 100644 --- a/src/test/fuzz/key.cpp +++ b/src/test/fuzz/key.cpp @@ -18,6 +18,7 @@ #include #include +#include #include #include #include @@ -99,6 +100,48 @@ FUZZ_TARGET(key, .init = initialize_key) const CPubKey pubkey = key.GetPubKey(); + const auto test_derive256 = [&](const std::array& child_index) { + CKey child_key; + ChainCode child_chaincode; + const bool private_ok = key.Derive256(child_key, child_chaincode, child_index, false, random_uint256); + + CPubKey child_pubkey; + ChainCode child_pub_chaincode; + const bool public_ok = pubkey.Derive256(child_pubkey, child_pub_chaincode, child_index, random_uint256); + assert(private_ok == public_ok); + if (private_ok) { + assert(child_key.IsValid()); + assert(child_pubkey == child_key.GetPubKey()); + assert(child_chaincode == child_pub_chaincode); + } + + CKey hardened_child_key; + ChainCode hardened_child_chaincode; + if (key.Derive256(hardened_child_key, hardened_child_chaincode, child_index, true, random_uint256)) { + assert(hardened_child_key.IsValid()); + } + }; + + std::array wide_child_index; + std::copy(random_uint256.begin(), random_uint256.end(), wide_child_index.begin()); + wide_child_index.front() |= 1; + test_derive256(wide_child_index); + + std::array boundary_child_index{}; + std::copy(random_uint256.begin(), random_uint256.begin() + 4, boundary_child_index.end() - 4); + boundary_child_index[27] = 1; + test_derive256(boundary_child_index); + + std::array bip32_child_index{}; + std::copy(random_uint256.begin(), random_uint256.begin() + 4, bip32_child_index.end() - 4); + bip32_child_index[28] &= 0x7f; + test_derive256(bip32_child_index); + + bip32_child_index[28] |= 0x80; + CPubKey hardened_child_pubkey; + ChainCode hardened_child_chaincode; + assert(!pubkey.Derive256(hardened_child_pubkey, hardened_child_chaincode, bip32_child_index, random_uint256)); + { assert(pubkey.size() == 33); assert(key.VerifyPubKey(pubkey)); diff --git a/test/util/data/non-backported.txt b/test/util/data/non-backported.txt index 0cecc5006c14..c393f7d9a6bb 100644 --- a/test/util/data/non-backported.txt +++ b/test/util/data/non-backported.txt @@ -61,6 +61,7 @@ src/test/block_reward_reallocation_tests.cpp src/test/bls_tests.cpp src/test/coinjoin_*.cpp src/test/dip0020opcodes_tests.cpp +src/test/dip14_tests.cpp src/test/dynamic_activation*.cpp src/test/evo*.cpp src/test/llmq*.cpp