diff --git a/res/lang/de.json b/res/lang/de.json index 594c194..df24539 100644 --- a/res/lang/de.json +++ b/res/lang/de.json @@ -1008,11 +1008,13 @@ "lite_birthday_backup": "Geburtstag: %llu (auch diesen sichern)", "lite_birthday_hint": "Blockhöhe, ab der gescannt werden soll. Bei 0 belassen, falls unbekannt (langsamerer vollständiger Scan).", "lite_birthday_label": "Geburtsblock", + "lite_confirm_label": "Bestätigen", "lite_console_backend_commands": "Backend-Befehle:", "lite_console_help_passthrough": "Jede andere Eingabe wird als Lite-Wallet-Konsolenbefehl ausgeführt.", "lite_copy": "Kopieren", "lite_could_not_start": "Der Vorgang konnte nicht gestartet werden", "lite_could_not_write": "Konnte nicht schreiben ", + "lite_encrypt_offline_warning": "Wer die Wallet-Datei hat, kann Passphrasen offline durchprobieren. Sichern Sie zuerst Ihre Wiederherstellungsphrase und verwenden Sie eine lange Passphrase.", "lite_encrypt_wallet": "Wallet verschlüsseln", "lite_encryption_removed": "Verschlüsselung entfernt", "lite_enter_all_seed_words": "Alle 24 Seed-Wörter zur Wiederherstellung eingeben (%d erhalten)", @@ -2059,6 +2061,7 @@ "tt_keep_daemon": "Der Daemon wird beim Ausführen des Einrichtungsassistenten gestoppt", "tt_language": "Schnittstellensprache der Wallet-UI", "tt_layout_hotkey": "Hotkey: Links-/Rechts-Pfeiltasten zum Wechseln der Balance-Layouts", + "tt_lite_confirm_pass": "Geben Sie die neue Passphrase erneut ein; sie muss übereinstimmen und mindestens 8 Zeichen lang sein", "tt_lite_copy": "Das angezeigte Geheimnis in die Zwischenablage kopieren", "tt_lite_decrypt_pass": "Gib deine Passphrase ein, um die Verschlüsselung von der Wallet zu entfernen", "tt_lite_encrypt": "Die Wallet mit der obigen Passphrase verschlüsseln; sie wird sofort gesperrt und benötigt die Passphrase zum Entsperren", diff --git a/res/lang/es.json b/res/lang/es.json index 0166240..6048d1f 100644 --- a/res/lang/es.json +++ b/res/lang/es.json @@ -1008,11 +1008,13 @@ "lite_birthday_backup": "Cumpleaños: %llu (respalda esto también)", "lite_birthday_hint": "Altura de bloque desde la que empezar a escanear. Deja 0 si se desconoce (escaneo completo más lento).", "lite_birthday_label": "Fecha de creación", + "lite_confirm_label": "Confirmar", "lite_console_backend_commands": "Comandos del backend:", "lite_console_help_passthrough": "Cualquier otra entrada se ejecuta como un comando de consola de la cartera lite.", "lite_copy": "Copiar", "lite_could_not_start": "No se pudo iniciar la operación", "lite_could_not_write": "No se pudo escribir ", + "lite_encrypt_offline_warning": "Cualquiera que tenga el archivo de la cartera puede probar frases de contraseña sin conexión. Haz primero una copia de seguridad de tu frase de recuperación y usa una frase de contraseña larga.", "lite_encrypt_wallet": "Cifrar cartera", "lite_encryption_removed": "Cifrado eliminado", "lite_enter_all_seed_words": "Ingrese las 24 palabras semilla para restaurar (se obtuvieron %d)", @@ -2059,6 +2061,7 @@ "tt_keep_daemon": "El daemon se detendrá cuando ejecute el asistente de configuración", "tt_language": "Idioma de la interfaz de la billetera", "tt_layout_hotkey": "Atajo: teclas de flecha izquierda/derecha para cambiar diseños de Balance", + "tt_lite_confirm_pass": "Escribe de nuevo la nueva frase de contraseña; debe coincidir y tener al menos 8 caracteres", "tt_lite_copy": "Copia el secreto revelado al portapapeles", "tt_lite_decrypt_pass": "Introduce tu frase de contraseña para quitar el cifrado de la cartera", "tt_lite_encrypt": "Cifra la cartera con la frase de contraseña de arriba; se bloquea de inmediato y requiere la frase para desbloquearse", diff --git a/res/lang/fr.json b/res/lang/fr.json index 0bfc071..ab0a373 100644 --- a/res/lang/fr.json +++ b/res/lang/fr.json @@ -1008,11 +1008,13 @@ "lite_birthday_backup": "Date de création : %llu (à sauvegarder également)", "lite_birthday_hint": "Hauteur de bloc à partir de laquelle commencer l'analyse. Laissez 0 si inconnue (analyse complète plus lente).", "lite_birthday_label": "Bloc de création", + "lite_confirm_label": "Confirmer", "lite_console_backend_commands": "Commandes du backend :", "lite_console_help_passthrough": "Toute autre entrée est exécutée comme une commande de la console du portefeuille lite.", "lite_copy": "Copier", "lite_could_not_start": "Impossible de démarrer l'opération", "lite_could_not_write": "Impossible d'écrire ", + "lite_encrypt_offline_warning": "Toute personne disposant du fichier du portefeuille peut essayer des phrases secrètes hors ligne. Sauvegardez d'abord votre phrase de récupération et utilisez une phrase secrète longue.", "lite_encrypt_wallet": "Chiffrer le portefeuille", "lite_encryption_removed": "Chiffrement supprimé", "lite_enter_all_seed_words": "Entrez les 24 mots de la phrase de récupération (%d obtenus)", @@ -2059,6 +2061,7 @@ "tt_keep_daemon": "Le daemon s'arrêtera lors de l'exécution de l'assistant de configuration", "tt_language": "Langue de l'interface du portefeuille", "tt_layout_hotkey": "Raccourci : touches fléchées gauche/droite pour changer les dispositions de Balance", + "tt_lite_confirm_pass": "Saisissez à nouveau la nouvelle phrase secrète ; elle doit correspondre et comporter au moins 8 caractères", "tt_lite_copy": "Copie le secret révélé dans le presse-papiers", "tt_lite_decrypt_pass": "Saisissez votre phrase de passe pour retirer le chiffrement du portefeuille", "tt_lite_encrypt": "Chiffre le portefeuille avec la phrase de passe ci-dessus ; il se verrouille immédiatement et requiert la phrase pour se déverrouiller", diff --git a/res/lang/ja.json b/res/lang/ja.json index e6429dd..e018e9c 100644 --- a/res/lang/ja.json +++ b/res/lang/ja.json @@ -1008,11 +1008,13 @@ "lite_birthday_backup": "誕生日:%llu (これもバックアップしてください)", "lite_birthday_hint": "スキャンを開始するブロック高。不明な場合は0のままにしてください(完全スキャンが遅くなります)。", "lite_birthday_label": "バースデー", + "lite_confirm_label": "確認", "lite_console_backend_commands": "バックエンドコマンド:", "lite_console_help_passthrough": "その他の入力はライトウォレットのコンソールコマンドとして実行されます。", "lite_copy": "コピー", "lite_could_not_start": "操作を開始できませんでした", "lite_could_not_write": "書き込めませんでした ", + "lite_encrypt_offline_warning": "ウォレットファイルを持つ人は誰でも、オフラインでパスフレーズを試せます。先にシードフレーズをバックアップし、長いパスフレーズを使ってください。", "lite_encrypt_wallet": "ウォレットを暗号化", "lite_encryption_removed": "暗号化を解除しました", "lite_enter_all_seed_words": "復元するには24個のシードワードをすべて入力してください(現在 %d 個)", @@ -2059,6 +2061,7 @@ "tt_keep_daemon": "セットアップウィザード実行時にデーモンは停止します", "tt_language": "ウォレット UI のインターフェース言語", "tt_layout_hotkey": "ホットキー:左右矢印キーでバランスレイアウトを切り替え", + "tt_lite_confirm_pass": "新しいパスフレーズをもう一度入力してください。一致し、8 文字以上である必要があります", "tt_lite_copy": "表示された秘密情報をクリップボードにコピーします", "tt_lite_decrypt_pass": "ウォレットの暗号化を解除するためにパスフレーズを入力します", "tt_lite_encrypt": "上のパスフレーズでウォレットを暗号化します。すぐにロックされ、解除にはパスフレーズが必要です", diff --git a/res/lang/ko.json b/res/lang/ko.json index 4e46102..e5c1de4 100644 --- a/res/lang/ko.json +++ b/res/lang/ko.json @@ -1008,11 +1008,13 @@ "lite_birthday_backup": "생성 블록: %llu (이 값도 백업하세요)", "lite_birthday_hint": "스캔을 시작할 블록 높이입니다. 모르면 0으로 두세요(전체 스캔이 느려짐).", "lite_birthday_label": "생일 블록", + "lite_confirm_label": "확인", "lite_console_backend_commands": "백엔드 명령:", "lite_console_help_passthrough": "그 외 입력은 라이트 지갑 콘솔 명령으로 실행됩니다.", "lite_copy": "복사", "lite_could_not_start": "작업을 시작할 수 없습니다", "lite_could_not_write": "쓸 수 없습니다: ", + "lite_encrypt_offline_warning": "지갑 파일을 가진 사람은 누구나 오프라인으로 암호를 시도할 수 있습니다. 먼저 시드 문구를 백업하고 긴 암호를 사용하세요.", "lite_encrypt_wallet": "지갑 암호화", "lite_encryption_removed": "암호화가 제거되었습니다", "lite_enter_all_seed_words": "복구하려면 24개의 시드 단어를 모두 입력하세요 (현재 %d개)", @@ -2059,6 +2061,7 @@ "tt_keep_daemon": "설정 마법사를 실행하면 데몬이 여전히 중지됩니다", "tt_language": "지갑 UI 인터페이스 언어", "tt_layout_hotkey": "단축키: 좌/우 화살표 키로 잔액 레이아웃 전환", + "tt_lite_confirm_pass": "새 암호를 한 번 더 입력하세요. 일치해야 하며 8자 이상이어야 합니다", "tt_lite_copy": "표시된 비밀을 클립보드에 복사합니다", "tt_lite_decrypt_pass": "지갑에서 암호화를 제거하려면 암호를 입력하세요", "tt_lite_encrypt": "위 암호로 지갑을 암호화합니다; 즉시 잠기며 잠금 해제하려면 암호가 필요합니다", diff --git a/res/lang/pt.json b/res/lang/pt.json index d89d386..628f2c3 100644 --- a/res/lang/pt.json +++ b/res/lang/pt.json @@ -1008,11 +1008,13 @@ "lite_birthday_backup": "Aniversário: %llu (faça o backup disto também)", "lite_birthday_hint": "Altura do bloco a partir da qual começar a escanear. Deixe 0 se desconhecida (escaneamento completo mais lento).", "lite_birthday_label": "Data de nascimento", + "lite_confirm_label": "Confirmar", "lite_console_backend_commands": "Comandos do backend:", "lite_console_help_passthrough": "Qualquer outra entrada é executada como um comando de console da carteira leve.", "lite_copy": "Copiar", "lite_could_not_start": "Não foi possível iniciar a operação", "lite_could_not_write": "Não foi possível gravar ", + "lite_encrypt_offline_warning": "Qualquer pessoa com o arquivo da carteira pode testar frases-passe offline. Faça primeiro o backup da sua frase de recuperação e use uma frase-passe longa.", "lite_encrypt_wallet": "Criptografar carteira", "lite_encryption_removed": "Criptografia removida", "lite_enter_all_seed_words": "Informe todas as 24 palavras-semente para restaurar (obtidas %d)", @@ -2059,6 +2061,7 @@ "tt_keep_daemon": "O daemon será parado ao executar o assistente de configuração", "tt_language": "Idioma da interface da carteira", "tt_layout_hotkey": "Atalho: teclas de seta esquerda/direita para alternar layouts de Saldo", + "tt_lite_confirm_pass": "Digite a nova frase-passe novamente; ela deve coincidir e ter pelo menos 8 caracteres", "tt_lite_copy": "Copiar o segredo revelado para a área de transferência", "tt_lite_decrypt_pass": "Digite sua frase-senha para remover a criptografia da carteira", "tt_lite_encrypt": "Criptografar a carteira com a frase-senha acima; ela é bloqueada imediatamente e exige a frase-senha para desbloquear", diff --git a/res/lang/ru.json b/res/lang/ru.json index 4622296..19f7714 100644 --- a/res/lang/ru.json +++ b/res/lang/ru.json @@ -1008,11 +1008,13 @@ "lite_birthday_backup": "Дата рождения: %llu (сохраните её тоже)", "lite_birthday_hint": "Высота блока, с которой начинать сканирование. Оставьте 0, если неизвестно (медленное полное сканирование).", "lite_birthday_label": "Дата рождения", + "lite_confirm_label": "Повтор", "lite_console_backend_commands": "Команды бэкенда:", "lite_console_help_passthrough": "Любой другой ввод выполняется как команда консоли лайт-кошелька.", "lite_copy": "Копировать", "lite_could_not_start": "Не удалось запустить операцию", "lite_could_not_write": "Не удалось записать ", + "lite_encrypt_offline_warning": "Любой, у кого есть файл кошелька, может подбирать парольные фразы офлайн. Сначала сохраните сид-фразу и используйте длинную парольную фразу.", "lite_encrypt_wallet": "Зашифровать кошелёк", "lite_encryption_removed": "Шифрование удалено", "lite_enter_all_seed_words": "Введите все 24 слова seed-фразы для восстановления (введено %d)", @@ -2059,6 +2061,7 @@ "tt_keep_daemon": "Демон будет остановлен при запуске мастера настройки", "tt_language": "Язык интерфейса кошелька", "tt_layout_hotkey": "Горячая клавиша: стрелки влево/вправо для переключения раскладок Баланса", + "tt_lite_confirm_pass": "Введите новую парольную фразу ещё раз; она должна совпадать и содержать не менее 8 символов", "tt_lite_copy": "Скопировать показанный секрет в буфер обмена", "tt_lite_decrypt_pass": "Введите пароль, чтобы снять шифрование с кошелька", "tt_lite_encrypt": "Зашифровать кошелёк паролем выше; он блокируется сразу же и требует пароль для разблокировки", diff --git a/res/lang/zh.json b/res/lang/zh.json index 11cf095..4819c64 100644 --- a/res/lang/zh.json +++ b/res/lang/zh.json @@ -1008,11 +1008,13 @@ "lite_birthday_backup": "生日区块:%llu (也请一并备份)", "lite_birthday_hint": "开始扫描的区块高度。如未知请保留 0(完整扫描更慢)。", "lite_birthday_label": "诞生区块", + "lite_confirm_label": "确认", "lite_console_backend_commands": "后端命令:", "lite_console_help_passthrough": "其他任何输入都将作为轻钱包控制台命令运行。", "lite_copy": "复制", "lite_could_not_start": "无法启动该操作", "lite_could_not_write": "无法写入 ", + "lite_encrypt_offline_warning": "任何拿到钱包文件的人都可以离线尝试密码。请先备份助记词,并使用较长的密码短语。", "lite_encrypt_wallet": "加密钱包", "lite_encryption_removed": "已移除加密", "lite_enter_all_seed_words": "请输入全部 24 个助记词以恢复(已输入 %d 个)", @@ -2059,6 +2061,7 @@ "tt_keep_daemon": "运行设置向导时守护进程仍会停止", "tt_language": "钱包界面语言", "tt_layout_hotkey": "快捷键:左/右箭头键切换余额布局", + "tt_lite_confirm_pass": "再次输入新的密码短语;两次输入必须一致,且至少 8 个字符", "tt_lite_copy": "将显示的机密复制到剪贴板", "tt_lite_decrypt_pass": "输入你的密码以移除钱包的加密", "tt_lite_encrypt": "用上方的密码加密钱包;加密后立即锁定,需要该密码才能解锁", diff --git a/src/ui/pages/settings_page.cpp b/src/ui/pages/settings_page.cpp index c0b8a6c..fbb0975 100644 --- a/src/ui/pages/settings_page.cpp +++ b/src/ui/pages/settings_page.cpp @@ -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 diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index d55871f..96bc4b6 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -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"; diff --git a/src/wallet/lite_wallet_lifecycle_ui_adapter.cpp b/src/wallet/lite_wallet_lifecycle_ui_adapter.cpp index db909f5..d0c8533 100644 --- a/src/wallet/lite_wallet_lifecycle_ui_adapter.cpp +++ b/src/wallet/lite_wallet_lifecycle_ui_adapter.cpp @@ -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 diff --git a/src/wallet/lite_wallet_lifecycle_ui_adapter.h b/src/wallet/lite_wallet_lifecycle_ui_adapter.h index e09255e..8e8a028 100644 --- a/src/wallet/lite_wallet_lifecycle_ui_adapter.h +++ b/src/wallet/lite_wallet_lifecycle_ui_adapter.h @@ -6,9 +6,27 @@ #include "../config/settings.h" +#include +#include + 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; diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index e928dad..cb1efdb 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -87,6 +87,7 @@ #include "wallet/lite_owned_string.h" #include "wallet/lite_rollout_policy.h" #include "wallet/lite_wallet_controller.h" +#include "wallet/lite_wallet_lifecycle_ui_adapter.h" #include "wallet/lite_wallet_gateway.h" #include "wallet/lite_wallet_state_mapper.h" #include "config/settings.h" @@ -8783,6 +8784,90 @@ void testLiteWalletControllerAsyncAppliesPassphrase() dragonx::test::g_liteFakeWarmupServerSubstr.clear(); } +// C2 (s1-D8, X7): a NEW Lite passphrase (Settings "Encrypt wallet", or Create/Restore with one entered) +// must be 8+ characters and confirmed. Opening and unlocking an existing wallet never apply the rule, so +// a wallet encrypted earlier with a short passphrase still opens and unlocks. +void testLiteNewPassphraseRules() +{ + using namespace dragonx::wallet; + using Op = LiteWalletLifecycleOperation; + + EXPECT_EQ(kLiteMinNewPassphraseLength, static_cast(8)); + + // The validator: empty, short or mismatched is rejected; 8+ and confirmed is accepted. + EXPECT_FALSE(liteEncryptPassphraseValid("", "")); + EXPECT_FALSE(liteEncryptPassphraseValid("a", "a")); + EXPECT_FALSE(liteEncryptPassphraseValid("1234567", "1234567")); // 7 < 8 + EXPECT_FALSE(liteEncryptPassphraseValid("", "12345678")); + EXPECT_FALSE(liteEncryptPassphraseValid("12345678", "")); // not confirmed + EXPECT_FALSE(liteEncryptPassphraseValid("12345678", "12345679")); // mismatch + EXPECT_FALSE(liteEncryptPassphraseValid("12345678", "12345678 ")); // a trailing space is a mismatch + EXPECT_TRUE(liteEncryptPassphraseValid("12345678", "12345678")); // exactly 8 + EXPECT_TRUE(liteEncryptPassphraseValid("correct horse battery staple", "correct horse battery staple")); + { + // The page passes its fixed char buffers straight in (no copy of the secret). + char pass[128] = "hunter2hunter2"; + char confirm[128] = "hunter2hunter2"; + EXPECT_TRUE(liteEncryptPassphraseValid(pass, confirm)); + confirm[0] = 'H'; + EXPECT_FALSE(liteEncryptPassphraseValid(pass, confirm)); + } + + // The Settings request gate. OpenExisting is never validated: a short or unconfirmed passphrase + // for an existing wallet passes. + EXPECT_TRUE(liteLifecyclePassphraseAcceptable(Op::OpenExisting, "abc", "")); + EXPECT_TRUE(liteLifecyclePassphraseAcceptable(Op::OpenExisting, "hunter2", "different")); + EXPECT_TRUE(liteLifecyclePassphraseAcceptable(Op::OpenExisting, "", "")); + // Create/Restore without a passphrase make an unencrypted wallet: nothing to confirm. + EXPECT_TRUE(liteLifecyclePassphraseAcceptable(Op::CreateNew, "", "")); + EXPECT_TRUE(liteLifecyclePassphraseAcceptable(Op::RestoreFromSeed, "", "")); + // With a passphrase they set a NEW one: 8+ characters and confirmed. + for (const Op op : {Op::CreateNew, Op::RestoreFromSeed}) { + EXPECT_FALSE(liteLifecyclePassphraseAcceptable(op, "abc", "abc")); + EXPECT_FALSE(liteLifecyclePassphraseAcceptable(op, "1234567", "1234567")); + EXPECT_FALSE(liteLifecyclePassphraseAcceptable(op, "12345678", "")); + EXPECT_FALSE(liteLifecyclePassphraseAcceptable(op, "12345678", "1234567X")); + EXPECT_TRUE(liteLifecyclePassphraseAcceptable(op, "12345678", "12345678")); + } + + // The controller's open and unlock paths take an existing short passphrase as is: an encrypted, + // locked wallet opens and unlocks with "abc" (as the Settings Open request sends it), and unlocks + // with it again after a lock. + { + const auto liteCaps = makeWalletCapabilities(WalletBuildKind::Lite, false, true); + LiteConnectionSettings conn; + conn.chainName = "main"; + conn.servers = { LiteServerEndpoint{"https://good.example", "Good", true} }; + conn.selectionMode = LiteServerSelectionMode::Sticky; + conn.stickyServerUrl = "https://good.example"; + + dragonx::test::resetLiteFakeCounters(); + dragonx::test::g_liteFakeWalletExists = true; + dragonx::test::g_liteFakeEncrypted = true; + dragonx::test::g_liteFakeLocked = true; + LiteWalletController controller(liteCaps, conn, + LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); + LiteWalletOpenRequest req; + req.passphrase = "abc"; + EXPECT_TRUE(liteLifecyclePassphraseAcceptable(Op::OpenExisting, req.passphrase, "")); + EXPECT_TRUE(controller.beginOpenWalletAsync(req)); + for (int i = 0; i < 400 && controller.lifecycleRequestInProgress(); ++i) + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + controller.pumpLifecycleResult(); + EXPECT_TRUE(controller.walletOpen()); + EXPECT_TRUE(controller.encryptionStatus().encrypted); + EXPECT_FALSE(controller.encryptionStatus().locked); // the short passphrase unlocked it + EXPECT_TRUE(controller.lockWallet()); + EXPECT_TRUE(controller.encryptionStatus().locked); + EXPECT_TRUE(controller.unlockWallet("abc")); + EXPECT_FALSE(controller.encryptionStatus().locked); + } + + dragonx::test::g_liteFakeWalletExists = false; + dragonx::test::g_liteFakeEncrypted = false; + dragonx::test::g_liteFakeLocked = false; +} + // WalletState::tx_revision (the History / Mining caches key on it): sameTransactionInfo covers every // TransactionInfo field and compares `amount` bit-exactly; setTransactionsIfChanged bumps only on a real change // and otherwise keeps the old list. @@ -15713,6 +15798,7 @@ int main() testLiteWalletControllerEncryption(); testLiteWalletControllerCreateEncryptsWithPassphrase(); testLiteWalletControllerAsyncAppliesPassphrase(); + testLiteNewPassphraseRules(); testLiteChainNameMigration(); testLiteRefreshModelAppliesToWalletState(); testSameTransactionList();