diff --git a/src/Makefile.am b/src/Makefile.am index e1ae049b15a..8e6dd71d419 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -336,6 +336,7 @@ BITCOIN_CORE_H = \ wallet/crypter.h \ wallet/db.h \ wallet/dump.h \ + wallet/encrypted_db.h \ wallet/external_signer_scriptpubkeyman.h \ wallet/feebumper.h \ wallet/fees.h \ @@ -516,7 +517,7 @@ libbitcoin_wallet_a_SOURCES = \ $(BITCOIN_CORE_H) if USE_SQLITE -libbitcoin_wallet_a_SOURCES += wallet/sqlite.cpp +libbitcoin_wallet_a_SOURCES += wallet/sqlite.cpp wallet/encrypted_db.cpp endif if USE_BDB libbitcoin_wallet_a_SOURCES += wallet/bdb.cpp wallet/salvage.cpp diff --git a/src/interfaces/wallet.h b/src/interfaces/wallet.h index 8c31112fc94..fcd62a9219a 100644 --- a/src/interfaces/wallet.h +++ b/src/interfaces/wallet.h @@ -321,10 +321,10 @@ class WalletLoader : public ChainClient { public: //! Create new wallet. - virtual util::Result> createWallet(const std::string& name, const SecureString& passphrase, uint64_t wallet_creation_flags, std::vector& warnings) = 0; + virtual util::Result> createWallet(const std::string& name, const SecureString& passphrase, const SecureString& db_passphrase, uint64_t wallet_creation_flags, std::vector& warnings) = 0; //! Load existing wallet. - virtual util::Result> loadWallet(const std::string& name, std::vector& warnings) = 0; + virtual util::Result> loadWallet(const std::string& name, std::vector& warnings, const SecureString& db_passphrase) = 0; //! Return default wallet directory. virtual std::string getWalletDir() = 0; @@ -344,6 +344,9 @@ class WalletLoader : public ChainClient using LoadWalletFn = std::function wallet)>; virtual std::unique_ptr handleLoadWallet(LoadWalletFn fn) = 0; + //! Return whether the named wallet has an encrypted database + virtual bool isWalletDBEncrypted(const std::string& name) = 0; + //! Return pointer to internal context, useful for testing. virtual wallet::WalletContext* context() { return nullptr; } }; diff --git a/src/qt/askpassphrasedialog.cpp b/src/qt/askpassphrasedialog.cpp index 0a96be038b2..606cec59e3a 100644 --- a/src/qt/askpassphrasedialog.cpp +++ b/src/qt/askpassphrasedialog.cpp @@ -19,7 +19,7 @@ #include #include -AskPassphraseDialog::AskPassphraseDialog(Mode _mode, QWidget *parent, SecureString* passphrase_out) : +AskPassphraseDialog::AskPassphraseDialog(Mode _mode, QWidget *parent, SecureString* passphrase_out, QString warning_text) : QDialog(parent, GUIUtil::dialog_flags), ui(new Ui::AskPassphraseDialog), mode(_mode), @@ -43,13 +43,20 @@ AskPassphraseDialog::AskPassphraseDialog(Mode _mode, QWidget *parent, SecureStri switch(mode) { case Encrypt: // Ask passphrase x2 - ui->warningLabel->setText(tr("Enter the new passphrase for the wallet.
Please use a passphrase of ten or more random characters, or eight or more words.")); + if (warning_text.isEmpty()) { + warning_text = tr("Enter the new passphrase for encrypting the private keys in the wallet."); + } + ui->warningLabel->setText(warning_text + tr("
Please use a passphrase of ten or more random characters, or eight or more words.")); ui->passLabel1->hide(); ui->passEdit1->hide(); setWindowTitle(tr("Encrypt wallet")); break; case Unlock: // Ask passphrase - ui->warningLabel->setText(tr("This operation needs your wallet passphrase to unlock the wallet.")); + if (warning_text.isEmpty()) { + ui->warningLabel->setText(tr("This operation needs your wallet passphrase to unlock the wallet.")); + } else { + ui->warningLabel->setText(warning_text); + } ui->passLabel2->hide(); ui->passEdit2->hide(); ui->passLabel3->hide(); @@ -58,7 +65,11 @@ AskPassphraseDialog::AskPassphraseDialog(Mode _mode, QWidget *parent, SecureStri break; case ChangePass: // Ask old passphrase + new passphrase x2 setWindowTitle(tr("Change passphrase")); - ui->warningLabel->setText(tr("Enter the old passphrase and new passphrase for the wallet.")); + if (warning_text.isEmpty()) { + ui->warningLabel->setText(tr("Enter the old passphrase and new passphrase for the wallet.")); + } else { + ui->warningLabel->setText(warning_text); + } break; } textChanged(); @@ -84,8 +95,6 @@ void AskPassphraseDialog::setModel(WalletModel *_model) void AskPassphraseDialog::accept() { SecureString oldpass, newpass1, newpass2; - if (!model && mode != Encrypt) - return; oldpass.reserve(MAX_PASSPHRASE_SIZE); newpass1.reserve(MAX_PASSPHRASE_SIZE); newpass2.reserve(MAX_PASSPHRASE_SIZE); @@ -151,29 +160,36 @@ void AskPassphraseDialog::accept() } } break; case Unlock: - try { - if (!model->setWalletLocked(false, oldpass)) { - // Check if the passphrase has a null character (see #27067 for details) - if (oldpass.find('\0') == std::string::npos) { - QMessageBox::critical(this, tr("Wallet unlock failed"), - tr("The passphrase entered for the wallet decryption was incorrect.")); + if (m_passphrase_out) { + m_passphrase_out->assign(oldpass); + QDialog::accept(); // Success + } else { + try { + assert(model); + if (!model->setWalletLocked(false, oldpass)) { + // Check if the passphrase has a null character (see #27067 for details) + if (oldpass.find('\0') == std::string::npos) { + QMessageBox::critical(this, tr("Wallet unlock failed"), + tr("The passphrase entered for the wallet decryption was incorrect.")); + } else { + QMessageBox::critical(this, tr("Wallet unlock failed"), + tr("The passphrase entered for the wallet decryption is incorrect. " + "It contains a null character (ie - a zero byte). " + "If the passphrase was set with a version of this software prior to 25.0, " + "please try again with only the characters up to — but not including — " + "the first null character. If this is successful, please set a new " + "passphrase to avoid this issue in the future.")); + } } else { - QMessageBox::critical(this, tr("Wallet unlock failed"), - tr("The passphrase entered for the wallet decryption is incorrect. " - "It contains a null character (ie - a zero byte). " - "If the passphrase was set with a version of this software prior to 25.0, " - "please try again with only the characters up to — but not including — " - "the first null character. If this is successful, please set a new " - "passphrase to avoid this issue in the future.")); + QDialog::accept(); // Success } - } else { - QDialog::accept(); // Success + } catch (const std::runtime_error& e) { + QMessageBox::critical(this, tr("Wallet unlock failed"), e.what()); } - } catch (const std::runtime_error& e) { - QMessageBox::critical(this, tr("Wallet unlock failed"), e.what()); } break; case ChangePass: + assert(model); if(newpass1 == newpass2) { if(model->changePassphrase(oldpass, newpass1)) diff --git a/src/qt/askpassphrasedialog.h b/src/qt/askpassphrasedialog.h index 370ea1de7ec..b9d56609189 100644 --- a/src/qt/askpassphrasedialog.h +++ b/src/qt/askpassphrasedialog.h @@ -28,7 +28,7 @@ class AskPassphraseDialog : public QDialog ChangePass, /**< Ask old passphrase + new passphrase twice */ }; - explicit AskPassphraseDialog(Mode mode, QWidget *parent, SecureString* passphrase_out = nullptr); + explicit AskPassphraseDialog(Mode mode, QWidget *parent, SecureString* passphrase_out = nullptr, QString warning_text = ""); ~AskPassphraseDialog(); void accept() override; diff --git a/src/qt/createwalletdialog.cpp b/src/qt/createwalletdialog.cpp index 5b3c8bcf481..1f8cb8bbea0 100644 --- a/src/qt/createwalletdialog.cpp +++ b/src/qt/createwalletdialog.cpp @@ -92,6 +92,8 @@ CreateWalletDialog::CreateWalletDialog(QWidget* parent) : ui->descriptor_checkbox->setChecked(false); ui->external_signer_checkbox->setEnabled(false); ui->external_signer_checkbox->setChecked(false); + ui->encrypt_db_checkbox->setEnabled(false); + ui->encrypt_db_checkbox->setChecked(false); #endif #ifndef USE_BDB @@ -144,6 +146,11 @@ bool CreateWalletDialog::isEncryptWalletChecked() const return ui->encrypt_wallet_checkbox->isChecked(); } +bool CreateWalletDialog::isEncryptDBChecked() const +{ + return ui->encrypt_db_checkbox->isChecked(); +} + bool CreateWalletDialog::isDisablePrivateKeysChecked() const { return ui->disable_privkeys_checkbox->isChecked(); diff --git a/src/qt/createwalletdialog.h b/src/qt/createwalletdialog.h index 939b82ff78c..1b777b1d8f1 100644 --- a/src/qt/createwalletdialog.h +++ b/src/qt/createwalletdialog.h @@ -33,6 +33,7 @@ class CreateWalletDialog : public QDialog QString walletName() const; bool isEncryptWalletChecked() const; + bool isEncryptDBChecked() const; bool isDisablePrivateKeysChecked() const; bool isMakeBlankWalletChecked() const; bool isDescriptorWalletChecked() const; diff --git a/src/qt/forms/createwalletdialog.ui b/src/qt/forms/createwalletdialog.ui index 56adbe17a5c..07b0e5486bc 100644 --- a/src/qt/forms/createwalletdialog.ui +++ b/src/qt/forms/createwalletdialog.ui @@ -7,7 +7,7 @@ 0 0 364 - 249 + 316 @@ -122,6 +122,16 @@ + + + + Encrypt the wallet's database. The database will be encrypted with a passphrase of your choice. Wallets with an encrypted database cannot be loaded automatically on startup. + + + Encrypt Database + + + diff --git a/src/qt/walletcontroller.cpp b/src/qt/walletcontroller.cpp index d782838d6ff..577da5812d3 100644 --- a/src/qt/walletcontroller.cpp +++ b/src/qt/walletcontroller.cpp @@ -220,17 +220,17 @@ CreateWalletActivity::~CreateWalletActivity() delete m_passphrase_dialog; } -void CreateWalletActivity::askPassphrase() +void CreateWalletActivity::askPassphrase(SecureString* passphrase_out, std::function next_func, QString warning_text) { - m_passphrase_dialog = new AskPassphraseDialog(AskPassphraseDialog::Encrypt, m_parent_widget, &m_passphrase); + m_passphrase_dialog = new AskPassphraseDialog(AskPassphraseDialog::Encrypt, m_parent_widget, passphrase_out, warning_text); m_passphrase_dialog->setWindowModality(Qt::ApplicationModal); m_passphrase_dialog->show(); connect(m_passphrase_dialog, &QObject::destroyed, [this] { m_passphrase_dialog = nullptr; }); - connect(m_passphrase_dialog, &QDialog::accepted, [this] { - createWallet(); + connect(m_passphrase_dialog, &QDialog::accepted, [next_func] { + next_func(); }); connect(m_passphrase_dialog, &QDialog::rejected, [this] { Q_EMIT finished(); @@ -262,7 +262,7 @@ void CreateWalletActivity::createWallet() } QTimer::singleShot(500ms, worker(), [this, name, flags] { - auto wallet{node().walletLoader().createWallet(name, m_passphrase, flags, m_warning_message)}; + auto wallet{node().walletLoader().createWallet(name, m_passphrase, m_db_passphrase, flags, m_warning_message)}; if (wallet) { m_wallet_model = m_wallet_controller->getOrCreateWallet(std::move(*wallet)); @@ -314,7 +314,24 @@ void CreateWalletActivity::create() }); connect(m_create_wallet_dialog, &QDialog::accepted, [this] { if (m_create_wallet_dialog->isEncryptWalletChecked()) { - askPassphrase(); + if (m_create_wallet_dialog->isEncryptDBChecked()) { + // When both are checked, we need to first get the passphrase for wallet encryption + // then the passphrase for db encryption, then make the wallet, hence this chain of binds + askPassphrase( + &m_passphrase, + std::bind( + &CreateWalletActivity::askPassphrase, + this, + &m_db_passphrase, + [this]() { createWallet(); }, + tr("Enter the new passphrase for encrypting all records in the wallet database.") + ) + ); + } else { + askPassphrase(&m_passphrase, std::bind(&CreateWalletActivity::createWallet, this)); + } + } else if (m_create_wallet_dialog->isEncryptDBChecked()) { + askPassphrase(&m_db_passphrase, std::bind(&CreateWalletActivity::createWallet, this), tr("Enter the new passphrase for encrypting all records in the wallet database.")); } else { createWallet(); } @@ -339,7 +356,33 @@ void OpenWalletActivity::finish() Q_EMIT finished(); } -void OpenWalletActivity::open(const std::string& path) +void OpenWalletActivity::askPassphrase(const std::string& name) +{ + m_passphrase_dialog = new AskPassphraseDialog(AskPassphraseDialog::Unlock, m_parent_widget, &m_db_passphrase); + m_passphrase_dialog->setWindowModality(Qt::ApplicationModal); + m_passphrase_dialog->show(); + + connect(m_passphrase_dialog, &QObject::destroyed, [this] { + m_passphrase_dialog = nullptr; + }); + connect(m_passphrase_dialog, &QDialog::accepted, [this, &name] { + openWallet(name); + }); + connect(m_passphrase_dialog, &QDialog::rejected, [this] { + Q_EMIT finished(); + }); +} + +void OpenWalletActivity::open(const std::string& name) +{ + if (node().walletLoader().isWalletDBEncrypted(name)) { + askPassphrase(name); + } else { + openWallet(name); + } +} + +void OpenWalletActivity::openWallet(const std::string& path) { QString name = path.empty() ? QString("["+tr("default wallet")+"]") : QString::fromStdString(path); @@ -351,7 +394,7 @@ void OpenWalletActivity::open(const std::string& path) tr("Opening Wallet %1…").arg(name.toHtmlEscaped())); QTimer::singleShot(0, worker(), [this, path] { - auto wallet{node().walletLoader().loadWallet(path, m_warning_message)}; + auto wallet{node().walletLoader().loadWallet(path, m_warning_message, m_db_passphrase)}; if (wallet) { m_wallet_model = m_wallet_controller->getOrCreateWallet(std::move(*wallet)); diff --git a/src/qt/walletcontroller.h b/src/qt/walletcontroller.h index fcd65756c67..ba90c00028f 100644 --- a/src/qt/walletcontroller.h +++ b/src/qt/walletcontroller.h @@ -124,11 +124,12 @@ class CreateWalletActivity : public WalletControllerActivity void created(WalletModel* wallet_model); private: - void askPassphrase(); + void askPassphrase(SecureString* passphrase_out, std::function next_func, QString warning_text = ""); void createWallet(); void finish(); SecureString m_passphrase; + SecureString m_db_passphrase; CreateWalletDialog* m_create_wallet_dialog{nullptr}; AskPassphraseDialog* m_passphrase_dialog{nullptr}; }; @@ -147,6 +148,11 @@ class OpenWalletActivity : public WalletControllerActivity private: void finish(); + void openWallet(const std::string& path); + void askPassphrase(const std::string& name); + + SecureString m_db_passphrase; + AskPassphraseDialog* m_passphrase_dialog{nullptr}; }; class LoadWalletsActivity : public WalletControllerActivity diff --git a/src/wallet/crypter.cpp b/src/wallet/crypter.cpp index e2799c2d057..faf5383cf8e 100644 --- a/src/wallet/crypter.cpp +++ b/src/wallet/crypter.cpp @@ -59,19 +59,34 @@ bool CCrypter::SetKeyFromPassphrase(const SecureString& strKeyData, const std::v bool CCrypter::SetKey(const CKeyingMaterial& chNewKey, const std::vector& chNewIV) { - if (chNewKey.size() != WALLET_CRYPTO_KEY_SIZE || chNewIV.size() != WALLET_CRYPTO_IV_SIZE) + return SetKey(chNewKey) && SetIV(chNewIV); +} + +bool CCrypter::SetKey(const CKeyingMaterial& chNewKey) +{ + if (chNewKey.size() != WALLET_CRYPTO_KEY_SIZE) return false; memcpy(vchKey.data(), chNewKey.data(), chNewKey.size()); - memcpy(vchIV.data(), chNewIV.data(), chNewIV.size()); fKeySet = true; return true; } +bool CCrypter::SetIV(const std::vector& chNewIV) +{ + if (chNewIV.size() != WALLET_CRYPTO_IV_SIZE) + return false; + + memcpy(vchIV.data(), chNewIV.data(), chNewIV.size()); + + m_iv_set = true; + return true; +} + bool CCrypter::Encrypt(const CKeyingMaterial& vchPlaintext, std::vector &vchCiphertext) const { - if (!fKeySet) + if (!fKeySet && !m_iv_set) return false; // max ciphertext len for a n bytes of plaintext is @@ -89,7 +104,7 @@ bool CCrypter::Encrypt(const CKeyingMaterial& vchPlaintext, std::vector& vchCiphertext, CKeyingMaterial& vchPlaintext) const { - if (!fKeySet) + if (!fKeySet && !m_iv_set) return false; // plaintext will always be equal to or lesser than length of ciphertext diff --git a/src/wallet/crypter.h b/src/wallet/crypter.h index b776a9c4979..53355552058 100644 --- a/src/wallet/crypter.h +++ b/src/wallet/crypter.h @@ -74,6 +74,7 @@ friend class wallet_crypto_tests::TestCrypter; // for test access to chKey/chIV std::vector> vchKey; std::vector> vchIV; bool fKeySet; + bool m_iv_set; int BytesToKeySHA512AES(const std::vector& chSalt, const SecureString& strKeyData, int count, unsigned char *key,unsigned char *iv) const; @@ -82,6 +83,8 @@ friend class wallet_crypto_tests::TestCrypter; // for test access to chKey/chIV bool Encrypt(const CKeyingMaterial& vchPlaintext, std::vector &vchCiphertext) const; bool Decrypt(const std::vector& vchCiphertext, CKeyingMaterial& vchPlaintext) const; bool SetKey(const CKeyingMaterial& chNewKey, const std::vector& chNewIV); + bool SetKey(const CKeyingMaterial& key); + bool SetIV(const std::vector& chNewIV); void CleanKey() { diff --git a/src/wallet/db.cpp b/src/wallet/db.cpp index 0c249205162..cb26f164869 100644 --- a/src/wallet/db.cpp +++ b/src/wallet/db.cpp @@ -16,6 +16,9 @@ #include namespace wallet { +bool operator<(BytePrefix a, Span b) { return a.prefix < b.subspan(0, std::min(a.prefix.size(), b.size())); } +bool operator<(Span a, BytePrefix b) { return a.subspan(0, std::min(a.size(), b.prefix.size())) < b.prefix; } + std::vector ListDatabases(const fs::path& wallet_dir) { std::vector paths; @@ -36,7 +39,12 @@ std::vector ListDatabases(const fs::path& wallet_dir) const fs::path path{it->path().lexically_relative(wallet_dir)}; if (it->status().type() == fs::file_type::directory && - (IsBDBFile(BDBDataFile(it->path())) || IsSQLiteFile(SQLiteDataFile(it->path())))) { + ( + IsBDBFile(BDBDataFile(it->path())) + || IsSQLiteFile(SQLiteDataFile(it->path())) + || IsEncryptedSQLiteFile(SQLiteDataFile(it->path())) + ) + ) { // Found a directory which contains wallet.dat btree file, add it as a wallet. paths.emplace_back(path); } else if (it.depth() == 0 && it->symlink_status().type() == fs::file_type::regular && IsBDBFile(it->path())) { @@ -105,7 +113,7 @@ bool IsBDBFile(const fs::path& path) return data == 0x00053162 || data == 0x62310500; } -bool IsSQLiteFile(const fs::path& path) +static bool IsSQLiteFile(const fs::path& path, std::array id) { if (!fs::exists(path)) return false; @@ -135,8 +143,32 @@ bool IsSQLiteFile(const fs::path& path) return false; } - // Check the application id matches our network magic - return memcmp(Params().MessageStart(), app_id, 4) == 0; + // Check the application id matches our intended id + return memcmp(id.data(), app_id, 4) == 0; +} + +bool IsSQLiteFile(const fs::path& path) +{ + // For unencrypted files, application id is the network magic + std::array app_id = { + std::byte{Params().MessageStart()[0]}, + std::byte{Params().MessageStart()[1]}, + std::byte{Params().MessageStart()[2]}, + std::byte{Params().MessageStart()[3]}, + }; + return IsSQLiteFile(path, app_id); +} + +bool IsEncryptedSQLiteFile(const fs::path& path) +{ + // For encrypted files, application id is the network magic XOR'd with 36932d47 + std::array app_id = { + std::byte{Params().MessageStart()[0]} ^ ENCRYPTED_DB_XOR[0], + std::byte{Params().MessageStart()[1]} ^ ENCRYPTED_DB_XOR[1], + std::byte{Params().MessageStart()[2]} ^ ENCRYPTED_DB_XOR[2], + std::byte{Params().MessageStart()[3]} ^ ENCRYPTED_DB_XOR[3], + }; + return IsSQLiteFile(path, app_id); } void ReadDatabaseArgs(const ArgsManager& args, DatabaseOptions& options) diff --git a/src/wallet/db.h b/src/wallet/db.h index 9d684225c34..a15d7c34337 100644 --- a/src/wallet/db.h +++ b/src/wallet/db.h @@ -20,8 +20,15 @@ class ArgsManager; struct bilingual_str; namespace wallet { +// BytePrefix compares equality with other byte spans that begin with the same prefix. +struct BytePrefix { Span prefix; }; +bool operator<(BytePrefix a, Span b); +bool operator<(Span a, BytePrefix b); + void SplitWalletPath(const fs::path& wallet_path, fs::path& env_directory, std::string& database_filename); +constexpr std::array ENCRYPTED_DB_XOR{std::byte{0x36}, std::byte{0x93}, std::byte{0x2d}, std::byte{0x47}}; + class DatabaseCursor { public: @@ -77,6 +84,16 @@ class DatabaseBatch } } + template + bool Read(const K& key, DataStream& value) + { + DataStream s_key{}; + s_key.reserve(1000); + s_key << key; + + return ReadKey(std::move(s_key), value); + } + template bool Write(const K& key, const T& value, bool fOverwrite = true) { @@ -178,6 +195,7 @@ class WalletDatabase enum class DatabaseFormat { BERKELEY, SQLITE, + ENCRYPTED_SQLITE, }; struct DatabaseOptions { @@ -185,7 +203,8 @@ struct DatabaseOptions { bool require_create = false; std::optional require_format; uint64_t create_flags = 0; - SecureString create_passphrase; + SecureString create_passphrase; //!< The passphrase for wallet-level encryption + SecureString db_passphrase; //!< The passphrase for database-level encryption // Specialized options. Not every option is supported by every backend. bool verify = true; //!< Check data integrity on load. @@ -218,6 +237,7 @@ fs::path BDBDataFile(const fs::path& path); fs::path SQLiteDataFile(const fs::path& path); bool IsBDBFile(const fs::path& path); bool IsSQLiteFile(const fs::path& path); +bool IsEncryptedSQLiteFile(const fs::path& path); } // namespace wallet #endif // BITCOIN_WALLET_DB_H diff --git a/src/wallet/encrypted_db.cpp b/src/wallet/encrypted_db.cpp new file mode 100644 index 00000000000..cf6c22827bb --- /dev/null +++ b/src/wallet/encrypted_db.cpp @@ -0,0 +1,366 @@ +// Copyright (c) 2023 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or https://www.opensource.org/licenses/mit-license.php. + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace wallet { +EncryptedDatabase::EncryptedDatabase(std::unique_ptr database, const SecureString& passphrase, bool create) + : m_database(std::move(database)) +{ + std::unique_ptr batch = m_database->MakeBatch(); + + if (create) { + // Doesn't exist, set it up + // Generate the encryption secret + CKeyingMaterial enc_secret; + enc_secret.resize(WALLET_CRYPTO_KEY_SIZE); + GetStrongRandBytes(enc_secret); + + // Encrypt the secret with the passphrase + CMasterKey enc_key; + enc_key.vchSalt.resize(WALLET_CRYPTO_SALT_SIZE); + GetStrongRandBytes(enc_key.vchSalt); + + CCrypter crypter; + constexpr MillisecondsDouble target{100}; + auto start{SteadyClock::now()}; + crypter.SetKeyFromPassphrase(passphrase, enc_key.vchSalt, 25000, enc_key.nDerivationMethod); + enc_key.nDeriveIterations = static_cast(25000 * target / (SteadyClock::now() - start)); + + start = SteadyClock::now(); + crypter.SetKeyFromPassphrase(passphrase, enc_key.vchSalt, enc_key.nDeriveIterations, enc_key.nDerivationMethod); + enc_key.nDeriveIterations = (enc_key.nDeriveIterations + static_cast(enc_key.nDeriveIterations * target / (SteadyClock::now() - start))) / 2; + + if (enc_key.nDeriveIterations < 25000) + enc_key.nDeriveIterations = 25000; + + if (!crypter.SetKeyFromPassphrase(passphrase, enc_key.vchSalt, enc_key.nDeriveIterations, enc_key.nDerivationMethod)) { + throw std::runtime_error("Unable to set encryption key from passphrase"); + } + if (!crypter.Encrypt(enc_secret, enc_key.vchCryptedKey)) { + throw std::runtime_error("Unable to encrypt key with passphrase"); + } + + // Write that to disk + if (!batch->Write(ENCRYPTION_RECORD, enc_key)) { + throw std::runtime_error("Unable to write the encryption key data"); + } + } + + // Read the encrypted encryption key + CMasterKey enc_key; + if (!batch->Read(ENCRYPTION_RECORD, enc_key)) { + throw std::runtime_error("Unable to read the encryption key data"); + } + batch.reset(); + + // Decrypt the key with passphrase + CCrypter crypter; + if (!crypter.SetKeyFromPassphrase(passphrase, enc_key.vchSalt, enc_key.nDeriveIterations, enc_key.nDerivationMethod)) { + throw std::runtime_error("Unable to get decryption key from passphrase"); + } + CKeyingMaterial enc_secret; + if (!crypter.Decrypt(enc_key.vchCryptedKey, enc_secret)) { + throw std::runtime_error("Unable to decrypt database, are you sure the passphrase is correct?"); + } + m_enc_secret = std::move(enc_secret); + + // Make sure our crypter has the correct secret + m_crypter.SetKey(m_enc_secret); + + Open(); +} + +util::Result EncryptedDatabase::DecryptRecordData(Span data) +{ + DataStream s_data(data); + std::vector iv; + std::vector ciphertext; + s_data >> iv; + s_data >> ciphertext; + + CKeyingMaterial plaintext; + if (!m_crypter.SetIV(iv)) return util::Error{Untranslated("IV is not valid")}; + if (!m_crypter.Decrypt(ciphertext, plaintext)) return util::Error{Untranslated("Could not decrypt data")}; + + SerializeData out{reinterpret_cast(plaintext.data()), reinterpret_cast(plaintext.data() + plaintext.size())}; + return out; +} + +util::Result EncryptedDatabase::EncryptRecordData(Span data) +{ + DataStream s_out; + + HashWriter hasher; + hasher << data; + uint256 hash = hasher.GetHash(); + std::vector iv(hash.begin(), hash.begin() + WALLET_CRYPTO_IV_SIZE); + if (!m_crypter.SetIV(iv)) return util::Error{Untranslated("Unable to set IV")}; + s_out << iv; + + CKeyingMaterial plaintext(UCharCast(data.begin()), UCharCast(data.end())); + std::vector enc; + if (!m_crypter.Encrypt(plaintext, enc)) return util::Error{Untranslated("Cound not encrypt data")}; + s_out << enc; + SerializeData out{s_out.begin(), s_out.end()}; + return out; +} + +void EncryptedDatabase::Open() +{ + // Read all records into memory and decrypt them + std::unique_ptr batch = m_database->MakeBatch(); + if (!batch) { + throw std::runtime_error("Error getting database batch"); + } + std::unique_ptr cursor = batch->GetNewCursor(); + if (!cursor) { + throw std::runtime_error("Error getting database cursor"); + } + DataStream key, value; + while (true) { + DatabaseCursor::Status status = cursor->Next(key, value); + if (status == DatabaseCursor::Status::DONE) { + break; + } else if (status == DatabaseCursor::Status::FAIL) { + throw std::runtime_error("Error reading next record in database"); + } + + // If this record is the encrypted key record, ignore it + if (key == ENCRYPTION_RECORD) { + continue; + } + + // Every key-value record is serialized in the same way: both keys and values are an + // IV followed by the ciphertext as a vector of bytes + // The decrypted ciphertext is the data that the application stored. + util::Result key_data = DecryptRecordData(key); + if (!key_data) { + throw std::runtime_error(util::ErrorString(key_data).original); + } + SerializeData enc_key_data{key.begin(), key.end()}; + m_record_keys.emplace(*key_data, enc_key_data); + } +} + +bool EncryptedDBBatch::ReadKey(DataStream&& key, DataStream& value) +{ + // Lookup the encrypted key data from our map + SerializeData key_data{key.begin(), key.end()}; + const auto& it = m_database.m_record_keys.find(key_data); + if (it == m_database.m_record_keys.end()) { + return false; + } + return ReadEncryptedKey(it->second, value); +} + +bool EncryptedDBBatch::ReadEncryptedKey(SerializeData enc_key, DataStream& value) +{ + DataStream crypt_value; + if (!m_batch->Read(MakeByteSpan(enc_key), crypt_value)) { + return false; + } + util::Result value_data = m_database.DecryptRecordData(crypt_value); + if (!value_data) { + return false; + } + value.write(*value_data); + return true; +} + +bool EncryptedDBBatch::WriteKey(DataStream&& key, DataStream&& value, bool overwrite) +{ + util::Result enc_value = m_database.EncryptRecordData(value); + if (!enc_value) { + return false; + } + + // Lookup the encrypted key data from our map + SerializeData key_data{key.begin(), key.end()}; + SerializeData enc_key; + const auto& it = m_database.m_record_keys.find(key_data); + if (it != m_database.m_record_keys.end()) { + enc_key = it->second; + } else { + util::Result enc_key_res = m_database.EncryptRecordData(key); + if (!enc_key_res) { + return false; + } + enc_key = *enc_key_res; + } + + if (!m_batch->Write(MakeByteSpan(enc_key), MakeByteSpan(*enc_value), overwrite)) { + return false; + } + if (m_txn_started) { + m_txn_writes.emplace_back(key_data, enc_key); + } else { + m_database.m_record_keys.emplace(key_data, enc_key); + } + return true; +} + +bool EncryptedDBBatch::EraseKey(DataStream&& key) +{ + // Lookup the encrypted key data from our map + SerializeData key_data{key.begin(), key.end()}; + const auto& it = m_database.m_record_keys.find(key_data); + if (it == m_database.m_record_keys.end()) { + return false; + } + if (!m_batch->Erase(MakeByteSpan(it->second))) { + return false; + } + if (m_txn_started) { + m_txn_erases.emplace_back(key_data); + } else { + m_database.m_record_keys.erase(it); + } + return true; +} +bool EncryptedDBBatch::HasKey(DataStream&& key) +{ + // Lookup the encrypted key data from our map + SerializeData key_data{key.begin(), key.end()}; + const auto& it = m_database.m_record_keys.find(key_data); + if (it == m_database.m_record_keys.end()) { + return false; + } + Assume(m_batch->Exists(MakeByteSpan(it->second))); + return true; +} + +void EncryptedDBBatch::Close() +{ + if (m_txn_started) { + TxnAbort(); + } + m_batch->Close(); +} + +bool EncryptedDBBatch::ErasePrefix(Span prefix) +{ + auto it = m_database.m_record_keys.begin(); + while (it != m_database.m_record_keys.end()) { + auto& key = it->first; + if (key.size() < prefix.size() || std::search(key.begin(), key.end(), prefix.begin(), prefix.end()) != key.begin()) { + it++; + continue; + } + m_batch->Erase(MakeByteSpan(it->second)); + if (m_txn_started) { + m_txn_erases.emplace_back(key); + it++; + } else { + it = m_database.m_record_keys.erase(it); + } + } + return true; +} + +bool EncryptedDBBatch::TxnBegin() +{ + if (m_txn_started) { + return false; + } + if (!m_batch->TxnBegin()) { + return false; + } + m_txn_writes.clear(); + m_txn_erases.clear(); + m_txn_started = true; + return true; +} + +bool EncryptedDBBatch::TxnCommit() +{ + if (!m_txn_started) { + return false; + } + if (!m_batch->TxnCommit()) { + return false; + } + + for (const auto& [key_data, enc_key] : m_txn_writes) { + m_database.m_record_keys.emplace(key_data, enc_key); + } + for (const auto& key_data : m_txn_erases) { + m_database.m_record_keys.erase(key_data); + } + + m_txn_started = false; + m_txn_writes.clear(); + m_txn_erases.clear(); + return true; +} + +bool EncryptedDBBatch::TxnAbort() +{ + if (!m_txn_started) { + return false; + } + if (!m_batch->TxnAbort()) { + return false; + } + m_txn_started = false; + m_txn_writes.clear(); + m_txn_erases.clear(); + return true; +} + +std::unique_ptr EncryptedDBBatch::GetNewCursor() +{ + return std::make_unique(m_database.m_record_keys, *this); +} + +std::unique_ptr EncryptedDBBatch::GetNewPrefixCursor(Span prefix) +{ + return std::make_unique(m_database.m_record_keys, *this, prefix); +} + +EncryptedDBCursor::EncryptedDBCursor(const DecryptedRecordKeys& records, EncryptedDBBatch& batch, Span prefix) : m_batch(batch) +{ + std::tie(m_cursor, m_cursor_end) = records.equal_range(BytePrefix{prefix}); +} + +DatabaseCursor::Status EncryptedDBCursor::Next(DataStream& key, DataStream& value) +{ + if (m_cursor == m_cursor_end) { + return DatabaseCursor::Status::DONE; + } + key.clear(); + value.clear(); + + const auto& [key_data, enc_key] = *m_cursor; + key.write(key_data); + + if (!m_batch.ReadEncryptedKey(enc_key, value)) { + return DatabaseCursor::Status::FAIL; + } + m_cursor++; + return DatabaseCursor::Status::MORE; +} + +std::unique_ptr MakeEncryptedSQLiteDatabase(const fs::path& path, const DatabaseOptions& options, DatabaseStatus& status, bilingual_str& error) +{ + std::unique_ptr backing_db = MakeSQLiteDatabase(path, options, status, error, ENCRYPTED_DB_XOR); + try { + auto db = std::make_unique(std::move(backing_db), options.db_passphrase, options.require_create); + status = DatabaseStatus::SUCCESS; + return db; + } catch (const std::runtime_error& e) { + status = DatabaseStatus::FAILED_LOAD; + error = Untranslated(e.what()); + return nullptr; + } +} + +} // namespace wallet diff --git a/src/wallet/encrypted_db.h b/src/wallet/encrypted_db.h new file mode 100644 index 00000000000..05f48ec2524 --- /dev/null +++ b/src/wallet/encrypted_db.h @@ -0,0 +1,127 @@ +// Copyright (c) 2023 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or https://www.opensource.org/licenses/mit-license.php. + +#ifndef BITCOIN_WALLET_ENCRYPTED_DB_H +#define BITCOIN_WALLET_ENCRYPTED_DB_H + +#include +#include +#include +#include + +namespace wallet { +// Map of decrypted record key data to the encrypted record key data +// This allows us to get the actual db key data in order to lookups against the underlying db. +using DecryptedRecordKeys = std::map>; + +class EncryptedDatabase; +class EncryptedDBBatch; + +class EncryptedDBCursor : public DatabaseCursor +{ +public: + DecryptedRecordKeys::const_iterator m_cursor; + DecryptedRecordKeys::const_iterator m_cursor_end; + EncryptedDBBatch& m_batch; + + explicit EncryptedDBCursor(const DecryptedRecordKeys& records, EncryptedDBBatch& batch) : m_cursor(records.begin()), m_cursor_end(records.end()), m_batch(batch) {} + EncryptedDBCursor(const DecryptedRecordKeys& records, EncryptedDBBatch& batch, Span prefix); + ~EncryptedDBCursor() {} + + Status Next(DataStream& key, DataStream& value) override; +}; + +/** RAII class that provides access to a WalletDatabase */ +class EncryptedDBBatch : public DatabaseBatch +{ +private: + //! A DatabaseBatch for the db underlying the EncryptedDatabase + std::unique_ptr m_batch; + EncryptedDatabase& m_database; + + bool m_txn_started{false}; + std::vector> m_txn_writes; + std::vector m_txn_erases; + + bool ReadKey(DataStream&& key, DataStream& value) override; + bool WriteKey(DataStream&& key, DataStream&& value, bool overwrite = true) override; + bool EraseKey(DataStream&& key) override; + bool HasKey(DataStream&& key) override; + +public: + explicit EncryptedDBBatch(std::unique_ptr batch, EncryptedDatabase& database) : m_batch(std::move(batch)), m_database(database) {} + ~EncryptedDBBatch() {} + + void Flush() override { m_batch->Flush(); } + void Close() override; + + bool ErasePrefix(Span prefix) override; + + std::unique_ptr GetNewCursor() override; + std::unique_ptr GetNewPrefixCursor(Span prefix) override; + bool TxnBegin() override; + bool TxnCommit() override; + bool TxnAbort() override; + + bool ReadEncryptedKey(SerializeData enc_key, DataStream& value); +}; + +/** + * EncryptedDatabase encrypts and decrypts records as they are read and written from an underlying + * database. Most functions are simply passed through. + * An unencrypted copy of every record key is held in memory. This allows to lookup records by + * unencrypted record key. The value will be read from the underlying db and decrypted. + **/ +class EncryptedDatabase : public WalletDatabase +{ +private: + /** The underlying database */ + std::unique_ptr m_database; + + /** CCrypter which encrypts and decrypts the data */ + CCrypter m_crypter; + /** The key used to encrypt the records */ + CKeyingMaterial m_enc_secret; + +public: + /** The unencrypted record keys, using a data type with secure allocation */ + DecryptedRecordKeys m_record_keys; + + EncryptedDatabase() = delete; + + EncryptedDatabase(std::unique_ptr database, const SecureString& passphrase, bool create); + + ~EncryptedDatabase() {}; + + inline static const Span ENCRYPTION_RECORD = MakeByteSpan("encrypted_db_key"); + + util::Result DecryptRecordData(Span data); + util::Result EncryptRecordData(Span data); + + /** Open the database if it is not already opened. */ + void Open() override; + + std::string Format() override { return "encrypted_" + m_database->Format(); } + + /** Passthrough */ + void Close() override { m_database->Close(); } + void AddRef() override { m_database->AddRef() ;} + void RemoveRef() override { m_database->RemoveRef(); } + bool Rewrite(const char* pszSkip=nullptr) override { return m_database->Rewrite(pszSkip); } + bool Backup(const std::string& strDest) const override { return m_database->Backup(strDest); } + void Flush() override { m_database->Flush(); } + bool PeriodicFlush() override { return m_database->PeriodicFlush(); } + void IncrementUpdateCounter() override { m_database->IncrementUpdateCounter(); } + void ReloadDbEnv() override { m_database->ReloadDbEnv(); } + std::string Filename() override { return m_database->Filename(); } + + /** Make a DatabaseBatch connected to this database */ + std::unique_ptr MakeBatch(bool flush_on_close = true) override { return std::make_unique(std::move(m_database->MakeBatch(flush_on_close)), *this); } +}; + +std::unique_ptr MakeEncryptedSQLiteDatabase(const fs::path& path, const DatabaseOptions& options, DatabaseStatus& status, bilingual_str& error); + +} // namespace wallet + +#endif // BITCOIN_WALLET_ENCRYPTED_DB_H diff --git a/src/wallet/interfaces.cpp b/src/wallet/interfaces.cpp index cd438cfe2f3..3d0a8fae4ec 100644 --- a/src/wallet/interfaces.cpp +++ b/src/wallet/interfaces.cpp @@ -590,7 +590,7 @@ class WalletLoaderImpl : public WalletLoader void setMockTime(int64_t time) override { return SetMockTime(time); } //! WalletLoader methods - util::Result> createWallet(const std::string& name, const SecureString& passphrase, uint64_t wallet_creation_flags, std::vector& warnings) override + util::Result> createWallet(const std::string& name, const SecureString& passphrase, const SecureString& db_passphrase, uint64_t wallet_creation_flags, std::vector& warnings) override { DatabaseOptions options; DatabaseStatus status; @@ -598,6 +598,7 @@ class WalletLoaderImpl : public WalletLoader options.require_create = true; options.create_flags = wallet_creation_flags; options.create_passphrase = passphrase; + options.db_passphrase = db_passphrase; bilingual_str error; std::unique_ptr wallet{MakeWallet(m_context, CreateWallet(m_context, name, /*load_on_start=*/true, options, status, error, warnings))}; if (wallet) { @@ -606,12 +607,13 @@ class WalletLoaderImpl : public WalletLoader return util::Error{error}; } } - util::Result> loadWallet(const std::string& name, std::vector& warnings) override + util::Result> loadWallet(const std::string& name, std::vector& warnings, const SecureString& db_passphrase) override { DatabaseOptions options; DatabaseStatus status; ReadDatabaseArgs(*m_context.args, options); options.require_existing = true; + options.db_passphrase = db_passphrase; bilingual_str error; std::unique_ptr wallet{MakeWallet(m_context, LoadWallet(m_context, name, /*load_on_start=*/true, options, status, error, warnings))}; if (wallet) { @@ -656,6 +658,10 @@ class WalletLoaderImpl : public WalletLoader return HandleLoadWallet(m_context, std::move(fn)); } WalletContext* context() override { return &m_context; } + bool isWalletDBEncrypted(const std::string& name) override + { + return IsEncryptedSQLiteFile(SQLiteDataFile(GetWalletDir() / fs::PathFromString(name))); + } WalletContext m_context; const std::vector m_wallet_filenames; diff --git a/src/wallet/load.cpp b/src/wallet/load.cpp index 4cdfadbee27..0dd286c95bb 100644 --- a/src/wallet/load.cpp +++ b/src/wallet/load.cpp @@ -92,6 +92,8 @@ bool VerifyWallets(WalletContext& context) if (!MakeWalletDatabase(wallet_file, options, status, error_string)) { if (status == DatabaseStatus::FAILED_NOT_FOUND) { chain.initWarning(Untranslated(strprintf("Skipping -wallet path that doesn't exist. %s", error_string.original))); + } else if (status == DatabaseStatus::FAILED_ENCRYPT) { + chain.initWarning(Untranslated(strprintf("Skipping -wallet path to encrypted wallet, use loadwallet to load it. %s", error_string.original))); } else { chain.initError(error_string); return false; @@ -120,7 +122,7 @@ bool LoadWallets(WalletContext& context) bilingual_str error; std::vector warnings; std::unique_ptr database = MakeWalletDatabase(name, options, status, error); - if (!database && status == DatabaseStatus::FAILED_NOT_FOUND) { + if (!database && (status == DatabaseStatus::FAILED_NOT_FOUND || status == DatabaseStatus::FAILED_ENCRYPT)) { continue; } chain.initMessage(_("Loading wallet…").translated); diff --git a/src/wallet/rpc/wallet.cpp b/src/wallet/rpc/wallet.cpp index fb4b642bbab..efd4c3a1972 100644 --- a/src/wallet/rpc/wallet.cpp +++ b/src/wallet/rpc/wallet.cpp @@ -216,6 +216,7 @@ static RPCHelpMan loadwallet() { {"filename", RPCArg::Type::STR, RPCArg::Optional::NO, "The wallet directory or .dat file."}, {"load_on_startup", RPCArg::Type::BOOL, RPCArg::Optional::OMITTED, "Save wallet name to persistent settings and load on startup. True to add wallet to startup list, false to remove, null to leave unchanged."}, + {"db_passphrase", RPCArg::Type::STR, RPCArg::Optional::OMITTED, "Passphrase for the wallet database if the database is encrypted"}, }, RPCResult{ RPCResult::Type::OBJ, "", "", @@ -244,6 +245,11 @@ static RPCHelpMan loadwallet() std::vector warnings; std::optional load_on_start = request.params[1].isNull() ? std::nullopt : std::optional(request.params[1].get_bool()); + options.db_passphrase.reserve(100); + if (!request.params[2].isNull()) { + options.db_passphrase = std::string_view{request.params[2].get_str()}; + } + { LOCK(context.wallets_mutex); if (std::any_of(context.wallets.begin(), context.wallets.end(), [&name](const auto& wallet) { return wallet->GetName() == name; })) { @@ -340,13 +346,14 @@ static RPCHelpMan createwallet() {"wallet_name", RPCArg::Type::STR, RPCArg::Optional::NO, "The name for the new wallet. If this is a path, the wallet will be created at the path location."}, {"disable_private_keys", RPCArg::Type::BOOL, RPCArg::Default{false}, "Disable the possibility of private keys (only watchonlys are possible in this mode)."}, {"blank", RPCArg::Type::BOOL, RPCArg::Default{false}, "Create a blank wallet. A blank wallet has no keys or HD seed. One can be set using sethdseed."}, - {"passphrase", RPCArg::Type::STR, RPCArg::Optional::OMITTED, "Encrypt the wallet with this passphrase."}, + {"passphrase", RPCArg::Type::STR, RPCArg::Optional::OMITTED, "Encrypt the keys stored in this wallet with this passphrase."}, {"avoid_reuse", RPCArg::Type::BOOL, RPCArg::Default{false}, "Keep track of coin reuse, and treat dirty and clean coins differently with privacy considerations in mind."}, {"descriptors", RPCArg::Type::BOOL, RPCArg::Default{true}, "Create a native descriptor wallet. The wallet will use descriptors internally to handle address creation." " Setting to \"false\" will create a legacy wallet; however, the legacy wallet type is being deprecated and" " support for creating and opening legacy wallets will be removed in the future."}, {"load_on_startup", RPCArg::Type::BOOL, RPCArg::Optional::OMITTED, "Save wallet name to persistent settings and load on startup. True to add wallet to startup list, false to remove, null to leave unchanged."}, {"external_signer", RPCArg::Type::BOOL, RPCArg::Default{false}, "Use an external signer such as a hardware wallet. Requires -signer to be configured. Wallet creation will fail if keys cannot be fetched. Requires disable_private_keys and descriptors set to true."}, + {"db_passphrase", RPCArg::Type::STR, RPCArg::Optional::OMITTED, "Encrypt the entire wallet database with this passphrase."}, }, RPCResult{ RPCResult::Type::OBJ, "", "", @@ -409,12 +416,23 @@ static RPCHelpMan createwallet() } #endif + SecureString db_passphrase; + db_passphrase.reserve(100); + if (!request.params[8].isNull()) { + db_passphrase = std::string_view{request.params[8].get_str()}; + if (db_passphrase.empty()) { + // Empty string means unencrypted + warnings.emplace_back(Untranslated("Empty string given as database passphrase, wallet database will not be encrypted.")); + } + } + DatabaseOptions options; DatabaseStatus status; ReadDatabaseArgs(*context.args, options); options.require_create = true; options.create_flags = flags; options.create_passphrase = passphrase; + options.db_passphrase = db_passphrase; bilingual_str error; std::optional load_on_start = request.params[6].isNull() ? std::nullopt : std::optional(request.params[6].get_bool()); const std::shared_ptr wallet = CreateWallet(context, request.params[0].get_str(), load_on_start, options, status, error, warnings); diff --git a/src/wallet/sqlite.cpp b/src/wallet/sqlite.cpp index ecd34bb96a6..2ce2d0c83fb 100644 --- a/src/wallet/sqlite.cpp +++ b/src/wallet/sqlite.cpp @@ -109,8 +109,18 @@ static void SetPragma(sqlite3* db, const std::string& key, const std::string& va Mutex SQLiteDatabase::g_sqlite_mutex; int SQLiteDatabase::g_sqlite_count = 0; -SQLiteDatabase::SQLiteDatabase(const fs::path& dir_path, const fs::path& file_path, const DatabaseOptions& options, bool mock) - : WalletDatabase(), m_mock(mock), m_dir_path(fs::PathToString(dir_path)), m_file_path(fs::PathToString(file_path)), m_use_unsafe_sync(options.use_unsafe_sync) +SQLiteDatabase::SQLiteDatabase(const fs::path& dir_path, const fs::path& file_path, const DatabaseOptions& options, bool mock, std::array app_id_xor) + : WalletDatabase(), + m_mock(mock), + m_dir_path(fs::PathToString(dir_path)), + m_file_path(fs::PathToString(file_path)), + m_app_id({ + std::byte{Params().MessageStart()[0]} ^ app_id_xor[0], + std::byte{Params().MessageStart()[1]} ^ app_id_xor[1], + std::byte{Params().MessageStart()[2]} ^ app_id_xor[2], + std::byte{Params().MessageStart()[3]} ^ app_id_xor[3], + }), + m_use_unsafe_sync(options.use_unsafe_sync) { { LOCK(g_sqlite_mutex); @@ -189,13 +199,13 @@ bool SQLiteDatabase::Verify(bilingual_str& error) { assert(m_db); - // Check the application ID matches our network magic + // Check the application ID matches the db's stored app_id auto read_result = ReadPragmaInteger(m_db, "application_id", "the application id", error); if (!read_result.has_value()) return false; uint32_t app_id = static_cast(read_result.value()); - uint32_t net_magic = ReadBE32(Params().MessageStart()); - if (app_id != net_magic) { - error = strprintf(_("SQLiteDatabase: Unexpected application id. Expected %u, got %u"), net_magic, app_id); + uint32_t magic = ReadBE32(reinterpret_cast(m_app_id.data())); + if (app_id != magic) { + error = strprintf(_("SQLiteDatabase: Unexpected application id. Expected %u, got %u"), magic, app_id); return false; } @@ -324,7 +334,7 @@ void SQLiteDatabase::Open() } // Set the application id - uint32_t app_id = ReadBE32(Params().MessageStart()); + uint32_t app_id = ReadBE32(reinterpret_cast(m_app_id.data())); SetPragma(m_db, "application_id", strprintf("%d", static_cast(app_id)), "Failed to set the application id"); @@ -634,11 +644,11 @@ bool SQLiteBatch::TxnAbort() return res == SQLITE_OK; } -std::unique_ptr MakeSQLiteDatabase(const fs::path& path, const DatabaseOptions& options, DatabaseStatus& status, bilingual_str& error) +std::unique_ptr MakeSQLiteDatabase(const fs::path& path, const DatabaseOptions& options, DatabaseStatus& status, bilingual_str& error, std::array app_id_xor) { try { fs::path data_file = SQLiteDataFile(path); - auto db = std::make_unique(data_file.parent_path(), data_file, options); + auto db = std::make_unique(data_file.parent_path(), data_file, options, /*mock=*/false, app_id_xor); if (options.verify && !db->Verify(error)) { status = DatabaseStatus::FAILED_VERIFY; return nullptr; diff --git a/src/wallet/sqlite.h b/src/wallet/sqlite.h index f1ce0567e18..69f8775d3cb 100644 --- a/src/wallet/sqlite.h +++ b/src/wallet/sqlite.h @@ -84,6 +84,8 @@ class SQLiteDatabase : public WalletDatabase const std::string m_file_path; + std::array m_app_id; + /** * This mutex protects SQLite initialization and shutdown. * sqlite3_config() and sqlite3_shutdown() are not thread-safe (sqlite3_initialize() is). @@ -99,7 +101,7 @@ class SQLiteDatabase : public WalletDatabase SQLiteDatabase() = delete; /** Create DB handle to real database */ - SQLiteDatabase(const fs::path& dir_path, const fs::path& file_path, const DatabaseOptions& options, bool mock = false); + SQLiteDatabase(const fs::path& dir_path, const fs::path& file_path, const DatabaseOptions& options, bool mock = false, std::array app_id_xor = {std::byte{0}, std::byte{0}, std::byte{0}, std::byte{0}}); ~SQLiteDatabase(); @@ -146,7 +148,7 @@ class SQLiteDatabase : public WalletDatabase bool m_use_unsafe_sync; }; -std::unique_ptr MakeSQLiteDatabase(const fs::path& path, const DatabaseOptions& options, DatabaseStatus& status, bilingual_str& error); +std::unique_ptr MakeSQLiteDatabase(const fs::path& path, const DatabaseOptions& options, DatabaseStatus& status, bilingual_str& error, std::array app_id_xor = {std::byte{0}, std::byte{0}, std::byte{0}, std::byte{0}}); std::string SQLiteDatabaseVersion(); } // namespace wallet diff --git a/src/wallet/test/db_tests.cpp b/src/wallet/test/db_tests.cpp index d341e84d9b5..934bd0b9c75 100644 --- a/src/wallet/test/db_tests.cpp +++ b/src/wallet/test/db_tests.cpp @@ -13,6 +13,7 @@ #endif #ifdef USE_SQLITE #include +#include #endif #include #include // for WALLET_FLAG_DESCRIPTORS @@ -126,6 +127,7 @@ static std::vector> TestDatabases(const fs::path { std::vector> dbs; DatabaseOptions options; + options.require_create = true; DatabaseStatus status; bilingual_str error; #ifdef USE_BDB @@ -133,6 +135,8 @@ static std::vector> TestDatabases(const fs::path #endif #ifdef USE_SQLITE dbs.emplace_back(MakeSQLiteDatabase(path_root / "sqlite", options, status, error)); + options.db_passphrase = "pass"; + dbs.emplace_back(MakeEncryptedSQLiteDatabase(path_root / "enc_sqlite", options, status, error)); #endif dbs.emplace_back(CreateMockableWalletDatabase()); return dbs; diff --git a/src/wallet/test/util.cpp b/src/wallet/test/util.cpp index 069ab25f260..a6ea87e1ec0 100644 --- a/src/wallet/test/util.cpp +++ b/src/wallet/test/util.cpp @@ -92,11 +92,6 @@ CTxDestination getNewDestination(CWallet& w, OutputType output_type) return *Assert(w.GetNewDestination(output_type, "")); } -// BytePrefix compares equality with other byte spans that begin with the same prefix. -struct BytePrefix { Span prefix; }; -bool operator<(BytePrefix a, Span b) { return a.prefix < b.subspan(0, std::min(a.prefix.size(), b.size())); } -bool operator<(Span a, BytePrefix b) { return a.subspan(0, std::min(a.size(), b.prefix.size())) < b.prefix; } - MockableCursor::MockableCursor(const MockableData& records, bool pass, Span prefix) { m_pass = pass; diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp index 8fa93b97d65..b59021927d7 100644 --- a/src/wallet/wallet.cpp +++ b/src/wallet/wallet.cpp @@ -342,7 +342,17 @@ std::shared_ptr CreateWallet(WalletContext& context, const std::string& uint64_t wallet_creation_flags = options.create_flags; const SecureString& passphrase = options.create_passphrase; - if (wallet_creation_flags & WALLET_FLAG_DESCRIPTORS) options.require_format = DatabaseFormat::SQLITE; + if (wallet_creation_flags & WALLET_FLAG_DESCRIPTORS) { + if (!options.db_passphrase.empty()) { + options.require_format = DatabaseFormat::ENCRYPTED_SQLITE; + } else { + options.require_format = DatabaseFormat::SQLITE; + } + } else if (!options.db_passphrase.empty()) { + error = Untranslated("Database encryption is only supported for descriptor wallets"); + status = DatabaseStatus::FAILED_CREATE; + return nullptr; + } // Indicate that the wallet is actually supposed to be blank and not just blank to make it encrypted bool create_blank = (wallet_creation_flags & WALLET_FLAG_BLANK_WALLET); diff --git a/src/wallet/walletdb.cpp b/src/wallet/walletdb.cpp index 8212c044649..2ed696b9fad 100644 --- a/src/wallet/walletdb.cpp +++ b/src/wallet/walletdb.cpp @@ -19,6 +19,7 @@ #include #endif #ifdef USE_SQLITE +#include #include #endif #include @@ -1457,6 +1458,19 @@ std::unique_ptr MakeDatabase(const fs::path& path, const Databas } format = DatabaseFormat::SQLITE; } + if (IsEncryptedSQLiteFile(SQLiteDataFile(path))) { + if (format) { + error = Untranslated(strprintf("Failed to load database path '%s'. Data is in ambiguous format.", fs::PathToString(path))); + status = DatabaseStatus::FAILED_BAD_FORMAT; + return nullptr; + } + format = DatabaseFormat::ENCRYPTED_SQLITE; + if (options.db_passphrase.empty()) { + error = Untranslated(strprintf("Unable to load database '%s'. Database is encrypted but passphrase was not provided.", fs::PathToString(path))); + status = DatabaseStatus::FAILED_ENCRYPT; + return nullptr; + } + } } else if (options.require_existing) { error = Untranslated(strprintf("Failed to load database path '%s'. Path does not exist.", fs::PathToString(path))); status = DatabaseStatus::FAILED_NOT_FOUND; @@ -1489,15 +1503,22 @@ std::unique_ptr MakeDatabase(const fs::path& path, const Databas if (!format) { #ifdef USE_SQLITE format = DatabaseFormat::SQLITE; + if (!options.db_passphrase.empty()) { + format = DatabaseFormat::ENCRYPTED_SQLITE; + } #endif #ifdef USE_BDB format = DatabaseFormat::BERKELEY; #endif } - if (format == DatabaseFormat::SQLITE) { + if (format == DatabaseFormat::SQLITE || format == DatabaseFormat::ENCRYPTED_SQLITE) { #ifdef USE_SQLITE - return MakeSQLiteDatabase(path, options, status, error); + if (format == DatabaseFormat::SQLITE) { + return MakeSQLiteDatabase(path, options, status, error); + } else if (format == DatabaseFormat::ENCRYPTED_SQLITE) { + return MakeEncryptedSQLiteDatabase(path, options, status, error); + } #endif error = Untranslated(strprintf("Failed to open database path '%s'. Build does not support SQLite database format.", fs::PathToString(path))); status = DatabaseStatus::FAILED_BAD_FORMAT; diff --git a/test/functional/test_framework/test_node.py b/test/functional/test_framework/test_node.py index 1fcef6ce1c8..448a1d1bef4 100755 --- a/test/functional/test_framework/test_node.py +++ b/test/functional/test_framework/test_node.py @@ -789,10 +789,10 @@ def __getattr__(self, name): def createwallet_passthrough(self, *args, **kwargs): return self.__getattr__("createwallet")(*args, **kwargs) - def createwallet(self, wallet_name, disable_private_keys=None, blank=None, passphrase='', avoid_reuse=None, descriptors=None, load_on_startup=None, external_signer=None): + def createwallet(self, wallet_name, disable_private_keys=None, blank=None, passphrase='', avoid_reuse=None, descriptors=None, load_on_startup=None, external_signer=None, db_passphrase=''): if descriptors is None: descriptors = self.descriptors - return self.__getattr__('createwallet')(wallet_name, disable_private_keys, blank, passphrase, avoid_reuse, descriptors, load_on_startup, external_signer) + return self.__getattr__('createwallet')(wallet_name, disable_private_keys, blank, passphrase, avoid_reuse, descriptors, load_on_startup, external_signer, db_passphrase) def importprivkey(self, privkey, label=None, rescan=None): wallet_info = self.getwalletinfo() diff --git a/test/functional/test_runner.py b/test/functional/test_runner.py index 9762476a5d6..689e422603a 100755 --- a/test/functional/test_runner.py +++ b/test/functional/test_runner.py @@ -292,6 +292,7 @@ 'p2p_leak.py', 'wallet_encryption.py --legacy-wallet', 'wallet_encryption.py --descriptors', + 'wallet_db_encryption.py --descriptors', 'feature_dersig.py', 'feature_cltv.py', 'rpc_uptime.py', diff --git a/test/functional/wallet_createwallet.py b/test/functional/wallet_createwallet.py index 75b507c3875..417dc375e77 100755 --- a/test/functional/wallet_createwallet.py +++ b/test/functional/wallet_createwallet.py @@ -16,6 +16,8 @@ EMPTY_PASSPHRASE_MSG = "Empty string given as passphrase, wallet will not be encrypted." +EMPTY_DB_PASSPHRASE_MSG = "Empty string given as database passphrase, wallet database will not be encrypted." +EMPTY_PASSPHRASE_MSGS = [EMPTY_PASSPHRASE_MSG, EMPTY_DB_PASSPHRASE_MSG] LEGACY_WALLET_MSG = "Wallet created successfully. The legacy wallet type is being deprecated and support for creating and opening legacy wallets will be removed in the future." @@ -161,7 +163,7 @@ def run_test(self): assert_equal(walletinfo['keypoolsize_hd_internal'], keys) # Allow empty passphrase, but there should be a warning resp = self.nodes[0].createwallet(wallet_name='w7', disable_private_keys=False, blank=False, passphrase='') - assert_equal(resp["warnings"], [EMPTY_PASSPHRASE_MSG] if self.options.descriptors else [EMPTY_PASSPHRASE_MSG, LEGACY_WALLET_MSG]) + assert_equal(resp["warnings"], EMPTY_PASSPHRASE_MSGS if self.options.descriptors else EMPTY_PASSPHRASE_MSGS + [LEGACY_WALLET_MSG]) w7 = node.get_wallet_rpc('w7') assert_raises_rpc_error(-15, 'Error: running with an unencrypted wallet, but walletpassphrase was called.', w7.walletpassphrase, '', 60) @@ -180,12 +182,12 @@ def run_test(self): result = self.nodes[0].createwallet(wallet_name="legacy_w0", descriptors=False, passphrase=None) assert_equal(result, { "name": "legacy_w0", - "warnings": [LEGACY_WALLET_MSG], + "warnings": [EMPTY_DB_PASSPHRASE_MSG, LEGACY_WALLET_MSG], }) result = self.nodes[0].createwallet(wallet_name="legacy_w1", descriptors=False, passphrase="") assert_equal(result, { "name": "legacy_w1", - "warnings": [EMPTY_PASSPHRASE_MSG, LEGACY_WALLET_MSG], + "warnings": EMPTY_PASSPHRASE_MSGS + [LEGACY_WALLET_MSG], }) diff --git a/test/functional/wallet_db_encryption.py b/test/functional/wallet_db_encryption.py new file mode 100755 index 00000000000..07fa8c8e26e --- /dev/null +++ b/test/functional/wallet_db_encryption.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +# Copyright (c) 2023 The Bitcoin Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or https://www.opensource.org/licenses/mit-license.php. +"""Test Wallets with encrypted database""" + +import subprocess + +from test_framework.test_framework import BitcoinTestFramework +from test_framework.util import ( + assert_raises_rpc_error, + assert_equal, + assert_greater_than, +) + + +class WalletDBEncryptionTest(BitcoinTestFramework): + PASSPHRASE = "WalletPassphrase" + PASSPHRASE2 = "SecondWalletPassphrase" + WRONG_PASSPHRASE = "NotTheRightPassphrase" + PASSPHRASE_WITH_NULLS = "Passphrase\0With\0Nulls" + + def add_options(self, parser): + self.add_wallet_options(parser, descriptors=True) + + def set_test_params(self): + self.setup_clean_chain = True + self.num_nodes = 1 + + def skip_test_if_missing_module(self): + self.skip_if_no_wallet() + + def test_no_legacy(self): + if not self.is_bdb_compiled(): + return + self.log.info("Test that legacy wallets do not support encrypted databases") + assert_raises_rpc_error(-4, "Database encryption is only supported for descriptor wallets", self.nodes[0].createwallet, wallet_name="legacy_encdb", db_passphrase=self.PASSPHRASE, descriptors=False) + + def test_create_and_load(self): + self.log.info("Testing that a wallet with encrypted database can be created") + self.nodes[0].createwallet(wallet_name="basic_encrypted_db", db_passphrase=self.PASSPHRASE) + wallet = self.nodes[0].get_wallet_rpc("basic_encrypted_db") + info = wallet.getwalletinfo() + assert_equal("encrypted_sqlite", info["format"]) + + # Add some data to the wallet that we should see persisted + addr = wallet.getnewaddress() + txid_in = self.default_wallet.sendtoaddress(addr, 10) + self.generate(self.nodes[0], 1) + txid_out = wallet.sendtoaddress(self.default_wallet.getnewaddress(), 5) + self.generate(self.nodes[0], 1) + + wallet.unloadwallet() + + self.log.info("Testing loading of a wallet with encrypted database") + assert_raises_rpc_error(-4, "Database is encrypted but passphrase was not provided", self.nodes[0].loadwallet, filename="basic_encrypted_db") + assert_raises_rpc_error(-4, "Unable to decrypt database, are you sure the passphrase is correct?", self.nodes[0].loadwallet, filename="basic_encrypted_db", db_passphrase=self.WRONG_PASSPHRASE) + self.nodes[0].loadwallet(filename="basic_encrypted_db", db_passphrase=self.PASSPHRASE) + info = wallet.getwalletinfo() + assert_equal("encrypted_sqlite", info["format"]) + + # Make sure that our presisted data is still here + addr_info = wallet.getaddressinfo(addr) + assert_equal(addr_info["ismine"], True) + tx_in = wallet.gettransaction(txid_in) + assert_equal(tx_in["amount"], 10) + tx_out = wallet.gettransaction(txid_out) + assert_equal(tx_out["amount"], -5) + wallet.sendtoaddress(self.default_wallet.getnewaddress(), 2) + + wallet.unloadwallet() + + self.log.info("Test that listwalletdir lists wallets with encrypted dbs") + wallets = [w["name"] for w in self.nodes[0].listwalletdir()["wallets"]] + assert "basic_encrypted_db" in wallets + + def test_passphrases_with_nulls(self): + self.log.info("Testing passphrases with nulls for wallets with encrypted databases") + self.nodes[0].createwallet(wallet_name="encdb_with_nulls", db_passphrase=self.PASSPHRASE_WITH_NULLS) + wallet = self.nodes[0].get_wallet_rpc("encdb_with_nulls") + info = wallet.getwalletinfo() + assert_equal("encrypted_sqlite", info["format"]) + wallet.unloadwallet() + + assert_raises_rpc_error(-4, "Unable to decrypt database, are you sure the passphrase is correct?", self.nodes[0].loadwallet, filename="encdb_with_nulls", db_passphrase=self.PASSPHRASE_WITH_NULLS.partition("\0")[0]) + self.nodes[0].loadwallet(filename="encdb_with_nulls", db_passphrase=self.PASSPHRASE_WITH_NULLS) + + def test_on_start(self): + self.log.info("Test that wallets with encrypted db are ignored on startup") + self.nodes[0].createwallet(wallet_name="startup_encdb", db_passphrase=self.PASSPHRASE) + with self.nodes[0].assert_debug_log(expected_msgs=["Warning: Skipping -wallet path to encrypted wallet, use loadwallet to load it."]): + self.stop_node(0) + self.start_node(0, extra_args=["-wallet=startup_encdb"]) + # Need to clear stderr file so that test shutdown works + self.nodes[0].stderr.seek(0) + self.nodes[0].stderr.truncate(0) + assert_equal(self.nodes[0].listwallets(), [self.default_wallet_name]) + self.nodes[0].loadwallet(filename="startup_encdb", db_passphrase=self.PASSPHRASE) + + def test_double_encrypted(self): + self.log.info("Test that wallet encryption is not db encryption") + self.nodes[0].createwallet(wallet_name="enc_wallet_not_db", passphrase=self.PASSPHRASE) + wallet = self.nodes[0].get_wallet_rpc("enc_wallet_not_db") + info = wallet.getwalletinfo() + assert_equal(info["format"], "sqlite") + assert_equal(info["unlocked_until"], 0) + + self.nodes[0].createwallet(wallet_name="enc_wallet_not_db2") + wallet = self.nodes[0].get_wallet_rpc("enc_wallet_not_db2") + wallet.encryptwallet(self.PASSPHRASE) + info = wallet.getwalletinfo() + assert_equal(info["format"], "sqlite") + assert_equal(info["unlocked_until"], 0) + + self.log.info("Test that wallets with encrypted db can also be encrypted normally") + self.nodes[0].createwallet(wallet_name="double_enc", db_passphrase=self.PASSPHRASE, passphrase=self.PASSPHRASE2) + wallet = self.nodes[0].get_wallet_rpc("double_enc") + info = wallet.getwalletinfo() + assert_equal(info["format"], "encrypted_sqlite") + assert_equal(info["unlocked_until"], 0) + wallet.walletpassphrase(self.PASSPHRASE2, 10) + assert_greater_than(wallet.getwalletinfo()["unlocked_until"], 0) + wallet.walletlock() + + self.nodes[0].createwallet(wallet_name="double_enc2", db_passphrase=self.PASSPHRASE) + wallet = self.nodes[0].get_wallet_rpc("double_enc2") + wallet.encryptwallet(self.PASSPHRASE2) + info = wallet.getwalletinfo() + assert_equal(info["format"], "encrypted_sqlite") + assert_equal(info["unlocked_until"], 0) + wallet.walletpassphrase(self.PASSPHRASE2, 10) + assert_greater_than(wallet.getwalletinfo()["unlocked_until"], 0) + wallet.walletlock() + + self.log.info("Test that changing wallet passphrase does not affect database passphrase") + wallet.walletpassphrase(self.PASSPHRASE2, 10) + wallet.walletpassphrasechange(self.PASSPHRASE2, self.PASSPHRASE_WITH_NULLS) + wallet.walletlock() + assert_raises_rpc_error(-14, "Error: The wallet passphrase entered was incorrect.", wallet.walletpassphrase, self.PASSPHRASE, 10) + assert_raises_rpc_error(-14, "Error: The wallet passphrase entered was incorrect.", wallet.walletpassphrase, self.PASSPHRASE2, 10) + wallet.walletpassphrase(self.PASSPHRASE_WITH_NULLS, 10) + wallet.unloadwallet() + + assert_raises_rpc_error(-4, "Wallet file verification failed. Unable to decrypt database, are you sure the passphrase is correct?", self.nodes[0].loadwallet, filename="double_enc2", db_passphrase=self.PASSPHRASE_WITH_NULLS) + assert_raises_rpc_error(-4, "Wallet file verification failed. Unable to decrypt database, are you sure the passphrase is correct?", self.nodes[0].loadwallet, filename="double_enc2", db_passphrase=self.PASSPHRASE2) + self.nodes[0].loadwallet(filename="double_enc2", db_passphrase=self.PASSPHRASE) + + def run_test(self): + self.default_wallet = self.nodes[0].get_wallet_rpc(self.default_wallet_name) + self.generate(self.nodes[0], 101) + + self.test_no_legacy() + self.test_create_and_load() + self.test_passphrases_with_nulls() + self.test_on_start() + self.test_double_encrypted() + +if __name__ == '__main__': + WalletDBEncryptionTest().main()