diff --git a/src/data/transaction_history_cache.cpp b/src/data/transaction_history_cache.cpp index 772b10c..6d62ff5 100644 --- a/src/data/transaction_history_cache.cpp +++ b/src/data/transaction_history_cache.cpp @@ -9,6 +9,7 @@ #include #include +#include #include #include @@ -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& 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 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 dbLock(db_mutex_); + if (!ensureOpen()) return false; std::string walletHash = walletIdentityHash(walletIdentity); std::vector 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 derived{}; + if (!deriveKey(passphrase, salt, derived)) return false; + { + std::lock_guard 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 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 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 keyLock(key_mutex_); + return key_ready_ && unlocked_wallet_hash_ == hash; +} + +bool TransactionHistoryCache::captureKey(const std::string& walletIdentity, + std::array& keyOut) const +{ + if (walletIdentity.empty()) return false; + const std::string hash = walletIdentityHash(walletIdentity); + std::lock_guard 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 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 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& 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& 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{}) 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(updatedAt); + payload["tip_height"] = job.tipHeight; + payload["tip_hash"] = job.tipHash; + payload["updated_at"] = static_cast(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 nonce; std::vector 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 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(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(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 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 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 TransactionHistoryCache::getOrCreateSalt(const std::s } bool TransactionHistoryCache::deriveKey(const std::string& passphrase, - const std::vector& salt) + const std::vector& salt, + std::array& 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& key, + const std::string& walletHash, const std::string& plainText, std::vector& nonce, - std::vector& cipherText) const + std::vector& 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(plainText.data()), plainText.size(), reinterpret_cast(associatedData.data()), associatedData.size(), - nullptr, nonce.data(), key_.data()); + nullptr, nonce.data(), key.data()); if (result != 0) return false; cipherText.resize(static_cast(cipherLength)); return true; @@ -461,10 +557,16 @@ bool TransactionHistoryCache::decryptPayload(const std::string& walletHash, const std::vector& 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 key{}; + { + std::lock_guard keyLock(key_mutex_); + if (!key_ready_) return false; + key = key_; + } std::string associatedData = associatedDataForWallet(walletHash); std::vector 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(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(plain.data()), static_cast(plainLength)); @@ -506,6 +609,7 @@ bool TransactionHistoryCache::readSnapshot(const std::string& walletHash, void TransactionHistoryCache::clearWalletByHash(const std::string& walletHash) { + std::lock_guard 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 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 dbLock(db_mutex_); if (!db_) return; sqlite3_close(db_); db_ = nullptr; diff --git a/src/data/transaction_history_cache.h b/src/data/transaction_history_cache.h index 454828f..47366d8 100644 --- a/src/data/transaction_history_cache.h +++ b/src/data/transaction_history_cache.h @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -26,6 +27,26 @@ public: std::unordered_map 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 key{}; + int tipHeight = 0; + std::string tipHash; + std::vector transactions; + std::time_t updatedAt = 0; + std::unordered_map 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& 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& transactions, std::time_t updatedAt, const std::unordered_map& 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 getOrCreateSalt(const std::string& walletHash); - bool deriveKey(const std::string& passphrase, - const std::vector& salt); - bool encryptPayload(const std::string& walletHash, - const std::string& plainText, - std::vector& nonce, - std::vector& cipherText) const; + static bool deriveKey(const std::string& passphrase, + const std::vector& salt, + std::array& keyOut); + static bool encryptPayload(const std::array& key, + const std::string& walletHash, + const std::string& plainText, + std::vector& nonce, + std::vector& cipherText); bool decryptPayload(const std::string& walletHash, const std::vector& nonce, const std::vector& 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 key_{}; bool key_ready_ = false; std::string unlocked_wallet_hash_; + std::uint64_t key_id_ = 0; }; } // namespace data diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 7f6f467..eab962c 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -6102,6 +6102,78 @@ void testTransactionHistoryCachePrunesOldWallets() 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 txs{TransactionInfo{"tx1", "mined", 1.0, 1700000000, 10, "R-a", "", ""}}; + + TransactionHistoryCache cache(dbPath); + EXPECT_EQ(cache.keyId(), static_cast(0)); + EXPECT_TRUE(cache.unlockWithPassphrase(idA, pass)); + const std::uint64_t idOfPass = cache.keyId(); + EXPECT_TRUE(idOfPass != 0); + std::array 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{})); // 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() { using Refresh = dragonx::services::NetworkRefreshService; @@ -12728,6 +12800,7 @@ int main() testExplorerBlockCache(); testTransactionHistoryCache(); testTransactionHistoryCachePrunesOldWallets(); + testTransactionHistoryCacheBackgroundWrite(); testAddressBookScope(); testWalletIndex(); testTransactionHistoryCacheRefreshApply();