feat(chat): service-level sensitive gating + local sensitive marks
Wires the sensitive-conversation plumbing into ChatService + Settings: - Settings gains a persisted local sensitive set (isChatSensitive / setChatSensitive / sensitiveChatConversations), mirroring the blocked set, for INSTANT effect the moment the user marks a conversation (before the on-chain signal is harvested back). - ChatService::isConversationSensitive() OR-combines that local mark with the on-chain reduction, and setPersistence() wires it as the DB's per-row key selector (sensitive rows -> vault key). - ingest() withholds a sensitive conversation's messages while the vault key is absent (fail-closed: not shown, not persisted); they re-import + persist under the vault key once it is unlocked and a scan runs. loadFromDatabase() already withholds via the DB's two-key try. Service-tested end to end against a real ChatDatabase (ingest + reload). Phase B, step 4 (service gating). App vault lifecycle + UI next. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -88,6 +88,10 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
|
|||||||
// in the same conversation has a different txid and flows through normally.
|
// in the same conversation has a different txid and flows through normally.
|
||||||
if (blocked_pred_ && blocked_pred_(message.conversation_id)) continue;
|
if (blocked_pred_ && blocked_pred_(message.conversation_id)) continue;
|
||||||
if (conversationHiddenOnChain(message.conversation_id)) continue; // hidden by an on-chain delete
|
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 (db_ && db_->isTombstoned(message)) continue;
|
||||||
|
|
||||||
if (meta.type == HushChatHeaderType::ContactRequest) {
|
if (meta.type == HushChatHeaderType::ContactRequest) {
|
||||||
@@ -200,6 +204,16 @@ bool ChatService::conversationSensitiveOnChain(const std::string& conversationId
|
|||||||
auto it = sensitive_control_.find(conversationId);
|
auto it = sensitive_control_.find(conversationId);
|
||||||
return it != sensitive_control_.end() && it->second.op == "sensitive";
|
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<std::pair<std::string, std::string>> ChatService::onChainSensitiveConversations() const {
|
std::vector<std::pair<std::string, std::string>> ChatService::onChainSensitiveConversations() const {
|
||||||
std::vector<std::pair<std::string, std::string>> out;
|
std::vector<std::pair<std::string, std::string>> out;
|
||||||
for (const auto& entry : sensitive_control_) {
|
for (const auto& entry : sensitive_control_) {
|
||||||
|
|||||||
+13
-1
@@ -53,7 +53,8 @@ public:
|
|||||||
|
|
||||||
// Attach a persistent backing store (Phase 2). Not owned. Pass nullptr to detach. New messages
|
// 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.
|
// 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
|
// 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.
|
// the seed-derived key) into the in-memory store. No-op without an unlocked database.
|
||||||
void loadFromDatabase();
|
void loadFromDatabase();
|
||||||
@@ -89,6 +90,16 @@ public:
|
|||||||
// Conversations whose latest on-chain control op is "sensitive", each paired with its control label.
|
// Conversations whose latest on-chain control op is "sensitive", each paired with its control label.
|
||||||
std::vector<std::pair<std::string, std::string>> onChainSensitiveConversations() const;
|
std::vector<std::pair<std::string, std::string>> 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<bool(const std::string&)> 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) ---
|
// --- Outgoing (compose) ---
|
||||||
// My chat public key (hex), or "" without an identity — goes in an outgoing header's "p".
|
// My chat public key (hex), or "" without an identity — goes in an outgoing header's "p".
|
||||||
std::string identityPublicKeyHex() const;
|
std::string identityPublicKeyHex() const;
|
||||||
@@ -131,6 +142,7 @@ private:
|
|||||||
ChatStore store_;
|
ChatStore store_;
|
||||||
ChatDatabase* db_ = nullptr; // optional; not owned
|
ChatDatabase* db_ = nullptr; // optional; not owned
|
||||||
std::function<bool(const std::string&)> blocked_pred_; // true => cid is blocked (drop its messages)
|
std::function<bool(const std::string&)> blocked_pred_; // true => cid is blocked (drop its messages)
|
||||||
|
std::function<bool(const std::string&)> local_sensitive_pred_; // true => cid locally marked sensitive
|
||||||
|
|
||||||
// Reduced on-chain control state, cid -> latest signal. Not persisted (re-derived from chain).
|
// Reduced on-chain control state, cid -> latest signal. Not persisted (re-derived from chain).
|
||||||
struct ControlState {
|
struct ControlState {
|
||||||
|
|||||||
@@ -180,6 +180,14 @@ bool Settings::load(const std::string& path)
|
|||||||
(c.contains("name") && c["name"].is_string())
|
(c.contains("name") && c["name"].is_string())
|
||||||
? c["name"].get<std::string>() : std::string()});
|
? c["name"].get<std::string>() : 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<std::string>(),
|
||||||
|
(c.contains("name") && c["name"].is_string())
|
||||||
|
? c["name"].get<std::string>() : std::string()});
|
||||||
|
}
|
||||||
// Chat-tab customization (re-clamped through the setters so hand-edited JSON stays in range).
|
// 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_emoji_color", chat_emoji_color_);
|
||||||
loadScalar(j, "chat_poll_rate_sec", chat_poll_rate_sec_); setChatPollRateSec(chat_poll_rate_sec_);
|
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;
|
o["name"] = b.name;
|
||||||
j["blocked_chat_convs"].push_back(o);
|
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_emoji_color"] = chat_emoji_color_;
|
||||||
j["chat_poll_rate_sec"] = chat_poll_rate_sec_;
|
j["chat_poll_rate_sec"] = chat_poll_rate_sec_;
|
||||||
j["chat_bubble_style"] = chat_bubble_style_;
|
j["chat_bubble_style"] = chat_bubble_style_;
|
||||||
|
|||||||
@@ -182,6 +182,26 @@ public:
|
|||||||
}
|
}
|
||||||
const std::vector<BlockedChatConv>& blockedChatConversations() const { return blocked_chat_convs_; }
|
const std::vector<BlockedChatConv>& 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<SensitiveChatConv>& sensitiveChatConversations() const { return sensitive_chat_convs_; }
|
||||||
|
|
||||||
// ── Chat-tab customization (chat settings modal + Settings → Chat & Contacts) ──────
|
// ── Chat-tab customization (chat settings modal + Settings → Chat & Contacts) ──────
|
||||||
bool getChatEmojiColor() const { return chat_emoji_color_; }
|
bool getChatEmojiColor() const { return chat_emoji_color_; }
|
||||||
void setChatEmojiColor(bool v) { chat_emoji_color_ = v; }
|
void setChatEmojiColor(bool v) { chat_emoji_color_ = v; }
|
||||||
@@ -694,6 +714,7 @@ private:
|
|||||||
std::vector<std::string> muted_chat_cids_; // muted chat conversations by cid (Q10)
|
std::vector<std::string> muted_chat_cids_; // muted chat conversations by cid (Q10)
|
||||||
std::vector<std::string> hidden_chat_cids_; // hidden chat conversations by cid
|
std::vector<std::string> hidden_chat_cids_; // hidden chat conversations by cid
|
||||||
std::vector<BlockedChatConv> blocked_chat_convs_; // blocked chat conversations (cid + last-known name)
|
std::vector<BlockedChatConv> blocked_chat_convs_; // blocked chat conversations (cid + last-known name)
|
||||||
|
std::vector<SensitiveChatConv> sensitive_chat_convs_; // sensitive chat conversations (cid + last-known name)
|
||||||
// Chat-tab customization (chat settings modal + Settings → Chat & Contacts).
|
// 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
|
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)
|
float chat_poll_rate_sec_ = 2.5f; // 0-conf chat fast-scan cadence (full node)
|
||||||
|
|||||||
@@ -15382,6 +15382,66 @@ void testChatSensitiveDatabase()
|
|||||||
scrub(dbPath);
|
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::int64_t>(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<unsigned char, 32> 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()
|
void testHushChatDatabase()
|
||||||
{
|
{
|
||||||
using namespace dragonx::chat;
|
using namespace dragonx::chat;
|
||||||
@@ -20238,6 +20298,7 @@ int main()
|
|||||||
testHushChatService();
|
testHushChatService();
|
||||||
testHushChatDatabase();
|
testHushChatDatabase();
|
||||||
testChatSensitiveDatabase();
|
testChatSensitiveDatabase();
|
||||||
|
testHushChatSensitiveService();
|
||||||
testHushChatDeleteConversation();
|
testHushChatDeleteConversation();
|
||||||
testChatStoreTxidIndex();
|
testChatStoreTxidIndex();
|
||||||
testChatStoreLatestAndMove();
|
testChatStoreLatestAndMove();
|
||||||
|
|||||||
Reference in New Issue
Block a user