wallet: make encryption state updates atomic #35752

pull l0rinc wants to merge 9 commits into bitcoin:master from l0rinc:l0rinc/wallet-encryption-write-failures changing 7 files +215 −55
  1. l0rinc commented at 2:02 AM on July 20, 2026: contributor

    Problem: Wallet encryption and passphrase changes update database records and live key state, but local database failures can leave them out of sync. Wallet encryption can report success after a master-key write fails, while a passphrase change can update only memory. Descriptor key write failures can publish keys that were not persisted, and erase failures can commit both plaintext and encrypted records. A failed transaction commit instead aborts the node after publishing live encryption state. These paths are not remotely triggerable.

    Fix: For wallet encryption, publish live key state only after all required database writes and erases succeed and the transaction commits. For passphrase changes, encrypt a copy of the master key and replace the live master key only after the database write succeeds. The covered failures leave database and memory unchanged so wallet encryption and passphrase changes can be retried. Five fault-injection tests exercise these paths through the public wallet interface.

    Follow-up to #35500

  2. DrahtBot added the label Wallet on Jul 20, 2026
  3. DrahtBot commented at 2:02 AM on July 20, 2026: contributor

    <!--e57a25ab6845829454e8d69fc972939a-->

    The following sections might be updated with supplementary metadata relevant to reviewers and maintainers.

    <!--006a51241073e994b41acfe9ec718e94-->

    Code Coverage & Benchmarks

    For details see: https://corecheck.dev/bitcoin/bitcoin/pulls/35752.

    <!--021abf342d371248e50ceaed478a90ca-->

    Reviews

    See the guideline and AI policy for information on the review process.

    Type Reviewers
    Concept ACK achow101, w0xlt

    If your review is incorrectly listed, please copy-paste <code>&lt;!--meta-tag:bot-skip--&gt;</code> into the comment that the bot should ignore.

    <!--174a7506f384e20aa4161008e828411d-->

    Conflicts

    Reviewers, this pull request conflicts with the following ones:

    • #35852 (scripted-diff: Use inline const(expr) over static constexpr in headers by maflcko)
    • #34909 (wallet, refactor: modularise wallet by extracting out legacy wallet migration by rkrux)
    • #34681 (wallet: move rescan logic into ChainScanner and wallet/scan by Eunovo)
    • #34400 (wallet: parallel fast rescan (approx 8x speed up with 8 threads) by Eunovo)
    • #30343 (wallet, logging: Replace WalletLogPrintf() with LogInfo() by ryanofsky)
    • #29278 (Wallet: Add maxfeerate wallet startup option by ismaelsadeeq)

    If you consider this pull request important, please also help to review the conflicting pull requests. Ideally, start with the one that should be merged first.

    <!--5faf32d7da4f0f540f40219e4f7537a3-->

  4. in src/wallet/scriptpubkeyman.cpp:968 in b388cb6b7a outdated
     964 | @@ -965,7 +965,9 @@ bool DescriptorScriptPubKeyMan::Encrypt(const CKeyingMaterial& master_key, Walle
     965 |              return false;
     966 |          }
     967 |          m_map_crypted_keys[pubkey.GetID()] = make_pair(pubkey, crypted_secret);
     968 | -        batch->WriteCryptedDescriptorKey(GetID(), pubkey, crypted_secret);
     969 | +        if (!batch->WriteCryptedDescriptorKey(GetID(), pubkey, crypted_secret)) {
    


    vicjuma commented at 2:56 PM on July 21, 2026:

    I did not observe any difference in the output before and after the change. It appears that the called function already handles this failure case, IMHO. Maybe I missed something


    l0rinc commented at 8:49 PM on August 7, 2026:

    Your test overrides WriteMasterKey(), so it never exercises the unchecked WriteCryptedDescriptorKey() failure fixed here.

  5. in src/wallet/wallet.cpp:662 in b388cb6b7a outdated
     661 | +                bool written{WalletBatch(GetDatabase()).WriteMasterKey(master_key_id, new_master_key)};
     662 | +                if (written) master_key = std::move(new_master_key);
     663 |                  if (fWasLocked)
     664 |                      Lock();
     665 | -                return true;
     666 | +                return written;
    


    vicjuma commented at 2:56 PM on July 21, 2026:

    Intro

    Works great. The error message is generalized, but I guess that is beyond this PR. Steps I used to reproduce the error. This mimics a database write failure

    Testing

    src/wallet/walletdb.cpp:151

    // this function is used in both before and after the change. See the output below
    bool WalletBatch::WriteMasterKey(unsigned int nID, const CMasterKey& kMasterKey)
    {
        // return WriteIC(std::make_pair(DBKeys::MASTER_KEY, nID), kMasterKey, true);
        return false;
    }
    

    Before this Change Fails silently with no error reporting and an unexpected log message

    ratedg@0xratedg:~/projects/contributions/bitcoin/build/bin$ ./bitcoin-cli help | grep encrypt
    encryptwallet "passphrase"
    ratedg@0xratedg:~/projects/contributions/bitcoin/build/bin$ ./bitcoin-cli createwallet "testencrypt"
    {
      "name": "testencrypt"
    }
    ratedg@0xratedg:~/projects/contributions/bitcoin/build/bin$ ./bitcoin-cli -rpcwallet=testencrypt encryptwallet "passphrase"
    wallet encrypted; The keypool has been flushed and a new HD seed was generated. You need to make a new backup with the backupwallet RPC.
    ratedg@0xratedg:~/projects/contributions/bitcoin/build/bin$  
    

    Even with the WriteMasterKey always returning false.

    After this Change Error is being reported successfully after the fail

    ratedg@0xratedg:~/projects/contributions/bitcoin/build/bin$ ./bitcoind
    Bitcoin Core starting
    ratedg@0xratedg:~/projects/contributions/bitcoin/build/bin$ ./bitcoin-cli createwallet "testencrypt-pr35752"
    {
      "name": "testencrypt-pr35752"
    }
    ratedg@0xratedg:~/projects/contributions/bitcoin/build/bin$ ./bitcoin-cli -rpcwallet=testencrypt-pr35752 encryptwallet "passphrase"
    error code: -16
    error message:
    Error: Failed to encrypt the wallet.
    ratedg@0xratedg:~/projects/contributions/bitcoin/build/bin$ 
    

    Conclusion

    Reviewed wallet encryption write-failure handling; tested RPC failure paths; behavior matches expectations.

  6. in src/wallet/wallet.cpp:865 in b388cb6b7a
     862 |              delete encrypted_batch;
     863 |              encrypted_batch = nullptr;
     864 |              return false;
     865 |          }
     866 | -        encrypted_batch->WriteMasterKey(nMasterKeyMaxID, master_key);
     867 | +        if (!encrypted_batch->WriteMasterKey(nMasterKeyMaxID + 1, master_key)) {
    


    vicjuma commented at 2:56 PM on July 21, 2026:

    Intro

    Did the same to the src/wallet/walletdb.cpp:151 as above, but only after a successful wallet encryption.

    Testing

    For successful encryption

    bool WalletBatch::WriteMasterKey(unsigned int nID, const CMasterKey& kMasterKey)
    {
        return WriteIC(std::make_pair(DBKeys::MASTER_KEY, nID), kMasterKey, true);
    }
    

    For testing silent failure

    // this function is used in both before and after the change. See the output below
    bool WalletBatch::WriteMasterKey(unsigned int nID, const CMasterKey& kMasterKey)
    {
        // return WriteIC(std::make_pair(DBKeys::MASTER_KEY, nID), kMasterKey, true);
        return false;
    }
    

    In this case, however, I am receiving an error, but it is somehow ambiguous Before the Change

    ratedg@0xratedg:~/projects/contributions/bitcoin/build/bin$ ./bitcoind
    Bitcoin Core starting
    ratedg@0xratedg:~/projects/contributions/bitcoin/build/bin$ ./bitcoin-cli loadwallet testpassphrase
    {
      "name": "testpassphrase"
    }
    ratedg@0xratedg:~/projects/contributions/bitcoin/build/bin$ ./bitcoin-cli -rpcwallet=testpassphrase encryptwallet "passphrase"
    error code: -15
    error message:
    Error: running with an encrypted wallet, but encryptwallet was called.
    ratedg@0xratedg:~/projects/contributions/bitcoin/build/bin$ ./bitcoin-cli -rpcwallet=testpassphrase walletpassphrase "passphrase" 600
    ratedg@0xratedg:~/projects/contributions/bitcoin/build/bin$ ./bitcoin-cli -rpcwallet=testpassphrase walletpassphrasechange "passphrase" "newpassphrase"
    ratedg@0xratedg:~/projects/contributions/bitcoin/build/bin$
    

    N/B: The last 2 commands were done with the write function that always returns false.

    After the Change

    ratedg@0xratedg:~/projects/contributions/bitcoin/build/bin$ ./bitcoin-cli loadwallet testpassphrase-pr35752
    {
      "name": "testpassphrase-pr35752"
    }
    ratedg@0xratedg:~/projects/contributions/bitcoin/build/bin$ ./bitcoin-cli -rpcwallet=testpassphrase-pr35752 encryptwallet "passphrase"
    error code: -15
    error message:
    Error: running with an encrypted wallet, but encryptwallet was called.
    ratedg@0xratedg:~/projects/contributions/bitcoin/build/bin$ ./bitcoin-cli -rpcwallet=testpassphrase-pr35752 walletpassphrase "passphrase" 600
    ratedg@0xratedg:~/projects/contributions/bitcoin/build/bin$ ./bitcoin-cli -rpcwallet=testpassphrase-pr35752 walletpassphrasechange "passphrase" "newpassphrase"
    error code: -14
    error message:
    Error: The wallet passphrase entered was incorrect.
    ratedg@0xratedg:~/projects/contributions/bitcoin/build/bin$
    

    The operation fails as expected. The reported error does not distinguish a database write failure from other failure modes (e.g. an incorrect passphrase), though this appears to be outside the scope of the changes in this PR.

  7. l0rinc force-pushed on Jul 22, 2026
  8. l0rinc marked this as a draft on Jul 23, 2026
  9. l0rinc commented at 12:01 AM on July 23, 2026: contributor

    Updated the failure handling and tests after review. Descriptor encryption now stages in-memory key updates until the database transaction commits, propagates encrypted-key write and plaintext-key erase failures, and returns false instead of aborting so encryption can be retried. The fault-injection tests exercise these failures through the public wallet interface. Turning to draft to get more conceptual review.

  10. l0rinc renamed this:
    wallet: handle encryption database write failures
    RFC wallet: handle encryption database write failures
    on Jul 23, 2026
  11. refactor/test: share SQLite execution blocker
    Move the test-only SQLite statement blocker into the wallet test utilities so transaction failure tests can reuse one implementation.
    
    Call the stateless base execution handler directly instead of storing a redundant handler instance.
    a5cf8b08d3
  12. test: characterize encryption transaction failures
    Wallet encryption currently reports success after a failed master-key write.
    A failed transaction commit aborts after publishing master and descriptor encryption state, leaving the same-process wallet unretryable.
    
    Record both outcomes before making the transaction recoverable and publishing memory only after commit.
    f15242d3c5
  13. wallet: abort failed encryption transactions
    Wallet encryption published master and descriptor key state before the database transaction completed.
    A failed master key write was ignored, while a failed commit terminated after memory had changed.
    
    Run the operation through `RunWithinTxn()` and publish the wallet master key only afterward.
    Descriptor encryption now requires an active batch transaction because its staged in-memory update is owned by a commit listener.
    Only a successful commit invokes that update, so abort and commit-failure paths leave descriptor memory unchanged and retryable.
    8bd65dc8d1
  14. test: characterize passphrase write failure
    Wallet passphrase changes currently report success after the master key write fails.
    The new passphrase works only in memory while the old passphrase remains on disk.
    
    Record that behavior before making master-key encryption non-mutating and publishing the result only after persistence succeeds.
    95df28ee22
  15. wallet: reject failed passphrase changes
    `ChangeWalletPassphrase()` updated the in-memory master key before writing it to the database.
    If `WriteMasterKey()` failed, the new passphrase worked only in memory while the old passphrase remained on disk.
    
    Encrypt into a local copy.
    Update the in-memory master key only after the database write succeeds.
    093deb7c86
  16. test: characterize descriptor key write failure
    Descriptor encryption currently reports success after an encrypted-key record write fails, publishing wallet and descriptor encryption state even though one encrypted record was not persisted.
    The published state also prevents retry.
    
    Record that behavior before propagating the write failure and staging descriptor memory.
    7d1bd29c1c
  17. wallet: abort failed descriptor key writes
    `DescriptorScriptPubKeyMan::Encrypt()` ignored failed encrypted-key writes, allowing the transaction to publish keys whose records were not persisted.
    
    Check each write before staging its encrypted value, so `RunWithinTxn()` aborts without publishing memory.
    `WriteCryptedDescriptorKey()` returns before attempting the plaintext erase when the encrypted write fails, and the unchanged state permits retry.
    d83522065a
  18. test: characterize descriptor key erase failure
    Descriptor encryption currently reports success after a plaintext-key erase fails.
    The transaction commits both plaintext and encrypted records, publishes encrypted descriptor state, and leaves the operation unretryable.
    
    Check both database record types and live descriptor state before propagating the erase failure.
    c511f473dc
  19. wallet: abort failed descriptor key erases
    `WriteCryptedDescriptorKey()` ignored failures to erase the corresponding plaintext descriptor key.
    Encryption could commit both record forms and publish encrypted descriptor state.
    
    Return the erase result so `RunWithinTxn()` aborts.
    The abort preserves the plaintext record, rolls back the encrypted record, and does not run the commit callback, leaving memory unchanged and retryable.
    4b243f486e
  20. l0rinc force-pushed on Jul 24, 2026
  21. l0rinc marked this as ready for review on Jul 24, 2026
  22. l0rinc renamed this:
    RFC wallet: handle encryption database write failures
    wallet: make encryption state updates atomic
    on Jul 24, 2026
  23. achow101 commented at 11:59 PM on August 7, 2026: member

    Concept ACK

  24. w0xlt commented at 5:56 AM on August 11, 2026: contributor

    Concept ACK

    I found the last commit particularly difficult to review because it changes WriteCryptedDescriptorKey() from ignoring the result of EraseIC(), as master does, to propagating it.

    I'm not sure it's handling failure propagation safely when AddDescriptorKeyWithDB() adds a private key to an already encrypted wallet: the encrypted key may already have been committed, leaving a partial state while still reporting failure.

    Something like the suggestion below may avoid this case, though there may be a better approach.

    The failure case is very narrow, but it may still be worth addressing.

    <details> <summary>suggestion</summary>

    diff --git a/src/wallet/scriptpubkeyman.cpp b/src/wallet/scriptpubkeyman.cpp
    index dea90f70eb..b509327f13 100644
    --- a/src/wallet/scriptpubkeyman.cpp
    +++ b/src/wallet/scriptpubkeyman.cpp
    @@ -966,7 +966,7 @@ bool DescriptorScriptPubKeyMan::Encrypt(const CKeyingMaterial& master_key, Walle
             if (!EncryptSecret(master_key, secret, pubkey.GetHash(), crypted_secret)) {
                 return false;
             }
    -        if (!batch->WriteCryptedDescriptorKey(GetID(), pubkey, crypted_secret)) {
    +        if (!batch->WriteCryptedDescriptorKey(GetID(), pubkey, crypted_secret, /*erase_plaintext_key=*/true)) {
                 return false;
             }
             crypted_keys[pubkey.GetID()] = make_pair(pubkey, std::move(crypted_secret));
    @@ -1185,7 +1185,7 @@ bool DescriptorScriptPubKeyMan::AddDescriptorKeyWithDB(WalletBatch& batch, const
             }
     
             m_map_crypted_keys[pubkey.GetID()] = make_pair(pubkey, crypted_secret);
    -        return batch.WriteCryptedDescriptorKey(GetID(), pubkey, crypted_secret);
    +        return batch.WriteCryptedDescriptorKey(GetID(), pubkey, crypted_secret, /*erase_plaintext_key=*/false);
         } else {
             m_map_keys[pubkey.GetID()] = key;
             return batch.WriteDescriptorKey(GetID(), pubkey, key.GetPrivKey());
    diff --git a/src/wallet/test/wallet_tests.cpp b/src/wallet/test/wallet_tests.cpp
    index 9f9c4e634e..06f49fd5e1 100644
    --- a/src/wallet/test/wallet_tests.cpp
    +++ b/src/wallet/test/wallet_tests.cpp
    @@ -156,9 +156,20 @@ struct EncryptionFailureSetup : WalletTestingSetup {
         {
             context.args = &m_args;
             context.chain = m_node.chain.get();
    +        CreateWallet(WALLET_FLAG_DESCRIPTORS);
    +    }
    +
    +    void CreateWallet(uint64_t create_flags)
    +    {
             auto database{std::make_unique<FaultInjectingDatabase>()};
             fail_db = database.get();
    -        wallet = TestCreateWallet(std::move(database), context, WALLET_FLAG_DESCRIPTORS);
    +        wallet = TestCreateWallet(std::move(database), context, create_flags);
    +    }
    +
    +    void RecreateWallet(uint64_t create_flags)
    +    {
    +        TestUnloadWallet(std::move(wallet));
    +        CreateWallet(create_flags);
         }
     
         ~EncryptionFailureSetup() { TestUnloadWallet(std::move(wallet)); }
    @@ -231,6 +242,27 @@ BOOST_FIXTURE_TEST_CASE(encrypt_wallet_descriptor_key_erase_failure, EncryptionF
         }
     }
     
    +BOOST_FIXTURE_TEST_CASE(add_encrypted_descriptor_key_skips_plaintext_erase, EncryptionFailureSetup)
    +{
    +    BOOST_REQUIRE(wallet->EncryptWallet("passphrase"));
    +    BOOST_REQUIRE(wallet->Unlock("passphrase"));
    +
    +    fail_db->FailNextErase(DBKeys::WALLETDESCRIPTORKEY);
    +    AddKey(*wallet, GenerateRandomKey());
    +}
    +
    +BOOST_FIXTURE_TEST_CASE(encrypt_wallet_fresh_descriptor_keys_skip_plaintext_erase, EncryptionFailureSetup)
    +{
    +    // Match the pre-encryption state of a non-blank wallet created with a passphrase: no plaintext keys or descriptors.
    +    RecreateWallet(WALLET_FLAG_DESCRIPTORS | WALLET_FLAG_BLANK_WALLET);
    +    wallet->UnsetWalletFlag(WALLET_FLAG_BLANK_WALLET);
    +
    +    fail_db->FailNextErase(DBKeys::WALLETDESCRIPTORKEY);
    +    BOOST_REQUIRE(wallet->EncryptWallet("passphrase"));
    +    BOOST_CHECK(wallet->HasEncryptionKeys());
    +    BOOST_CHECK(wallet->IsLocked());
    +}
    +
     BOOST_FIXTURE_TEST_CASE(update_non_range_descriptor, TestingSetup)
     {
         CWallet wallet(m_node.chain.get(), "", CreateMockableWalletDatabase());
    diff --git a/src/wallet/walletdb.cpp b/src/wallet/walletdb.cpp
    index 0916dcaa47..cc8ce80399 100644
    --- a/src/wallet/walletdb.cpp
    +++ b/src/wallet/walletdb.cpp
    @@ -222,12 +222,13 @@ bool WalletBatch::WriteDescriptorKey(const uint256& desc_id, const CPubKey& pubk
         return WriteIC(std::make_pair(DBKeys::WALLETDESCRIPTORKEY, std::make_pair(desc_id, pubkey)), std::make_pair(privkey, keypair_hash), false);
     }
     
    -bool WalletBatch::WriteCryptedDescriptorKey(const uint256& desc_id, const CPubKey& pubkey, const std::vector<unsigned char>& secret)
    +bool WalletBatch::WriteCryptedDescriptorKey(const uint256& desc_id, const CPubKey& pubkey,
    +                                            const std::vector<unsigned char>& secret, bool erase_plaintext_key)
     {
         if (!WriteIC(std::make_pair(DBKeys::WALLETDESCRIPTORCKEY, std::make_pair(desc_id, pubkey)), secret, false)) {
             return false;
         }
    -    return EraseIC(std::make_pair(DBKeys::WALLETDESCRIPTORKEY, std::make_pair(desc_id, pubkey)));
    +    return !erase_plaintext_key || EraseIC(std::make_pair(DBKeys::WALLETDESCRIPTORKEY, std::make_pair(desc_id, pubkey)));
     }
     
     bool WalletBatch::WriteDescriptor(const uint256& desc_id, const WalletDescriptor& descriptor)
    diff --git a/src/wallet/walletdb.h b/src/wallet/walletdb.h
    index 8397fff9cc..c75261557d 100644
    --- a/src/wallet/walletdb.h
    +++ b/src/wallet/walletdb.h
    @@ -249,7 +249,9 @@ public:
         bool WriteOrderPosNext(int64_t nOrderPosNext);
     
         bool WriteDescriptorKey(const uint256& desc_id, const CPubKey& pubkey, const CPrivKey& privkey);
    -    bool WriteCryptedDescriptorKey(const uint256& desc_id, const CPubKey& pubkey, const std::vector<unsigned char>& secret);
    +    //! Erase the plaintext key only when converting an existing key, not when adding a new encrypted key.
    +    bool WriteCryptedDescriptorKey(const uint256& desc_id, const CPubKey& pubkey,
    +                                   const std::vector<unsigned char>& secret, bool erase_plaintext_key);
         bool WriteDescriptor(const uint256& desc_id, const WalletDescriptor& descriptor);
         bool WriteDescriptorDerivedCache(const CExtPubKey& xpub, const uint256& desc_id, uint32_t key_exp_index, uint32_t der_index);
         bool WriteDescriptorParentCache(const CExtPubKey& xpub, const uint256& desc_id, uint32_t key_exp_index);
    

    </details>


github-metadata-mirror

This is a metadata mirror of the GitHub repository bitcoin/bitcoin. This site is not affiliated with GitHub. Content is generated from a GitHub metadata backup.
generated: 2026-08-14 17:51 UTC

This site is hosted by @0xB10C
More mirrored repositories can be found on mirror.b10c.me