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:
2026-10-09 21:09:48 -05:00
co-authored by Claude Opus 5.5
parent 67260cc6f0
commit c0568eb930
3 changed files with 258 additions and 37 deletions
+139 -29
View File
@@ -9,6 +9,7 @@
#include <algorithm> #include <algorithm>
#include <cstdio> #include <cstdio>
#include <cstring>
#include <filesystem> #include <filesystem>
#include <utility> #include <utility>
@@ -110,8 +111,25 @@ std::string associatedDataForWallet(const std::string& walletHash)
return std::string("obsidian-dragon-tx-history-v1:") + walletHash; return std::string("obsidian-dragon-tx-history-v1:") + walletHash;
} }
// Non-reversible 64-bit tag of a derived key (BLAKE2b keyed by the key, over a fixed label). Only used to
// notice that the key changed; nothing about the key can be recovered from it, and it never leaves memory.
std::uint64_t computeKeyId(const std::array<unsigned char, 32>& key)
{
static const unsigned char kLabel[] = "obsidian-dragon-tx-history-keyid";
unsigned char out[crypto_generichash_BYTES_MIN]; // 16, libsodium's minimum; the first 8 are used
crypto_generichash(out, sizeof(out), kLabel, sizeof(kLabel) - 1, key.data(), key.size());
std::uint64_t id = 0;
std::memcpy(&id, out, sizeof(id));
return id;
}
} // namespace } // namespace
TransactionHistoryCache::SnapshotJob::~SnapshotJob()
{
sodium_memzero(key.data(), key.size());
}
TransactionHistoryCache::TransactionHistoryCache() TransactionHistoryCache::TransactionHistoryCache()
: TransactionHistoryCache(defaultDatabasePath()) : TransactionHistoryCache(defaultDatabasePath())
{ {
@@ -170,6 +188,7 @@ std::string TransactionHistoryCache::walletIdentityHash(const std::string& walle
bool TransactionHistoryCache::ensureOpen() bool TransactionHistoryCache::ensureOpen()
{ {
std::lock_guard<std::recursive_mutex> dbLock(db_mutex_);
if (db_) return true; if (db_) return true;
try { try {
@@ -212,29 +231,70 @@ bool TransactionHistoryCache::ensureOpen()
bool TransactionHistoryCache::unlockWithPassphrase(const std::string& walletIdentity, bool TransactionHistoryCache::unlockWithPassphrase(const std::string& walletIdentity,
const std::string& passphrase) const std::string& passphrase)
{ {
if (walletIdentity.empty() || passphrase.empty() || !ensureOpen()) return false; if (walletIdentity.empty() || passphrase.empty()) return false;
// Held across the KDF on purpose: "this wallet's salt row exists" and "this wallet is the unlocked one"
// must be atomic with respect to a writer job's prune (writeSnapshot keeps the unlocked wallet), or a job
// for the previous wallet could delete the salt this key is being derived from. The UI thread only waits
// here for a write already in flight, never for the queue.
std::lock_guard<std::recursive_mutex> dbLock(db_mutex_);
if (!ensureOpen()) return false;
std::string walletHash = walletIdentityHash(walletIdentity); std::string walletHash = walletIdentityHash(walletIdentity);
std::vector<unsigned char> salt = getOrCreateSalt(walletHash); std::vector<unsigned char> salt = getOrCreateSalt(walletHash);
if (salt.empty()) return false; if (salt.empty()) return false;
if (!deriveKey(passphrase, salt)) return false; std::array<unsigned char, kKeyBytes> derived{};
unlocked_wallet_hash_ = std::move(walletHash); if (!deriveKey(passphrase, salt, derived)) return false;
key_ready_ = true; {
std::lock_guard<std::mutex> keyLock(key_mutex_);
key_ = derived;
key_id_ = computeKeyId(derived);
unlocked_wallet_hash_ = std::move(walletHash);
key_ready_ = true;
}
sodium_memzero(derived.data(), derived.size());
return true; return true;
} }
void TransactionHistoryCache::lockKey() void TransactionHistoryCache::lockKey()
{ {
std::lock_guard<std::mutex> keyLock(key_mutex_);
if (key_ready_) sodium_memzero(key_.data(), key_.size()); if (key_ready_) sodium_memzero(key_.data(), key_.size());
key_ready_ = false; key_ready_ = false;
unlocked_wallet_hash_.clear(); unlocked_wallet_hash_.clear();
// key_id_ is kept on purpose: re-unlocking with the same passphrase yields the same id.
}
bool TransactionHistoryCache::hasKey() const
{
std::lock_guard<std::mutex> keyLock(key_mutex_);
return key_ready_;
} }
bool TransactionHistoryCache::isUnlockedFor(const std::string& walletIdentity) const bool TransactionHistoryCache::isUnlockedFor(const std::string& walletIdentity) const
{ {
return key_ready_ && !walletIdentity.empty() && if (walletIdentity.empty()) return false;
unlocked_wallet_hash_ == walletIdentityHash(walletIdentity); const std::string hash = walletIdentityHash(walletIdentity); // outside the lock
std::lock_guard<std::mutex> keyLock(key_mutex_);
return key_ready_ && unlocked_wallet_hash_ == hash;
}
bool TransactionHistoryCache::captureKey(const std::string& walletIdentity,
std::array<unsigned char, 32>& keyOut) const
{
if (walletIdentity.empty()) return false;
const std::string hash = walletIdentityHash(walletIdentity);
std::lock_guard<std::mutex> keyLock(key_mutex_);
if (!key_ready_ || unlocked_wallet_hash_ != hash) return false;
keyOut = key_;
return true;
}
std::uint64_t TransactionHistoryCache::keyId() const
{
std::lock_guard<std::mutex> keyLock(key_mutex_);
return key_id_;
} }
TransactionHistoryCache::LoadResult TransactionHistoryCache::load( TransactionHistoryCache::LoadResult TransactionHistoryCache::load(
@@ -243,6 +303,7 @@ TransactionHistoryCache::LoadResult TransactionHistoryCache::load(
const std::string& currentTipHash) const std::string& currentTipHash)
{ {
LoadResult result; LoadResult result;
std::lock_guard<std::recursive_mutex> dbLock(db_mutex_); // serialised against the writer's INSERT/prune
if (!isUnlockedFor(walletIdentity) || !ensureOpen()) return result; if (!isUnlockedFor(walletIdentity) || !ensureOpen()) return result;
std::string walletHash = walletIdentityHash(walletIdentity); std::string walletHash = walletIdentityHash(walletIdentity);
@@ -303,21 +364,42 @@ bool TransactionHistoryCache::replace(const std::string& walletIdentity,
std::time_t updatedAt, std::time_t updatedAt,
const std::unordered_map<std::string, int>& shieldedScanHeights) const std::unordered_map<std::string, int>& shieldedScanHeights)
{ {
if (!isUnlockedFor(walletIdentity) || !ensureOpen()) return false; // The synchronous form (tests, tools): the same write the background writer does.
SnapshotJob job;
if (!captureKey(walletIdentity, job.key)) return false;
job.walletIdentity = walletIdentity;
job.tipHeight = tipHeight;
job.tipHash = tipHash;
job.transactions = transactions;
job.updatedAt = updatedAt;
job.shieldedScanHeights = shieldedScanHeights;
return writeSnapshot(std::move(job));
}
std::string walletHash = walletIdentityHash(walletIdentity); bool TransactionHistoryCache::writeSnapshot(SnapshotJob&& job)
{
struct WipeKey {
std::array<unsigned char, 32>& key;
~WipeKey() { sodium_memzero(key.data(), key.size()); }
} wipe{job.key};
// A job without a key would replace the good snapshot with one nobody can read.
if (job.walletIdentity.empty() || job.key == std::array<unsigned char, 32>{}) return false;
const std::string walletHash = walletIdentityHash(job.walletIdentity);
// ---- the expensive part (JSON DOM, dump, AEAD), with no lock held ----
json payload; json payload;
payload["schema_version"] = kSchemaVersion; payload["schema_version"] = kSchemaVersion;
payload["wallet_hash"] = walletHash; payload["wallet_hash"] = walletHash;
payload["tip_height"] = tipHeight; payload["tip_height"] = job.tipHeight;
payload["tip_hash"] = tipHash; payload["tip_hash"] = job.tipHash;
payload["updated_at"] = static_cast<std::int64_t>(updatedAt); payload["updated_at"] = static_cast<std::int64_t>(job.updatedAt);
payload["transactions"] = json::array(); payload["transactions"] = json::array();
for (const auto& transaction : transactions) { for (const auto& transaction : job.transactions) {
payload["transactions"].push_back(transactionToJson(transaction)); payload["transactions"].push_back(transactionToJson(transaction));
} }
payload["shielded_scan_heights"] = json::object(); payload["shielded_scan_heights"] = json::object();
for (const auto& [address, height] : shieldedScanHeights) { for (const auto& [address, height] : job.shieldedScanHeights) {
if (!address.empty() && height >= 0) { if (!address.empty() && height >= 0) {
payload["shielded_scan_heights"][address] = height; payload["shielded_scan_heights"][address] = height;
} }
@@ -326,10 +408,14 @@ bool TransactionHistoryCache::replace(const std::string& walletIdentity,
std::string plainText = payload.dump(); std::string plainText = payload.dump();
std::vector<unsigned char> nonce; std::vector<unsigned char> nonce;
std::vector<unsigned char> cipherText; std::vector<unsigned char> cipherText;
bool encrypted = encryptPayload(walletHash, plainText, nonce, cipherText); const bool encrypted = encryptPayload(job.key, walletHash, plainText, nonce, cipherText);
sodium_memzero(plainText.data(), plainText.size()); sodium_memzero(plainText.data(), plainText.size());
if (!encrypted) return false; if (!encrypted) return false;
// ---- one autocommit INSERT OR REPLACE (WAL: a crash leaves the previous snapshot), then the prune ----
std::lock_guard<std::recursive_mutex> dbLock(db_mutex_);
if (!ensureOpen()) return false;
Statement statement(db_, Statement statement(db_,
"INSERT OR REPLACE INTO transaction_history_snapshots " "INSERT OR REPLACE INTO transaction_history_snapshots "
"(wallet_hash, schema_version, tip_height, tip_hash, updated_at, nonce, ciphertext) " "(wallet_hash, schema_version, tip_height, tip_hash, updated_at, nonce, ciphertext) "
@@ -338,14 +424,22 @@ bool TransactionHistoryCache::replace(const std::string& walletIdentity,
if (!bindText(statement.handle, 1, walletHash)) return false; if (!bindText(statement.handle, 1, walletHash)) return false;
sqlite3_bind_int(statement.handle, 2, kSchemaVersion); sqlite3_bind_int(statement.handle, 2, kSchemaVersion);
sqlite3_bind_int(statement.handle, 3, std::max(0, tipHeight)); sqlite3_bind_int(statement.handle, 3, std::max(0, job.tipHeight));
if (!bindText(statement.handle, 4, tipHash)) return false; if (!bindText(statement.handle, 4, job.tipHash)) return false;
sqlite3_bind_int64(statement.handle, 5, static_cast<sqlite3_int64>(updatedAt)); sqlite3_bind_int64(statement.handle, 5, static_cast<sqlite3_int64>(job.updatedAt));
if (!bindBlob(statement.handle, 6, nonce)) return false; if (!bindBlob(statement.handle, 6, nonce)) return false;
if (!bindBlob(statement.handle, 7, cipherText)) return false; if (!bindBlob(statement.handle, 7, cipherText)) return false;
if (sqlite3_step(statement.handle) != SQLITE_DONE) return false; if (sqlite3_step(statement.handle) != SQLITE_DONE) {
pruneOtherWallets(walletHash); // bound DB growth — drop stale-hash snapshots/salts DEBUG_LOGF("Transaction history cache write failed: %s\n", sqlite3_errmsg(db_));
return false;
}
std::string liveHash;
{
std::lock_guard<std::mutex> keyLock(key_mutex_);
liveHash = unlocked_wallet_hash_;
}
pruneOtherWallets(walletHash, liveHash); // bound DB growth — drop stale-hash snapshots/salts
return true; return true;
} }
@@ -357,6 +451,7 @@ void TransactionHistoryCache::clearWallet(const std::string& walletIdentity)
int TransactionHistoryCache::snapshotCount() int TransactionHistoryCache::snapshotCount()
{ {
std::lock_guard<std::recursive_mutex> dbLock(db_mutex_);
if (!ensureOpen()) return 0; if (!ensureOpen()) return 0;
Statement statement(db_, "SELECT COUNT(*) FROM transaction_history_snapshots"); Statement statement(db_, "SELECT COUNT(*) FROM transaction_history_snapshots");
if (!statement.handle || sqlite3_step(statement.handle) != SQLITE_ROW) return 0; if (!statement.handle || sqlite3_step(statement.handle) != SQLITE_ROW) return 0;
@@ -418,7 +513,8 @@ std::vector<unsigned char> TransactionHistoryCache::getOrCreateSalt(const std::s
} }
bool TransactionHistoryCache::deriveKey(const std::string& passphrase, bool TransactionHistoryCache::deriveKey(const std::string& passphrase,
const std::vector<unsigned char>& salt) const std::vector<unsigned char>& salt,
std::array<unsigned char, 32>& keyOut)
{ {
if (salt.size() != crypto_pwhash_SALTBYTES) return false; if (salt.size() != crypto_pwhash_SALTBYTES) return false;
unsigned char derived[kKeyBytes]; unsigned char derived[kKeyBytes];
@@ -429,17 +525,17 @@ bool TransactionHistoryCache::deriveKey(const std::string& passphrase,
crypto_pwhash_MEMLIMIT_INTERACTIVE, crypto_pwhash_MEMLIMIT_INTERACTIVE,
crypto_pwhash_ALG_ARGON2ID13); crypto_pwhash_ALG_ARGON2ID13);
if (result != 0) return false; if (result != 0) return false;
std::copy(derived, derived + sizeof(derived), key_.begin()); std::copy(derived, derived + sizeof(derived), keyOut.begin());
sodium_memzero(derived, sizeof(derived)); sodium_memzero(derived, sizeof(derived));
return true; return true;
} }
bool TransactionHistoryCache::encryptPayload(const std::string& walletHash, bool TransactionHistoryCache::encryptPayload(const std::array<unsigned char, 32>& key,
const std::string& walletHash,
const std::string& plainText, const std::string& plainText,
std::vector<unsigned char>& nonce, std::vector<unsigned char>& nonce,
std::vector<unsigned char>& cipherText) const std::vector<unsigned char>& cipherText)
{ {
if (!key_ready_) return false;
nonce.resize(crypto_aead_xchacha20poly1305_ietf_NPUBBYTES); nonce.resize(crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
randombytes_buf(nonce.data(), nonce.size()); randombytes_buf(nonce.data(), nonce.size());
@@ -450,7 +546,7 @@ bool TransactionHistoryCache::encryptPayload(const std::string& walletHash,
cipherText.data(), &cipherLength, cipherText.data(), &cipherLength,
reinterpret_cast<const unsigned char*>(plainText.data()), plainText.size(), reinterpret_cast<const unsigned char*>(plainText.data()), plainText.size(),
reinterpret_cast<const unsigned char*>(associatedData.data()), associatedData.size(), reinterpret_cast<const unsigned char*>(associatedData.data()), associatedData.size(),
nullptr, nonce.data(), key_.data()); nullptr, nonce.data(), key.data());
if (result != 0) return false; if (result != 0) return false;
cipherText.resize(static_cast<std::size_t>(cipherLength)); cipherText.resize(static_cast<std::size_t>(cipherLength));
return true; return true;
@@ -461,10 +557,16 @@ bool TransactionHistoryCache::decryptPayload(const std::string& walletHash,
const std::vector<unsigned char>& cipherText, const std::vector<unsigned char>& cipherText,
std::string& plainText) const std::string& plainText) const
{ {
if (!key_ready_ || nonce.size() != crypto_aead_xchacha20poly1305_ietf_NPUBBYTES || if (nonce.size() != crypto_aead_xchacha20poly1305_ietf_NPUBBYTES ||
cipherText.size() < crypto_aead_xchacha20poly1305_ietf_ABYTES) { cipherText.size() < crypto_aead_xchacha20poly1305_ietf_ABYTES) {
return false; return false;
} }
std::array<unsigned char, kKeyBytes> key{};
{
std::lock_guard<std::mutex> keyLock(key_mutex_);
if (!key_ready_) return false;
key = key_;
}
std::string associatedData = associatedDataForWallet(walletHash); std::string associatedData = associatedDataForWallet(walletHash);
std::vector<unsigned char> plain(cipherText.size() - crypto_aead_xchacha20poly1305_ietf_ABYTES); std::vector<unsigned char> plain(cipherText.size() - crypto_aead_xchacha20poly1305_ietf_ABYTES);
@@ -473,7 +575,8 @@ bool TransactionHistoryCache::decryptPayload(const std::string& walletHash,
plain.data(), &plainLength, nullptr, plain.data(), &plainLength, nullptr,
cipherText.data(), cipherText.size(), cipherText.data(), cipherText.size(),
reinterpret_cast<const unsigned char*>(associatedData.data()), associatedData.size(), reinterpret_cast<const unsigned char*>(associatedData.data()), associatedData.size(),
nonce.data(), key_.data()); nonce.data(), key.data());
sodium_memzero(key.data(), key.size());
if (result != 0) return false; if (result != 0) return false;
plainText.assign(reinterpret_cast<const char*>(plain.data()), static_cast<std::size_t>(plainLength)); plainText.assign(reinterpret_cast<const char*>(plain.data()), static_cast<std::size_t>(plainLength));
@@ -506,6 +609,7 @@ bool TransactionHistoryCache::readSnapshot(const std::string& walletHash,
void TransactionHistoryCache::clearWalletByHash(const std::string& walletHash) void TransactionHistoryCache::clearWalletByHash(const std::string& walletHash)
{ {
std::lock_guard<std::recursive_mutex> dbLock(db_mutex_);
if (!ensureOpen()) return; if (!ensureOpen()) return;
Statement statement(db_, "DELETE FROM transaction_history_snapshots WHERE wallet_hash = ?"); Statement statement(db_, "DELETE FROM transaction_history_snapshots WHERE wallet_hash = ?");
if (!statement.handle) return; if (!statement.handle) return;
@@ -513,22 +617,28 @@ void TransactionHistoryCache::clearWalletByHash(const std::string& walletHash)
sqlite3_step(statement.handle); sqlite3_step(statement.handle);
} }
void TransactionHistoryCache::pruneOtherWallets(const std::string& keepWalletHash) void TransactionHistoryCache::pruneOtherWallets(const std::string& keepWalletHash,
const std::string& alsoKeepWalletHash)
{ {
std::lock_guard<std::recursive_mutex> dbLock(db_mutex_);
if (!ensureOpen() || keepWalletHash.empty()) return; if (!ensureOpen() || keepWalletHash.empty()) return;
const std::string& alsoKeep = alsoKeepWalletHash.empty() ? keepWalletHash : alsoKeepWalletHash;
// Table names are hardcoded literals (no injection surface). Prune both the snapshot // Table names are hardcoded literals (no injection surface). Prune both the snapshot
// blobs and the now-orphaned salt rows so a stale salt can't outlive its ciphertext. // blobs and the now-orphaned salt rows so a stale salt can't outlive its ciphertext.
for (const char* table : {"transaction_history_snapshots", "transaction_history_keys"}) { for (const char* table : {"transaction_history_snapshots", "transaction_history_keys"}) {
const std::string sql = std::string("DELETE FROM ") + table + " WHERE wallet_hash <> ?"; const std::string sql = std::string("DELETE FROM ") + table +
" WHERE wallet_hash <> ? AND wallet_hash <> ?";
Statement statement(db_, sql.c_str()); Statement statement(db_, sql.c_str());
if (!statement.handle) continue; if (!statement.handle) continue;
if (!bindText(statement.handle, 1, keepWalletHash)) continue; if (!bindText(statement.handle, 1, keepWalletHash)) continue;
if (!bindText(statement.handle, 2, alsoKeep)) continue;
sqlite3_step(statement.handle); sqlite3_step(statement.handle);
} }
} }
void TransactionHistoryCache::close() void TransactionHistoryCache::close()
{ {
std::lock_guard<std::recursive_mutex> dbLock(db_mutex_);
if (!db_) return; if (!db_) return;
sqlite3_close(db_); sqlite3_close(db_);
db_ = nullptr; db_ = nullptr;
+46 -8
View File
@@ -5,6 +5,7 @@
#include <array> #include <array>
#include <cstdint> #include <cstdint>
#include <ctime> #include <ctime>
#include <mutex>
#include <string> #include <string>
#include <unordered_map> #include <unordered_map>
#include <vector> #include <vector>
@@ -26,6 +27,26 @@ public:
std::unordered_map<std::string, int> shieldedScanHeights; 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(); TransactionHistoryCache();
explicit TransactionHistoryCache(std::string databasePath); explicit TransactionHistoryCache(std::string databasePath);
~TransactionHistoryCache(); ~TransactionHistoryCache();
@@ -41,8 +62,13 @@ public:
bool ensureOpen(); bool ensureOpen();
bool unlockWithPassphrase(const std::string& walletIdentity, const std::string& passphrase); bool unlockWithPassphrase(const std::string& walletIdentity, const std::string& passphrase);
void lockKey(); void lockKey();
bool hasKey() const { return key_ready_; } bool hasKey() const;
bool isUnlockedFor(const std::string& walletIdentity) 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, LoadResult load(const std::string& walletIdentity,
int currentTipHeight, int currentTipHeight,
@@ -53,6 +79,9 @@ public:
const std::vector<TransactionInfo>& transactions, const std::vector<TransactionInfo>& transactions,
std::time_t updatedAt, std::time_t updatedAt,
const std::unordered_map<std::string, int>& shieldedScanHeights = {}); 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); void clearWallet(const std::string& walletIdentity);
int snapshotCount(); int snapshotCount();
@@ -60,12 +89,14 @@ private:
bool exec(const char* sql); bool exec(const char* sql);
bool createSchema(); bool createSchema();
std::vector<unsigned char> getOrCreateSalt(const std::string& walletHash); std::vector<unsigned char> getOrCreateSalt(const std::string& walletHash);
bool deriveKey(const std::string& passphrase, static bool deriveKey(const std::string& passphrase,
const std::vector<unsigned char>& salt); const std::vector<unsigned char>& salt,
bool encryptPayload(const std::string& walletHash, std::array<unsigned char, 32>& keyOut);
const std::string& plainText, static bool encryptPayload(const std::array<unsigned char, 32>& key,
std::vector<unsigned char>& nonce, const std::string& walletHash,
std::vector<unsigned char>& cipherText) const; const std::string& plainText,
std::vector<unsigned char>& nonce,
std::vector<unsigned char>& cipherText);
bool decryptPayload(const std::string& walletHash, bool decryptPayload(const std::string& walletHash,
const std::vector<unsigned char>& nonce, const std::vector<unsigned char>& nonce,
const std::vector<unsigned char>& cipherText, const std::vector<unsigned char>& cipherText,
@@ -79,14 +110,21 @@ private:
void clearWalletByHash(const std::string& walletHash); void clearWalletByHash(const std::string& walletHash);
// Delete snapshot + salt rows for every wallet hash except the live one, bounding the // 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. // 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(); 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; sqlite3* db_ = nullptr;
std::string database_path_; std::string database_path_;
std::array<unsigned char, 32> key_{}; std::array<unsigned char, 32> key_{};
bool key_ready_ = false; bool key_ready_ = false;
std::string unlocked_wallet_hash_; std::string unlocked_wallet_hash_;
std::uint64_t key_id_ = 0;
}; };
} // namespace data } // namespace data
+73
View File
@@ -6102,6 +6102,78 @@ void testTransactionHistoryCachePrunesOldWallets()
removeTree(dir); removeTree(dir);
} }
// The background writer's path: a job carries its own key copy (a lockKey() after queueing cannot drop it),
// the copy is wiped, a keyless job is refused, keyId() tracks the key across lock/unlock, and a late job for the
// previous wallet keeps the salt of the wallet unlocked since.
void testTransactionHistoryCacheBackgroundWrite()
{
using dragonx::TransactionInfo;
using dragonx::data::TransactionHistoryCache;
fs::path dir = makeTempDir();
const std::string dbPath = (dir / "transaction_history.sqlite").string();
const std::string idA = "mainnet|R-a|zs-a";
const std::string idB = "mainnet|R-a|zs-a|zs-b";
const std::string pass = "correct horse battery staple";
const std::vector<TransactionInfo> txs{TransactionInfo{"tx1", "mined", 1.0, 1700000000, 10, "R-a", "", ""}};
TransactionHistoryCache cache(dbPath);
EXPECT_EQ(cache.keyId(), static_cast<std::uint64_t>(0));
EXPECT_TRUE(cache.unlockWithPassphrase(idA, pass));
const std::uint64_t idOfPass = cache.keyId();
EXPECT_TRUE(idOfPass != 0);
std::array<unsigned char, 32> scratch{};
EXPECT_FALSE(cache.captureKey(idB, scratch)); // not unlocked for B
{ // a keyless job is refused: it would replace the snapshot with one nobody can read
TransactionHistoryCache::SnapshotJob keyless;
keyless.walletIdentity = idA;
keyless.transactions = txs;
EXPECT_FALSE(cache.writeSnapshot(std::move(keyless)));
EXPECT_EQ(cache.snapshotCount(), 0);
}
// A job captured before lockKey() still lands after it (flush-before-lock).
TransactionHistoryCache::SnapshotJob job;
job.walletIdentity = idA;
EXPECT_TRUE(cache.captureKey(idA, job.key));
job.tipHeight = 100;
job.tipHash = "tip";
job.transactions = txs;
job.updatedAt = 1700000000;
cache.lockKey();
EXPECT_FALSE(cache.isUnlockedFor(idA));
EXPECT_TRUE(cache.writeSnapshot(std::move(job)));
EXPECT_TRUE(job.key == (std::array<unsigned char, 32>{})); // wiped
EXPECT_EQ(cache.keyId(), idOfPass); // survives lockKey()
EXPECT_TRUE(cache.unlockWithPassphrase(idA, pass));
EXPECT_EQ(cache.keyId(), idOfPass); // same passphrase -> same id
EXPECT_TRUE(cache.load(idA, 100, "tip").loaded);
EXPECT_TRUE(cache.unlockWithPassphrase(idA, "another passphrase"));
EXPECT_TRUE(cache.keyId() != idOfPass); // a passphrase change is visible
// A late job for wallet A must not prune the salt of B, unlocked since.
EXPECT_TRUE(cache.unlockWithPassphrase(idA, pass));
TransactionHistoryCache::SnapshotJob lateA;
lateA.walletIdentity = idA;
EXPECT_TRUE(cache.captureKey(idA, lateA.key));
lateA.tipHeight = 100;
lateA.tipHash = "tip";
lateA.transactions = txs;
lateA.updatedAt = 1700000001;
EXPECT_TRUE(cache.unlockWithPassphrase(idB, pass));
EXPECT_TRUE(cache.writeSnapshot(std::move(lateA)));
EXPECT_EQ(cache.snapshotCount(), 1); // A's row; B's salt row must survive
EXPECT_TRUE(cache.replace(idB, 100, "tip-b", txs, 1700000002));
EXPECT_EQ(cache.snapshotCount(), 1); // B's own write prunes A
{
TransactionHistoryCache reopened(dbPath);
EXPECT_TRUE(reopened.unlockWithPassphrase(idB, pass));
EXPECT_TRUE(reopened.load(idB, 100, "tip-b").loaded); // the salt B's key came from survived
}
removeTree(dir);
}
void testTransactionHistoryCacheRefreshApply() void testTransactionHistoryCacheRefreshApply()
{ {
using Refresh = dragonx::services::NetworkRefreshService; using Refresh = dragonx::services::NetworkRefreshService;
@@ -12728,6 +12800,7 @@ int main()
testExplorerBlockCache(); testExplorerBlockCache();
testTransactionHistoryCache(); testTransactionHistoryCache();
testTransactionHistoryCachePrunesOldWallets(); testTransactionHistoryCachePrunesOldWallets();
testTransactionHistoryCacheBackgroundWrite();
testAddressBookScope(); testAddressBookScope();
testWalletIndex(); testWalletIndex();
testTransactionHistoryCacheRefreshApply(); testTransactionHistoryCacheRefreshApply();