diff --git a/src/app.cpp b/src/app.cpp index 028ec56..a4377b0 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -602,6 +602,13 @@ void App::rebuildLiteWallet(bool force) // controller but never reopen, leaving a permanent "disconnected" state. lite_autoopen_done_ = false; lite_open_error_.clear(); + + // A rebuilt controller may back a different wallet (server switch / re-open); drop any chat + // identity and re-arm provisioning so it re-derives from the newly opened wallet's seed. + chat_service_.clearIdentity(); + chat_identity_provisioned_ = false; + chat_identity_fetch_in_flight_ = false; + chat_identity_unavailable_ = false; } void App::update() @@ -696,6 +703,11 @@ void App::update() } async_tasks_.reapCompleted(); + // HushChat: once the wallet seed is reachable+unlocked, derive & set the chat identity so the + // transaction-refresh harvest can decrypt incoming memos. Cheap early-outs keep it idle until + // it can act; compiled away when the feature is off (constexpr gate inside). + maybeProvisionChatIdentity(); + // Auto-lock check (only when connected + encrypted + unlocked) if (state_.connected && state_.isUnlocked()) { checkAutoLock(); diff --git a/src/app.h b/src/app.h index ec41ff8..bdfacb0 100644 --- a/src/app.h +++ b/src/app.h @@ -23,6 +23,7 @@ #include "util/async_task_manager.h" #include "util/pool_stats_service.h" #include "wallet/wallet_capabilities.h" +#include "chat/chat_service.h" #include "ui/sidebar.h" #include "ui/windows/console_tab.h" #include "imgui.h" @@ -570,6 +571,20 @@ private: // Reason an existing lite wallet failed to auto-open (e.g. server unreachable). Surfaced in // the UI so a stuck "disconnected" state isn't silent; cleared once a wallet opens. std::string lite_open_error_; + + // HushChat (experimental; gated by DRAGONX_ENABLE_CHAT — inert when OFF). App owns the chat + // service so the transaction-refresh harvest can decrypt incoming memos into threaded messages. + // The identity is derived from the wallet's OWN SDXLite-compatible seed phrase (full-node + // z_exportmnemonic / lite exportSeed → the same KDF), so it is portable across both variants. + chat::ChatService chat_service_; + 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 + bool chat_identity_unavailable_ = false; // provisioning failed definitively (e.g. non-mnemonic wallet) + // Provision the chat identity once the wallet seed is reachable+unlocked (per-tick, both + // variants); derives via deriveChatIdentityFromSecret and wipes the secret. No-op when the + // feature is off, already provisioned, in flight, or unavailable. + void maybeProvisionChatIdentity(); + void provisionChatIdentityFromSecret(std::string secret); // Lite first-run welcome prompt: dismissed for the session once the user picks an action. bool lite_firstrun_dismissed_ = false; // Lite send-time unlock: set to show the unlock modal when a spend is attempted while locked. diff --git a/src/app_network.cpp b/src/app_network.cpp index ed5e4ce..5192894 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -33,6 +33,9 @@ #include "rpc/rpc_client.h" #include "rpc/rpc_worker.h" #include "rpc/connection.h" +#include "chat/chat_identity.h" // deriveChatIdentityFromSecret for HushChat identity provisioning +#include // sodium_memzero for wiping the fetched mnemonic +#include #include "config/settings.h" #include "wallet/lite_wallet_controller.h" // lite send/new-address routing #include "daemon/daemon_controller.h" @@ -1476,6 +1479,13 @@ void App::refreshTransactionData() confirmed_cache_block_, last_tx_block_height_ }; + // HushChat: decrypt & thread any harvested chat memos BEFORE `result` is moved into + // applyTransactionRefreshResult. No-op when the feature is off or no identity is set; + // the store dedups (txid+position) so the full + recent refresh paths ingest safely. + if (chat::hushChatFeatureEnabledAtBuild() && chat_service_.hasIdentity() && + !result.hushChatMetadata.empty()) { + chat_service_.ingest(result.hushChatMetadata, std::time(nullptr)); + } NetworkRefreshService::applyTransactionRefreshResult( state_, cacheUpdate, std::move(result), std::time(nullptr)); shielded_history_scan_heights_ = std::move(shieldedScanHeights); @@ -1526,6 +1536,12 @@ void App::refreshRecentTransactionData() confirmed_cache_block_, last_tx_block_height_ }; + // HushChat: decrypt & thread any harvested chat memos BEFORE `result` is moved (see the + // full-refresh path above for rationale; the store dedups across both paths). + if (chat::hushChatFeatureEnabledAtBuild() && chat_service_.hasIdentity() && + !result.hushChatMetadata.empty()) { + chat_service_.ingest(result.hushChatMetadata, std::time(nullptr)); + } NetworkRefreshService::applyTransactionRefreshResult( state_, cacheUpdate, std::move(result), std::time(nullptr)); shielded_history_scan_heights_ = std::move(shieldedScanHeights); @@ -2329,6 +2345,100 @@ void App::exportPrivateKey(const std::string& address, std::function(c)) != 0; }; + while (!secret.empty() && isws(secret.back())) secret.pop_back(); + std::size_t start = 0; + while (start < secret.size() && isws(secret[start])) ++start; + if (start) secret.erase(0, start); + + chat::ChatKeyPair keys; + const auto result = chat::deriveChatIdentityFromSecret(secret, keys); + if (!secret.empty()) sodium_memzero(&secret[0], secret.size()); + + if (result.status == chat::ChatIdentityStatus::Ready) { + chat_service_.setIdentity(keys); // copies the keypair + chat_identity_provisioned_ = true; + } else { + chat_identity_unavailable_ = true; + } + chat::wipeChatKeyPair(keys); +} + +// Provision the chat identity once the wallet's seed phrase is reachable. Called every update() +// tick; cheap early-outs keep it idle until it can act. Both variants derive the SAME identity +// because they feed the SAME SDXLite-compatible mnemonic into the KDF (identity derivation is +// local — peers only ever exchange public keys). +void App::maybeProvisionChatIdentity() +{ + if (!chat::hushChatFeatureEnabledAtBuild()) return; // constexpr — folds away in OFF builds + + // Locked (encrypted && locked, mirrored for both variants) → the seed is unreadable. Drop any + // in-memory identity and re-arm so it re-derives on the next unlock. Unencrypted wallets report + // isLocked()==false and fall through. + if (state_.isLocked()) { + if (chat_identity_provisioned_ || chat_service_.hasIdentity()) { + chat_service_.clearIdentity(); + chat_identity_provisioned_ = false; + chat_identity_unavailable_ = false; + } + return; + } + + if (chat_service_.hasIdentity() || chat_identity_provisioned_ || + chat_identity_fetch_in_flight_ || chat_identity_unavailable_) return; + + if (supportsLiteBackend()) { + // Lite: the backend's `seed` command returns the 24-word mnemonic synchronously. + if (!lite_wallet_ || !lite_wallet_->walletOpen()) return; + wallet::LiteSeedResult seed = lite_wallet_->exportSeed(); + if (!seed.ok || seed.seedPhrase.empty()) { + wallet::secureWipeLiteSecret(seed.seedPhrase); + chat_identity_unavailable_ = true; + return; + } + provisionChatIdentityFromSecret(seed.seedPhrase); // copies internally + wallet::secureWipeLiteSecret(seed.seedPhrase); + } else if (supportsFullNodeLifecycleActions()) { + // Full-node: z_exportmnemonic blocks on synchronous curl — fetch it off the UI thread and + // provision on the main thread. Requires a mnemonic wallet (created/restored from a phrase); + // a legacy random-seed wallet is rejected by the daemon (RpcError) → identity unavailable. + if (!state_.connected || !rpc_ || !worker_) return; + if (!state_.encryption_state_known) return; // don't fetch before we know the lock state + chat_identity_fetch_in_flight_ = true; + worker_->post([this]() -> rpc::RPCWorker::MainCb { + std::string mnemonic; + bool definitiveFail = false; // daemon rejected (non-mnemonic wallet) — do not retry + bool transientFail = false; // connection blip — allow a later retry + try { + rpc::RPCClient::TraceScope trace("HushChat / identity"); + mnemonic = rpc_->call("z_exportmnemonic").value("mnemonic", std::string()); + } catch (const rpc::RpcError&) { + definitiveFail = true; + } catch (const std::exception&) { + transientFail = true; + } + return [this, mnemonic = std::move(mnemonic), definitiveFail, transientFail]() mutable { + chat_identity_fetch_in_flight_ = false; + if (definitiveFail) { chat_identity_unavailable_ = true; return; } + if (transientFail || mnemonic.empty()) return; // retry a later tick + provisionChatIdentityFromSecret(mnemonic); // copies internally + if (!mnemonic.empty()) sodium_memzero(&mnemonic[0], mnemonic.size()); + }; + }); + } +} + void App::exportAllKeys(std::function callback) { if (!state_.connected || !rpc_) {