fix(chat): tear down the chat session immediately on lite wallet lock

Security-review finding (low): on the lite variant, "Lock now" ran the backend
`lock` but never updated state_.locked — that only refreshes on the next ~2s
poll. Since chat teardown is driven by state_.isLocked() in maybeProvision
ChatIdentity, the decrypted in-memory store, the chat identity secret key, and
the seed-derived AEAD DB key all lingered in RAM for up to ~2s past an explicit
lock (the full-node path closes this immediately, since App::lockWallet sets
state_.locked synchronously).

New App::lockLiteWallet() mirrors the full-node behavior: on a successful lite
lock it sets state_.locked and tears down the chat session now (clearIdentity +
store().clear() + chat_db_.lock() + re-arm). The lite "Lock now" button routes
through it. (Note: the lite variant has no auto-lock at all — checkAutoLock ->
App::lockWallet early-returns for lite — which is a separate, general lite gap.)

Completes the end-to-end feature security review (1 confirmed finding of 3 raw,
now fixed). Verified: Linux + Windows build with chat ON, ctest 100%, hygiene clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-06 19:51:44 -05:00
parent 516e5ec688
commit f5bde67f64
3 changed files with 24 additions and 1 deletions

View File

@@ -182,6 +182,9 @@ public:
const std::string& liteOpenError() const { return lite_open_error_; }
// Show the lite send-time unlock modal (called when a spend is attempted on a locked wallet).
void requestLiteUnlock() { lite_unlock_prompt_ = true; }
// Lock the lite wallet AND immediately tear down the chat session (the lite backend `lock`
// doesn't update state_.locked until the next poll, so chat secrets would otherwise linger).
bool lockLiteWallet();
// (Re)build the lite controller from current settings so a changed lite-server selection
// takes effect. No-op on non-lite/unlinked builds; preserves a live wallet (see app.cpp).
void rebuildLiteWallet(bool force = false);

View File

@@ -2660,6 +2660,25 @@ void App::ingestLiteChatMemos(const wallet::LiteWalletAppRefreshModel& model)
if (!metadata.empty()) chat_service_.ingest(metadata, txTimestamps, std::time(nullptr));
}
bool App::lockLiteWallet()
{
if (!lite_wallet_) return false;
const bool ok = lite_wallet_->lockWallet();
if (ok) {
// Full-node App::lockWallet() sets state_.locked synchronously; the lite backend `lock`
// doesn't, and state_.locked only refreshes on the next ~2s poll. Mirror the full-node
// behavior here and tear down the chat session NOW so no decrypted store / unlocked DB key
// lingers past an explicit lock. (These chat calls are safe no-ops when chat is off/idle.)
state_.locked = true;
chat_service_.clearIdentity();
chat_service_.store().clear();
chat_db_.lock();
chat_identity_provisioned_ = false;
chat_identity_unavailable_ = false;
}
return ok;
}
void App::seedChatDemoData()
{
if (!chat::hushChatFeatureEnabledAtBuild()) return;

View File

@@ -1818,8 +1818,9 @@ void RenderSettingsPage(App* app) {
} else {
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
if (TactileButton(TrId("lite_lock_now", "LiteLock").c_str(), ImVec2(0, 0), S.resolveFont("button"))) {
// Route through App so the chat session is torn down immediately on lock.
s_settingsState.lite_encryption_status =
app->liteWallet()->lockWallet() ? TR("lite_wallet_locked") : TR("lite_lock_failed");
app->lockLiteWallet() ? TR("lite_wallet_locked") : TR("lite_lock_failed");
}
}
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));