From 14166543856bf784a380410dda9823ccfc6654ab Mon Sep 17 00:00:00 2001 From: DanS Date: Tue, 13 Oct 2026 22:03:00 -0500 Subject: [PATCH] perf(tx-cache): skip Argon2id for unencrypted wallets Opening the transaction-history cache always derived its encryption key with Argon2id, even for an unencrypted wallet. For those wallets the passphrase is just a value derivable from the database itself, so the ~50-100 ms Argon2id stall bought no additional secrecy and ran on the UI thread at open. - Unencrypted wallets now derive the cache key with a cheap, domain-separated BLAKE2b KDF over the wallet identity, keyed by the per-wallet salt. The secret is derivable from the DB, so this is security-equivalent for the unencrypted case and avoids the stall. - Passphrase-encrypted wallets are unchanged: they keep the Argon2id key derivation. The two paths use separate domain labels, so the fast-path key and the Argon2id key never collide for the same DB and salt; a cache written under one cannot be read under the other. Tests cover the fast-path round-trip and reopen, and assert that an Argon2id unlock of the same DB produces a different key and fails to read the fast-path snapshot. Co-Authored-By: Claude Opus 4.8 --- src/app_network.cpp | 11 +------- src/data/transaction_history_cache.cpp | 35 ++++++++++++++++++++++++++ src/data/transaction_history_cache.h | 6 +++++ tests/test_phase4.cpp | 34 +++++++++++++++++++++++++ 4 files changed, 76 insertions(+), 10 deletions(-) diff --git a/src/app_network.cpp b/src/app_network.cpp index 48179ba..b41cbb1 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -93,12 +93,6 @@ bool isPageEnabledForBuild(ui::NavPage page) wallet::currentWalletCapabilities(), ui::NavPageSurface(page)); } -std::string unencryptedTransactionHistoryCacheKey(const std::string& walletIdentity) -{ - return std::string("obsidian-dragon-unencrypted-tx-cache-v1:") + - data::TransactionHistoryCache::walletIdentityHash(walletIdentity); -} - class AppRefreshRpcGateway final : public NetworkRefreshService::RefreshRpcGateway { public: // `maintenanceOwner`: the App whose rpc_ this is, for callLong() (see App::MaintenanceCallScope). @@ -1961,10 +1955,7 @@ bool App::ensureTransactionHistoryCacheUnlockedFor(const std::string& walletIden } if (state_.encryption_state_known && !state_.encrypted) { - std::string cacheKey = unencryptedTransactionHistoryCacheKey(walletIdentity); - bool unlocked = transaction_history_cache_.unlockWithPassphrase(walletIdentity, cacheKey); - util::SecureVault::secureZero(cacheKey.data(), cacheKey.size()); - return unlocked; + return transaction_history_cache_.unlockUnencrypted(walletIdentity); } return false; diff --git a/src/data/transaction_history_cache.cpp b/src/data/transaction_history_cache.cpp index 6d62ff5..4c9e589 100644 --- a/src/data/transaction_history_cache.cpp +++ b/src/data/transaction_history_cache.cpp @@ -257,6 +257,27 @@ bool TransactionHistoryCache::unlockWithPassphrase(const std::string& walletIden return true; } +bool TransactionHistoryCache::unlockUnencrypted(const std::string& walletIdentity) +{ + if (walletIdentity.empty()) return false; + 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; + std::array derived{}; + if (!deriveKeyFast(walletHash, 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_); @@ -512,6 +533,20 @@ std::vector TransactionHistoryCache::getOrCreateSalt(const std::s return salt; } +bool TransactionHistoryCache::deriveKeyFast(const std::string& walletHash, + const std::vector& salt, + std::array& keyOut) +{ + if (salt.size() != crypto_pwhash_SALTBYTES) return false; + static const unsigned char kLabel[] = "obsidian-dragon-tx-history-unenc-v1"; + // keyed BLAKE2b over label||walletHash, keyed by the per-wallet salt -> 32-byte key. + std::string msg(reinterpret_cast(kLabel), sizeof(kLabel) - 1); + msg += walletHash; + return crypto_generichash(keyOut.data(), keyOut.size(), + reinterpret_cast(msg.data()), msg.size(), + salt.data(), salt.size()) == 0; +} + bool TransactionHistoryCache::deriveKey(const std::string& passphrase, const std::vector& salt, std::array& keyOut) diff --git a/src/data/transaction_history_cache.h b/src/data/transaction_history_cache.h index 47366d8..29719e0 100644 --- a/src/data/transaction_history_cache.h +++ b/src/data/transaction_history_cache.h @@ -61,6 +61,9 @@ public: bool ensureOpen(); bool unlockWithPassphrase(const std::string& walletIdentity, const std::string& passphrase); + // Unlock an UNENCRYPTED wallet's cache without Argon2id: the "secret" is derivable from the DB, so a + // cheap domain-separated BLAKE2b KDF is security-equivalent and avoids a ~50-100 ms UI-thread stall. + bool unlockUnencrypted(const std::string& walletIdentity); void lockKey(); bool hasKey() const; bool isUnlockedFor(const std::string& walletIdentity) const; @@ -89,6 +92,9 @@ private: bool exec(const char* sql); bool createSchema(); std::vector getOrCreateSalt(const std::string& walletHash); + static bool deriveKeyFast(const std::string& walletHash, + const std::vector& salt, + std::array& keyOut); // BLAKE2b, label-separated static bool deriveKey(const std::string& passphrase, const std::vector& salt, std::array& keyOut); diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index b0fbee1..1d308a0 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -9028,6 +9028,40 @@ void testTransactionHistoryCache() EXPECT_EQ(cache.snapshotCount(), 0); } + // Unencrypted-wallet fast path (BLAKE2b, no Argon2id): round-trip, reopen, key separation. + { + fs::path unencPath = dir / "transaction_history_unenc.sqlite"; + std::uint64_t fastKeyId = 0; + { + TransactionHistoryCache cache(unencPath.string()); + EXPECT_TRUE(cache.unlockUnencrypted(walletIdentity)); + EXPECT_TRUE(cache.isUnlockedFor(walletIdentity)); + fastKeyId = cache.keyId(); + EXPECT_TRUE(fastKeyId != 0); + EXPECT_TRUE(cache.replace(walletIdentity, 120, "tip-120", transactions, 1700000200)); + auto loaded = cache.load(walletIdentity, 120, "tip-120"); + EXPECT_TRUE(loaded.loaded); + EXPECT_EQ(loaded.transactions.size(), static_cast(2)); + } + { + TransactionHistoryCache reopened(unencPath.string()); + EXPECT_TRUE(reopened.unlockUnencrypted(walletIdentity)); + EXPECT_EQ(reopened.keyId(), fastKeyId); + auto loaded = reopened.load(walletIdentity, 120, "tip-120"); + EXPECT_TRUE(loaded.loaded); + EXPECT_EQ(loaded.transactions[0].txid, std::string("tx-sensitive-send")); + } + { + // Same DB and salt, Argon2 passphrase unlock: key must differ and the snapshot must fail closed. + TransactionHistoryCache argon(unencPath.string()); + EXPECT_TRUE(argon.unlockWithPassphrase(walletIdentity, passphrase)); + EXPECT_TRUE(argon.keyId() != fastKeyId); + auto crossLoad = argon.load(walletIdentity, 120, "tip-120"); + EXPECT_FALSE(crossLoad.loaded); + EXPECT_EQ(crossLoad.transactions.size(), static_cast(0)); + } + } + removeTree(dir); }