diff --git a/src/chat/chat_service.cpp b/src/chat/chat_service.cpp index 6b169f0..5502f9b 100644 --- a/src/chat/chat_service.cpp +++ b/src/chat/chat_service.cpp @@ -88,6 +88,10 @@ int ChatService::ingest(const std::vector& metadata // in the same conversation has a different txid and flows through normally. if (blocked_pred_ && blocked_pred_(message.conversation_id)) continue; if (conversationHiddenOnChain(message.conversation_id)) continue; // hidden by an on-chain delete + // Withhold a sensitive conversation's messages while its local vault key is absent (locked vault or + // a fresh seed import). Fail-closed: if we can't store it under the vault key we don't surface it. + // They re-import and persist under the vault key once it is unlocked and a scan runs. + if (isConversationSensitive(message.conversation_id) && !(db_ && db_->hasSensitiveKey())) continue; if (db_ && db_->isTombstoned(message)) continue; if (meta.type == HushChatHeaderType::ContactRequest) { @@ -200,6 +204,16 @@ bool ChatService::conversationSensitiveOnChain(const std::string& conversationId auto it = sensitive_control_.find(conversationId); return it != sensitive_control_.end() && it->second.op == "sensitive"; } +bool ChatService::isConversationSensitive(const std::string& conversationId) const { + if (conversationSensitiveOnChain(conversationId)) return true; + return local_sensitive_pred_ && local_sensitive_pred_(conversationId); +} +void ChatService::setPersistence(ChatDatabase* db) { + db_ = db; + // The DB picks the per-row key by this predicate: a sensitive conversation's rows are stored under the + // local vault key. Combines the on-chain reduction with the local (instant) mark. + if (db_) db_->setSensitivePredicate([this](const std::string& cid) { return isConversationSensitive(cid); }); +} std::vector> ChatService::onChainSensitiveConversations() const { std::vector> out; for (const auto& entry : sensitive_control_) { diff --git a/src/chat/chat_service.h b/src/chat/chat_service.h index 588d6f7..1a78f77 100644 --- a/src/chat/chat_service.h +++ b/src/chat/chat_service.h @@ -53,7 +53,8 @@ public: // 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; } + // Also wires the DB's per-conversation sensitivity predicate to isConversationSensitive(). + void setPersistence(ChatDatabase* 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(); @@ -89,6 +90,16 @@ public: // Conversations whose latest on-chain control op is "sensitive", each paired with its control label. std::vector> onChainSensitiveConversations() const; + // A local "sensitive" predicate (typically Settings::isChatSensitive) giving INSTANT effect the moment + // the user marks a conversation, before the on-chain signal is harvested back. OR-combined with the + // on-chain reduction by isConversationSensitive(). + void setLocalSensitivePredicate(std::function pred) { + local_sensitive_pred_ = std::move(pred); + } + // True if this conversation is sensitive (locally marked OR on-chain). Its rows are stored under the + // local vault key; while that key is absent its messages are withheld from the store on ingest/load. + bool isConversationSensitive(const std::string& conversationId) const; + // --- Outgoing (compose) --- // My chat public key (hex), or "" without an identity — goes in an outgoing header's "p". std::string identityPublicKeyHex() const; @@ -131,6 +142,7 @@ private: ChatStore store_; ChatDatabase* db_ = nullptr; // optional; not owned std::function blocked_pred_; // true => cid is blocked (drop its messages) + std::function local_sensitive_pred_; // true => cid locally marked sensitive // Reduced on-chain control state, cid -> latest signal. Not persisted (re-derived from chain). struct ControlState { diff --git a/src/config/settings.cpp b/src/config/settings.cpp index ebabe7b..1419d5f 100644 --- a/src/config/settings.cpp +++ b/src/config/settings.cpp @@ -180,6 +180,14 @@ bool Settings::load(const std::string& path) (c.contains("name") && c["name"].is_string()) ? c["name"].get() : std::string()}); } + if (j.contains("sensitive_chat_convs") && j["sensitive_chat_convs"].is_array()) { + sensitive_chat_convs_.clear(); + for (const auto& c : j["sensitive_chat_convs"]) + if (c.is_object() && c.contains("cid") && c["cid"].is_string()) + sensitive_chat_convs_.push_back({c["cid"].get(), + (c.contains("name") && c["name"].is_string()) + ? c["name"].get() : std::string()}); + } // Chat-tab customization (re-clamped through the setters so hand-edited JSON stays in range). loadScalar(j, "chat_emoji_color", chat_emoji_color_); loadScalar(j, "chat_poll_rate_sec", chat_poll_rate_sec_); setChatPollRateSec(chat_poll_rate_sec_); @@ -544,6 +552,13 @@ bool Settings::save(const std::string& path) o["name"] = b.name; j["blocked_chat_convs"].push_back(o); } + j["sensitive_chat_convs"] = json::array(); + for (const auto& s : sensitive_chat_convs_) { + json o; + o["cid"] = s.cid; + o["name"] = s.name; + j["sensitive_chat_convs"].push_back(o); + } j["chat_emoji_color"] = chat_emoji_color_; j["chat_poll_rate_sec"] = chat_poll_rate_sec_; j["chat_bubble_style"] = chat_bubble_style_; diff --git a/src/config/settings.h b/src/config/settings.h index d0e4829..b73de58 100644 --- a/src/config/settings.h +++ b/src/config/settings.h @@ -182,6 +182,26 @@ public: } const std::vector& blockedChatConversations() const { return blocked_chat_convs_; } + // Sensitive chat conversations (by cid): their local rows are stored under the local chat vault key + // (not the seed key) and are withheld without it. Kept locally for INSTANT effect the moment the user + // marks one; the mark is also signaled on-chain so other devices / a fresh seed import gate it too. + // The last-known peer name labels the sensitive-conversations list. + struct SensitiveChatConv { std::string cid; std::string name; }; + bool isChatSensitive(const std::string& cid) const { + for (const auto& s : sensitive_chat_convs_) if (s.cid == cid) return true; + return false; + } + void setChatSensitive(const std::string& cid, const std::string& name, bool sensitive) { + const bool already = isChatSensitive(cid); + if (sensitive && !already) sensitive_chat_convs_.push_back({cid, name}); + else if (!sensitive && already) + sensitive_chat_convs_.erase( + std::remove_if(sensitive_chat_convs_.begin(), sensitive_chat_convs_.end(), + [&](const SensitiveChatConv& s) { return s.cid == cid; }), + sensitive_chat_convs_.end()); + } + const std::vector& sensitiveChatConversations() const { return sensitive_chat_convs_; } + // ── Chat-tab customization (chat settings modal + Settings → Chat & Contacts) ────── bool getChatEmojiColor() const { return chat_emoji_color_; } void setChatEmojiColor(bool v) { chat_emoji_color_ = v; } @@ -694,6 +714,7 @@ private: std::vector muted_chat_cids_; // muted chat conversations by cid (Q10) std::vector hidden_chat_cids_; // hidden chat conversations by cid std::vector blocked_chat_convs_; // blocked chat conversations (cid + last-known name) + std::vector sensitive_chat_convs_; // sensitive chat conversations (cid + last-known name) // Chat-tab customization (chat settings modal + Settings → Chat & Contacts). bool chat_emoji_color_ = true; // true = color (needs FreeType; falls back to mono if absent), false = monochrome float chat_poll_rate_sec_ = 2.5f; // 0-conf chat fast-scan cadence (full node) diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 0a2b5ff..fa14a6c 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -15382,6 +15382,66 @@ void testChatSensitiveDatabase() scrub(dbPath); } +// Service-level sensitive gating: a sensitive conversation's messages are withheld on ingest and on +// reload while the DB vault key is absent, and flow + persist once it is present. +void testHushChatSensitiveService() +{ + using namespace dragonx::chat; + namespace fs = std::filesystem; + const std::string dbPath = (fs::temp_directory_path() / "drgx_chat_senssvc_test.sqlite").string(); + auto scrub = [](const std::string& p) { fs::remove(p); fs::remove(p + "-wal"); fs::remove(p + "-shm"); }; + scrub(dbPath); + + ChatKeyPair me, peer; + ChatIdentityResult rm = deriveChatIdentityFromSecret("senssvc-me", me, true); + ChatIdentityResult rp = deriveChatIdentityFromSecret("senssvc-peer", peer, true); + EXPECT_TRUE(rm.status == ChatIdentityStatus::Ready && rp.status == ChatIdentityStatus::Ready); + + ChatDatabase db(dbPath); + EXPECT_TRUE(db.unlockWithSecret("seed-xyz")); + ChatService svc; svc.setIdentity(me); svc.setPersistence(&db); + svc.setLocalSensitivePredicate([](const std::string& cid) { return cid == "sens"; }); + EXPECT_TRUE(svc.isConversationSensitive("sens")); + EXPECT_TRUE(!svc.isConversationSensitive("normal")); + + auto feedPeerMsg = [&](const std::string& cid, const std::string& txid, const std::string& body) { + OutgoingChatMemos m; + EXPECT_TRUE(buildOutgoingMessage(peer, rp.public_key_hex, "zs-peer", rm.public_key_hex, "zs-me", + cid, body, m) == ChatComposeStatus::Ok); + HushChatTransactionInput tx; tx.txid = txid; + tx.outputs.push_back({0, m.headerMemo}); tx.outputs.push_back({1, m.payloadMemo}); + auto ex = extractHushChatTransactionMetadata(tx, true); + const std::int64_t t = static_cast(std::time(nullptr)); + return svc.ingest(ex.metadata, {{txid, t}}, t); + }; + + // Vault locked: a sensitive message is withheld (fail-closed); a normal one flows. + EXPECT_EQ(feedPeerMsg("normal", "n1", "hi"), 1); + EXPECT_EQ(feedPeerMsg("sens", "s1", "secret"), 0); + EXPECT_EQ((int)svc.store().conversation("sens").size(), 0); + EXPECT_EQ((int)svc.store().conversation("normal").size(), 1); + + // Unlock the vault (provide a key): the sensitive message now flows and persists. + std::array vkey; vkey.fill(0x33); + db.setSensitiveKey(vkey); + EXPECT_EQ(feedPeerMsg("sens", "s1", "secret"), 1); + EXPECT_EQ((int)svc.store().conversation("sens").size(), 1); + + // Reload from the DB with the vault LOCKED: sensitive withheld, normal loads. + db.lockSensitive(); + svc.store().clear(); + svc.loadFromDatabase(); + EXPECT_EQ((int)svc.store().conversation("sens").size(), 0); + EXPECT_EQ((int)svc.store().conversation("normal").size(), 1); + + // Reload with the vault key present: the sensitive thread reappears. + db.setSensitiveKey(vkey); + svc.store().clear(); + svc.loadFromDatabase(); + EXPECT_EQ((int)svc.store().conversation("sens").size(), 1); + scrub(dbPath); +} + void testHushChatDatabase() { using namespace dragonx::chat; @@ -20238,6 +20298,7 @@ int main() testHushChatService(); testHushChatDatabase(); testChatSensitiveDatabase(); + testHushChatSensitiveService(); testHushChatDeleteConversation(); testChatStoreTxidIndex(); testChatStoreLatestAndMove();