feat(chat): App vault lifecycle for sensitive conversations

Adds the App-level glue that makes sensitive mode operable:
- Owns ChatSensitiveVault (path <configDir>/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 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 17:47:57 -05:00
co-authored by Claude Opus 4.8
parent fe3d2aa935
commit 6e285bd4a4
2 changed files with 99 additions and 0 deletions
+18
View File
@@ -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; // <configDir>/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
+81
View File
@@ -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<unsigned char, 32> 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<unsigned char, 32> 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;