diff --git a/doc/release-notes-8002.md b/doc/release-notes-8002.md new file mode 100644 index 000000000000..07491463d2a6 --- /dev/null +++ b/doc/release-notes-8002.md @@ -0,0 +1,14 @@ +Wallet +------ + +- New wallet RPC `bls operatorkey` issues masternode operator keys derived from + the wallet mnemonic at `m/9'/coin'/3'/3'/index`, the keys the DashPay mobile + wallets derive from the same recovery phrase, so that phrase is the only + backup an operator needs. Each call issues the next key; `bls operatorkey + ` returns a key issued earlier. A wallet restored from the phrase + skips indexes whose key is in the masternode list at the chain tip, and a + rescan moves past every key it sees assigned by a ProRegTx or ProUpRegTx, + so keys rotated away earlier are not issued again. Rescans driven by block + filters cannot see operator keys, and a pruned node cannot rescan at all, + so issuing on a pruned node returns a warning. Requires an unlocked + descriptor wallet with a mnemonic. (#8002) diff --git a/src/rpc/client.cpp b/src/rpc/client.cpp index 1bef88730638..9903b99f30dc 100644 --- a/src/rpc/client.cpp +++ b/src/rpc/client.cpp @@ -276,6 +276,7 @@ static const CRPCConvertParam vRPCConvertParams[] = // Compound RPCs (note: index position is offset by one to account for subcommand) { "bls generate", 1, "legacy" }, { "bls fromsecret", 2, "legacy" }, + { "bls operatorkey", 1, "index" }, { "coinjoinsalt generate", 1, "overwrite" }, { "coinjoinsalt set", 2, "overwrite" }, { "gobject list-prepared", 1, "count" }, diff --git a/src/rpc/evo.cpp b/src/rpc/evo.cpp index 212feea5839c..69dfd82eee25 100644 --- a/src/rpc/evo.cpp +++ b/src/rpc/evo.cpp @@ -54,6 +54,7 @@ using wallet::DEFAULT_DISABLE_WALLET; using wallet::GetWalletForJSONRPCRequest; using wallet::HELP_REQUIRING_PASSPHRASE; using wallet::isminetype; +using wallet::PlatformKeyStatus; #endif // ENABLE_WALLET // Defined here rather than with the other ToJson() in evo/core_write.cpp: dash-tx never prints @@ -2462,6 +2463,115 @@ static RPCHelpMan bls_fromsecret() }; } +#ifdef ENABLE_WALLET +[[noreturn]] static void ThrowOperatorKeyError(PlatformKeyStatus status, uint32_t index) +{ + switch (status) { + case PlatformKeyStatus::WALLET_LOCKED: + throw JSONRPCError(RPC_WALLET_UNLOCK_NEEDED, + "Error: Please enter the wallet passphrase with walletpassphrase first."); + case PlatformKeyStatus::NOT_SUPPORTED: + throw JSONRPCError(RPC_WALLET_ERROR, "Operator keys need a descriptor wallet with private keys and a mnemonic"); + case PlatformKeyStatus::AMBIGUOUS_SOURCE: + throw JSONRPCError(RPC_WALLET_ERROR, "Active descriptors of this wallet come from different root keys"); + case PlatformKeyStatus::INVALID_SOURCE: + throw JSONRPCError(RPC_WALLET_ERROR, "Wallet mnemonic does not match the descriptor root key"); + case PlatformKeyStatus::INVALID_ARGUMENT: + throw JSONRPCError(RPC_INVALID_PARAMETER, strprintf("operator key %d has not been issued by this wallet", index)); + case PlatformKeyStatus::DATABASE_ERROR: + throw JSONRPCError(RPC_WALLET_ERROR, "Operator key records could not be read or written"); + case PlatformKeyStatus::DERIVATION_ERROR: + throw JSONRPCError(RPC_WALLET_ERROR, "Operator key derivation failed"); + case PlatformKeyStatus::SUCCESS: + case PlatformKeyStatus::IMPORT_ERROR: + break; + } + NONFATAL_UNREACHABLE(); +} + +static RPCHelpMan bls_operatorkey() +{ + return RPCHelpMan{ + "bls operatorkey", + "\nIssues the next masternode operator key derived from the wallet mnemonic, or returns a key issued earlier.\n" + "Keys follow m/9'/coin'/3'/3'/index with the BLS HD scheme of the DashPay mobile wallets, so restoring\n" + "the mnemonic anywhere reproduces the same keys in the same order. Indexes whose key is in the masternode\n" + "list at the chain tip are skipped.\n" + + HELP_REQUIRING_PASSPHRASE, + { + {"index", RPCArg::Type::NUM, RPCArg::Optional::OMITTED, + "Return the key issued earlier at this index instead of issuing a new one"}, + }, + RPCResult{RPCResult::Type::OBJ, + "", + "", + { + {RPCResult::Type::NUM, "index", "Derivation index of the key"}, + {RPCResult::Type::STR_HEX, "secret", "BLS secret key"}, + {RPCResult::Type::STR_HEX, "public", "BLS public key"}, + {RPCResult::Type::STR, "scheme", "BLS scheme, always basic (use bls fromsecret for legacy)"}, + {RPCResult::Type::ARR, + "warnings", + /*optional=*/true, + "Why the key may have been used before", + { + {RPCResult::Type::STR, "", ""}, + }}, + }}, + RPCExamples{HelpExampleCli("bls operatorkey", "") + HelpExampleCli("bls operatorkey", "0")}, + [&](const RPCHelpMan& self, const JSONRPCRequest& request) -> UniValue { + std::shared_ptr const wallet{GetWalletForJSONRPCRequest(request)}; + if (!wallet) return UniValue::VNULL; + // Make sure the results are valid at least up to the most recent block + // the user could have gotten from another RPC command prior to now + wallet->BlockUntilSyncedToCurrentChain(); + EnsureWalletIsUnlocked(*wallet); + + uint32_t index{0}; + CBLSSecretKey secret; + PlatformKeyStatus status; + if (request.params[0].isNull()) { + const NodeContext& node{EnsureAnyNodeContext(request.context)}; + std::set> operator_keys; + CHECK_NONFATAL(node.dmnman)->GetListAtChainTip().ForEachMN(/*onlyValid=*/false, [&](const auto& dmn) { + operator_keys.insert(dmn.pdmnState->pubKeyOperator.Get().ToByteVector(/*specificLegacyScheme=*/false)); + }); + LOCK(wallet->cs_wallet); + status = wallet->IssueOperatorKey( + [&operator_keys](const CBLSPublicKey& pubkey) { + return operator_keys.count(pubkey.ToByteVector(/*specificLegacyScheme=*/false)) != 0; + }, + index, secret); + } else { + const int64_t requested{request.params[0].getInt()}; + if (requested < 0 || requested >> 31) { + throw JSONRPCError(RPC_INVALID_PARAMETER, "Operator key index out of range"); + } + index = requested; + LOCK(wallet->cs_wallet); + status = wallet->GetOperatorKey(index, secret); + } + if (status != PlatformKeyStatus::SUCCESS) ThrowOperatorKeyError(status, index); + + UniValue ret(UniValue::VOBJ); + ret.pushKV("index", index); + ret.pushKV("secret", secret.ToString()); + ret.pushKV("public", secret.GetPublicKey().ToString(/*specificLegacyScheme=*/false)); + ret.pushKV("scheme", "basic"); + if (request.params[0].isNull() && wallet->chain().havePruned()) { + UniValue warnings(UniValue::VARR); + warnings.push_back( + "This node is pruned, so provider transactions in pruned blocks were not scanned: a key " + "rotated away before this wallet was restored may be issued again. Issue the key on a " + "node with full blocks or rotate the operator key after registering."); + PushWarnings(warnings, ret); + } + return ret; + }, + }; +} +#endif // ENABLE_WALLET + static RPCHelpMan bls_help() { return RPCHelpMan{"bls", @@ -2469,6 +2579,9 @@ static RPCHelpMan bls_help() "To get help on individual commands, use \"help bls command\".\n" "\nAvailable commands:\n" " generate - Create a BLS secret/public key pair\n" +#ifdef ENABLE_WALLET + " operatorkey - Issue or return a masternode operator key derived from the wallet mnemonic\n" +#endif " fromsecret - Parse a BLS secret key and return the secret/public key pair\n", { {"command", RPCArg::Type::STR, RPCArg::Optional::NO, "The command to execute"}, @@ -2507,6 +2620,7 @@ Span GetWalletEvoRPCCommands() {"evo", &protx_shared_dissolve_prepare}, {"evo", &protx_shared_update_share}, {"evo", &protx_shared_update_registrar_prepare}, + {"evo", &bls_operatorkey}, {"hidden", &protx_register_legacy}, {"hidden", &protx_register_fund_legacy}, {"hidden", &protx_register_prepare_legacy}, diff --git a/src/test/rpc_tests.cpp b/src/test/rpc_tests.cpp index 0501a4f264fb..1d30bcc92474 100644 --- a/src/test/rpc_tests.cpp +++ b/src/test/rpc_tests.cpp @@ -590,6 +590,10 @@ BOOST_AUTO_TEST_CASE(rpc_convert_composite_commands) BOOST_CHECK_EQUAL(result[3][1].get_str(), "1.0.0.1:19999"); BOOST_CHECK_EQUAL(result[4].get_str(), "yhq7ifNCtTKEpY4Yu5XPCcztQco6Fh6JsZ"); + BOOST_CHECK_NO_THROW(result = RPCConvertValues("bls", {"operatorkey", "0"})); + BOOST_CHECK_EQUAL(result[0].get_str(), "operatorkey"); + BOOST_CHECK_EQUAL(result[1].getInt(), 0); + // Validate that array syntax is not interpreted as string literal (named parameter) BOOST_CHECK_NO_THROW(result = RPCConvertNamedValues("protx", { "register_prepare", diff --git a/src/wallet/platformkeys.cpp b/src/wallet/platformkeys.cpp index e887ba32c7af..15e7533a7245 100644 --- a/src/wallet/platformkeys.cpp +++ b/src/wallet/platformkeys.cpp @@ -4,10 +4,12 @@ #include +#include #include #include #include #include +#include #include #include @@ -83,6 +85,51 @@ Path FriendshipPath(uint32_t coin_type, uint32_t account, Span us }; } +//! m/9'/coin'/3'/3'. Soft derivation below it hashes the parent public key, +//! and only its legacy serialization reproduces the keys DashSync and dashj +//! derive. +static bls::ExtendedPrivateKey OperatorKeyParent(Span seed, uint32_t coin_type) +{ + constexpr uint32_t HARDENED{0x80000000u}; + return bls::ExtendedPrivateKey::FromSeed(bls::Bytes(seed.data(), seed.size())) + .PrivateChild(FEATURE_PURPOSE | HARDENED, /*fLegacy=*/true) + .PrivateChild(coin_type | HARDENED, /*fLegacy=*/true) + .PrivateChild(FEATURE_MASTERNODE | HARDENED, /*fLegacy=*/true) + .PrivateChild(MASTERNODE_KEY_OPERATOR | HARDENED, /*fLegacy=*/true); +} + +CBLSSecretKey DeriveOperatorKey(Span seed, uint32_t coin_type, uint32_t index) +{ + if (index >> 31) return {}; + const auto leaf{OperatorKeyParent(seed, coin_type).PrivateChild(index, /*fLegacy=*/true)}; + std::array bytes; + leaf.GetPrivateKey().Serialize(bytes.data()); + const CBLSSecretKey secret(bytes); + memory_cleanse(bytes.data(), bytes.size()); + return secret; +} + +std::vector OperatorKeyChain(Span seed, uint32_t coin_type) +{ + return OperatorKeyParent(seed, coin_type).GetExtendedPublicKey(/*fLegacy=*/true).Serialize(/*fLegacy=*/true); +} + +CBLSPublicKey OperatorPubKey(Span chain, uint32_t index) +{ + CBLSPublicKey pubkey; + if (index >> 31 || chain.size() != bls::ExtendedPublicKey::SIZE) return pubkey; + try { + const auto child{bls::ExtendedPublicKey::FromBytes(bls::Bytes(chain.data(), chain.size()), /*fLegacy=*/true) + .PublicChild(index, /*fLegacy=*/true) + .GetPublicKey() + .Serialize(/*fLegacy=*/false)}; + pubkey.SetBytes(child, /*specificLegacyScheme=*/false); + } catch (const std::exception&) { + // Bytes that do not decode as an extended public key yield no key. + } + return pubkey; +} + bool DeriveExtKey(const CExtKey& parent, const Path& path, ExtKey256& out) { out = {}; diff --git a/src/wallet/platformkeys.h b/src/wallet/platformkeys.h index 4136195a80f4..2dfeb050e6e3 100644 --- a/src/wallet/platformkeys.h +++ b/src/wallet/platformkeys.h @@ -16,6 +16,9 @@ #include #include +class CBLSPublicKey; +class CBLSSecretKey; + /** * Dash Platform (DIP-9/13/14/15) key derivation for the wallet. * @@ -28,7 +31,9 @@ * - DIP-13 identity keys (m/9'/coin'/5'/...) * - DIP-14 256-bit child indexes (CKey::Derive256) * - DIP-15 DashPay friendship keychains (m/9'/coin'/15'/account'/idA/idB) - * - dashj DerivationPathFactory.java / FriendKeyChain.java (reference impl) + * - DIP-3 masternode operator keys (m/9'/coin'/3'/3'/index, BLS HD scheme) + * - dashj DerivationPathFactory.java / FriendKeyChain.java / + * BLSDeterministicKey.java (reference impl) */ namespace wallet::platformkeys { @@ -44,11 +49,15 @@ struct PathElement { }; using Path = std::vector; -// DIP-9 feature purpose, and DIP-13/DIP-15 feature types under it. +// DIP-9 feature purpose, and DIP-3/DIP-13/DIP-15 feature types under it. inline constexpr uint32_t FEATURE_PURPOSE{9}; +inline constexpr uint32_t FEATURE_MASTERNODE{3}; // DIP-3 inline constexpr uint32_t FEATURE_IDENTITIES{5}; // DIP-13 inline constexpr uint32_t FEATURE_DASHPAY{15}; // DIP-15 +// DIP-3 masternode key sub-features: m/9'/coin'/3'/' +inline constexpr uint32_t MASTERNODE_KEY_OPERATOR{3}; + // DIP-13 sub-features: m/9'/coin'/5'/' inline constexpr uint32_t IDENTITY_AUTHENTICATION{0}; inline constexpr uint32_t IDENTITY_REGISTRATION_FUNDING{1}; @@ -91,6 +100,21 @@ Path PlatformKeyPath(uint32_t coin_type, const PlatformKeyRequest& request); //! (userA = my id, userB = their id), the sending chain is the reverse. Path FriendshipPath(uint32_t coin_type, uint32_t account, Span user_a_id, Span user_b_id); +//! m/9'/coin'/3'/3'/ — DIP-3 masternode operator key, derived the way +//! DashSync and dashj do it: bls::ExtendedPrivateKey from the 64-byte BIP39 +//! seed with legacy child derivation at every step. The leaf index is soft +//! and must be below 2^31; an invalid key is returned otherwise. +CBLSSecretKey DeriveOperatorKey(Span seed, uint32_t coin_type, uint32_t index); + +//! Serialized bls::ExtendedPublicKey at m/9'/coin'/3'/3', the parent of every +//! operator key. Its soft children are the operator public keys, so it stands +//! in for the seed wherever only public keys are needed. +std::vector OperatorKeyChain(Span seed, uint32_t coin_type); + +//! Operator public key at `index` under a chain from OperatorKeyChain; invalid +//! for a malformed chain or an index of 2^31 or above. +CBLSPublicKey OperatorPubKey(Span chain, uint32_t index); + //! Derive the extended key at `path` from a BIP32 extended private key. //! Returns false if the parent key or any derivation step is invalid. [[nodiscard]] bool DeriveExtKey(const CExtKey& parent, const Path& path, ExtKey256& out); diff --git a/src/wallet/platformtypes.h b/src/wallet/platformtypes.h index 64aeb0b3f21e..4a1fa9b553e4 100644 --- a/src/wallet/platformtypes.h +++ b/src/wallet/platformtypes.h @@ -26,6 +26,7 @@ enum class PlatformKeyStatus : uint8_t { INVALID_ARGUMENT, IMPORT_ERROR, DERIVATION_ERROR, + DATABASE_ERROR, }; template diff --git a/src/wallet/rpc/wallet.cpp b/src/wallet/rpc/wallet.cpp index e0a85beb6d8a..93029e27f33c 100644 --- a/src/wallet/rpc/wallet.cpp +++ b/src/wallet/rpc/wallet.cpp @@ -431,6 +431,12 @@ static RPCHelpMan upgradetohd() if (pwallet->IsWalletFlagSet(WALLET_FLAG_DESCRIPTORS)) { pwallet->SetupDescriptorScriptPubKeyMans(mnemonic, mnemonic_passphrase); + // A supplied mnemonic may already have operator keys on-chain; the + // chain record lets the rescan below recognise them and must exist + // before encryption locks the seed away. + if (!generate_mnemonic && pwallet->SetupOperatorKeyChain() != PlatformKeyStatus::SUCCESS) { + throw JSONRPCError(RPC_WALLET_ERROR, "Failed to store the operator key chain"); + } } else { auto spk_man = pwallet->GetLegacyScriptPubKeyMan(); if (!spk_man) { diff --git a/src/wallet/scriptpubkeyman.cpp b/src/wallet/scriptpubkeyman.cpp index 5f17ef369fa5..d4142b46e436 100644 --- a/src/wallet/scriptpubkeyman.cpp +++ b/src/wallet/scriptpubkeyman.cpp @@ -2,6 +2,7 @@ // Distributed under the MIT software license, see the accompanying // file COPYING or http://www.opensource.org/licenses/mit-license.php. +#include #include #include #include @@ -2781,6 +2782,58 @@ PlatformKeyStatus DescriptorScriptPubKeyMan::DerivePlatformKey(const platformkey return platformkeys::DeriveExtKey(root, path, out) ? PlatformKeyStatus::SUCCESS : PlatformKeyStatus::DERIVATION_ERROR; } +PlatformKeyStatus DescriptorScriptPubKeyMan::OperatorKeySeed(SecureVector& seed) const +{ + std::vector identifier; + if (const auto status{GetPlatformKeySource(identifier)}; status != PlatformKeyStatus::SUCCESS) return status; + if (m_storage.IsLocked(false)) return PlatformKeyStatus::WALLET_LOCKED; + + // Only the mnemonic reproduces the BLS tree the mobile wallets derive + // from; a wallet built from an imported extended key has no BIP39 seed. + // The encrypted path of GetMnemonicString appends one decrypted character + // at a time; reserving keeps them out of the small-string buffer, which + // the secure allocator does not cover. + SecureString mnemonic; + SecureString passphrase; + mnemonic.reserve(256); + passphrase.reserve(256); + if (!GetMnemonicString(mnemonic, passphrase) || mnemonic.empty()) return PlatformKeyStatus::NOT_SUPPORTED; + CMnemonic::ToSeed(mnemonic, passphrase, seed); + + // The mnemonic is stored beside the descriptor root key; one that does not + // reproduce it would hand out keys no backup of this wallet recovers. + CExtKey master; + master.SetSeed(MakeByteSpan(seed)); + CExtPubKey root; + { + LOCK(cs_desc_man); + if (!m_wallet_descriptor.descriptor->GetRootExtPubKey(root)) return PlatformKeyStatus::INVALID_SOURCE; + } + return master.Neuter() == root ? PlatformKeyStatus::SUCCESS : PlatformKeyStatus::INVALID_SOURCE; +} + +PlatformKeyStatus DescriptorScriptPubKeyMan::DeriveOperatorKeys(uint32_t coin_type, uint32_t first, uint32_t count, + std::vector& out) const +{ + out.clear(); + SecureVector seed; + if (const auto status{OperatorKeySeed(seed)}; status != PlatformKeyStatus::SUCCESS) return status; + for (uint32_t i{0}; i < count; ++i) { + out.push_back(platformkeys::DeriveOperatorKey(seed, coin_type, first + i)); + if (!out.back().IsValid()) return PlatformKeyStatus::DERIVATION_ERROR; + } + return PlatformKeyStatus::SUCCESS; +} + +PlatformKeyStatus DescriptorScriptPubKeyMan::OperatorKeyChain(uint32_t coin_type, std::vector& out) const +{ + out.clear(); + SecureVector seed; + if (const auto status{OperatorKeySeed(seed)}; status != PlatformKeyStatus::SUCCESS) return status; + out = platformkeys::OperatorKeyChain(seed, coin_type); + return out.empty() ? PlatformKeyStatus::DERIVATION_ERROR : PlatformKeyStatus::SUCCESS; +} + TransactionError DescriptorScriptPubKeyMan::FillPSBT(PartiallySignedTransaction& psbtx, const PrecomputedTransactionData& txdata, int sighash_type, bool sign, bool bip32derivs, int* n_signed, bool finalize) const { if (n_signed) { diff --git a/src/wallet/scriptpubkeyman.h b/src/wallet/scriptpubkeyman.h index 4fae8272ebbc..f237498edcaa 100644 --- a/src/wallet/scriptpubkeyman.h +++ b/src/wallet/scriptpubkeyman.h @@ -251,6 +251,17 @@ class ScriptPubKeyMan { return PlatformKeyStatus::NOT_SUPPORTED; } + //! Derive the DIP-3 operator keys at indexes [first, first + count) from the mnemonic behind the root key. + virtual PlatformKeyStatus DeriveOperatorKeys(uint32_t coin_type, uint32_t first, uint32_t count, + std::vector& out) const + { + return PlatformKeyStatus::NOT_SUPPORTED; + } + //! Extended public key over the operator keys, see platformkeys::OperatorKeyChain. + virtual PlatformKeyStatus OperatorKeyChain(uint32_t coin_type, std::vector& out) const + { + return PlatformKeyStatus::NOT_SUPPORTED; + } /** Adds script and derivation path information to a PSBT, and optionally signs it. */ virtual TransactionError FillPSBT(PartiallySignedTransaction& psbt, const PrecomputedTransactionData& txdata, int sighash_type = 1 /* SIGHASH_ALL */, bool sign = true, bool bip32derivs = false, int* n_signed = nullptr, bool finalize = true) const { return TransactionError::INVALID_PSBT; } @@ -599,6 +610,9 @@ class DescriptorScriptPubKeyMan : public ScriptPubKeyMan bool AddDescriptorKeyWithDB(WalletBatch& batch, const CKey& key, const CPubKey &pubkey, const SecureString& mnemonic, const SecureString& mnemonic_passphrase) EXCLUSIVE_LOCKS_REQUIRED(cs_desc_man); + //! Rebuild the BIP39 seed from the stored mnemonic once it is checked to reproduce the descriptor root. + PlatformKeyStatus OperatorKeySeed(SecureVector& seed) const; + KeyMap GetKeys() const EXCLUSIVE_LOCKS_REQUIRED(cs_desc_man); // Cached FlatSigningProviders to avoid regenerating them each time they are needed. @@ -681,6 +695,9 @@ class DescriptorScriptPubKeyMan : public ScriptPubKeyMan bool SignSpecialTxPayload(const uint256& hash, const CKeyID& keyid, std::vector& vchSig) const override; PlatformKeyStatus GetPlatformKeySource(std::vector& identifier) const override; PlatformKeyStatus DerivePlatformKey(const platformkeys::Path& path, platformkeys::ExtKey256& out) const override; + PlatformKeyStatus DeriveOperatorKeys(uint32_t coin_type, uint32_t first, uint32_t count, + std::vector& out) const override; + PlatformKeyStatus OperatorKeyChain(uint32_t coin_type, std::vector& out) const override; TransactionError FillPSBT(PartiallySignedTransaction& psbt, const PrecomputedTransactionData& txdata, int sighash_type = 1 /* SIGHASH_ALL */, bool sign = true, bool bip32derivs = false, int* n_signed = nullptr, bool finalize = true) const override; uint256 GetID() const override; diff --git a/src/wallet/test/platformkeys_tests.cpp b/src/wallet/test/platformkeys_tests.cpp index 088b7c84ad3c..b5c451380596 100644 --- a/src/wallet/test/platformkeys_tests.cpp +++ b/src/wallet/test/platformkeys_tests.cpp @@ -2,7 +2,10 @@ // 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 @@ -225,6 +228,65 @@ BOOST_AUTO_TEST_CASE(account_reference_uses_ios_ask28) BOOST_CHECK_EQUAL(AccountReferenceFromMac(mac), 0x01c1d1e1U); } +//! Phrase that test/functional/wallet_upgradetohd.py restores. +static constexpr const char* REGTEST_MNEMONIC{ + "similar behave slot swim scissors throw planet view ghost laugh drift calm"}; + +static bool NeverInUse(const CBLSPublicKey&) { return false; } + +//! Operator keys must come out identical to DashSync and dashj for the same +//! recovery phrase: published answers for m/9'/coin'/3'/3'/0 on testnet, plus +//! mainnet, further indexes and a passphrase computed with the same scheme. +BOOST_AUTO_TEST_CASE(operator_key_vectors) +{ + struct Vector { + const char* mnemonic; + const char* passphrase; + uint32_t coin_type; + uint32_t index; + const char* secret; + const char* public_legacy; + const char* public_basic; + }; + static constexpr const char* DASHSYNC_MNEMONIC{ + "enemy check owner stumble unaware debris suffer peanut good fabric bleak outside"}; + static constexpr const char* DASHJ_MNEMONIC{ + "bundle liberty enlist super solution elder minor shell fold vivid multiply throw"}; + const Vector vectors[]{ + {DASHSYNC_MNEMONIC, "", 1, 0, "344e7bf67fddf1c2d2f629f2392ce2b4af99393cd8a4c70d4bbaef7aaf4c364b", + "157b10706659e25eb362b5d902d809f9160b1688e201ee6e94b40f9b5062d7074683ef05a2d5efb7793c47059c878dfa", + "957b10706659e25eb362b5d902d809f9160b1688e201ee6e94b40f9b5062d7074683ef05a2d5efb7793c47059c878dfa"}, + {DASHJ_MNEMONIC, "", 1, 0, "392e5db8a1fb59608723c9d77320909924dbb06e397ce195429a1c4a7fcefb45", + "0cb6db5b5827e24138abb32e3476c5c7768bda0431c78030d62e9c5b107d3d73f5c78043401c5e8ad8f21a460a02bbbe", + "8cb6db5b5827e24138abb32e3476c5c7768bda0431c78030d62e9c5b107d3d73f5c78043401c5e8ad8f21a460a02bbbe"}, + {DASHSYNC_MNEMONIC, "", 5, 0, "0461b8370ac28e7d7c453ab769d6edc97e9a79e400ae100bfbbf7ea9d834fd2c", + "8efa464e7d58cfd90f3bb1bac18598928ffc7c112d4c64455c0ec72915fcab770a402c439ccc728288f3369ebbd530a5", + "aefa464e7d58cfd90f3bb1bac18598928ffc7c112d4c64455c0ec72915fcab770a402c439ccc728288f3369ebbd530a5"}, + {DASHSYNC_MNEMONIC, "", 1, 1, "25abfe0eeb4535a8e07d6c56f524296111b52bf42751e0d27809d1b991b3ea7e", "", ""}, + {DASHSYNC_MNEMONIC, "", 1, 2, "385f03e9082612ff2db91b0d6eeddbe6c4e810deb867aaebcbd2976e7d762d57", "", ""}, + {REGTEST_MNEMONIC, "", 1, 0, "60a5ae2824bf8dcd7b37b751b73ba419d0ebfe6f6f58aaf6a26eaa9ec43fd686", "", + "a5776ac29c3f02a57157d05303248615ce4676a8fc06e61602ecc5cf3d6c0ecbf6a438edd5b6f3d51941cea51894065b"}, + {REGTEST_MNEMONIC, "", 1, 1, "51a5ec2e84ef20a7ab8cb6b7d591711604973abc2528ebb7cd2ae835833eb1c5", "", ""}, + {REGTEST_MNEMONIC, "custom-passphrase", 1, 0, + "6ab36f50b340904e85d52fbab8223459b1d0b415ac950d7c02eb2b1503d808a4", "", ""}, + }; + for (const auto& vector : vectors) { + SecureVector seed; + CMnemonic::ToSeed(SecureString{vector.mnemonic}, SecureString{vector.passphrase}, seed); + const CBLSSecretKey key{DeriveOperatorKey(seed, vector.coin_type, vector.index)}; + BOOST_CHECK_EQUAL(key.ToString(), vector.secret); + BOOST_CHECK(OperatorPubKey(OperatorKeyChain(seed, vector.coin_type), vector.index) == key.GetPublicKey()); + if (*vector.public_legacy) { + BOOST_CHECK_EQUAL(key.GetPublicKey().ToString(/*specificLegacyScheme=*/true), vector.public_legacy); + } + if (*vector.public_basic) { + BOOST_CHECK_EQUAL(key.GetPublicKey().ToString(/*specificLegacyScheme=*/false), vector.public_basic); + } + } + BOOST_CHECK(!DeriveOperatorKey(SecureVector(64, 0), 1, 0x80000000u).IsValid()); + BOOST_CHECK(!OperatorPubKey(OperatorKeyChain(SecureVector(64, 0), 1), 0x80000000u).IsValid()); +} + //! Mnemonic behind Dip14Seed() above. static constexpr const char* DIP14_MNEMONIC{ "birth kingdom trash renew flavor utility donkey gasp regular alert pave layer"}; @@ -410,6 +472,11 @@ BOOST_FIXTURE_TEST_CASE(platform_key_source_must_be_unambiguous, Dip14WalletSetu const auto ambiguous{m_iface->getPlatformPubKey(IdentityAuthKey{0, 0})}; BOOST_CHECK(ambiguous.status == PlatformKeyStatus::AMBIGUOUS_SOURCE); BOOST_CHECK(m_iface->platformKeySourceStatus() == PlatformKeyStatus::AMBIGUOUS_SOURCE); + + uint32_t index; + CBLSSecretKey operator_key; + LOCK(m_wallet->cs_wallet); + BOOST_CHECK(m_wallet->IssueOperatorKey(NeverInUse, index, operator_key) == PlatformKeyStatus::AMBIGUOUS_SOURCE); } //! An imported root xprv is sufficient for Platform derivation. The mnemonic @@ -424,6 +491,13 @@ BOOST_FIXTURE_TEST_CASE(xprv_only_wallet_derives_platform_keys, FriendshipWallet ExtKey256 expected; BOOST_REQUIRE(DeriveExtKey(master, IdentityAuthKeyPath(static_cast(Params().ExtCoinType()), 0, 0), expected)); BOOST_CHECK(result.value == expected.key.GetPubKey()); + + // Operator keys are the exception: the mobile wallets derive them from + // the BIP39 seed, which an imported xprv does not carry. + uint32_t index; + CBLSSecretKey operator_key; + LOCK(wallet->cs_wallet); + BOOST_CHECK(wallet->IssueOperatorKey(NeverInUse, index, operator_key) == PlatformKeyStatus::NOT_SUPPORTED); } //! The standardized Platform paths are absolute paths from m. An imported @@ -530,6 +604,159 @@ BOOST_FIXTURE_TEST_CASE(legacy_wallet_has_no_platform_keys, FriendshipWalletSetu const auto result{iface->getPlatformPubKey(IdentityAuthKey{0, 0})}; BOOST_CHECK(result.status == PlatformKeyStatus::NOT_SUPPORTED); BOOST_CHECK(iface->platformKeySourceStatus() == PlatformKeyStatus::NOT_SUPPORTED); + + uint32_t index; + CBLSSecretKey operator_key; + LOCK(wallet->cs_wallet); + BOOST_CHECK(wallet->IssueOperatorKey(NeverInUse, index, operator_key) == PlatformKeyStatus::NOT_SUPPORTED); +} + +//! Descriptor wallet restored from the phrase behind the regtest operator-key +//! vectors. +struct OperatorKeyWalletSetup : public FriendshipWalletSetup { + OperatorKeyWalletSetup() : + FriendshipWalletSetup(SecureString{REGTEST_MNEMONIC}) + { + } +}; + +static CBLSPublicKey RegtestOperatorPubKey(uint32_t index) +{ + SecureVector seed; + CMnemonic::ToSeed(SecureString{REGTEST_MNEMONIC}, SecureString{}, seed); + return DeriveOperatorKey(seed, /*coin_type=*/1, index).GetPublicKey(); +} + +static CTransactionRef ProUpRegAssigning(const CBLSPublicKey& operator_key) +{ + CProUpRegTx payload; + payload.nVersion = ProTxVersion::BasicBLS; + payload.pubKeyOperator.Set(operator_key, /*specificLegacyScheme=*/false); + CMutableTransaction tx; + tx.nVersion = 3; + tx.nType = TRANSACTION_PROVIDER_UPDATE_REGISTRAR; + SetTxPayload(tx, payload); + return MakeTransactionRef(tx); +} + +//! Issuance is lowest-index-first and retrieval covers issued indexes only. A +//! seed-only restore (fresh database) hands out the same keys in the same +//! order, skipping only indexes reported in use, and they are the vector keys +//! a mobile wallet shows for this phrase. +BOOST_FIXTURE_TEST_CASE(operator_keys_rederive_after_restore, OperatorKeyWalletSetup) +{ + uint32_t index; + CBLSSecretKey key_0, key_1, key; + { + LOCK(m_wallet->cs_wallet); + BOOST_CHECK(m_wallet->GetOperatorKey(0, key) == PlatformKeyStatus::INVALID_ARGUMENT); + BOOST_REQUIRE(m_wallet->IssueOperatorKey(NeverInUse, index, key_0) == PlatformKeyStatus::SUCCESS); + BOOST_CHECK_EQUAL(index, 0U); + BOOST_CHECK_EQUAL(key_0.ToString(), "60a5ae2824bf8dcd7b37b751b73ba419d0ebfe6f6f58aaf6a26eaa9ec43fd686"); + BOOST_REQUIRE(m_wallet->IssueOperatorKey(NeverInUse, index, key_1) == PlatformKeyStatus::SUCCESS); + BOOST_CHECK_EQUAL(index, 1U); + BOOST_CHECK_EQUAL(key_1.ToString(), "51a5ec2e84ef20a7ab8cb6b7d591711604973abc2528ebb7cd2ae835833eb1c5"); + BOOST_REQUIRE(m_wallet->GetOperatorKey(0, key) == PlatformKeyStatus::SUCCESS); + BOOST_CHECK(key == key_0); + BOOST_CHECK(m_wallet->GetOperatorKey(2, key) == PlatformKeyStatus::INVALID_ARGUMENT); + + // No key leaves the wallet unless its consumption is on disk first. + GetMockableDatabase(*m_wallet).m_pass = false; + BOOST_CHECK(m_wallet->IssueOperatorKey(NeverInUse, index, key) == PlatformKeyStatus::DATABASE_ERROR); + BOOST_CHECK(!key.IsValid()); + GetMockableDatabase(*m_wallet).m_pass = true; + BOOST_REQUIRE(m_wallet->IssueOperatorKey(NeverInUse, index, key) == PlatformKeyStatus::SUCCESS); + BOOST_CHECK_EQUAL(index, 2U); + } + + // The restored wallet has no issuance record; only the masternode list + // tells it that key 0 is taken. + const auto [wallet_b, iface_b]{MakeSeededWallet(SecureString{REGTEST_MNEMONIC})}; + const auto key_0_in_use{[&key_0](const CBLSPublicKey& pubkey) { return pubkey == key_0.GetPublicKey(); }}; + LOCK(wallet_b->cs_wallet); + BOOST_REQUIRE(wallet_b->IssueOperatorKey(key_0_in_use, index, key) == PlatformKeyStatus::SUCCESS); + BOOST_CHECK_EQUAL(index, 1U); + BOOST_CHECK(key == key_1); + BOOST_REQUIRE(wallet_b->GetOperatorKey(0, key) == PlatformKeyStatus::SUCCESS); + BOOST_CHECK(key == key_0); + + // A key of a registered masternode anywhere in the window marks every + // index up to it as used, so the next key lands above it. + const auto key_5{RegtestOperatorPubKey(5)}; + const auto key_5_in_use{[&key_5](const CBLSPublicKey& pubkey) { return pubkey == key_5; }}; + BOOST_REQUIRE(wallet_b->IssueOperatorKey(key_5_in_use, index, key) == PlatformKeyStatus::SUCCESS); + BOOST_CHECK_EQUAL(index, 6U); + BOOST_REQUIRE(wallet_b->GetOperatorKey(5, key) == PlatformKeyStatus::SUCCESS); + BOOST_CHECK(key.GetPublicKey() == key_5); +} + +//! A locked wallet neither issues nor re-derives, and unlocking gives back +//! the key issued before encryption. +BOOST_FIXTURE_TEST_CASE(operator_keys_need_unlocked_wallet, OperatorKeyWalletSetup) +{ + uint32_t index; + CBLSSecretKey before, key; + { + LOCK(m_wallet->cs_wallet); + BOOST_REQUIRE(m_wallet->IssueOperatorKey(NeverInUse, index, before) == PlatformKeyStatus::SUCCESS); + } + BOOST_REQUIRE(m_wallet->EncryptWallet("passphrase")); + BOOST_REQUIRE(m_wallet->IsLocked()); + + LOCK(m_wallet->cs_wallet); + BOOST_CHECK(m_wallet->IssueOperatorKey(NeverInUse, index, key) == PlatformKeyStatus::WALLET_LOCKED); + BOOST_CHECK(m_wallet->GetOperatorKey(0, key) == PlatformKeyStatus::WALLET_LOCKED); + BOOST_CHECK(!key.IsValid()); + + BOOST_REQUIRE(m_wallet->Unlock("passphrase")); + BOOST_REQUIRE(m_wallet->GetOperatorKey(0, key) == PlatformKeyStatus::SUCCESS); + BOOST_CHECK(key == before); +} + +//! A provider transaction seen on the sync path that assigns one of the +//! lookahead keys moves the counter past it, so a restored wallet learns its +//! used indexes from a rescan rather than from the masternode list, even +//! while locked. +BOOST_FIXTURE_TEST_CASE(operator_key_counter_advances_from_synced_protx, OperatorKeyWalletSetup) +{ + uint32_t index; + CBLSSecretKey key_0, key; + WITH_LOCK(m_wallet->cs_wallet, + BOOST_REQUIRE(m_wallet->IssueOperatorKey(NeverInUse, index, key_0) == PlatformKeyStatus::SUCCESS)); + + // Restore: the chain is stored while the seed is at hand, then the wallet + // is encrypted and locked before the chain is synced. + const auto [wallet_b, iface_b]{MakeSeededWallet(SecureString{REGTEST_MNEMONIC})}; + WITH_LOCK(wallet_b->cs_wallet, BOOST_REQUIRE(wallet_b->SetupOperatorKeyChain() == PlatformKeyStatus::SUCCESS)); + BOOST_REQUIRE(wallet_b->EncryptWallet("passphrase")); + wallet_b->transactionAddedToMempool(ProUpRegAssigning(key_0.GetPublicKey()), /*nAcceptTime=*/0); + + BOOST_REQUIRE(wallet_b->Unlock("passphrase")); + LOCK(wallet_b->cs_wallet); + BOOST_REQUIRE(wallet_b->GetOperatorKey(0, key) == PlatformKeyStatus::SUCCESS); + BOOST_CHECK(key == key_0); + BOOST_REQUIRE(wallet_b->IssueOperatorKey(NeverInUse, index, key) == PlatformKeyStatus::SUCCESS); + BOOST_CHECK_EQUAL(index, 1U); + + // A used key whose consumption cannot be recorded aborts the sync instead + // of being dropped silently. + GetMockableDatabase(*wallet_b).m_pass = false; + BOOST_CHECK_THROW(wallet_b->transactionAddedToMempool(ProUpRegAssigning(RegtestOperatorPubKey(2)), /*nAcceptTime=*/0), + std::runtime_error); + GetMockableDatabase(*wallet_b).m_pass = true; + + // Key 1000 lies outside the initial window and is seen only because the hit + // on key 999 rebuilt the window from the new counter, which needs no seed: + // the wallet stays locked throughout the sync. + const auto [wallet_c, iface_c]{MakeSeededWallet(SecureString{REGTEST_MNEMONIC})}; + WITH_LOCK(wallet_c->cs_wallet, BOOST_REQUIRE(wallet_c->SetupOperatorKeyChain() == PlatformKeyStatus::SUCCESS)); + BOOST_REQUIRE(wallet_c->EncryptWallet("passphrase")); + wallet_c->transactionAddedToMempool(ProUpRegAssigning(RegtestOperatorPubKey(999)), /*nAcceptTime=*/0); + wallet_c->transactionAddedToMempool(ProUpRegAssigning(RegtestOperatorPubKey(1000)), /*nAcceptTime=*/0); + BOOST_REQUIRE(wallet_c->Unlock("passphrase")); + LOCK(wallet_c->cs_wallet); + BOOST_REQUIRE(wallet_c->IssueOperatorKey(NeverInUse, index, key) == PlatformKeyStatus::SUCCESS); + BOOST_CHECK_EQUAL(index, 1001U); } //! Two independent wallet instances restored from the same recovery phrase: diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp index 2a328d3de670..b1eabb06991a 100644 --- a/src/wallet/wallet.cpp +++ b/src/wallet/wallet.cpp @@ -7,6 +7,7 @@ #include #include +#include #include #include #include @@ -53,6 +54,7 @@ #include #include +#include #include #include @@ -1331,6 +1333,10 @@ bool CWallet::AddToWalletIfInvolvingMe(const CTransactionRef& ptx, const SyncTxS } } + // A provider transaction assigning one of this wallet's operator keys + // is normally not "ours" by IsMine, so the counter is checked first. + AdvanceOperatorKeyCounter(tx); + bool fExisted = mapWallet.count(tx.GetHash()) != 0; if (fExisted && !fUpdate) return false; if (fExisted || IsMine(tx) || IsFromMe(tx)) @@ -1616,7 +1622,13 @@ void CWallet::blockConnected(const interfaces::BlockInfo& block) // No need to scan block if it was created before the wallet birthday. // Uses chain max time and twice the grace period to adjust time for block time variability. - if (block.chain_time_max < m_birth_time.load() - (TIMESTAMP_WINDOW * 2)) return; + if (block.chain_time_max < m_birth_time.load() - (TIMESTAMP_WINDOW * 2)) { + // Operator keys of a restored mnemonic may predate the wallet itself. + for (const auto& tx : block.data->vtx) { + AdvanceOperatorKeyCounter(*tx); + } + return; + } // Scan block WalletBatch batch(GetDatabase()); @@ -2082,8 +2094,15 @@ CWallet::ScanResult CWallet::ScanForWalletTransactions(const uint256& start_bloc uint256 block_hash = start_block; ScanResult result; + // Operator keys are not block filter elements, so a wallet that follows + // them has to inspect every block. + // TODO: add operator public keys to ExtractSpecialTxFilterElements and to + // the FastWalletRescanFilter query so such wallets can use the fast variant. + const bool follows_operator_keys{WITH_LOCK(cs_wallet, return !m_operator_key_window.empty())}; std::unique_ptr fast_rescan_filter; - if (!IsLegacy() && chain().hasBlockFilterIndex(BlockFilterType::BASIC_FILTER)) fast_rescan_filter = std::make_unique(*this); + if (!IsLegacy() && !follows_operator_keys && chain().hasBlockFilterIndex(BlockFilterType::BASIC_FILTER)) { + fast_rescan_filter = std::make_unique(*this); + } WalletLogPrintf("Rescan started from block %s... (%s)\n", start_block.ToString(), fast_rescan_filter ? "fast variant using block filters" : "slow variant inspecting all blocks"); @@ -2634,6 +2653,7 @@ DBErrors CWallet::LoadWallet() nKeysLeftSinceAutoBackup = 0; } } + RebuildOperatorKeyWindow(); if (m_spk_managers.empty()) { assert(m_external_spk_managers == nullptr); @@ -3449,6 +3469,10 @@ std::shared_ptr CWallet::Create(WalletContext& context, const std::stri args.ForceRemoveArg("mnemonic"); args.ForceRemoveArg("mnemonicpassphrase"); walletInstance->SetupDescriptorScriptPubKeyMans(mnemonic, mnemonic_passphrase); + if (!mnemonic.empty() && walletInstance->SetupOperatorKeyChain() != PlatformKeyStatus::SUCCESS) { + error = _("Unable to store the operator key chain"); + return nullptr; + } // SetupDescriptorScriptPubKeyMans already calls SetupGeneration for us so we don't need to call SetupGeneration separately } else { // Top up the keypool // Legacy wallets need SetupGeneration here. @@ -4107,6 +4131,155 @@ PlatformKeyStatus CWallet::GetPlatformKeySourceStatus() const return GetPlatformKeySource(source); } +static const std::string OPERATOR_KEY_NEXT_INDEX{"operatorkeys/next_index"}; +//! Serialized bls::ExtendedPublicKey at m/9'/coin'/3'/3', see platformkeys::OperatorKeyChain. +static const std::string OPERATOR_KEY_CHAIN{"operatorkeys/chain"}; +static constexpr uint32_t OPERATOR_KEY_WINDOW_SIZE{1000}; + +PlatformKeyStatus CWallet::DeriveOperatorKeys(uint32_t first, uint32_t count, std::vector& out) const +{ + AssertLockHeld(cs_wallet); + const ScriptPubKeyMan* source{nullptr}; + if (const auto status{GetPlatformKeySource(source)}; status != PlatformKeyStatus::SUCCESS) return status; + return source->DeriveOperatorKeys(static_cast(Params().ExtCoinType()), first, count, out); +} + +std::optional CWallet::OperatorKeysIssued() const +{ + AssertLockHeld(cs_wallet); + const auto it{m_platform_data.find(OPERATOR_KEY_NEXT_INDEX)}; + if (it == m_platform_data.end()) return 0; + if (it->second.size() != sizeof(uint32_t)) return std::nullopt; + return ReadLE32(it->second.data()); +} + +bool CWallet::WriteOperatorKeyCounter(uint32_t next_index) +{ + AssertLockHeld(cs_wallet); + std::vector value(sizeof(uint32_t)); + WriteLE32(value.data(), next_index); + if (!WritePlatformData(OPERATOR_KEY_NEXT_INDEX, value)) return false; + RebuildOperatorKeyWindow(); + return true; +} + +PlatformKeyStatus CWallet::IssueOperatorKey(const std::function& in_use, uint32_t& index, + CBLSSecretKey& secret) +{ + AssertLockHeld(cs_wallet); + secret.Reset(); + if (const auto status{SetupOperatorKeyChain()}; status != PlatformKeyStatus::SUCCESS) return status; + + // Masternodes registered from another wallet on this mnemonic use their + // indexes even when no rescan has seen the registration: the counter moves + // past the highest such key in the window until the window holds none. + constexpr size_t KEY_SIZE{CBLSPublicKey::SerSize}; + while (true) { + const auto issued{OperatorKeysIssued()}; + if (!issued) return PlatformKeyStatus::DATABASE_ERROR; + std::optional used; + for (size_t offset{0}; offset + KEY_SIZE <= m_operator_key_window.size(); offset += KEY_SIZE) { + CBLSPublicKey pubkey; + pubkey.SetBytes(Span{m_operator_key_window}.subspan(offset, KEY_SIZE), /*specificLegacyScheme=*/false); + if (in_use(pubkey)) used = *issued + static_cast(offset / KEY_SIZE); + } + if (!used) { + index = *issued; + break; + } + if (!WriteOperatorKeyCounter(*used + 1)) return PlatformKeyStatus::DATABASE_ERROR; + } + + std::vector keys; + if (const auto status{DeriveOperatorKeys(index, 1, keys)}; status != PlatformKeyStatus::SUCCESS) return status; + if (!WriteOperatorKeyCounter(index + 1)) return PlatformKeyStatus::DATABASE_ERROR; + secret = keys.front(); + return PlatformKeyStatus::SUCCESS; +} + +PlatformKeyStatus CWallet::SetupOperatorKeyChain() +{ + AssertLockHeld(cs_wallet); + if (m_platform_data.find(OPERATOR_KEY_CHAIN) == m_platform_data.end()) { + const ScriptPubKeyMan* source{nullptr}; + if (const auto status{GetPlatformKeySource(source)}; status != PlatformKeyStatus::SUCCESS) return status; + std::vector chain; + const auto status{source->OperatorKeyChain(static_cast(Params().ExtCoinType()), chain)}; + if (status != PlatformKeyStatus::SUCCESS) return status; + if (!WritePlatformData(OPERATOR_KEY_CHAIN, chain)) return PlatformKeyStatus::DATABASE_ERROR; + } + RebuildOperatorKeyWindow(); + return PlatformKeyStatus::SUCCESS; +} + +void CWallet::RebuildOperatorKeyWindow() +{ + AssertLockHeld(cs_wallet); + m_operator_key_window.clear(); + const auto chain{m_platform_data.find(OPERATOR_KEY_CHAIN)}; + const auto issued{OperatorKeysIssued()}; + if (chain == m_platform_data.end() || !issued) return; + for (uint32_t i{0}; i < OPERATOR_KEY_WINDOW_SIZE; ++i) { + const auto pubkey{platformkeys::OperatorPubKey(chain->second, *issued + i)}; + if (!pubkey.IsValid()) return; + const auto bytes{pubkey.ToByteVector(/*specificLegacyScheme=*/false)}; + m_operator_key_window.insert(m_operator_key_window.end(), bytes.begin(), bytes.end()); + } +} + +static std::optional ProviderOperatorKey(const CTransaction& tx) +{ + switch (tx.nType) { + case TRANSACTION_PROVIDER_REGISTER: + if (const auto payload{GetTxPayload(tx)}) return payload->pubKeyOperator.Get(); + break; + case TRANSACTION_PROVIDER_UPDATE_REGISTRAR: + if (const auto payload{GetTxPayload(tx)}) return payload->pubKeyOperator.Get(); + break; + case TRANSACTION_PROVIDER_UPDATE_SHARED_REGISTRAR: + if (const auto payload{GetTxPayload(tx)}) return payload->pubKeyOperator.Get(); + break; + } + return std::nullopt; +} + +void CWallet::AdvanceOperatorKeyCounter(const CTransaction& tx) +{ + AssertLockHeld(cs_wallet); + if (m_operator_key_window.empty()) return; + const auto pubkey{ProviderOperatorKey(tx)}; + if (!pubkey) return; + + constexpr size_t KEY_SIZE{CBLSPublicKey::SerSize}; + const auto target{pubkey->ToByteVector(/*specificLegacyScheme=*/false)}; + for (size_t offset{0}; offset + KEY_SIZE <= m_operator_key_window.size(); offset += KEY_SIZE) { + if (!std::equal(target.begin(), target.end(), m_operator_key_window.begin() + offset)) continue; + // The window is rebuilt from the counter whenever the counter moves. + const auto issued{OperatorKeysIssued()}; + if (!Assume(issued)) return; + const uint32_t index{*issued + static_cast(offset / KEY_SIZE)}; + if (!WriteOperatorKeyCounter(index + 1)) { + throw std::runtime_error(strprintf("DB error recording operator key %u as used, write failed", index)); + } + WalletLogPrintf("Operator key %u is used by %s; next operator key index is %u\n", index, + tx.GetHash().ToString(), index + 1); + return; + } +} + +PlatformKeyStatus CWallet::GetOperatorKey(uint32_t index, CBLSSecretKey& secret) const +{ + AssertLockHeld(cs_wallet); + secret.Reset(); + const auto issued{OperatorKeysIssued()}; + if (!issued) return PlatformKeyStatus::DATABASE_ERROR; + if (index >= *issued) return PlatformKeyStatus::INVALID_ARGUMENT; + std::vector keys; + if (const auto status{DeriveOperatorKeys(index, 1, keys)}; status != PlatformKeyStatus::SUCCESS) return status; + secret = keys.front(); + return PlatformKeyStatus::SUCCESS; +} + PlatformKeyResult CWallet::GetPlatformPubKey(const PlatformKeyRequest& request) const { LOCK(cs_wallet); @@ -4915,6 +5088,10 @@ bool CWallet::ApplyMigrationData(MigrationData& data, bilingual_str& error) // Use the existing master key if we have it if (data.master_key.key.IsValid()) { SetupDescriptorScriptPubKeyMans(data.master_key, data.mnemonic, data.mnemonic_passphrase); + if (!data.mnemonic.empty() && SetupOperatorKeyChain() != PlatformKeyStatus::SUCCESS) { + error = _("Error: Failed to store the operator key chain during migration."); + return false; + } // Advance the active pkh() descriptors past the legacy chain // counters so post-migration getnewaddress doesn't hand back diff --git a/src/wallet/wallet.h b/src/wallet/wallet.h index 02af32e21391..a49f26c523b3 100644 --- a/src/wallet/wallet.h +++ b/src/wallet/wallet.h @@ -52,6 +52,7 @@ #include +class CBLSPublicKey; class CKey; class CScript; class CTxDSIn; @@ -503,6 +504,16 @@ class CWallet final : public WalletStorage, public interfaces::Chain::Notificati EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); PlatformKeyStatus DerivePlatformKey(const platformkeys::Path& path, platformkeys::ExtKey256& out) const EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); + PlatformKeyStatus DeriveOperatorKeys(uint32_t first, uint32_t count, std::vector& out) const + EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); + //! Operator key indexes below this were issued; nullopt for an unreadable counter record. + std::optional OperatorKeysIssued() const EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); + //! Move the counter past a window key that a synced provider transaction assigns. + void AdvanceOperatorKeyCounter(const CTransaction& tx) EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); + //! Derive the window above the counter from the stored chain; empty without a chain or a readable counter. + void RebuildOperatorKeyWindow() EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); + //! Persist the next operator key index and rebuild the window above it. + bool WriteOperatorKeyCounter(uint32_t next_index) EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); public: /** @@ -546,6 +557,9 @@ class CWallet final : public WalletStorage, public interfaces::Chain::Notificati // Generic per-wallet key/value records used by Dash Platform integration // (opaque to the wallet; persisted as DBKeys::PLATFORM_DATA). std::map> m_platform_data GUARDED_BY(cs_wallet); + //! Basic-scheme public keys of the next operator key indexes above the counter, the keypool of this key + //! chain. Derived from the stored chain, so it follows the counter with the wallet locked or not. + std::vector m_operator_key_window GUARDED_BY(cs_wallet); typedef std::map MasterKeyMap; MasterKeyMap mapMasterKeys; @@ -1111,6 +1125,18 @@ class CWallet final : public WalletStorage, public interfaces::Chain::Notificati PlatformKeyResult PlatformECDHSecret(const IdentityAuthKey& key, const CPubKey& counterparty) const; PlatformKeyResult PlatformAccountReference(const IdentityAuthKey& key, const CompactXpub& compact_xpub) const; PlatformKeyResult EnsureFriendshipReceivingKeychain(const FriendshipKeychainRequest& request); + /** Issue the next DIP-3 operator key m/9'/coin'/3'/3'/index from the wallet mnemonic + * (platformkeys::DeriveOperatorKey): the lowest index above every key in the window that `in_use` accepts, + * so keys of masternodes registered elsewhere consume their indexes. The index is recorded as issued before + * the key is returned. */ + PlatformKeyStatus IssueOperatorKey(const std::function& in_use, uint32_t& index, + CBLSSecretKey& secret) EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); + /** Re-derive an operator key issued earlier; INVALID_ARGUMENT for an index not issued yet. */ + PlatformKeyStatus GetOperatorKey(uint32_t index, CBLSSecretKey& secret) const EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); + /** Store the extended public key over the operator keys (platformkeys::OperatorKeyChain) unless the wallet + * has one, and build the window of public keys that synced provider transactions are matched against. + * Storing needs the seed, so this runs where a mnemonic enters the wallet and at the first issuance. */ + PlatformKeyStatus SetupOperatorKeyChain() EXCLUSIVE_LOCKS_REQUIRED(cs_wallet); /* Returns the time of the first created key or, in case of an import, it could be the time of the first received transaction */ int64_t GetBirthTime() const { return m_birth_time; } diff --git a/test/functional/feature_dip3_deterministicmns.py b/test/functional/feature_dip3_deterministicmns.py index 602a9db6301a..c0709fec6cc3 100755 --- a/test/functional/feature_dip3_deterministicmns.py +++ b/test/functional/feature_dip3_deterministicmns.py @@ -17,7 +17,7 @@ BitcoinTestFramework, MasternodeInfo, ) -from test_framework.util import assert_equal, force_finish_mnsync, p2p_port, softfork_active +from test_framework.util import assert_equal, assert_raises_rpc_error, force_finish_mnsync, get_mnemonic, p2p_port, softfork_active class DIP3Test(BitcoinTestFramework): def add_options(self, parser): @@ -120,6 +120,11 @@ def dip3_inactive_error(): self.log.info("registering MNs") for i in range(self.num_initial_mn): mn: MasternodeInfo = self.prepare_mn(self.nodes[0], i + 2, "mn-%d" % i) + if i == 0 and self.options.descriptors: + self.log.info(f"{mn.friendlyName} takes its operator key from the controller wallet") + operator_key = self.nodes[0].bls('operatorkey') + mn.keyOperator, mn.pubKeyOperator = operator_key['secret'], operator_key['public'] + wallet_key_mn = mn mns.append(mn) # start a few MNs before they are registered and a few after they are registered @@ -146,6 +151,30 @@ def dip3_inactive_error(): self.sync_all() self.assert_mnlists(mns) + if self.options.descriptors: + self.log.info("A wallet restored without rescan skips operator keys that are in the masternode list") + mnemonic, passphrase = get_mnemonic(self.nodes[0]) + controller = self.nodes[0].get_wallet_rpc(self.default_wallet_name) + self.nodes[0].createwallet("restored", blank=True) + restored = self.nodes[0].get_wallet_rpc("restored") + restored.upgradetohd(mnemonic, passphrase, "", False) + assert_raises_rpc_error(-8, "operator key 0 has not been issued by this wallet", restored.bls, 'operatorkey', 0) + assert_equal(restored.bls('operatorkey'), controller.bls('operatorkey')) + assert_equal(restored.bls('operatorkey', 0)['secret'], wallet_key_mn.keyOperator) + self.log.info("The issued-key counter survives a wallet reload") + restored.unloadwallet() + self.nodes[0].loadwallet("restored") + assert_equal(restored.bls('operatorkey')['index'], 2) + restored.unloadwallet() + + self.log.info("A restore with rescan recognises the registered operator key and moves the counter past it") + self.nodes[0].createwallet("rescanned", blank=True) + rescanned = self.nodes[0].get_wallet_rpc("rescanned") + rescanned.upgradetohd(mnemonic, passphrase) + assert_equal(rescanned.bls('operatorkey', 0)['secret'], wallet_key_mn.keyOperator) + assert_equal(rescanned.bls('operatorkey')['index'], 1) + rescanned.unloadwallet() + self.log.info("test that MNs disappear from the list when the ProTx collateral is spent") # also make sure "protx info" returns correct historical data for spent collaterals spend_mns_count = 3 diff --git a/test/functional/wallet_upgradetohd.py b/test/functional/wallet_upgradetohd.py index 2f00de4fa35f..ec5347770477 100755 --- a/test/functional/wallet_upgradetohd.py +++ b/test/functional/wallet_upgradetohd.py @@ -214,6 +214,7 @@ def run_test(self): assert_raises_rpc_error(-13, "Error: Wallet encrypted but passphrase not supplied to RPC.", node.upgradetohd, mnemonic[0]) assert_raises_rpc_error(-14, "Error: The wallet passphrase entered was incorrect", node.upgradetohd, mnemonic[0], "", "111") assert node.upgradetohd(mnemonic[0], "", walletpass) + assert_raises_rpc_error(-13, "Error: Please enter the wallet passphrase with walletpassphrase first.", node.bls, 'operatorkey') if not self.options.descriptors: assert_raises_rpc_error(-13, "Error: Please enter the wallet passphrase with walletpassphrase first.", node.dumphdinfo) else: @@ -245,6 +246,21 @@ def run_test(self): w11 = node.get_wallet_rpc("wallet-11") w11.upgradetohd(custom_mnemonic) assert_equal(11, w11.getbalance()) + + self.log.info("Operator keys derive from the restored mnemonic in order and are retrievable by index; legacy wallets are refused") + if self.options.descriptors: + key_0 = w11.bls('operatorkey') + assert_equal(key_0, { + 'index': 0, + 'secret': '60a5ae2824bf8dcd7b37b751b73ba419d0ebfe6f6f58aaf6a26eaa9ec43fd686', + 'public': 'a5776ac29c3f02a57157d05303248615ce4676a8fc06e61602ecc5cf3d6c0ecbf6a438edd5b6f3d51941cea51894065b', + 'scheme': 'basic', + }) + assert_equal(w11.bls('operatorkey')['secret'], '51a5ec2e84ef20a7ab8cb6b7d591711604973abc2528ebb7cd2ae835833eb1c5') + assert_equal(w11.bls('operatorkey', 0), key_0) + assert_raises_rpc_error(-8, "operator key 5 has not been issued by this wallet", w11.bls, 'operatorkey', 5) + else: + assert_raises_rpc_error(-4, "Operator keys need a descriptor wallet", w11.bls, 'operatorkey') w11.unloadwallet() node.createwallet("wallet-12", blank=True) @@ -254,6 +270,9 @@ def run_test(self): assert_equal(get_mnemonic(w12)[0], custom_mnemonic) assert_equal(get_mnemonic(w12)[1], "custom-passphrase") assert_equal(12, w12.getbalance()) + if self.options.descriptors: + self.log.info("The mnemonic passphrase changes the operator keys") + assert_equal(w12.bls('operatorkey')['secret'], '6ab36f50b340904e85d52fbab8223459b1d0b415ac950d7c02eb2b1503d808a4') w12.unloadwallet() self.log.info("Check if null character at the end of mnemonic-passphrase matters")