refactor(history): make TransactionHistoryCache safe for a writer thread
The tx-history cache was only ever used from the UI thread and had no locking. To let snapshots be written from a background thread: - every SQL statement runs under one database mutex and the key fields under a second one, taken after it and never held across I/O or the key derivation; - a write is a self-contained, move-only SnapshotJob holding the rows and a private copy of the key, which is wiped after the write or when a job never runs; the JSON build and encryption hold no lock; - a job with an all-zero key is refused instead of overwriting a good snapshot with one nobody can read; - the prune after a write also keeps the wallet unlocked now, so a late job for the previous wallet can't delete the salt of the one just switched to; - keyId() gives a non-reversible tag of the installed key, so a passphrase change can be told from a re-unlock with the same one. replace() stays a synchronous wrapper with the same result. A failed INSERT is now logged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <ctime>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
@@ -26,6 +27,26 @@ public:
|
||||
std::unordered_map<std::string, int> shieldedScanHeights;
|
||||
};
|
||||
|
||||
// Everything one snapshot write needs, BY VALUE, so it can run on the history writer thread after the UI
|
||||
// state it came from has moved on (and after lockKey() zeroed the live key). Move-only; the private key
|
||||
// copy is wiped by writeSnapshot() and, for a job that never ran (superseded), by the destructor.
|
||||
struct SnapshotJob {
|
||||
std::string walletIdentity;
|
||||
std::array<unsigned char, 32> key{};
|
||||
int tipHeight = 0;
|
||||
std::string tipHash;
|
||||
std::vector<TransactionInfo> transactions;
|
||||
std::time_t updatedAt = 0;
|
||||
std::unordered_map<std::string, int> shieldedScanHeights;
|
||||
|
||||
SnapshotJob() = default;
|
||||
SnapshotJob(const SnapshotJob&) = delete;
|
||||
SnapshotJob& operator=(const SnapshotJob&) = delete;
|
||||
SnapshotJob(SnapshotJob&&) = default;
|
||||
SnapshotJob& operator=(SnapshotJob&&) = default;
|
||||
~SnapshotJob();
|
||||
};
|
||||
|
||||
TransactionHistoryCache();
|
||||
explicit TransactionHistoryCache(std::string databasePath);
|
||||
~TransactionHistoryCache();
|
||||
@@ -41,8 +62,13 @@ public:
|
||||
bool ensureOpen();
|
||||
bool unlockWithPassphrase(const std::string& walletIdentity, const std::string& passphrase);
|
||||
void lockKey();
|
||||
bool hasKey() const { return key_ready_; }
|
||||
bool hasKey() const;
|
||||
bool isUnlockedFor(const std::string& walletIdentity) const;
|
||||
// Copy of the live key if it is unlocked for `walletIdentity` (keyOut untouched otherwise). Caller wipes it.
|
||||
bool captureKey(const std::string& walletIdentity, std::array<unsigned char, 32>& keyOut) const;
|
||||
// Non-reversible id of the key last installed. Survives lockKey(), so re-unlocking with the same
|
||||
// passphrase gives the same id and a passphrase change gives another. 0 = never unlocked.
|
||||
std::uint64_t keyId() const;
|
||||
|
||||
LoadResult load(const std::string& walletIdentity,
|
||||
int currentTipHeight,
|
||||
@@ -53,6 +79,9 @@ public:
|
||||
const std::vector<TransactionInfo>& transactions,
|
||||
std::time_t updatedAt,
|
||||
const std::unordered_map<std::string, int>& shieldedScanHeights = {});
|
||||
// Thread-safe snapshot write (the history writer thread; replace() wraps it): JSON + AEAD with no lock
|
||||
// held, then INSERT OR REPLACE + prune under the DB lock. Always wipes job.key. False for a keyless job.
|
||||
bool writeSnapshot(SnapshotJob&& job);
|
||||
void clearWallet(const std::string& walletIdentity);
|
||||
int snapshotCount();
|
||||
|
||||
@@ -60,12 +89,14 @@ private:
|
||||
bool exec(const char* sql);
|
||||
bool createSchema();
|
||||
std::vector<unsigned char> getOrCreateSalt(const std::string& walletHash);
|
||||
bool deriveKey(const std::string& passphrase,
|
||||
const std::vector<unsigned char>& salt);
|
||||
bool encryptPayload(const std::string& walletHash,
|
||||
const std::string& plainText,
|
||||
std::vector<unsigned char>& nonce,
|
||||
std::vector<unsigned char>& cipherText) const;
|
||||
static bool deriveKey(const std::string& passphrase,
|
||||
const std::vector<unsigned char>& salt,
|
||||
std::array<unsigned char, 32>& keyOut);
|
||||
static bool encryptPayload(const std::array<unsigned char, 32>& key,
|
||||
const std::string& walletHash,
|
||||
const std::string& plainText,
|
||||
std::vector<unsigned char>& nonce,
|
||||
std::vector<unsigned char>& cipherText);
|
||||
bool decryptPayload(const std::string& walletHash,
|
||||
const std::vector<unsigned char>& nonce,
|
||||
const std::vector<unsigned char>& cipherText,
|
||||
@@ -79,14 +110,21 @@ private:
|
||||
void clearWalletByHash(const std::string& walletHash);
|
||||
// Delete snapshot + salt rows for every wallet hash except the live one, bounding the
|
||||
// DB so generating a new address (which changes the hash) doesn't orphan history forever.
|
||||
void pruneOtherWallets(const std::string& keepWalletHash);
|
||||
// Also keeps `alsoKeepWalletHash` (the wallet unlocked now, if any): a delayed writer job for the previous
|
||||
// wallet must not delete the salt of the wallet just switched to.
|
||||
void pruneOtherWallets(const std::string& keepWalletHash, const std::string& alsoKeepWalletHash);
|
||||
void close();
|
||||
|
||||
// Lock order: db_mutex_ then key_mutex_. key_mutex_ is never held across I/O or the KDF. Only the UI
|
||||
// thread changes the key fields; the writer thread reads unlocked_wallet_hash_ (prune) under both locks.
|
||||
mutable std::recursive_mutex db_mutex_; // db_ and every SQL statement
|
||||
mutable std::mutex key_mutex_; // key_, key_ready_, unlocked_wallet_hash_, key_id_
|
||||
sqlite3* db_ = nullptr;
|
||||
std::string database_path_;
|
||||
std::array<unsigned char, 32> key_{};
|
||||
bool key_ready_ = false;
|
||||
std::string unlocked_wallet_hash_;
|
||||
std::uint64_t key_id_ = 0;
|
||||
};
|
||||
|
||||
} // namespace data
|
||||
|
||||
Reference in New Issue
Block a user