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 <cstdio>
#include <cstring>
#include <filesystem>
#include <utility>
@@ -110,8 +111,25 @@ std::string associatedDataForWallet(const std::string& 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
TransactionHistoryCache::SnapshotJob::~SnapshotJob()
{
sodium_memzero(key.data(), key.size());
}
TransactionHistoryCache::TransactionHistoryCache()
: TransactionHistoryCache(defaultDatabasePath())
{
@@ -170,6 +188,7 @@ std::string TransactionHistoryCache::walletIdentityHash(const std::string& walle
bool TransactionHistoryCache::ensureOpen()
{
std::lock_guard<std::recursive_mutex> dbLock(db_mutex_);
if (db_) return true;
try {
@@ -212,29 +231,70 @@ bool TransactionHistoryCache::ensureOpen()
bool TransactionHistoryCache::unlockWithPassphrase(const std::string& walletIdentity,
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::vector<unsigned char> salt = getOrCreateSalt(walletHash);
if (salt.empty()) return false;
if (!deriveKey(passphrase, salt)) return false;
unlocked_wallet_hash_ = std::move(walletHash);
key_ready_ = true;
std::array<unsigned char, kKeyBytes> derived{};
if (!deriveKey(passphrase, salt, derived)) return false;
{
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;
}
void TransactionHistoryCache::lockKey()
{
std::lock_guard<std::mutex> keyLock(key_mutex_);
if (key_ready_) sodium_memzero(key_.data(), key_.size());
key_ready_ = false;
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
{
return key_ready_ && !walletIdentity.empty() &&
unlocked_wallet_hash_ == walletIdentityHash(walletIdentity);
if (walletIdentity.empty()) return false;
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(
@@ -243,6 +303,7 @@ TransactionHistoryCache::LoadResult TransactionHistoryCache::load(
const std::string& currentTipHash)
{
LoadResult result;
std::lock_guard<std::recursive_mutex> dbLock(db_mutex_); // serialised against the writer's INSERT/prune
if (!isUnlockedFor(walletIdentity) || !ensureOpen()) return result;
std::string walletHash = walletIdentityHash(walletIdentity);
@@ -303,21 +364,42 @@ bool TransactionHistoryCache::replace(const std::string& walletIdentity,
std::time_t updatedAt,
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;
payload["schema_version"] = kSchemaVersion;
payload["wallet_hash"] = walletHash;
payload["tip_height"] = tipHeight;
payload["tip_hash"] = tipHash;
payload["updated_at"] = static_cast<std::int64_t>(updatedAt);
payload["tip_height"] = job.tipHeight;
payload["tip_hash"] = job.tipHash;
payload["updated_at"] = static_cast<std::int64_t>(job.updatedAt);
payload["transactions"] = json::array();
for (const auto& transaction : transactions) {
for (const auto& transaction : job.transactions) {
payload["transactions"].push_back(transactionToJson(transaction));
}
payload["shielded_scan_heights"] = json::object();
for (const auto& [address, height] : shieldedScanHeights) {
for (const auto& [address, height] : job.shieldedScanHeights) {
if (!address.empty() && height >= 0) {
payload["shielded_scan_heights"][address] = height;
}
@@ -326,10 +408,14 @@ bool TransactionHistoryCache::replace(const std::string& walletIdentity,
std::string plainText = payload.dump();
std::vector<unsigned char> nonce;
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());
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_,
"INSERT OR REPLACE INTO transaction_history_snapshots "
"(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;
sqlite3_bind_int(statement.handle, 2, kSchemaVersion);
sqlite3_bind_int(statement.handle, 3, std::max(0, tipHeight));
if (!bindText(statement.handle, 4, tipHash)) return false;
sqlite3_bind_int64(statement.handle, 5, static_cast<sqlite3_int64>(updatedAt));
sqlite3_bind_int(statement.handle, 3, std::max(0, job.tipHeight));
if (!bindText(statement.handle, 4, job.tipHash)) return false;
sqlite3_bind_int64(statement.handle, 5, static_cast<sqlite3_int64>(job.updatedAt));
if (!bindBlob(statement.handle, 6, nonce)) return false;
if (!bindBlob(statement.handle, 7, cipherText)) return false;
if (sqlite3_step(statement.handle) != SQLITE_DONE) return false;
pruneOtherWallets(walletHash); // bound DB growth — drop stale-hash snapshots/salts
if (sqlite3_step(statement.handle) != SQLITE_DONE) {
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;
}
@@ -357,6 +451,7 @@ void TransactionHistoryCache::clearWallet(const std::string& walletIdentity)
int TransactionHistoryCache::snapshotCount()
{
std::lock_guard<std::recursive_mutex> dbLock(db_mutex_);
if (!ensureOpen()) return 0;
Statement statement(db_, "SELECT COUNT(*) FROM transaction_history_snapshots");
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,
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;
unsigned char derived[kKeyBytes];
@@ -429,17 +525,17 @@ bool TransactionHistoryCache::deriveKey(const std::string& passphrase,
crypto_pwhash_MEMLIMIT_INTERACTIVE,
crypto_pwhash_ALG_ARGON2ID13);
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));
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,
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);
randombytes_buf(nonce.data(), nonce.size());
@@ -450,7 +546,7 @@ bool TransactionHistoryCache::encryptPayload(const std::string& walletHash,
cipherText.data(), &cipherLength,
reinterpret_cast<const unsigned char*>(plainText.data()), plainText.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;
cipherText.resize(static_cast<std::size_t>(cipherLength));
return true;
@@ -461,10 +557,16 @@ bool TransactionHistoryCache::decryptPayload(const std::string& walletHash,
const std::vector<unsigned char>& cipherText,
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) {
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::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,
cipherText.data(), cipherText.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;
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)
{
std::lock_guard<std::recursive_mutex> dbLock(db_mutex_);
if (!ensureOpen()) return;
Statement statement(db_, "DELETE FROM transaction_history_snapshots WHERE wallet_hash = ?");
if (!statement.handle) return;
@@ -513,22 +617,28 @@ void TransactionHistoryCache::clearWalletByHash(const std::string& walletHash)
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;
const std::string& alsoKeep = alsoKeepWalletHash.empty() ? keepWalletHash : alsoKeepWalletHash;
// 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.
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());
if (!statement.handle) continue;
if (!bindText(statement.handle, 1, keepWalletHash)) continue;
if (!bindText(statement.handle, 2, alsoKeep)) continue;
sqlite3_step(statement.handle);
}
}
void TransactionHistoryCache::close()
{
std::lock_guard<std::recursive_mutex> dbLock(db_mutex_);
if (!db_) return;
sqlite3_close(db_);
db_ = nullptr;
+46 -8
View File
@@ -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