From 46eec3701354d0480aab407b8f427bbf09f691c5 Mon Sep 17 00:00:00 2001 From: DanS Date: Mon, 6 Jul 2026 14:49:33 -0500 Subject: [PATCH] feat(chat): persistent, seed-encrypted message store (Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swap the in-memory-only chat store for durable sqlite persistence with real per-transaction timestamps, encrypted at rest under a key derived from the wallet's own seed (no passphrase, works on encrypted and unencrypted wallets). - ChatDatabase (src/chat/chat_database.{h,cpp}): sqlite store mirroring data::TransactionHistoryCache. unlockWithSecret(seed) derives a 32-byte AEAD storage key and a wallet-partition tag via domain-separated keyed BLAKE2b (generichash) contexts. Every record — bodies, peer z-addrs, threading, timestamps — is crypto_aead_xchacha20poly1305_ietf-encrypted with a random nonce and the wallet tag as associated data; even the dedup key is a keyed hash of txid+position, so nothing about your conversations is in cleartext on disk. Rows are partitioned per-wallet; a different seed sees nothing. Messages are decrypted once at ingest then re-encrypted under the storage key, so load() needs only the storage key, not the chat identity. - ChatService: ingest() now stamps each message with its own transaction time (txid->time map + fallback) and writes new (store-deduped) messages through to the database; loadFromDatabase() rehydrates the in-memory read model on unlock. - App: unlock the chat DB with the same seed in provisionChatIdentityFromSecret and load prior history; lock the DB + clear the decrypted in-memory store on relock and on lite-controller rebuild. Adversarial review (4 confirmed findings, all fixed): don't provision if the wallet locks mid-fetch (re-check isLocked at completion); wipe the serialized plaintext temporary in append(); trim the seed into a separate fully-wiped buffer (no residue past a shrunk size()); scrub the mnemonic copy in the RPC json result. Tests: ChatDatabase round-trip (persist/reload, field + order fidelity), dedup, per-wallet isolation, lock inertness, and ChatService write-through + reload without an identity. Gated by DRAGONX_ENABLE_CHAT (default OFF). Verified: Linux + Windows(mingw) build with chat ON, ctest 100%, hygiene clean; caches restored to the OFF default. Co-Authored-By: Claude Opus 4.8 (1M context) --- CMakeLists.txt | 3 + src/app.cpp | 5 +- src/app.h | 2 + src/app_network.cpp | 53 ++++-- src/chat/chat_database.cpp | 328 +++++++++++++++++++++++++++++++++++++ src/chat/chat_database.h | 74 +++++++++ src/chat/chat_message.h | 4 +- src/chat/chat_service.cpp | 23 ++- src/chat/chat_service.h | 24 ++- src/chat/chat_store.h | 3 +- tests/test_phase4.cpp | 129 ++++++++++++++- 11 files changed, 619 insertions(+), 29 deletions(-) create mode 100644 src/chat/chat_database.cpp create mode 100644 src/chat/chat_database.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 83f5720..e1449d3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -415,6 +415,7 @@ set(APP_SOURCES src/chat/chat_identity.cpp src/chat/chat_store.cpp src/chat/chat_service.cpp + src/chat/chat_database.cpp src/wallet/lite_owned_string.cpp src/wallet/lite_rollout_policy.cpp src/wallet/lite_client_bridge.cpp @@ -566,6 +567,7 @@ set(APP_HEADERS src/chat/chat_message.h src/chat/chat_store.h src/chat/chat_service.h + src/chat/chat_database.h src/config/version.h src/data/wallet_state.h src/data/transaction_history_cache.h @@ -1018,6 +1020,7 @@ if(BUILD_TESTING) src/chat/chat_identity.cpp src/chat/chat_store.cpp src/chat/chat_service.cpp + src/chat/chat_database.cpp src/wallet/lite_owned_string.cpp src/wallet/lite_rollout_policy.cpp src/wallet/lite_client_bridge.cpp diff --git a/src/app.cpp b/src/app.cpp index a4377b0..76c89f2 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -604,8 +604,11 @@ void App::rebuildLiteWallet(bool force) lite_open_error_.clear(); // A rebuilt controller may back a different wallet (server switch / re-open); drop any chat - // identity and re-arm provisioning so it re-derives from the newly opened wallet's seed. + // identity + decrypted messages, lock the DB, and re-arm provisioning so it re-derives (and + // reloads the right wallet's history) from the newly opened wallet's seed. chat_service_.clearIdentity(); + chat_service_.store().clear(); + chat_db_.lock(); chat_identity_provisioned_ = false; chat_identity_fetch_in_flight_ = false; chat_identity_unavailable_ = false; diff --git a/src/app.h b/src/app.h index bdfacb0..9542712 100644 --- a/src/app.h +++ b/src/app.h @@ -24,6 +24,7 @@ #include "util/pool_stats_service.h" #include "wallet/wallet_capabilities.h" #include "chat/chat_service.h" +#include "chat/chat_database.h" #include "ui/sidebar.h" #include "ui/windows/console_tab.h" #include "imgui.h" @@ -577,6 +578,7 @@ private: // The identity is derived from the wallet's OWN SDXLite-compatible seed phrase (full-node // z_exportmnemonic / lite exportSeed → the same KDF), so it is portable across both variants. chat::ChatService chat_service_; + chat::ChatDatabase chat_db_; // persistent backing (seed-derived encryption at rest) bool chat_identity_provisioned_ = false; // identity set on the service this session bool chat_identity_fetch_in_flight_ = false; // a z_exportmnemonic worker job is pending bool chat_identity_unavailable_ = false; // provisioning failed definitively (e.g. non-mnemonic wallet) diff --git a/src/app_network.cpp b/src/app_network.cpp index 5192894..d928664 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -1484,7 +1484,9 @@ void App::refreshTransactionData() // the store dedups (txid+position) so the full + recent refresh paths ingest safely. if (chat::hushChatFeatureEnabledAtBuild() && chat_service_.hasIdentity() && !result.hushChatMetadata.empty()) { - chat_service_.ingest(result.hushChatMetadata, std::time(nullptr)); + std::unordered_map chatTxTimes; + for (const auto& tx : result.transactions) chatTxTimes[tx.txid] = tx.timestamp; + chat_service_.ingest(result.hushChatMetadata, chatTxTimes, std::time(nullptr)); } NetworkRefreshService::applyTransactionRefreshResult( state_, cacheUpdate, std::move(result), std::time(nullptr)); @@ -1540,7 +1542,9 @@ void App::refreshRecentTransactionData() // full-refresh path above for rationale; the store dedups across both paths). if (chat::hushChatFeatureEnabledAtBuild() && chat_service_.hasIdentity() && !result.hushChatMetadata.empty()) { - chat_service_.ingest(result.hushChatMetadata, std::time(nullptr)); + std::unordered_map chatTxTimes; + for (const auto& tx : result.transactions) chatTxTimes[tx.txid] = tx.timestamp; + chat_service_.ingest(result.hushChatMetadata, chatTxTimes, std::time(nullptr)); } NetworkRefreshService::applyTransactionRefreshResult( state_, cacheUpdate, std::move(result), std::time(nullptr)); @@ -2355,23 +2359,32 @@ void App::provisionChatIdentityFromSecret(std::string secret) { // Defensive: strip surrounding whitespace so a stray newline can't change the identity — the // same wallet must derive the SAME identity on full-node and lite (both return the canonical - // single-space phrase today, so this is belt-and-suspenders). + // single-space phrase today, so this is belt-and-suspenders). Trim into a SEPARATE buffer so + // both the original (kept full-size) and the trimmed copy (size == content) are fully wiped — + // an in-place erase/pop_back would shrink size() and leave un-scrubbed seed bytes past it. auto isws = [](char c) { return std::isspace(static_cast(c)) != 0; }; - while (!secret.empty() && isws(secret.back())) secret.pop_back(); - std::size_t start = 0; - while (start < secret.size() && isws(secret[start])) ++start; - if (start) secret.erase(0, start); + std::size_t begin = 0, end = secret.size(); + while (begin < end && isws(secret[begin])) ++begin; + while (end > begin && isws(secret[end - 1])) --end; + std::string trimmed = secret.substr(begin, end - begin); chat::ChatKeyPair keys; - const auto result = chat::deriveChatIdentityFromSecret(secret, keys); - if (!secret.empty()) sodium_memzero(&secret[0], secret.size()); + const auto result = chat::deriveChatIdentityFromSecret(trimmed, keys); if (result.status == chat::ChatIdentityStatus::Ready) { chat_service_.setIdentity(keys); // copies the keypair chat_identity_provisioned_ = true; + // Persistence: unlock the seed-derived chat DB with the SAME secret and rehydrate the store + // with prior messages (decrypted at rest under a key only this seed can derive). + chat_service_.setPersistence(&chat_db_); + if (chat_db_.unlockWithSecret(trimmed)) { + chat_service_.loadFromDatabase(); + } } else { chat_identity_unavailable_ = true; } + if (!trimmed.empty()) sodium_memzero(&trimmed[0], trimmed.size()); + if (!secret.empty()) sodium_memzero(&secret[0], secret.size()); chat::wipeChatKeyPair(keys); } @@ -2389,6 +2402,8 @@ void App::maybeProvisionChatIdentity() if (state_.isLocked()) { if (chat_identity_provisioned_ || chat_service_.hasIdentity()) { chat_service_.clearIdentity(); + chat_service_.store().clear(); // decrypted plaintext in RAM — drop it; DB reloads on unlock + chat_db_.lock(); chat_identity_provisioned_ = false; chat_identity_unavailable_ = false; } @@ -2422,7 +2437,14 @@ void App::maybeProvisionChatIdentity() bool transientFail = false; // connection blip — allow a later retry try { rpc::RPCClient::TraceScope trace("HushChat / identity"); - mnemonic = rpc_->call("z_exportmnemonic").value("mnemonic", std::string()); + auto response = rpc_->call("z_exportmnemonic"); + if (response.contains("mnemonic") && response["mnemonic"].is_string()) { + // Scrub the json's own copy of the seed after taking ours (the rest of the RPC + // response chain is unmanaged — a fuller fix belongs in the RPC layer). + auto& phrase = response["mnemonic"].get_ref(); + mnemonic = phrase; + if (!phrase.empty()) sodium_memzero(&phrase[0], phrase.size()); + } } catch (const rpc::RpcError&) { definitiveFail = true; } catch (const std::exception&) { @@ -2430,9 +2452,14 @@ void App::maybeProvisionChatIdentity() } return [this, mnemonic = std::move(mnemonic), definitiveFail, transientFail]() mutable { chat_identity_fetch_in_flight_ = false; - if (definitiveFail) { chat_identity_unavailable_ = true; return; } - if (transientFail || mnemonic.empty()) return; // retry a later tick - provisionChatIdentityFromSecret(mnemonic); // copies internally + if (definitiveFail) chat_identity_unavailable_ = true; + // Provision only on success AND while still unlocked: a lock (e.g. auto-lock) can + // land during the blocking fetch, and provisioning then would unlock the chat DB + + // load decrypted history while the wallet is locked. Leaving the flag cleared here + // re-arms a fresh fetch on the next unlock. + if (!definitiveFail && !transientFail && !mnemonic.empty() && !state_.isLocked()) { + provisionChatIdentityFromSecret(mnemonic); // copies internally + } if (!mnemonic.empty()) sodium_memzero(&mnemonic[0], mnemonic.size()); }; }); diff --git a/src/chat/chat_database.cpp b/src/chat/chat_database.cpp new file mode 100644 index 0000000..c5be405 --- /dev/null +++ b/src/chat/chat_database.cpp @@ -0,0 +1,328 @@ +// DragonX Wallet - HushChat persistent message store (implementation). + +#include "chat_database.h" + +#include "../util/logger.h" +#include "../util/platform.h" + +#include +#include +#include + +#include +#include +#include + +namespace fs = std::filesystem; + +namespace dragonx::chat { + +namespace { + +// Domain-separated KDF contexts (used as the keyed-BLAKE2b key, like chat_identity). Both lengths +// sit inside crypto_generichash's key-length bounds. Bumping a context rotates that derivation. +constexpr char kStorageKeyContext[] = "DragonX-HushChat-Storage-v1"; +constexpr char kWalletTagContext[] = "DragonX-HushChat-WalletId-v1"; +constexpr std::size_t kStorageKeyContextLen = sizeof(kStorageKeyContext) - 1; +constexpr std::size_t kWalletTagContextLen = sizeof(kWalletTagContext) - 1; + +std::string toHex(const unsigned char* data, std::size_t len) +{ + static const char* kHex = "0123456789abcdef"; + std::string out; + out.reserve(len * 2); + for (std::size_t i = 0; i < len; ++i) { + out.push_back(kHex[data[i] >> 4]); + out.push_back(kHex[data[i] & 0x0F]); + } + return out; +} + +// keyed-BLAKE2b: out = generichash(in=secret, key=context). Deterministic, so the same seed always +// derives the same storage key + wallet tag across sessions. +bool deriveKeyed(const std::string& secret, const char* context, std::size_t contextLen, + unsigned char* out, std::size_t outLen) +{ + return crypto_generichash(out, outLen, + reinterpret_cast(secret.data()), secret.size(), + reinterpret_cast(context), contextLen) == 0; +} + +std::string associatedData(const std::string& walletTag) +{ + return std::string("obsidian-dragon-hushchat-v1:") + walletTag; +} + +} // namespace + +ChatDatabase::ChatDatabase() : database_path_(defaultDatabasePath()) {} +ChatDatabase::ChatDatabase(std::string databasePath) : database_path_(std::move(databasePath)) {} + +ChatDatabase::~ChatDatabase() +{ + lock(); + close(); +} + +std::string ChatDatabase::defaultDatabasePath() +{ + return (fs::path(util::Platform::getConfigDir()) / "chat_messages.sqlite").string(); +} + +bool ChatDatabase::unlockWithSecret(const std::string& secret) +{ + if (sodium_init() < 0) return false; + + if (!deriveKeyed(secret, kStorageKeyContext, kStorageKeyContextLen, key_.data(), key_.size())) + return false; + + unsigned char tag[32]; + if (!deriveKeyed(secret, kWalletTagContext, kWalletTagContextLen, tag, sizeof(tag))) { + sodium_memzero(key_.data(), key_.size()); + return false; + } + wallet_tag_ = toHex(tag, sizeof(tag)); + sodium_memzero(tag, sizeof(tag)); + key_ready_ = true; + + if (!ensureOpen()) { + lock(); + return false; + } + return true; +} + +void ChatDatabase::lock() +{ + sodium_memzero(key_.data(), key_.size()); + key_ready_ = false; + wallet_tag_.clear(); +} + +bool ChatDatabase::append(const ChatMessage& message) +{ + if (!key_ready_ || !ensureOpen()) return false; + + std::vector nonce; + std::vector cipher; + std::string plain = serialize(message); // full plaintext (decrypted body + metadata) + const bool encrypted = encrypt(plain, nonce, cipher); + if (!plain.empty()) sodium_memzero(&plain[0], plain.size()); // don't leave it on the heap + if (!encrypted) return false; + + const std::string dedup = dedupHash(message.txid, message.payload_position); + + sqlite3_stmt* stmt = nullptr; + if (sqlite3_prepare_v2(db_, + "INSERT OR IGNORE INTO chat_messages (wallet_tag, dedup_hash, nonce, payload) " + "VALUES (?, ?, ?, ?)", + -1, &stmt, nullptr) != SQLITE_OK) { + return false; + } + sqlite3_bind_text(stmt, 1, wallet_tag_.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 2, dedup.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_blob(stmt, 3, nonce.data(), static_cast(nonce.size()), SQLITE_TRANSIENT); + sqlite3_bind_blob(stmt, 4, cipher.data(), static_cast(cipher.size()), SQLITE_TRANSIENT); + const bool done = sqlite3_step(stmt) == SQLITE_DONE; + sqlite3_finalize(stmt); + if (!done) return false; + return sqlite3_changes(db_) > 0; +} + +std::vector ChatDatabase::load() +{ + std::vector out; + if (!key_ready_ || !ensureOpen()) return out; + + sqlite3_stmt* stmt = nullptr; + if (sqlite3_prepare_v2(db_, + "SELECT nonce, payload FROM chat_messages WHERE wallet_tag = ? ORDER BY rowid", + -1, &stmt, nullptr) != SQLITE_OK) { + return out; + } + sqlite3_bind_text(stmt, 1, wallet_tag_.c_str(), -1, SQLITE_TRANSIENT); + while (sqlite3_step(stmt) == SQLITE_ROW) { + const auto* noncePtr = static_cast(sqlite3_column_blob(stmt, 0)); + const int nonceLen = sqlite3_column_bytes(stmt, 0); + const auto* cipherPtr = static_cast(sqlite3_column_blob(stmt, 1)); + const int cipherLen = sqlite3_column_bytes(stmt, 1); + if (!noncePtr || !cipherPtr) continue; + + std::vector nonce(noncePtr, noncePtr + nonceLen); + std::vector cipher(cipherPtr, cipherPtr + cipherLen); + std::string plain; + if (!decrypt(nonce, cipher, plain)) continue; // wrong wallet / tampered — skip + + ChatMessage message; + if (deserialize(plain, message)) out.push_back(std::move(message)); + sodium_memzero(&plain[0], plain.size()); + } + sqlite3_finalize(stmt); + return out; +} + +void ChatDatabase::clearWallet() +{ + if (wallet_tag_.empty() || !ensureOpen()) return; + sqlite3_stmt* stmt = nullptr; + if (sqlite3_prepare_v2(db_, "DELETE FROM chat_messages WHERE wallet_tag = ?", -1, &stmt, nullptr) + != SQLITE_OK) { + return; + } + sqlite3_bind_text(stmt, 1, wallet_tag_.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_step(stmt); + sqlite3_finalize(stmt); +} + +bool ChatDatabase::ensureOpen() +{ + if (db_) return true; + + try { + fs::path path(database_path_); + if (!path.parent_path().empty()) fs::create_directories(path.parent_path()); + } catch (const std::exception& exception) { + DEBUG_LOGF("Failed to create chat database directory: %s\n", exception.what()); + return false; + } + + sqlite3* openedDb = nullptr; + if (sqlite3_open(database_path_.c_str(), &openedDb) != SQLITE_OK) { + DEBUG_LOGF("Failed to open chat database: %s\n", + openedDb ? sqlite3_errmsg(openedDb) : "unknown error"); + if (openedDb) sqlite3_close(openedDb); + return false; + } + + db_ = openedDb; + sqlite3_busy_timeout(db_, 2000); + exec("PRAGMA journal_mode=WAL"); + exec("PRAGMA synchronous=NORMAL"); + + if (!createSchema()) { + close(); + return false; + } + return true; +} + +bool ChatDatabase::exec(const char* sql) +{ + if (!db_) return false; + char* error = nullptr; + if (sqlite3_exec(db_, sql, nullptr, nullptr, &error) != SQLITE_OK) { + DEBUG_LOGF("Chat database SQL error: %s\n", error ? error : sqlite3_errmsg(db_)); + if (error) sqlite3_free(error); + return false; + } + return true; +} + +bool ChatDatabase::createSchema() +{ + return exec("CREATE TABLE IF NOT EXISTS chat_messages (" + "wallet_tag TEXT NOT NULL, " + "dedup_hash TEXT NOT NULL, " + "nonce BLOB NOT NULL, " + "payload BLOB NOT NULL, " + "PRIMARY KEY (wallet_tag, dedup_hash))"); +} + +std::string ChatDatabase::dedupHash(const std::string& txid, std::size_t position) const +{ + const std::string input = txid + ":" + std::to_string(position); + unsigned char hash[32]; + crypto_generichash(hash, sizeof(hash), + reinterpret_cast(input.data()), input.size(), + key_.data(), key_.size()); // keyed by the storage key → txid stays private + return toHex(hash, sizeof(hash)); +} + +std::string ChatDatabase::serialize(const ChatMessage& message) const +{ + nlohmann::json json; + json["d"] = static_cast(message.direction); + json["k"] = static_cast(message.kind); + json["txid"] = message.txid; + json["cid"] = message.conversation_id; + json["z"] = message.peer_zaddr; + json["p"] = message.peer_public_key_hex; + json["b"] = message.body; + json["ts"] = message.timestamp; + json["pos"] = static_cast(message.payload_position); + return json.dump(); +} + +bool ChatDatabase::deserialize(const std::string& json, ChatMessage& out) const +{ + try { + const auto parsed = nlohmann::json::parse(json); + out.direction = static_cast(parsed.value("d", 0)); + out.kind = static_cast(parsed.value("k", 0)); + out.txid = parsed.value("txid", std::string()); + out.conversation_id = parsed.value("cid", std::string()); + out.peer_zaddr = parsed.value("z", std::string()); + out.peer_public_key_hex = parsed.value("p", std::string()); + out.body = parsed.value("b", std::string()); + out.timestamp = parsed.value("ts", static_cast(0)); + out.payload_position = static_cast(parsed.value("pos", static_cast(0))); + return true; + } catch (const std::exception&) { + return false; + } +} + +bool ChatDatabase::encrypt(const std::string& plain, + std::vector& nonce, + std::vector& cipher) const +{ + if (!key_ready_) return false; + nonce.resize(crypto_aead_xchacha20poly1305_ietf_NPUBBYTES); + randombytes_buf(nonce.data(), nonce.size()); + + const std::string ad = associatedData(wallet_tag_); + cipher.resize(plain.size() + crypto_aead_xchacha20poly1305_ietf_ABYTES); + unsigned long long cipherLen = 0; + if (crypto_aead_xchacha20poly1305_ietf_encrypt( + cipher.data(), &cipherLen, + reinterpret_cast(plain.data()), plain.size(), + reinterpret_cast(ad.data()), ad.size(), + nullptr, nonce.data(), key_.data()) != 0) { + return false; + } + cipher.resize(static_cast(cipherLen)); + return true; +} + +bool ChatDatabase::decrypt(const std::vector& nonce, + const std::vector& cipher, + std::string& plain) const +{ + if (!key_ready_) return false; + if (nonce.size() != crypto_aead_xchacha20poly1305_ietf_NPUBBYTES) return false; + if (cipher.size() < crypto_aead_xchacha20poly1305_ietf_ABYTES) return false; + + const std::string ad = associatedData(wallet_tag_); + std::vector out(cipher.size()); + unsigned long long outLen = 0; + if (crypto_aead_xchacha20poly1305_ietf_decrypt( + out.data(), &outLen, nullptr, + cipher.data(), cipher.size(), + reinterpret_cast(ad.data()), ad.size(), + nonce.data(), key_.data()) != 0) { + return false; + } + plain.assign(reinterpret_cast(out.data()), static_cast(outLen)); + sodium_memzero(out.data(), out.size()); + return true; +} + +void ChatDatabase::close() +{ + if (db_) { + sqlite3_close(db_); + db_ = nullptr; + } +} + +} // namespace dragonx::chat diff --git a/src/chat/chat_database.h b/src/chat/chat_database.h new file mode 100644 index 0000000..fbb7198 --- /dev/null +++ b/src/chat/chat_database.h @@ -0,0 +1,74 @@ +#pragma once + +// DragonX Wallet - HushChat persistent message store (Phase 2). +// +// Sqlite-backed, encrypted at rest with a SEED-DERIVED key (no wallet passphrase). Every record — +// message bodies, peer z-addresses, threading (conversation id), and timestamps — is AEAD-encrypted +// under a key derived from the wallet's own seed secret (the same secret used for the chat +// identity), and even the per-message dedup key is a KEYED hash of the txid — so the database +// reveals nothing about your conversations to disk-level access without the seed. Rows are +// partitioned by a seed-derived wallet tag so one file can hold several wallets, each readable only +// with its own seed. Not thread-safe — drive from the main thread. Mirrors the lifecycle of +// data::TransactionHistoryCache. + +#include "chat_message.h" + +#include +#include +#include +#include + +struct sqlite3; + +namespace dragonx::chat { + +class ChatDatabase { +public: + ChatDatabase(); + explicit ChatDatabase(std::string databasePath); + ~ChatDatabase(); + + ChatDatabase(const ChatDatabase&) = delete; + ChatDatabase& operator=(const ChatDatabase&) = delete; + + static std::string defaultDatabasePath(); + + // Derive the storage key + wallet tag from the wallet's seed secret and open the DB. The caller + // still owns and must wipe `secret`. Returns false on sodium/db failure (DB then stays locked). + bool unlockWithSecret(const std::string& secret); + void lock(); // wipe the key material (DB handle stays open); load()/append() then no-op + bool hasKey() const { return key_ready_; } + + // Persist one message (INSERT OR IGNORE, deduped by a keyed hash of txid+payload_position). + // Returns true if newly inserted; false on duplicate or while locked. + bool append(const ChatMessage& message); + + // Decrypt and return every stored message for the unlocked wallet, in insertion order. Empty + // while locked or if none. Rows that fail to decrypt/parse are skipped. + std::vector load(); + + void clearWallet(); // delete the unlocked wallet's rows + +private: + bool ensureOpen(); + bool exec(const char* sql); + bool createSchema(); + std::string dedupHash(const std::string& txid, std::size_t position) const; + std::string serialize(const ChatMessage& message) const; + bool deserialize(const std::string& json, ChatMessage& out) const; + bool encrypt(const std::string& plain, + std::vector& nonce, + std::vector& cipher) const; + bool decrypt(const std::vector& nonce, + const std::vector& cipher, + std::string& plain) const; + void close(); + + sqlite3* db_ = nullptr; + std::string database_path_; + std::array key_{}; // AEAD storage key (seed-derived) + std::string wallet_tag_; // seed-derived row partition (a keyed hash, hex) + bool key_ready_ = false; +}; + +} // namespace dragonx::chat diff --git a/src/chat/chat_message.h b/src/chat/chat_message.h index a44814e..1dc7365 100644 --- a/src/chat/chat_message.h +++ b/src/chat/chat_message.h @@ -1,7 +1,7 @@ #pragma once -// DragonX Wallet - HushChat in-memory message model (no libsodium, no persistence). -// Phase 1 keeps decrypted messages in memory only; sqlite persistence is Phase 2. +// DragonX Wallet - HushChat decrypted message model. Held in memory by ChatStore and persisted at +// rest (encrypted under a seed-derived key) by ChatDatabase. #include #include diff --git a/src/chat/chat_service.cpp b/src/chat/chat_service.cpp index 137dff5..039b906 100644 --- a/src/chat/chat_service.cpp +++ b/src/chat/chat_service.cpp @@ -2,6 +2,8 @@ #include "chat_service.h" +#include "chat_database.h" + #include namespace dragonx::chat { @@ -21,7 +23,8 @@ void ChatService::clearIdentity() { } int ChatService::ingest(const std::vector& metadata, - std::int64_t txTimestamp) { + const std::unordered_map& txTimestamps, + std::int64_t fallbackTimestamp) { if (!has_identity_) return 0; int added = 0; @@ -32,7 +35,8 @@ int ChatService::ingest(const std::vector& metadata message.conversation_id = meta.conversation_id; message.peer_zaddr = meta.reply_zaddr; message.peer_public_key_hex = meta.sender_public_key_hex; - message.timestamp = txTimestamp; + const auto timeIt = txTimestamps.find(meta.txid); + message.timestamp = timeIt != txTimestamps.end() ? timeIt->second : fallbackTimestamp; message.payload_position = meta.payload_position; if (meta.type == HushChatHeaderType::ContactRequest) { @@ -48,9 +52,22 @@ int ChatService::ingest(const std::vector& metadata message.body = std::move(plaintext); } - if (store_.append(message)) ++added; + // In-memory store dedups (txid+position); only persist the genuinely new ones. On the next + // session loadFromDatabase() repopulates the store, so re-scanning the chain re-ingests but + // the store dedup prevents a duplicate write. + if (store_.append(message)) { + if (db_) db_->append(message); + ++added; + } } return added; } +void ChatService::loadFromDatabase() { + if (!db_) return; + for (const auto& message : db_->load()) { + store_.append(message); + } +} + } // namespace dragonx::chat diff --git a/src/chat/chat_service.h b/src/chat/chat_service.h index 975499a..f58a42e 100644 --- a/src/chat/chat_service.h +++ b/src/chat/chat_service.h @@ -10,10 +10,14 @@ #include "chat_store.h" #include +#include +#include #include namespace dragonx::chat { +class ChatDatabase; // optional persistent backing (Phase 2); set via setPersistence + class ChatService { public: ChatService() = default; @@ -30,10 +34,21 @@ public: void clearIdentity(); // wipes the held secret key // Decrypt/record each metadata entry (a Message is decrypted; a ContactRequest carries its - // plaintext through) and thread it into the store. `txTimestamp` is stamped onto every - // message in this batch. Returns the number of NEW messages added. Returns 0 with no - // identity. Undecryptable Messages are dropped silently (no logging of memo/plaintext). - int ingest(const std::vector& metadata, std::int64_t txTimestamp = 0); + // plaintext through) and thread it into the store. Each message is stamped with its own + // transaction time via `txTimestamps` (keyed by txid), falling back to `fallbackTimestamp` + // when the txid isn't present. Newly-added messages are also persisted (if a database is + // attached). Returns the number of NEW messages added; 0 with no identity. Undecryptable + // Messages are dropped silently (no logging of memo/plaintext). + int ingest(const std::vector& metadata, + const std::unordered_map& txTimestamps, + std::int64_t fallbackTimestamp = 0); + + // Attach a persistent backing store (Phase 2). Not owned. Pass nullptr to detach. New messages + // from ingest() are written through; loadFromDatabase() rehydrates the in-memory store from it. + void setPersistence(ChatDatabase* db) { db_ = db; } + // Load previously-persisted messages (already decrypted at ingest, re-encrypted at rest under + // the seed-derived key) into the in-memory store. No-op without an unlocked database. + void loadFromDatabase(); const ChatStore& store() const { return store_; } ChatStore& store() { return store_; } @@ -42,6 +57,7 @@ private: ChatKeyPair identity_{}; bool has_identity_ = false; ChatStore store_; + ChatDatabase* db_ = nullptr; // optional; not owned }; } // namespace dragonx::chat diff --git a/src/chat/chat_store.h b/src/chat/chat_store.h index d660413..4795fc7 100644 --- a/src/chat/chat_store.h +++ b/src/chat/chat_store.h @@ -1,6 +1,7 @@ #pragma once -// DragonX Wallet - HushChat in-memory message store (Phase 1; sqlite persistence is Phase 2). +// DragonX Wallet - HushChat in-memory message store: the fast read model / dedup view. Durable +// persistence lives in ChatDatabase; ChatService rehydrates this store from it on unlock. #include "chat_message.h" diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index ded20ab..de5638d 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -1,6 +1,7 @@ #include "chat/chat_crypto.h" #include "chat/chat_identity.h" #include "chat/chat_service.h" +#include "chat/chat_database.h" #include "daemon/daemon_controller.h" #include "data/transaction_history_cache.h" #include "daemon/lifecycle_adapters.h" @@ -5691,13 +5692,13 @@ void testHushChatService() ChatService svc; EXPECT_TRUE(!svc.hasIdentity()); - EXPECT_EQ(svc.ingest(batch), 0); // no identity -> nothing ingested + EXPECT_EQ(svc.ingest(batch, {}), 0); // no identity -> nothing ingested svc.setIdentity(bob); EXPECT_TRUE(svc.hasIdentity()); - EXPECT_EQ(svc.ingest(batch, 1000), 2); + EXPECT_EQ(svc.ingest(batch, {}, 1000), 2); EXPECT_EQ((int)svc.store().size(), 2); - EXPECT_EQ(svc.ingest(batch, 1000), 0); // re-scan dedups + EXPECT_EQ(svc.ingest(batch, {}, 1000), 0); // re-scan dedups EXPECT_EQ((int)svc.store().size(), 2); std::vector conv = svc.store().conversation("conv-x"); @@ -5716,7 +5717,7 @@ void testHushChatService() creq[0].sender_public_key_hex = ra.public_key_hex; creq[0].payload_memo = "hi, add me"; creq[0].payload_position = 1; - EXPECT_EQ(svc.ingest(creq), 1); + EXPECT_EQ(svc.ingest(creq, {}), 1); std::vector cy = svc.store().conversation("conv-y"); EXPECT_EQ((int)cy.size(), 1); EXPECT_TRUE(cy[0].kind == ChatMessageKind::ContactRequest); @@ -5727,10 +5728,127 @@ void testHushChatService() ChatKeyPair mallory; deriveChatIdentityFromSecret("mallory-svc", mallory, true); svc2.setIdentity(mallory); - EXPECT_EQ(svc2.ingest(batch, 1000), 0); + EXPECT_EQ(svc2.ingest(batch, {}, 1000), 0); EXPECT_TRUE(svc2.store().empty()); } +// Phase 2: persistent, seed-encrypted store — round-trip, dedup, per-wallet isolation, and +// ChatService write-through + reload (reload needs only the storage key, not the chat identity). +void testHushChatDatabase() +{ + using namespace dragonx::chat; + namespace fs = std::filesystem; + + const std::string dbPath = (fs::temp_directory_path() / "drgx_chat_db_test.sqlite").string(); + const std::string dbPath2 = (fs::temp_directory_path() / "drgx_chat_db_test2.sqlite").string(); + auto scrub = [](const std::string& p) { + fs::remove(p); fs::remove(p + "-wal"); fs::remove(p + "-shm"); + }; + scrub(dbPath); scrub(dbPath2); + + const std::string seedA = "wallet A seed phrase words here"; + const std::string seedB = "a completely different wallet B seed"; + + auto makeMsg = [](const std::string& txid, std::size_t pos, const std::string& cid, + const std::string& body, ChatMessageKind kind, std::int64_t ts) { + ChatMessage m; + m.direction = ChatDirection::Incoming; + m.kind = kind; + m.txid = txid; + m.conversation_id = cid; + m.peer_zaddr = "zs-peer"; + m.peer_public_key_hex = "deadbeef"; + m.body = body; + m.timestamp = ts; + m.payload_position = pos; + return m; + }; + + // Locked DB is inert; unlock, then write three (with a duplicate that is ignored). + { + ChatDatabase db(dbPath); + EXPECT_TRUE(!db.hasKey()); + EXPECT_TRUE(db.append(makeMsg("t1", 1, "c", "hello", ChatMessageKind::Message, 111)) == false); + EXPECT_TRUE(db.unlockWithSecret(seedA)); + EXPECT_TRUE(db.hasKey()); + EXPECT_TRUE(db.append(makeMsg("t1", 1, "c", "hello", ChatMessageKind::Message, 111))); + EXPECT_TRUE(db.append(makeMsg("t2", 1, "c", "world", ChatMessageKind::Message, 222))); + EXPECT_TRUE(db.append(makeMsg("t3", 0, "c", "add me", ChatMessageKind::ContactRequest, 333))); + EXPECT_TRUE(db.append(makeMsg("t1", 1, "c", "hello", ChatMessageKind::Message, 111)) == false); // dup + } + + // Reopen with the SAME seed → messages persist, fields + order intact. + { + ChatDatabase db(dbPath); + EXPECT_TRUE(db.unlockWithSecret(seedA)); + std::vector all = db.load(); + EXPECT_EQ((int)all.size(), 3); + EXPECT_EQ(all[0].body, std::string("hello")); + EXPECT_EQ(all[0].txid, std::string("t1")); + EXPECT_EQ(all[0].timestamp, (std::int64_t)111); + EXPECT_EQ(all[1].body, std::string("world")); + EXPECT_TRUE(all[2].kind == ChatMessageKind::ContactRequest); + EXPECT_EQ(all[2].body, std::string("add me")); + EXPECT_EQ(all[2].peer_public_key_hex, std::string("deadbeef")); + } + + // A DIFFERENT seed is partitioned + can't decrypt → sees nothing, writes in isolation. + { + ChatDatabase db(dbPath); + EXPECT_TRUE(db.unlockWithSecret(seedB)); + EXPECT_TRUE(db.load().empty()); + EXPECT_TRUE(db.append(makeMsg("t1", 1, "c", "B-secret", ChatMessageKind::Message, 999))); + EXPECT_EQ((int)db.load().size(), 1); + } + // ...and wallet A still sees exactly its three. + { + ChatDatabase db(dbPath); + EXPECT_TRUE(db.unlockWithSecret(seedA)); + EXPECT_EQ((int)db.load().size(), 3); + db.lock(); + EXPECT_TRUE(!db.hasKey()); + EXPECT_TRUE(db.load().empty()); // inert once locked + } + + // End-to-end: ChatService write-through on ingest, then reload into a fresh service WITHOUT an + // identity (proves stored messages are decryptable with the storage key alone). + { + ChatKeyPair alice, bob; + ChatIdentityResult ra = deriveChatIdentityFromSecret("db-alice", alice, true); + ChatIdentityResult rb = deriveChatIdentityFromSecret("db-bob", bob, true); + std::string e, ct; + EXPECT_TRUE(encryptOutgoing(alice, rb.public_key_hex, "persisted!", e, ct) == ChatCryptoStatus::Ok); + HushChatTransactionMetadata m; + m.txid = "txp"; m.type = HushChatHeaderType::Message; m.conversation_id = "cp"; + m.reply_zaddr = "zs-alice"; m.sender_public_key_hex = ra.public_key_hex; + m.secretstream_header_hex = e; m.payload_memo = ct; m.payload_position = 1; + std::vector batch{m}; + std::unordered_map times{{"txp", 4242}}; + + { + ChatDatabase db(dbPath2); + EXPECT_TRUE(db.unlockWithSecret("e2e-seed")); + ChatService svc; + svc.setPersistence(&db); + svc.setIdentity(bob); + EXPECT_EQ(svc.ingest(batch, times), 1); + } + { + ChatDatabase db(dbPath2); + EXPECT_TRUE(db.unlockWithSecret("e2e-seed")); + ChatService svc; + svc.setPersistence(&db); + svc.loadFromDatabase(); // no setIdentity() — reload doesn't need it + std::vector conv = svc.store().conversation("cp"); + EXPECT_EQ((int)conv.size(), 1); + EXPECT_EQ(conv[0].body, std::string("persisted!")); + EXPECT_EQ(conv[0].timestamp, (std::int64_t)4242); + } + } + + scrub(dbPath); scrub(dbPath2); +} + } // namespace int main() @@ -5824,6 +5942,7 @@ int main() testHushChatCrypto(); testHushChatReceivePath(); testHushChatService(); + testHushChatDatabase(); testAddressChecksumValidation(); testLiteServerProbeLive(); testXmrigLiveInstall();