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:
2026-10-13 22:03:00 -05:00
co-authored by Claude Opus 4.8
parent 3554ba1e2d
commit 1416654385
4 changed files with 76 additions and 10 deletions
+34
View File
@@ -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);
}