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 <noreply@anthropic.com>
This commit is contained in:
+1
-10
@@ -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;
|
||||
|
||||
@@ -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<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;
|
||||
std::array<unsigned char, kKeyBytes> derived{};
|
||||
if (!deriveKeyFast(walletHash, 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_);
|
||||
@@ -512,6 +533,20 @@ std::vector<unsigned char> TransactionHistoryCache::getOrCreateSalt(const std::s
|
||||
return salt;
|
||||
}
|
||||
|
||||
bool TransactionHistoryCache::deriveKeyFast(const std::string& walletHash,
|
||||
const std::vector<unsigned char>& salt,
|
||||
std::array<unsigned char, 32>& 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<const char*>(kLabel), sizeof(kLabel) - 1);
|
||||
msg += walletHash;
|
||||
return crypto_generichash(keyOut.data(), keyOut.size(),
|
||||
reinterpret_cast<const unsigned char*>(msg.data()), msg.size(),
|
||||
salt.data(), salt.size()) == 0;
|
||||
}
|
||||
|
||||
bool TransactionHistoryCache::deriveKey(const std::string& passphrase,
|
||||
const std::vector<unsigned char>& salt,
|
||||
std::array<unsigned char, 32>& keyOut)
|
||||
|
||||
@@ -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<unsigned char> getOrCreateSalt(const std::string& walletHash);
|
||||
static bool deriveKeyFast(const std::string& walletHash,
|
||||
const std::vector<unsigned char>& salt,
|
||||
std::array<unsigned char, 32>& keyOut); // BLAKE2b, label-separated
|
||||
static bool deriveKey(const std::string& passphrase,
|
||||
const std::vector<unsigned char>& salt,
|
||||
std::array<unsigned char, 32>& keyOut);
|
||||
|
||||
@@ -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<size_t>(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<size_t>(0));
|
||||
}
|
||||
}
|
||||
|
||||
removeTree(dir);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user