Skip to content

Commit 80f4979

Browse files
committed
Merge bitcoin/bitcoin#26347: wallet: ensure the wallet is unlocked when needed for rescanning
6a5b348 test: test rescanning encrypted wallets (ishaanam) 493b813 wallet: ensure that the passphrase is not deleted from memory when being used to rescan (ishaanam) 66a86eb wallet: keep track of when the passphrase is needed when rescanning (ishaanam) Pull request description: Wallet passphrases are needed to top up the keypool of encrypted wallets during a rescan. The following RPCs need the passphrase when rescanning: - `importdescriptors` - `rescanblockchain` The following RPCs use the information about whether or not the passphrase is being used to ensure that full rescans are able to take place (meaning the following RPCs should not be able to run if a rescan requiring the wallet to be unlocked is taking place): - `walletlock` - `encryptwallet` - `walletpassphrasechange` `m_relock_mutex` is also introduced so that the passphrase is not deleted from memory when the timeout provided in `walletpassphrase` is up and the wallet is still rescanning. Fixes #25702, #11249 Thanks to achow101 for coming up with the idea of using a new mutex to solve this issue and for answering related questions. ACKs for top commit: achow101: ACK 6a5b348 hernanmarino: ACK 6a5b348 furszy: Tested ACK 6a5b348 Tree-SHA512: 0b6db692714f6f94594fa47249f5ee24f85713bfa70ac295a7e84b9ca6c07dda65df7b47781a2dc73e5b603a8725343a2f864428ae20d3e126c5b4802abc4ab5
2 parents ad46141 + 6a5b348 commit 80f4979

7 files changed

+106
-13
lines changed

src/wallet/rpc/backup.cpp

+5-1
Original file line numberDiff line numberDiff line change
@@ -1650,10 +1650,14 @@ RPCHelpMan importdescriptors()
16501650
}
16511651

