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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user