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:
2026-10-12 13:22:00 -05:00
co-authored by Claude Opus 5.5
parent e52c9e1b92
commit eac0993512
13 changed files with 262 additions and 13 deletions
+107 -8
View File
@@ -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