16521652
WalletRescanReserver reserver(*pwallet);
1653-
if (!reserver.reserve()) {
1653+
if (!reserver.reserve(/*with_passphrase=*/true)) {
16541654
throw JSONRPCError(RPC_WALLET_ERROR, "Wallet is currently rescanning. Abort existing rescan or wait.");
16551655
}
16561656

1657+
// Ensure that the wallet is not locked for the remainder of this RPC, as
1658+
// the passphrase is used to top up the keypool.
1659+
LOCK(pwallet->m_relock_mutex);
1660+
16571661
const UniValue& requests = main_request.params[0];
16581662
const int64_t minimum_timestamp = 1;
16591663
int64_t now = 0;

src/wallet/rpc/encrypt.cpp

+19-7
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ RPCHelpMan walletpassphrase()
9090
std::weak_ptr<CWallet> weak_wallet = wallet;
9191
pwallet->chain().rpcRunLater(strprintf("lockwallet(%s)", pwallet->GetName()), [weak_wallet, relock_time] {
9292
if (auto shared_wallet = weak_wallet.lock()) {
93-
LOCK(shared_wallet->cs_wallet);
93+
LOCK2(shared_wallet->m_relock_mutex, shared_wallet->cs_wallet);
9494
// Skip if this is not the most recent rpcRunLater callback.
9595
if (shared_wallet->nRelockTime != relock_time) return;
9696
shared_wallet->Lock();
@@ -122,12 +122,16 @@ RPCHelpMan walletpassphrasechange()
122122
std::shared_ptr<CWallet> const pwallet = GetWalletForJSONRPCRequest(request);
123123
if (!pwallet) return UniValue::VNULL;
124124

125-
LOCK(pwallet->cs_wallet);
126-
127125
if (!pwallet->IsCrypted()) {
128126
throw JSONRPCError(RPC_WALLET_WRONG_ENC_STATE, "Error: running with an unencrypted wallet, but walletpassphrasechange was called.");
129127
}
130128

129+
if (pwallet->IsScanningWithPassphrase()) {
130+
throw JSONRPCError(RPC_WALLET_ERROR, "Error: the wallet is currently being used to rescan the blockchain for related transactions. Please call `abortrescan` before changing the passphrase.");
131+
}
132+
133+
LOCK2(pwallet->m_relock_mutex, pwallet->cs_wallet);
134+
131135
// TODO: get rid of these .c_str() calls by implementing SecureString::operator=(std::string)
132136
// Alternately, find a way to make request.params[0] mlock()'d to begin with.
133137
SecureString strOldWalletPass;
@@ -175,12 +179,16 @@ RPCHelpMan walletlock()
175179
std::shared_ptr<CWallet> const pwallet = GetWalletForJSONRPCRequest(request);
176180
if (!pwallet) return UniValue::VNULL;
177181

178-
LOCK(pwallet->cs_wallet);
179-
180182
if (!pwallet->IsCrypted()) {
181183
throw JSONRPCError(RPC_WALLET_WRONG_ENC_STATE, "Error: running with an unencrypted wallet, but walletlock was called.");
182184
}
183185

186+
if (pwallet->IsScanningWithPassphrase()) {
187+
throw JSONRPCError(RPC_WALLET_ERROR, "Error: the wallet is currently being used to rescan the blockchain for related transactions. Please call `abortrescan` before locking the wallet.");
188+
}
189+
190+
LOCK2(pwallet->m_relock_mutex, pwallet->cs_wallet);
191+
184192
pwallet->Lock();
185193
pwallet->nRelockTime = 0;
186194

@@ -219,8 +227,6 @@ RPCHelpMan encryptwallet()
219227
std::shared_ptr<CWallet> const pwallet = GetWalletForJSONRPCRequest(request);
220228
if (!pwallet) return UniValue::VNULL;
221229

222-
LOCK(pwallet->cs_wallet);
223-
224230
if (pwallet->IsWalletFlagSet(WALLET_FLAG_DISABLE_PRIVATE_KEYS)) {
225231
throw JSONRPCError(RPC_WALLET_ENCRYPTION_FAILED, "Error: wallet does not contain private keys, nothing to encrypt.");
226232
}
@@ -229,6 +235,12 @@ RPCHelpMan encryptwallet()
229235
throw JSONRPCError(RPC_WALLET_WRONG_ENC_STATE, "Error: running with an encrypted wallet, but encryptwallet was called.");
230236
}
231237

238+
if (pwallet->IsScanningWithPassphrase()) {
239+
throw JSONRPCError(RPC_WALLET_ERROR, "Error: the wallet is currently being used to rescan the blockchain for related transactions. Please call `abortrescan` before encrypting the wallet.");
240+
}
241+
242+
LOCK2(pwallet->m_relock_mutex, pwallet->cs_wallet);
243+
232244
// TODO: get rid of this .c_str() by implementing SecureString::operator=(std::string)
233245
// Alternately, find a way to make request.params[0] mlock()'d to begin with.
234246
SecureString strWalletPass;

src/wallet/rpc/transactions.cpp

+4-1
Original file line numberDiff line numberDiff line change
@@ -872,15 +872,18 @@ RPCHelpMan rescanblockchain()
872872
wallet.BlockUntilSyncedToCurrentChain();
873873

874874
WalletRescanReserver reserver(*pwallet);
875-
if (!reserver.reserve()) {
875+
if (!reserver.reserve(/*with_passphrase=*/true)) {
876876
throw JSONRPCError(RPC_WALLET_ERROR, "Wallet is currently rescanning. Abort existing rescan or wait.");
877877
}
878878

879879
int start_height = 0;
880880
std::optional<int> stop_height;
881881
uint256 start_block;
882+
883+
LOCK(pwallet->m_relock_mutex);
882884
{
883885
LOCK(pwallet->cs_wallet);
886+
EnsureWalletIsUnlocked(*pwallet);
884887
int tip_height = pwallet->GetLastBlockHeight();
885888

886889
if (!request.params[0].isNull()) {

src/wallet/wallet.cpp

+3-3
Original file line numberDiff line numberDiff line change
@@ -552,7 +552,7 @@ bool CWallet::ChangeWalletPassphrase(const SecureString& strOldWalletPassphrase,
552552
bool fWasLocked = IsLocked();
553553

554554
{
555-
LOCK(cs_wallet);
555+
LOCK2(m_relock_mutex, cs_wallet);
556556
Lock();
557557

558558
CCrypter crypter;
@@ -787,7 +787,7 @@ bool CWallet::EncryptWallet(const SecureString& strWalletPassphrase)
787787
return false;
788788

789789
{
790-
LOCK(cs_wallet);
790+
LOCK2(m_relock_mutex, cs_wallet);
791791
mapMasterKeys[++nMasterKeyMaxID] = kMasterKey;
792792
WalletBatch* encrypted_batch = new WalletBatch(GetDatabase());
793793
if (!encrypted_batch->TxnBegin()) {
@@ -3412,7 +3412,7 @@ bool CWallet::Lock()
34123412
return false;
34133413

34143414
{
3415-
LOCK(cs_wallet);
3415+
LOCK2(m_relock_mutex, cs_wallet);
34163416
if (!vMasterKey.empty()) {
34173417
memory_cleanse(vMasterKey.data(), vMasterKey.size() * sizeof(decltype(vMasterKey)::value_type));
34183418
vMasterKey.clear();

src/wallet/wallet.h

+8-1
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,7 @@ class CWallet final : public WalletStorage, public interfaces::Chain::Notificati
243243
std::atomic<bool> fAbortRescan{false};
244244
std::atomic<bool> fScanningWallet{false}; // controlled by WalletRescanReserver
245245
std::atomic<bool> m_attaching_chain{false};
246+
std::atomic<bool> m_scanning_with_passphrase{false};
246247
std::atomic<int64_t> m_scanning_start{0};
247248
std::atomic<double> m_scanning_progress{0};
248249
friend class WalletRescanReserver;
@@ -463,6 +464,7 @@ class CWallet final : public WalletStorage, public interfaces::Chain::Notificati
463464
void AbortRescan() { fAbortRescan = true; }
464465
bool IsAbortingRescan() const { return fAbortRescan; }
465466
bool IsScanning() const { return fScanningWallet; }
467+
bool IsScanningWithPassphrase() const { return m_scanning_with_passphrase; }
466468
int64_t ScanningDuration() const { return fScanningWallet ? GetTimeMillis() - m_scanning_start : 0; }
467469
double ScanningProgress() const { return fScanningWallet ? (double) m_scanning_progress : 0; }
468470

@@ -482,6 +484,9 @@ class CWallet final : public WalletStorage, public interfaces::Chain::Notificati
482484

483485
// Used to prevent concurrent calls to walletpassphrase RPC.
484486
Mutex m_unlock_mutex;
487+
// Used to prevent deleting the passphrase from memory when it is still in use.
488+
RecursiveMutex m_relock_mutex;
489+
485490
bool Unlock(const SecureString& strWalletPassphrase, bool accept_no_keys = false);
486491
bool ChangeWalletPassphrase(const SecureString& strOldWalletPassphrase, const SecureString& strNewWalletPassphrase);
487492
bool EncryptWallet(const SecureString& strWalletPassphrase);
@@ -962,12 +967,13 @@ class WalletRescanReserver
962967
public:
963968
explicit WalletRescanReserver(CWallet& w) : m_wallet(w) {}
964969

965-
bool reserve()
970+
bool reserve(bool with_passphrase = false)
966971
{
967972
assert(!m_could_reserve);
968973
if (m_wallet.fScanningWallet.exchange(true)) {
969974
return false;
970975
}
976+
m_wallet.m_scanning_with_passphrase.exchange(with_passphrase);
971977
m_wallet.m_scanning_start = GetTimeMillis();
972978
m_wallet.m_scanning_progress = 0;
973979
m_could_reserve = true;
@@ -987,6 +993,7 @@ class WalletRescanReserver
987993
{
988994
if (m_could_reserve) {
989995
m_wallet.fScanningWallet = false;
996+
m_wallet.m_scanning_with_passphrase = false;
990997
}
991998
}
992999
};

test/functional/wallet_importdescriptors.py

+28
Original file line numberDiff line numberDiff line change
@@ -667,5 +667,33 @@ def run_test(self):
667667
success=True,
668668
warnings=["Unknown output type, cannot set descriptor to active."])
669669

670+
self.log.info("Test importing a descriptor to an encrypted wallet")
671+
672+
descriptor = {"desc": descsum_create("pkh(" + xpriv + "/1h/*h)"),
673+
"timestamp": "now",
674+
"active": True,
675+
"range": [0,4000],
676+
"next_index": 4000}
677+
678+
self.nodes[0].createwallet("temp_wallet", blank=True, descriptors=True)
679+
temp_wallet = self.nodes[0].get_wallet_rpc("temp_wallet")
680+
temp_wallet.importdescriptors([descriptor])
681+
self.generatetoaddress(self.nodes[0], COINBASE_MATURITY + 1, temp_wallet.getnewaddress())
682+
self.generatetoaddress(self.nodes[0], COINBASE_MATURITY + 1, temp_wallet.getnewaddress())
683+
684+
self.nodes[0].createwallet("encrypted_wallet", blank=True, descriptors=True, passphrase="passphrase")
685+
encrypted_wallet = self.nodes[0].get_wallet_rpc("encrypted_wallet")
686+
687+
descriptor["timestamp"] = 0
688+
descriptor["next_index"] = 0
689+
690+
batch = []
691+
batch.append(encrypted_wallet.walletpassphrase.get_request("passphrase", 3))
692+
batch.append(encrypted_wallet.importdescriptors.get_request([descriptor]))
693+
694+
encrypted_wallet.batch(batch)
695+
696+
assert_equal(temp_wallet.getbalance(), encrypted_wallet.getbalance())
697+
670698
if __name__ == '__main__':
671699
ImportDescriptorsTest().main()

test/functional/wallet_transactiontime_rescan.py

+39
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414
assert_raises_rpc_error,
1515
set_node_times,
1616
)
17+
from test_framework.wallet_util import (
18+
get_generate_key,
19+
)
1720

1821

1922
class TransactionTimeRescanTest(BitcoinTestFramework):
@@ -23,6 +26,10 @@ def add_options(self, parser):
2326
def set_test_params(self):
2427
self.setup_clean_chain = False
2528
self.num_nodes = 3
29+
self.extra_args = [["-keypool=400"],
30+
["-keypool=400"],
31+
[]
32+
]
2633

2734
def skip_test_if_missing_module(self):
2835
self.skip_if_no_wallet()
@@ -167,6 +174,38 @@ def run_test(self):
167174
assert_raises_rpc_error(-8, "Invalid stop_height", restorewo_wallet.rescanblockchain, 1, -1)
168175
assert_raises_rpc_error(-8, "stop_height must be greater than start_height", restorewo_wallet.rescanblockchain, 20, 10)
169176

177+
self.log.info("Test `rescanblockchain` fails when wallet is encrypted and locked")
178+
usernode.createwallet(wallet_name="enc_wallet", passphrase="passphrase")
179+
enc_wallet = usernode.get_wallet_rpc("enc_wallet")
180+
assert_raises_rpc_error(-13, "Error: Please enter the wallet passphrase with walletpassphrase first.", enc_wallet.rescanblockchain)
181+
182+
if not self.options.descriptors:
183+
self.log.info("Test rescanning an encrypted wallet")
184+
hd_seed = get_generate_key().privkey
185+
186+
usernode.createwallet(wallet_name="temp_wallet", blank=True, descriptors=False)
187+
temp_wallet = usernode.get_wallet_rpc("temp_wallet")
188+
temp_wallet.sethdseed(seed=hd_seed)
189+
190+
for i in range(399):
191+
temp_wallet.getnewaddress()
192+
193+
self.generatetoaddress(usernode, COINBASE_MATURITY + 1, temp_wallet.getnewaddress())
194+
self.generatetoaddress(usernode, COINBASE_MATURITY + 1, temp_wallet.getnewaddress())
195+
196+
minernode.createwallet("encrypted_wallet", blank=True, passphrase="passphrase", descriptors=False)
197+
encrypted_wallet = minernode.get_wallet_rpc("encrypted_wallet")
198+
199+
encrypted_wallet.walletpassphrase("passphrase", 1)
200+
encrypted_wallet.sethdseed(seed=hd_seed)
201+
202+
batch = []
203+
batch.append(encrypted_wallet.walletpassphrase.get_request("passphrase", 3))
204+
batch.append(encrypted_wallet.rescanblockchain.get_request())
205+
206+
encrypted_wallet.batch(batch)
207+
208+
assert_equal(encrypted_wallet.getbalance(), temp_wallet.getbalance())
170209

171210
if __name__ == '__main__':
172211
TransactionTimeRescanTest().main()

0 commit comments

Comments
 (0)