feat(lite): encryption UI — encrypt/unlock/lock/decrypt in Settings

Add a "Security" subsection to Settings → Backup & keys (open wallet only) that
wires the encryption controller methods to the UI:

- Unencrypted wallet: passphrase field + "Encrypt wallet".
- Encrypted + locked: "Unlock" (passphrase) ; Encrypted + unlocked: "Lock now".
- Encrypted: passphrase + "Remove encryption" (decrypt).
- Status line reflects the result; state shown from WalletState.isEncrypted()/
  isLocked() (kept current by the controller's encryptionstatus refresh poll).

Secret hygiene: the passphrase inputs (lite_enc_pass / lite_dec_pass) are
sodium-zeroed immediately after each action and when the wallet closes while the
section was open.

Runtime-checked: app auto-opens a wallet and the new encryptionstatus worker poll
runs clean (no errors); tests pass; hygiene clean.

Follow-ups (not yet): a send-time unlock prompt and a startup lock-screen overlay
for an encrypted+locked wallet (today: unlock via Settings; balances remain
viewable while locked).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 17:55:09 -05:00
parent 50b0419dfe
commit 9569b0ba43

View File

@@ -128,6 +128,11 @@ struct SettingsPageState {
std::string lite_export_label;
char lite_import_key[512] = "";
std::string lite_backup_status;
// Encryption passphrase inputs (SECRET: zeroed right after each action). lite_enc_pass is
// reused for Encrypt (unencrypted wallet) and Unlock (locked wallet); lite_dec_pass for Decrypt.
char lite_enc_pass[128] = "";
char lite_dec_pass[128] = "";
std::string lite_encryption_status;
bool mine_when_idle = false;
int mine_idle_delay = 120;
bool idle_thread_scaling = false;
@@ -1809,10 +1814,74 @@ void RenderSettingsPage(App* app) {
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(),
s_settingsState.lite_backup_status.c_str());
}
// ---- Security: passphrase encryption (encrypt / unlock / lock / decrypt) ----
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
Type().text(TypeStyle::Body2, "Security");
const auto& wstate = app->getWalletState();
const float encLabelX = leftX - sectionOrigin.x + liteLabelW;
if (!wstate.isEncrypted()) {
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted("Passphrase");
ImGui::SameLine(encLabelX);
ImGui::SetNextItemWidth(liteInputW);
ImGui::InputText("##LiteEncryptPass", s_settingsState.lite_enc_pass,
sizeof(s_settingsState.lite_enc_pass), ImGuiInputTextFlags_Password);
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
if (TactileButton("Encrypt wallet##LiteEncrypt", ImVec2(0, 0), S.resolveFont("button"))) {
const auto r = app->liteWallet()->encryptWallet(s_settingsState.lite_enc_pass);
sodium_memzero(s_settingsState.lite_enc_pass, sizeof(s_settingsState.lite_enc_pass));
s_settingsState.lite_encryption_status = r.ok ? "Wallet encrypted" : r.error;
}
} else {
if (wstate.isLocked()) {
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted("Unlock");
ImGui::SameLine(encLabelX);
ImGui::SetNextItemWidth(liteInputW);
ImGui::InputText("##LiteUnlockPass", s_settingsState.lite_enc_pass,
sizeof(s_settingsState.lite_enc_pass), ImGuiInputTextFlags_Password);
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
if (TactileButton("Unlock##LiteUnlock", ImVec2(0, 0), S.resolveFont("button"))) {
const bool ok = app->liteWallet()->unlockWallet(s_settingsState.lite_enc_pass);
sodium_memzero(s_settingsState.lite_enc_pass, sizeof(s_settingsState.lite_enc_pass));
s_settingsState.lite_encryption_status = ok ? "Wallet unlocked" : "Unlock failed";
}
} else {
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
if (TactileButton("Lock now##LiteLock", ImVec2(0, 0), S.resolveFont("button"))) {
s_settingsState.lite_encryption_status =
app->liteWallet()->lockWallet() ? "Wallet locked" : "Lock failed";
}
}
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted("Passphrase");
ImGui::SameLine(encLabelX);
ImGui::SetNextItemWidth(liteInputW);
ImGui::InputText("##LiteDecryptPass", s_settingsState.lite_dec_pass,
sizeof(s_settingsState.lite_dec_pass), ImGuiInputTextFlags_Password);
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
if (TactileButton("Remove encryption##LiteDecrypt", ImVec2(0, 0), S.resolveFont("button"))) {
const auto r = app->liteWallet()->decryptWallet(s_settingsState.lite_dec_pass);
sodium_memzero(s_settingsState.lite_dec_pass, sizeof(s_settingsState.lite_dec_pass));
s_settingsState.lite_encryption_status = r.ok ? "Encryption removed" : r.error;
}
}
if (!s_settingsState.lite_encryption_status.empty()) {
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(),
s_settingsState.lite_encryption_status.c_str());
}
} else if (!s_settingsState.lite_export_secret.empty()) {
// Wallet closed while a backup was revealed — don't leave it in memory.
// Wallet closed while a backup/secret was revealed — don't leave it in memory.
wallet::secureWipeLiteSecret(s_settingsState.lite_export_secret);
s_settingsState.lite_export_label.clear();
sodium_memzero(s_settingsState.lite_enc_pass, sizeof(s_settingsState.lite_enc_pass));
sodium_memzero(s_settingsState.lite_dec_pass, sizeof(s_settingsState.lite_dec_pass));
}
} else {