fix(lite): require a confirmed 8+ character passphrase to encrypt
The Lite "Encrypt wallet" field and the Create/Restore passphrase had no confirmation and no minimum length; only an empty passphrase was refused. Encrypting locks the wallet at once, and a locked wallet cannot reveal its seed, so a typo there could cost a user without a seed backup their funds. The full-node encrypt dialog already requires 8 characters and a confirmation. This applies to new passphrases only. Opening and unlocking are unchanged: an existing wallet with a shorter passphrase still opens and unlocks. - A pure liteEncryptPassphraseValid(pass, confirm): at least 8 bytes and equal to the confirmation. It takes string_views, so the page's secret buffers are never copied. - liteLifecyclePassphraseAcceptable(op, pass, confirm) gates the Settings create/open/restore request. Open is never checked, because its passphrase field also unlocks an existing wallet. Create and Restore are checked only when a passphrase was entered; an empty one still creates an unencrypted wallet. - Encrypt gets a Confirm field. The button stays disabled until the passphrase is valid, and a hint says why (too short, with the count, or not matching). - Create/Restore show a Confirm row once a passphrase is typed, with the same hint. Validate stays disabled while the passphrase is invalid, so a click never wipes a typed seed over a passphrase typo. The request builder re-checks the rule. - The confirmation buffers are wiped with their passphrase: after a run or an encrypt, and when the Confirm row is hidden. - Once the wallet is closed, the Settings page wipes the Encrypt, Confirm and Decrypt passphrase buffers whole every time, also when no secret was revealed; before, closing the wallet wiped them only while clearing a revealed backup or secret. They are zeroed in full because an emptied field can still hold the old tail past its first NUL. - New copy under Encrypt: "Anyone with the wallet file can try passphrases offline. Back up your seed phrase first and use a long passphrase." - i18n: three new keys (lite_confirm_label, tt_lite_confirm_pass, lite_encrypt_offline_warning) in i18n.cpp and the 8 language files, added additively. The hints reuse the wizard's wiz_pass_too_short and wiz_pass_mismatch. No new CJK glyphs, so the subset font is unchanged. Tests: the validator rejects empty, short (7) and mismatched or unconfirmed passphrases, and accepts 8 or more with a match. The request gate always accepts Open, even "abc" with no confirmation, accepts Create/Restore without a passphrase, and applies the rule to them otherwise. An encrypted, locked wallet still opens and unlocks through the controller with the 3-character passphrase "abc". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -120,6 +120,8 @@ struct SettingsPageState {
|
||||
int lite_lifecycle_operation = 0;
|
||||
char lite_wallet_path[256] = "";
|
||||
char lite_lifecycle_passphrase[128] = "";
|
||||
// Confirmation of a NEW Create/Restore passphrase (SECRET: wiped with the passphrase). Open never uses it.
|
||||
char lite_lifecycle_passphrase_confirm[128] = "";
|
||||
char lite_restore_seed[512] = "";
|
||||
int lite_restore_birthday = 0;
|
||||
int lite_restore_account = 0;
|
||||
@@ -140,7 +142,9 @@ struct SettingsPageState {
|
||||
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.
|
||||
// lite_enc_confirm confirms a NEW passphrase for Encrypt only; Unlock never uses it.
|
||||
char lite_enc_pass[128] = "";
|
||||
char lite_enc_confirm[128] = "";
|
||||
char lite_dec_pass[128] = "";
|
||||
std::string lite_encryption_status;
|
||||
bool mine_when_idle = false;
|
||||
@@ -245,6 +249,22 @@ static int liteSeedWordCount(const char* s) {
|
||||
dragonx::util::normalizeSeedPhrase(s ? std::string(s) : std::string()));
|
||||
}
|
||||
|
||||
// Why a NEW passphrase is not accepted (empty = nothing to report), with the full-node wizard's
|
||||
// wording. While typing, an empty passphrase or confirmation is not reported yet; on submit
|
||||
// (`submitted`), a missing confirmation counts as a mismatch.
|
||||
static std::string liteNewPassphraseIssue(const char* pass, const char* confirm, bool submitted) {
|
||||
const size_t len = std::strlen(pass);
|
||||
if (len < wallet::kLiteMinNewPassphraseLength && (len > 0 || submitted)) {
|
||||
char buf[256];
|
||||
snprintf(buf, sizeof(buf), TR("wiz_pass_too_short"), len);
|
||||
return buf;
|
||||
}
|
||||
if (len >= wallet::kLiteMinNewPassphraseLength && (confirm[0] != '\0' || submitted) &&
|
||||
std::strcmp(pass, confirm) != 0)
|
||||
return TR("wiz_pass_mismatch");
|
||||
return std::string();
|
||||
}
|
||||
|
||||
static wallet::LiteWalletLifecycleOperation liteLifecycleOperationFromPageState() {
|
||||
switch (s_settingsState.lite_lifecycle_operation) {
|
||||
case 1: return wallet::LiteWalletLifecycleOperation::OpenExisting;
|
||||
@@ -298,6 +318,8 @@ static void evaluateLiteLifecycleRequestFromPageState(App* app) {
|
||||
~LiteSecretScrubber() {
|
||||
sodium_memzero(s_settingsState.lite_lifecycle_passphrase,
|
||||
sizeof(s_settingsState.lite_lifecycle_passphrase));
|
||||
sodium_memzero(s_settingsState.lite_lifecycle_passphrase_confirm,
|
||||
sizeof(s_settingsState.lite_lifecycle_passphrase_confirm));
|
||||
sodium_memzero(s_settingsState.lite_restore_seed,
|
||||
sizeof(s_settingsState.lite_restore_seed));
|
||||
wallet::secureWipeLiteSecret(in.request.createRequest.passphrase);
|
||||
@@ -333,6 +355,19 @@ static void evaluateLiteLifecycleRequestFromPageState(App* app) {
|
||||
}
|
||||
}
|
||||
|
||||
// A NEW passphrase (Create/Restore with one entered) must be 8+ characters and confirmed, so a
|
||||
// typo can't lock the user out. Open is never checked: its passphrase unlocks an existing
|
||||
// wallet, which may predate the rule. (The scrubber above still wipes both fields here.)
|
||||
if (!wallet::liteLifecyclePassphraseAcceptable(input.request.operation,
|
||||
s_settingsState.lite_lifecycle_passphrase,
|
||||
s_settingsState.lite_lifecycle_passphrase_confirm)) {
|
||||
s_settingsState.lite_lifecycle_status =
|
||||
liteNewPassphraseIssue(s_settingsState.lite_lifecycle_passphrase,
|
||||
s_settingsState.lite_lifecycle_passphrase_confirm, /*submitted=*/true);
|
||||
s_settingsState.lite_lifecycle_summary.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
// When a linked lite backend is present, execute the operation for real through the App-owned
|
||||
// controller — ASYNCHRONOUSLY, with server failover, so an unreachable/flaky server never
|
||||
// freezes the UI. The request (with its secrets) is moved into the controller; the page polls
|
||||
@@ -1749,6 +1784,35 @@ void RenderSettingsPage(App* app) {
|
||||
ImGuiInputTextFlags_Password);
|
||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_lite_lifecycle_pass"));
|
||||
|
||||
// Create/Restore with a passphrase sets a NEW one: confirm it (8+ characters).
|
||||
// Open unlocks an existing wallet and is never checked.
|
||||
const bool liteNewPassphrase = s_settingsState.lite_lifecycle_operation != 1 &&
|
||||
s_settingsState.lite_lifecycle_passphrase[0] != '\0';
|
||||
if (liteNewPassphrase) {
|
||||
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
|
||||
ImGui::AlignTextToFramePadding();
|
||||
ImGui::TextUnformatted(TR("lite_confirm_label"));
|
||||
ImGui::SameLine(leftX - sectionOrigin.x + liteLabelW);
|
||||
ImGui::SetNextItemWidth(liteInputW);
|
||||
ImGui::InputText("##LiteLifecyclePassphraseConfirm",
|
||||
s_settingsState.lite_lifecycle_passphrase_confirm,
|
||||
sizeof(s_settingsState.lite_lifecycle_passphrase_confirm),
|
||||
ImGuiInputTextFlags_Password);
|
||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_lite_confirm_pass"));
|
||||
const std::string issue = liteNewPassphraseIssue(
|
||||
s_settingsState.lite_lifecycle_passphrase,
|
||||
s_settingsState.lite_lifecycle_passphrase_confirm, /*submitted=*/false);
|
||||
if (!issue.empty()) {
|
||||
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
|
||||
Type().textColored(TypeStyle::Caption, Error(), issue.c_str());
|
||||
}
|
||||
} else {
|
||||
// Hidden (Open, or the passphrase was cleared): always wipe the whole buffer; an
|
||||
// emptied field can still hold an old passphrase past its first NUL.
|
||||
sodium_memzero(s_settingsState.lite_lifecycle_passphrase_confirm,
|
||||
sizeof(s_settingsState.lite_lifecycle_passphrase_confirm));
|
||||
}
|
||||
|
||||
// Poll a completed async create/open/restore (driven by the App-owned
|
||||
// controller; finalized on the main thread by App::update's
|
||||
// pumpLifecycleResult()). While in flight the button is disabled.
|
||||
@@ -1773,13 +1837,18 @@ void RenderSettingsPage(App* app) {
|
||||
}
|
||||
|
||||
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
|
||||
const bool liteLifecycleBusy = s_settingsState.lite_lifecycle_pending;
|
||||
if (liteLifecycleBusy) ImGui::BeginDisabled();
|
||||
// Also unavailable while a NEW passphrase is unconfirmed or too short (the hint
|
||||
// above says why), so a click never wipes a typed seed over a passphrase typo.
|
||||
const bool liteLifecycleBlocked = s_settingsState.lite_lifecycle_pending ||
|
||||
!wallet::liteLifecyclePassphraseAcceptable(liteLifecycleOperationFromPageState(),
|
||||
s_settingsState.lite_lifecycle_passphrase,
|
||||
s_settingsState.lite_lifecycle_passphrase_confirm);
|
||||
if (liteLifecycleBlocked) ImGui::BeginDisabled();
|
||||
if (TactileButton(TrId("lite_validate", "LiteLifecycleValidate").c_str(), ImVec2(0, 0), S.resolveFont("button"))) {
|
||||
evaluateLiteLifecycleRequestFromPageState(app);
|
||||
}
|
||||
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) material::Tooltip("%s", TR("tt_lite_lifecycle_run"));
|
||||
if (liteLifecycleBusy) ImGui::EndDisabled();
|
||||
if (liteLifecycleBlocked) ImGui::EndDisabled();
|
||||
|
||||
if (!s_settingsState.lite_lifecycle_status.empty()) {
|
||||
ImGui::SameLine(0, Layout::spacingSm());
|
||||
@@ -1949,8 +2018,30 @@ void RenderSettingsPage(App* app) {
|
||||
ImGui::InputText("##LiteEncryptPass", s_settingsState.lite_enc_pass,
|
||||
sizeof(s_settingsState.lite_enc_pass), ImGuiInputTextFlags_Password);
|
||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_lite_encrypt_pass"));
|
||||
// A NEW passphrase: confirm it and require 8+ characters (a typo here locks
|
||||
// the wallet at once). Unlock below never applies this rule.
|
||||
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
|
||||
ImGui::BeginDisabled(liteScanning || app->liteWallet()->walletOpInProgress());
|
||||
ImGui::AlignTextToFramePadding();
|
||||
ImGui::TextUnformatted(TR("lite_confirm_label"));
|
||||
ImGui::SameLine(encLabelX);
|
||||
ImGui::SetNextItemWidth(liteInputW);
|
||||
ImGui::InputText("##LiteEncryptConfirm", s_settingsState.lite_enc_confirm,
|
||||
sizeof(s_settingsState.lite_enc_confirm), ImGuiInputTextFlags_Password);
|
||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_lite_confirm_pass"));
|
||||
const std::string encIssue = liteNewPassphraseIssue(
|
||||
s_settingsState.lite_enc_pass, s_settingsState.lite_enc_confirm, /*submitted=*/false);
|
||||
if (!encIssue.empty()) {
|
||||
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
|
||||
Type().textColored(TypeStyle::Caption, Error(), encIssue.c_str());
|
||||
}
|
||||
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
|
||||
ImGui::PushTextWrapPos((leftX + leftColW) - ImGui::GetWindowPos().x);
|
||||
Type().textColored(TypeStyle::Caption, Warning(), TR("lite_encrypt_offline_warning"));
|
||||
ImGui::PopTextWrapPos();
|
||||
const bool encPassOk = wallet::liteEncryptPassphraseValid(s_settingsState.lite_enc_pass,
|
||||
s_settingsState.lite_enc_confirm);
|
||||
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
|
||||
ImGui::BeginDisabled(liteScanning || app->liteWallet()->walletOpInProgress() || !encPassOk);
|
||||
if (TactileButton(TrId("lite_encrypt_wallet", "LiteEncrypt").c_str(), ImVec2(0, 0), S.resolveFont("button"))) {
|
||||
s_settingsState.lite_encryption_status = TR("lite_working");
|
||||
app->liteWallet()->encryptWalletAsync(s_settingsState.lite_enc_pass,
|
||||
@@ -1959,11 +2050,15 @@ void RenderSettingsPage(App* app) {
|
||||
r.ok ? std::string(TR("lite_wallet_encrypted")) : r.error;
|
||||
});
|
||||
sodium_memzero(s_settingsState.lite_enc_pass, sizeof(s_settingsState.lite_enc_pass));
|
||||
sodium_memzero(s_settingsState.lite_enc_confirm, sizeof(s_settingsState.lite_enc_confirm));
|
||||
}
|
||||
ImGui::EndDisabled();
|
||||
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
|
||||
material::Tooltip("%s", liteScanning ? TR("send_tooltip_syncing") : TR("tt_lite_encrypt"));
|
||||
} else {
|
||||
// Encrypted: the Encrypt confirmation is hidden; wipe the whole buffer (an emptied field
|
||||
// can still hold an old passphrase past its first NUL).
|
||||
sodium_memzero(s_settingsState.lite_enc_confirm, sizeof(s_settingsState.lite_enc_confirm));
|
||||
if (wstate.isLocked()) {
|
||||
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
|
||||
ImGui::AlignTextToFramePadding();
|
||||
@@ -2036,11 +2131,15 @@ void RenderSettingsPage(App* app) {
|
||||
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
|
||||
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(),
|
||||
scanning ? TR("lite_redownload_running") : TR("lite_redownload_desc"));
|
||||
} else if (!s_settingsState.lite_export_secret.empty()) {
|
||||
// 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();
|
||||
} else {
|
||||
// Wallet closed: don't leave a revealed backup/secret or a typed passphrase in memory.
|
||||
if (!s_settingsState.lite_export_secret.empty()) {
|
||||
wallet::secureWipeLiteSecret(s_settingsState.lite_export_secret);
|
||||
s_settingsState.lite_export_label.clear();
|
||||
}
|
||||
// Unconditional: an emptied field can still hold the old tail past its first NUL.
|
||||
sodium_memzero(s_settingsState.lite_enc_pass, sizeof(s_settingsState.lite_enc_pass));
|
||||
sodium_memzero(s_settingsState.lite_enc_confirm, sizeof(s_settingsState.lite_enc_confirm));
|
||||
sodium_memzero(s_settingsState.lite_dec_pass, sizeof(s_settingsState.lite_dec_pass));
|
||||
}
|
||||
} // lite WALLET block
|
||||
|
||||
@@ -826,6 +826,7 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["tt_lite_import_key"] = "Paste a private spending or viewing key to import; its history appears after the next sync";
|
||||
strings_["tt_lite_import_key_btn"] = "Import the entered private key into this wallet; funds and history appear after the next sync";
|
||||
strings_["tt_lite_encrypt_pass"] = "Passphrase to encrypt the wallet with. If lost, the wallet cannot be unlocked or recovered";
|
||||
strings_["tt_lite_confirm_pass"] = "Type the new passphrase again; it must match and be at least 8 characters long";
|
||||
strings_["tt_lite_encrypt"] = "Encrypt the wallet with the passphrase above; it locks immediately and requires the passphrase to unlock";
|
||||
strings_["tt_lite_unlock_pass"] = "Enter your passphrase to unlock the encrypted wallet";
|
||||
strings_["tt_lite_unlock"] = "Unlock the encrypted wallet using the passphrase above";
|
||||
@@ -943,6 +944,7 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["lite_account_label"] = "Account";
|
||||
strings_["lite_overwrite"] = "Overwrite";
|
||||
strings_["lite_passphrase_label"] = "Passphrase";
|
||||
strings_["lite_confirm_label"] = "Confirm";
|
||||
strings_["lite_validate"] = "Validate";
|
||||
strings_["lite_working"] = "Working…";
|
||||
strings_["lite_enter_wallet_path"] = "Enter a wallet path";
|
||||
@@ -967,6 +969,7 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["lite_key_import_failed"] = "Couldn't import the key \xE2\x80\x94 check it, unlock the wallet (if encrypted) and try again.";
|
||||
strings_["lite_security"] = "Security";
|
||||
strings_["lite_encrypt_wallet"] = "Encrypt wallet";
|
||||
strings_["lite_encrypt_offline_warning"] = "Anyone with the wallet file can try passphrases offline. Back up your seed phrase first and use a long passphrase.";
|
||||
strings_["lite_wallet_encrypted"] = "Wallet encrypted";
|
||||
strings_["lite_unlock"] = "Unlock";
|
||||
strings_["lite_wallet_unlocked"] = "Wallet unlocked";
|
||||
|
||||
@@ -1,7 +1,26 @@
|
||||
#include "lite_wallet_lifecycle_ui_adapter.h"
|
||||
|
||||
// The lite-wallet lifecycle UI adapter is now header-only: it declares the live
|
||||
// LiteWalletLifecycleUiExecutionInput DTO (and its request-intent / prerequisites
|
||||
// sub-structs) that Settings drives the App-owned controller with. The former
|
||||
// dry-run "readiness" executor and its status/issue reporting were removed as dead
|
||||
// scaffolding, leaving no out-of-line definitions in this translation unit.
|
||||
// The lite-wallet lifecycle UI adapter declares the live LiteWalletLifecycleUiExecutionInput DTO
|
||||
// (and its request-intent / prerequisites sub-structs) that Settings drives the App-owned
|
||||
// controller with. The former dry-run "readiness" executor and its status/issue reporting were
|
||||
// removed as dead scaffolding. The only out-of-line definitions here are the pure passphrase rules
|
||||
// Settings applies to NEW passphrases.
|
||||
|
||||
namespace dragonx {
|
||||
namespace wallet {
|
||||
|
||||
bool liteEncryptPassphraseValid(std::string_view pass, std::string_view confirm)
|
||||
{
|
||||
return pass.size() >= kLiteMinNewPassphraseLength && pass == confirm;
|
||||
}
|
||||
|
||||
bool liteLifecyclePassphraseAcceptable(LiteWalletLifecycleOperation operation,
|
||||
std::string_view pass, std::string_view confirm)
|
||||
{
|
||||
if (operation == LiteWalletLifecycleOperation::OpenExisting) return true;
|
||||
if (pass.empty()) return true;
|
||||
return liteEncryptPassphraseValid(pass, confirm);
|
||||
}
|
||||
|
||||
} // namespace wallet
|
||||
} // namespace dragonx
|
||||
|
||||
@@ -6,9 +6,27 @@
|
||||
|
||||
#include "../config/settings.h"
|
||||
|
||||
#include <cstddef>
|
||||
#include <string_view>
|
||||
|
||||
namespace dragonx {
|
||||
namespace wallet {
|
||||
|
||||
// Minimum length of a NEW Lite wallet passphrase, in bytes (the full-node encrypt dialog's rule).
|
||||
inline constexpr std::size_t kLiteMinNewPassphraseLength = 8;
|
||||
|
||||
// A NEW passphrase (Settings "Encrypt wallet", or Create/Restore with a passphrase) must be at
|
||||
// least kLiteMinNewPassphraseLength long and equal its confirmation. Pure, and it takes views so
|
||||
// the page's secret buffers are never copied. Never apply it to opening or unlocking an existing
|
||||
// wallet: that passphrase may predate the rule.
|
||||
bool liteEncryptPassphraseValid(std::string_view pass, std::string_view confirm);
|
||||
|
||||
// The passphrase gate for a Settings create/open/restore request. OpenExisting is never checked,
|
||||
// because its passphrase unlocks an existing wallet. CreateNew and RestoreFromSeed are checked only
|
||||
// when a passphrase was entered; an empty one creates an unencrypted wallet.
|
||||
bool liteLifecyclePassphraseAcceptable(LiteWalletLifecycleOperation operation,
|
||||
std::string_view pass, std::string_view confirm);
|
||||
|
||||
struct LiteWalletLifecycleUiPrerequisites {
|
||||
bool selectedServerDisplayReady = false;
|
||||
bool lifecycleUiOwnerReady = false;
|
||||
|
||||
Reference in New Issue
Block a user