diff --git a/CMakeLists.txt b/CMakeLists.txt index c39b801..83f5720 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -413,6 +413,8 @@ set(APP_SOURCES src/chat/chat_protocol.cpp src/chat/chat_crypto.cpp src/chat/chat_identity.cpp + src/chat/chat_store.cpp + src/chat/chat_service.cpp src/wallet/lite_owned_string.cpp src/wallet/lite_rollout_policy.cpp src/wallet/lite_client_bridge.cpp @@ -561,6 +563,9 @@ set(APP_HEADERS src/chat/chat_protocol.h src/chat/chat_crypto.h src/chat/chat_identity.h + src/chat/chat_message.h + src/chat/chat_store.h + src/chat/chat_service.h src/config/version.h src/data/wallet_state.h src/data/transaction_history_cache.h @@ -1011,6 +1016,8 @@ if(BUILD_TESTING) src/chat/chat_protocol.cpp src/chat/chat_crypto.cpp src/chat/chat_identity.cpp + src/chat/chat_store.cpp + src/chat/chat_service.cpp src/wallet/lite_owned_string.cpp src/wallet/lite_rollout_policy.cpp src/wallet/lite_client_bridge.cpp diff --git a/src/chat/chat_message.h b/src/chat/chat_message.h new file mode 100644 index 0000000..a44814e --- /dev/null +++ b/src/chat/chat_message.h @@ -0,0 +1,26 @@ +#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. + +#include +#include + +namespace dragonx::chat { + +enum class ChatDirection { Incoming, Outgoing }; +enum class ChatMessageKind { Message, ContactRequest }; + +struct ChatMessage { + ChatDirection direction = ChatDirection::Incoming; + ChatMessageKind kind = ChatMessageKind::Message; + std::string txid; + std::string conversation_id; // cid — the conversation thread key + std::string peer_zaddr; // header "z": peer's reply z-address + std::string peer_public_key_hex; // header "p": peer's crypto_kx public key + std::string body; // decrypted plaintext (Message) or request text (ContactRequest) + std::int64_t timestamp = 0; // tx time in seconds; set by the ingesting caller + std::size_t payload_position = 0; // together with txid, the dedup key +}; + +} // namespace dragonx::chat diff --git a/src/chat/chat_service.cpp b/src/chat/chat_service.cpp new file mode 100644 index 0000000..137dff5 --- /dev/null +++ b/src/chat/chat_service.cpp @@ -0,0 +1,56 @@ +// DragonX Wallet - HushChat service (implementation). + +#include "chat_service.h" + +#include + +namespace dragonx::chat { + +ChatService::~ChatService() { + clearIdentity(); +} + +void ChatService::setIdentity(const ChatKeyPair& keys) { + identity_ = keys; + has_identity_ = true; +} + +void ChatService::clearIdentity() { + wipeChatKeyPair(identity_); + has_identity_ = false; +} + +int ChatService::ingest(const std::vector& metadata, + std::int64_t txTimestamp) { + if (!has_identity_) return 0; + + int added = 0; + for (const auto& meta : metadata) { + ChatMessage message; + message.direction = ChatDirection::Incoming; + message.txid = meta.txid; + 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; + message.payload_position = meta.payload_position; + + if (meta.type == HushChatHeaderType::ContactRequest) { + message.kind = ChatMessageKind::ContactRequest; + message.body = meta.payload_memo; // plaintext request text + } else { + message.kind = ChatMessageKind::Message; + std::string plaintext; + const ChatCryptoStatus status = decryptIncoming( + identity_, meta.sender_public_key_hex, meta.secretstream_header_hex, + meta.payload_memo, plaintext); + if (status != ChatCryptoStatus::Ok) continue; // drop undecryptable silently + message.body = std::move(plaintext); + } + + if (store_.append(message)) ++added; + } + return added; +} + +} // namespace dragonx::chat diff --git a/src/chat/chat_service.h b/src/chat/chat_service.h new file mode 100644 index 0000000..975499a --- /dev/null +++ b/src/chat/chat_service.h @@ -0,0 +1,47 @@ +#pragma once + +// DragonX Wallet - HushChat service: turns harvested memo metadata into decrypted, threaded +// messages. Owns the long-lived chat identity keypair (a secret) + the in-memory store. +// Move-disabled (the secret stays pinned); wipes the secret on destruction/clear. +// Not thread-safe — drive from the main thread (where refresh results are applied). + +#include "chat_crypto.h" // ChatKeyPair +#include "chat_protocol.h" // HushChatTransactionMetadata +#include "chat_store.h" + +#include +#include + +namespace dragonx::chat { + +class ChatService { +public: + ChatService() = default; + ~ChatService(); + ChatService(const ChatService&) = delete; + ChatService& operator=(const ChatService&) = delete; + ChatService(ChatService&&) = delete; + ChatService& operator=(ChatService&&) = delete; + + // Provision (or replace) the chat identity. Copies the keypair — the caller should wipe + // its own copy afterwards (see chat_identity.h ownership note). + void setIdentity(const ChatKeyPair& keys); + bool hasIdentity() const { return has_identity_; } + 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); + + const ChatStore& store() const { return store_; } + ChatStore& store() { return store_; } + +private: + ChatKeyPair identity_{}; + bool has_identity_ = false; + ChatStore store_; +}; + +} // namespace dragonx::chat diff --git a/src/chat/chat_store.cpp b/src/chat/chat_store.cpp new file mode 100644 index 0000000..e43218d --- /dev/null +++ b/src/chat/chat_store.cpp @@ -0,0 +1,39 @@ +// DragonX Wallet - HushChat in-memory message store (implementation). + +#include "chat_store.h" + +namespace dragonx::chat { + +std::string ChatStore::dedupKey(const ChatMessage& message) { + return message.txid + ":" + std::to_string(message.payload_position); +} + +bool ChatStore::append(const ChatMessage& message) { + if (!seen_.insert(dedupKey(message)).second) return false; + messages_.push_back(message); + return true; +} + +std::vector ChatStore::conversation(const std::string& conversationId) const { + std::vector out; + for (const auto& message : messages_) { + if (message.conversation_id == conversationId) out.push_back(message); + } + return out; +} + +std::vector ChatStore::conversationIds() const { + std::vector ids; + std::unordered_set seenIds; + for (const auto& message : messages_) { + if (seenIds.insert(message.conversation_id).second) ids.push_back(message.conversation_id); + } + return ids; +} + +void ChatStore::clear() { + messages_.clear(); + seen_.clear(); +} + +} // namespace dragonx::chat diff --git a/src/chat/chat_store.h b/src/chat/chat_store.h new file mode 100644 index 0000000..d660413 --- /dev/null +++ b/src/chat/chat_store.h @@ -0,0 +1,37 @@ +#pragma once + +// DragonX Wallet - HushChat in-memory message store (Phase 1; sqlite persistence is Phase 2). + +#include "chat_message.h" + +#include +#include +#include + +namespace dragonx::chat { + +// Threads messages by conversation_id (cid) and deduplicates by (txid, payload_position) so +// re-scanning the chain never double-inserts. Not thread-safe — drive from the main thread. +class ChatStore { +public: + // Returns true if newly inserted, false if a duplicate was ignored. + bool append(const ChatMessage& message); + + // Messages in a conversation, in insertion order. + std::vector conversation(const std::string& conversationId) const; + + // Distinct conversation ids, in first-seen order. + std::vector conversationIds() const; + + std::size_t size() const { return messages_.size(); } + bool empty() const { return messages_.empty(); } + void clear(); + +private: + static std::string dedupKey(const ChatMessage& message); + + std::vector messages_; + std::unordered_set seen_; +}; + +} // namespace dragonx::chat diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 3490e64..ded20ab 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -1,5 +1,6 @@ #include "chat/chat_crypto.h" #include "chat/chat_identity.h" +#include "chat/chat_service.h" #include "daemon/daemon_controller.h" #include "data/transaction_history_cache.h" #include "daemon/lifecycle_adapters.h" @@ -5662,6 +5663,74 @@ void testHushChatReceivePath() EXPECT_EQ((int)off.metadata.size(), 0); } +// ChatService: metadata batch -> decrypt -> threaded, deduped in-memory store. +void testHushChatService() +{ + using namespace dragonx::chat; + + ChatKeyPair alice, bob; + ChatIdentityResult ra = deriveChatIdentityFromSecret("alice-svc", alice, true); + ChatIdentityResult rb = deriveChatIdentityFromSecret("bob-svc", bob, true); + + // Two Alice->Bob messages in one conversation, as harvested metadata. + std::vector batch; + for (int i = 0; i < 2; ++i) { + std::string e, ct; + EXPECT_TRUE(encryptOutgoing(alice, rb.public_key_hex, i == 0 ? "first" : "second", e, ct) == ChatCryptoStatus::Ok); + HushChatTransactionMetadata m; + m.txid = std::string("tx") + std::to_string(i); + m.type = HushChatHeaderType::Message; + m.conversation_id = "conv-x"; + 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; + batch.push_back(m); + } + + ChatService svc; + EXPECT_TRUE(!svc.hasIdentity()); + 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((int)svc.store().size(), 2); + 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"); + EXPECT_EQ((int)conv.size(), 2); + EXPECT_EQ(conv[0].body, std::string("first")); + EXPECT_EQ(conv[1].body, std::string("second")); + EXPECT_TRUE(conv[0].direction == ChatDirection::Incoming); + EXPECT_EQ(conv[0].peer_public_key_hex, ra.public_key_hex); + EXPECT_EQ(conv[0].timestamp, (std::int64_t)1000); + + // A contact request carries plaintext through without decryption. + std::vector creq(1); + creq[0].txid = "txc"; + creq[0].type = HushChatHeaderType::ContactRequest; + creq[0].conversation_id = "conv-y"; + 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); + std::vector cy = svc.store().conversation("conv-y"); + EXPECT_EQ((int)cy.size(), 1); + EXPECT_TRUE(cy[0].kind == ChatMessageKind::ContactRequest); + EXPECT_EQ(cy[0].body, std::string("hi, add me")); + + // Wrong identity can't decrypt -> messages dropped silently. + ChatService svc2; + ChatKeyPair mallory; + deriveChatIdentityFromSecret("mallory-svc", mallory, true); + svc2.setIdentity(mallory); + EXPECT_EQ(svc2.ingest(batch, 1000), 0); + EXPECT_TRUE(svc2.store().empty()); +} + } // namespace int main() @@ -5754,6 +5823,7 @@ int main() testAtomicFileWrite(); testHushChatCrypto(); testHushChatReceivePath(); + testHushChatService(); testAddressChecksumValidation(); testLiteServerProbeLive(); testXmrigLiveInstall();