From 274f7ea1af316d29a7308c540b88d86b18d29a27 Mon Sep 17 00:00:00 2001 From: DanS Date: Sun, 7 Jun 2026 14:17:54 -0500 Subject: [PATCH] fix(storage): owner-only secret files + bound SQLite cache growth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Write vault.dat atomically and 0600 (it holds the PIN-encrypted passphrase, so a world-readable copy enables an offline brute-force of the short PIN), and chmod the tx-history SQLite + its WAL/SHM sidecars to 0600 on open. - The tx-history snapshot and key-salt rows are keyed on a hash of the full address set, which changes whenever a new address is generated — orphaning the prior hash's full-history blob and salt forever. pruneOtherWallets() now drops rows for every non-live wallet hash on each save, bounding the database. Co-Authored-By: Claude Opus 4.8 --- src/data/transaction_history_cache.cpp | 30 +++++++++++++++++++++++++- src/data/transaction_history_cache.h | 3 +++ src/util/secure_vault.cpp | 27 ++++++++++------------- 3 files changed, 43 insertions(+), 17 deletions(-) diff --git a/src/data/transaction_history_cache.cpp b/src/data/transaction_history_cache.cpp index 20f7579..772b10c 100644 --- a/src/data/transaction_history_cache.cpp +++ b/src/data/transaction_history_cache.cpp @@ -12,6 +12,10 @@ #include #include +#ifndef _WIN32 +#include +#endif + namespace fs = std::filesystem; using json = nlohmann::json; @@ -194,6 +198,14 @@ bool TransactionHistoryCache::ensureOpen() return false; } +#ifndef _WIN32 + // Owner-only (0600): although the payload is encrypted, don't leave the cache (or its + // WAL/SHM sidecars) world-readable. Best-effort; sidecars may not exist until first write. + ::chmod(database_path_.c_str(), 0600); + ::chmod((database_path_ + "-wal").c_str(), 0600); + ::chmod((database_path_ + "-shm").c_str(), 0600); +#endif + return true; } @@ -332,7 +344,9 @@ bool TransactionHistoryCache::replace(const std::string& walletIdentity, if (!bindBlob(statement.handle, 6, nonce)) return false; if (!bindBlob(statement.handle, 7, cipherText)) return false; - return sqlite3_step(statement.handle) == SQLITE_DONE; + if (sqlite3_step(statement.handle) != SQLITE_DONE) return false; + pruneOtherWallets(walletHash); // bound DB growth — drop stale-hash snapshots/salts + return true; } void TransactionHistoryCache::clearWallet(const std::string& walletIdentity) @@ -499,6 +513,20 @@ void TransactionHistoryCache::clearWalletByHash(const std::string& walletHash) sqlite3_step(statement.handle); } +void TransactionHistoryCache::pruneOtherWallets(const std::string& keepWalletHash) +{ + if (!ensureOpen() || keepWalletHash.empty()) return; + // 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 <> ?"; + Statement statement(db_, sql.c_str()); + if (!statement.handle) continue; + if (!bindText(statement.handle, 1, keepWalletHash)) continue; + sqlite3_step(statement.handle); + } +} + void TransactionHistoryCache::close() { if (!db_) return; diff --git a/src/data/transaction_history_cache.h b/src/data/transaction_history_cache.h index bc486e1..454828f 100644 --- a/src/data/transaction_history_cache.h +++ b/src/data/transaction_history_cache.h @@ -77,6 +77,9 @@ private: std::vector& nonce, std::vector& cipherText); 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); void close(); sqlite3* db_ = nullptr; diff --git a/src/util/secure_vault.cpp b/src/util/secure_vault.cpp index 0699e02..b56211f 100644 --- a/src/util/secure_vault.cpp +++ b/src/util/secure_vault.cpp @@ -114,25 +114,20 @@ bool SecureVault::store(const std::string& pin, const std::string& passphrase) { return false; } - // Write vault file: version + salt + nonce + ciphertext + // Write vault file: version + salt + nonce + ciphertext. + // Atomic + owner-only (0600): the vault holds the PIN-encrypted passphrase, so it must + // not be world-readable (a copy enables an offline brute-force of the short PIN), and a + // crash mid-write must not leave a truncated vault. std::string vaultPath = getVaultPath(); - fs::create_directories(fs::path(vaultPath).parent_path()); + std::string content; + content.reserve(1 + sizeof(salt) + sizeof(nonce) + cipherLen); + content.push_back(static_cast(VAULT_VERSION)); + content.append(reinterpret_cast(salt), sizeof(salt)); + content.append(reinterpret_cast(nonce), sizeof(nonce)); + content.append(reinterpret_cast(ciphertext.data()), cipherLen); - std::ofstream out(vaultPath, std::ios::binary); - if (!out.is_open()) { - DEBUG_LOGF("[SecureVault] Cannot create vault file: %s\n", vaultPath.c_str()); - return false; - } - - out.write(reinterpret_cast(&VAULT_VERSION), 1); - out.write(reinterpret_cast(salt), sizeof(salt)); - out.write(reinterpret_cast(nonce), sizeof(nonce)); - out.write(reinterpret_cast(ciphertext.data()), cipherLen); - out.close(); - - if (!out.good()) { + if (!Platform::writeFileAtomically(vaultPath, content, /*restrictPermissions=*/true)) { DEBUG_LOGF("[SecureVault] Write failed\n"); - fs::remove(vaultPath); return false; }