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
+35
View File
@@ -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)
+6
View File
@@ -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);