From 6e285bd4a4e1b8600f8ba2484ea2c24bc0586284 Mon Sep 17 00:00:00 2001 From: DanS Date: Fri, 23 Oct 2026 17:47:57 -0500 Subject: [PATCH] feat(chat): App vault lifecycle for sensitive conversations Adds the App-level glue that makes sensitive mode operable: - Owns ChatSensitiveVault (path /chat_sensitive.vault); provisioning wires the service's local sensitive predicate to Settings::isChatSensitive and points the vault at its path (left LOCKED until the user enters its passphrase). - chatSensitiveVaultState() (NotConfigured/Locked/Unlocked) + create / unlock / lock: unlock pushes the key to chat_db_ and pulls sensitive rows into the store (+ re-harvest); lock drops the key and reloads so sensitive threads vanish. - markChatConversationSensitive / unmark: migrate the thread's existing rows to/from the vault key via upsert (so the whole thread hides when locked) and broadcast the on-chain "sensitive"/"unsensitive" signal via sendChatControl. (wallet lock already wipes the vault key via chat_db_.lock().) Phase B, step 5a (App lifecycle). UI next. Co-Authored-By: Claude Opus 4.8 --- src/app.h | 18 ++++++++++ src/app_network.cpp | 81 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+) diff --git a/src/app.h b/src/app.h index 22ee680..e7cf1b6 100644 --- a/src/app.h +++ b/src/app.h @@ -49,6 +49,7 @@ #include "wallet/wallet_capabilities.h" #include "chat/chat_service.h" #include "chat/chat_database.h" +#include "chat/chat_sensitive_vault.h" #include "ui/sidebar.h" #include "ui/windows/console_tab.h" #include "imgui.h" @@ -325,6 +326,21 @@ public: // hides/restores this conversation. `label` is a plaintext hint (peer address/name) carried on-chain. // Reuses the chat send coordinator + the 0-value kChatMinFeeDrgx z_sendmany path; no UI echo bubble. void sendChatControl(const std::string& conversationId, const std::string& op, const std::string& label); + + // --- Sensitive conversations (per-conversation local-key encryption) --- + enum class ChatVaultState { NotConfigured, Locked, Unlocked }; + ChatVaultState chatSensitiveVaultState() const; + bool chatSensitiveVaultConfigured() const; // a vault file exists (regardless of lock state) + // Create the vault (first-time setup) and unlock it. Returns false if one already exists or on error. + bool createChatSensitiveVault(const std::string& passphrase); + // Provide the vault key to the chat DB so sensitive rows decrypt; also pulls them into the store. + bool unlockChatSensitiveVault(const std::string& passphrase); + void lockChatSensitiveVault(); // drop the key; sensitive threads vanish from the view + // Mark/unmark a conversation sensitive. Requires the vault UNLOCKED (migrates its rows to/from the + // vault key) and broadcasts the on-chain "sensitive"/"unsensitive" signal. `peerAddr` labels it. + bool markChatConversationSensitive(const std::string& cid, const std::string& peerAddr); + void unmarkChatConversationSensitive(const std::string& cid); + // Debug/sweep convenience: give the Chat tab a demo identity + a few sample conversations // (in-memory only, not persisted) so the screenshot sweep captures the populated UI. No-op when // the chat feature is off. Returns true when it had to fabricate the (random) demo identity @@ -1024,6 +1040,8 @@ private: // 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) + chat::ChatSensitiveVault chat_sensitive_vault_; // local key vault for "sensitive" conversations + std::string chatSensitiveVaultPath() const; // /chat_sensitive.vault 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 std::chrono::steady_clock::time_point chat_identity_retry_at_{}; // after a transient failure diff --git a/src/app_network.cpp b/src/app_network.cpp index 9b274e1..1db3405 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -4217,6 +4217,13 @@ void App::provisionChatIdentityFromSecret(std::string secret) chat_service_.setBlockedPredicate([this](const std::string& cid) { return settings_ && settings_->isChatBlocked(cid); }); + // Sensitive conversations: the local mark gives instant effect; the DB routes those rows to the + // vault key. The vault itself stays LOCKED until the user enters its passphrase (the DB then + // withholds sensitive rows), so provisioning never auto-unlocks it. + chat_service_.setLocalSensitivePredicate([this](const std::string& cid) { + return settings_ && settings_->isChatSensitive(cid); + }); + chat_sensitive_vault_.setPath(chatSensitiveVaultPath()); if (chat_db_.unlockWithSecret(trimmed)) { chat_service_.loadFromDatabase(); // Baseline unread: treat everything already in the store at load as read, so only messages @@ -4704,6 +4711,80 @@ void App::sendChatControl(const std::string& conversationId, const std::string& enqueueChatSend(LiteOpKind::ChatSend, memos, /*echoLocalId=*/""); } +std::string App::chatSensitiveVaultPath() const +{ + return (std::filesystem::path(util::Platform::getConfigDir()) / "chat_sensitive.vault").string(); +} + +bool App::chatSensitiveVaultConfigured() const +{ + chat::ChatSensitiveVault probe(chatSensitiveVaultPath()); + return probe.exists(); +} + +App::ChatVaultState App::chatSensitiveVaultState() const +{ + if (!chatSensitiveVaultConfigured()) return ChatVaultState::NotConfigured; + return chat_db_.hasSensitiveKey() ? ChatVaultState::Unlocked : ChatVaultState::Locked; +} + +bool App::createChatSensitiveVault(const std::string& passphrase) +{ + chat_sensitive_vault_.setPath(chatSensitiveVaultPath()); + std::array key{}; + const bool ok = chat_sensitive_vault_.create(passphrase, key); + if (ok) chat_db_.setSensitiveKey(key); // the DB now encrypts sensitive rows under this key + sodium_memzero(key.data(), key.size()); + return ok; +} + +bool App::unlockChatSensitiveVault(const std::string& passphrase) +{ + chat_sensitive_vault_.setPath(chatSensitiveVaultPath()); + std::array key{}; + const bool ok = chat_sensitive_vault_.unlock(passphrase, key); + if (ok) { + chat_db_.setSensitiveKey(key); + chat_service_.loadFromDatabase(); // sensitive rows now decrypt → pull them into the store + chat_note_rescan_requested_ = true; // re-harvest any that were withheld while the vault was locked + } + sodium_memzero(key.data(), key.size()); + return ok; +} + +void App::lockChatSensitiveVault() +{ + chat_db_.lockSensitive(); + // Drop the now-undecryptable sensitive threads from the view at once: reload (the DB withholds any + // row it can't decrypt without the vault key), keeping the in-memory store == what is readable. + chat_service_.store().clear(); + chat_service_.loadFromDatabase(); +} + +bool App::markChatConversationSensitive(const std::string& cid, const std::string& peerAddr) +{ + if (cid.empty() || chatSensitiveVaultState() != ChatVaultState::Unlocked) return false; + if (settings_) { settings_->setChatSensitive(cid, peerAddr, true); settings_->save(); } + // Migrate this conversation's EXISTING rows from the seed key to the vault key: the predicate is now + // true, so upsert() re-encrypts each under the vault key — so the WHOLE thread hides when the vault + // locks (new messages already go to the vault key). Incoming history stays re-harvestable from chain + // (the honest caveat the UI warns about); this protects the local at-rest copy. + for (const auto& m : chat_service_.store().conversation(cid)) chat_db_.upsert(m); + sendChatControl(cid, "sensitive", peerAddr.empty() ? cid : peerAddr); // signal other devices / fresh import + return true; +} + +void App::unmarkChatConversationSensitive(const std::string& cid) +{ + if (cid.empty()) return; + if (settings_) { settings_->setChatSensitive(cid, "", false); settings_->save(); } + // Migrate rows back to the seed key (predicate now false). Needs the vault unlocked so the rows are in + // the store to re-write; if it is locked there is nothing to migrate (they re-key on the next scan). + if (chat_db_.hasSensitiveKey()) + for (const auto& m : chat_service_.store().conversation(cid)) chat_db_.upsert(m); + sendChatControl(cid, "unsensitive", cid); +} + void App::startChatConversation(const std::string& peerZaddr, const std::string& text) { if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return;