diff --git a/res/lang/de.json b/res/lang/de.json index 3a85009..38b742c 100644 --- a/res/lang/de.json +++ b/res/lang/de.json @@ -237,6 +237,7 @@ "change_pass_confirm": "Neue bestätigen:", "change_pass_current": "Aktuelle Passphrase:", "change_pass_new": "Neue Passphrase:", + "change_pass_pin_note": "Diese Wallet hat eine Schnell-Entsperr-PIN. Nach der Änderung ist sie veraltet: Entfernen Sie die PIN in den Einstellungen und legen Sie sie neu fest.", "change_pass_title": "Passphrase ändern", "characters": "Zeichen", "chat": "Chat", @@ -1688,10 +1689,13 @@ "sec_mode_passphrase": " Passphrase", "sec_not_connected_to_daemon": "Nicht mit Daemon verbunden", "sec_not_connected_to_daemon_pin": "Nicht mit Daemon verbunden", + "sec_passphrase_changed_pin_stale": "Passphrase geändert; Ihre PIN ist jetzt veraltet", "sec_passphrase_changed_successfully": "Passphrase erfolgreich geändert", "sec_pin_changed_successfully": "PIN erfolgreich geändert", "sec_pin_removed": "PIN entfernt", "sec_pin_set_successfully": "PIN erfolgreich festgelegt", + "sec_pin_stale_msg": "PIN veraltet. Verwenden Sie Ihre Passphrase.", + "sec_pin_unreadable": "PIN-Datei unlesbar. Verwenden Sie Ihre Passphrase.", "sec_restart_daemon_for_encryption": "Bitte starten Sie Ihren Daemon neu, damit die Verschlüsselung wirksam wird.", "sec_too_many_attempts_wait": "Zu viele Versuche. Warten Sie %.0f Sekunden...", "sec_total_elapsed_fmt": "Gesamtdauer: %dm %02ds", @@ -1840,6 +1844,7 @@ "settings_open_log_folder": "Log-Ordner öffnen", "settings_other": "Sonstiges", "settings_pin_active": "PIN", + "settings_pin_out_of_date": "PIN veraltet", "settings_privacy": "Datenschutz", "settings_quick_unlock_pin": "Schnell-Entsperr-PIN", "settings_reduce_transparency": "Transparenz reduzieren", @@ -2097,6 +2102,7 @@ "tt_open_data_dir": "Den Ordner mit Ihren Wallet- und Blockchain-Daten im Dateimanager öffnen", "tt_open_dir": "Klicken, um im Dateimanager zu öffnen", "tt_open_log_folder": "Öffnet den Ordner mit den Debug- und Absturzprotokollen", + "tt_pin_out_of_date": "Die Wallet-Passphrase wurde geändert, nachdem diese PIN festgelegt wurde, daher kann die PIN die Wallet nicht mehr entsperren. Entfernen Sie die PIN und legen Sie sie neu fest.", "tt_reduce_motion": "Animierte Übergänge und Saldo-Lerp für Barrierefreiheit deaktivieren", "tt_remove_encrypt": "Verschlüsselung entfernen und Wallet ungeschützt speichern", "tt_remove_pin": "PIN entfernen und Passphrase zum Entsperren erfordern", diff --git a/res/lang/es.json b/res/lang/es.json index 1cd629e..f54f653 100644 --- a/res/lang/es.json +++ b/res/lang/es.json @@ -237,6 +237,7 @@ "change_pass_confirm": "Confirmar nueva:", "change_pass_current": "Frase de contraseña actual:", "change_pass_new": "Nueva frase de contraseña:", + "change_pass_pin_note": "Esta cartera tiene un PIN de desbloqueo rápido. Tras el cambio quedará desactualizado: quita el PIN en Ajustes y vuelve a establecerlo.", "change_pass_title": "Cambiar frase de contraseña", "characters": "caracteres", "chat": "Chat", @@ -1688,10 +1689,13 @@ "sec_mode_passphrase": " Frase de contraseña", "sec_not_connected_to_daemon": "Sin conexión con el daemon", "sec_not_connected_to_daemon_pin": "Sin conexión con el daemon", + "sec_passphrase_changed_pin_stale": "Frase de contraseña cambiada; tu PIN está desactualizado", "sec_passphrase_changed_successfully": "Frase de contraseña cambiada correctamente", "sec_pin_changed_successfully": "PIN cambiado correctamente", "sec_pin_removed": "PIN eliminado", "sec_pin_set_successfully": "PIN configurado correctamente", + "sec_pin_stale_msg": "PIN desactualizado. Usa tu frase de contraseña.", + "sec_pin_unreadable": "Archivo del PIN ilegible. Usa tu frase de contraseña.", "sec_restart_daemon_for_encryption": "Reinicia el daemon para que el cifrado surta efecto.", "sec_too_many_attempts_wait": "Demasiados intentos. Espera %.0f segundos...", "sec_total_elapsed_fmt": "Tiempo total: %dm %02ds", @@ -1840,6 +1844,7 @@ "settings_open_log_folder": "Abrir carpeta de registros", "settings_other": "Otros", "settings_pin_active": "PIN", + "settings_pin_out_of_date": "PIN desactualizado", "settings_privacy": "Privacidad", "settings_quick_unlock_pin": "PIN de desbloqueo rápido", "settings_reduce_transparency": "Reducir transparencia", @@ -2097,6 +2102,7 @@ "tt_open_data_dir": "Abre en el gestor de archivos la carpeta con los datos de tu cartera y de la blockchain", "tt_open_dir": "Clic para abrir en explorador de archivos", "tt_open_log_folder": "Abre la carpeta que contiene los registros de depuración y de fallos", + "tt_pin_out_of_date": "La frase de contraseña de la cartera cambió después de establecer este PIN, así que el PIN ya no puede desbloquearla. Quita el PIN y vuelve a establecerlo.", "tt_reduce_motion": "Desactivar transiciones animadas y lerp de saldo para accesibilidad", "tt_remove_encrypt": "Quitar cifrado y almacenar la billetera sin protección", "tt_remove_pin": "Quitar PIN y requerir contraseña para desbloquear", diff --git a/res/lang/fr.json b/res/lang/fr.json index f55804f..d20fdf9 100644 --- a/res/lang/fr.json +++ b/res/lang/fr.json @@ -237,6 +237,7 @@ "change_pass_confirm": "Confirmer la nouvelle :", "change_pass_current": "Phrase secrète actuelle :", "change_pass_new": "Nouvelle phrase secrète :", + "change_pass_pin_note": "Ce portefeuille a un PIN de déverrouillage rapide. Après le changement, il sera obsolète : supprimez le PIN dans les Paramètres puis définissez-le à nouveau.", "change_pass_title": "Changer la phrase secrète", "characters": "caractères", "chat": "Discussion", @@ -1688,10 +1689,13 @@ "sec_mode_passphrase": " Phrase secrète", "sec_not_connected_to_daemon": "Non connecté au démon", "sec_not_connected_to_daemon_pin": "Non connecté au démon", + "sec_passphrase_changed_pin_stale": "Phrase secrète modifiée ; votre PIN est obsolète", "sec_passphrase_changed_successfully": "Phrase secrète modifiée avec succès", "sec_pin_changed_successfully": "PIN modifié avec succès", "sec_pin_removed": "PIN supprimé", "sec_pin_set_successfully": "PIN défini avec succès", + "sec_pin_stale_msg": "PIN obsolète. Utilisez votre phrase secrète.", + "sec_pin_unreadable": "Fichier du PIN illisible. Utilisez votre phrase secrète.", "sec_restart_daemon_for_encryption": "Veuillez redémarrer votre démon pour que le chiffrement prenne effet.", "sec_too_many_attempts_wait": "Trop de tentatives. Patientez %.0f secondes...", "sec_total_elapsed_fmt": "Temps total écoulé : %dm %02ds", @@ -1840,6 +1844,7 @@ "settings_open_log_folder": "Ouvrir le dossier des journaux", "settings_other": "Autres", "settings_pin_active": "PIN", + "settings_pin_out_of_date": "PIN obsolète", "settings_privacy": "Confidentialité", "settings_quick_unlock_pin": "PIN de déverrouillage rapide", "settings_reduce_transparency": "Réduire la transparence", @@ -2097,6 +2102,7 @@ "tt_open_data_dir": "Ouvrir le dossier contenant les données de votre portefeuille et de la blockchain dans le gestionnaire de fichiers", "tt_open_dir": "Cliquer pour ouvrir dans l'explorateur de fichiers", "tt_open_log_folder": "Ouvre le dossier contenant les journaux de débogage et de plantage", + "tt_pin_out_of_date": "La phrase secrète du portefeuille a changé après la définition de ce PIN ; le PIN ne peut plus le déverrouiller. Supprimez le PIN puis définissez-le à nouveau.", "tt_reduce_motion": "Désactiver les transitions animées et le lerp de solde pour l'accessibilité", "tt_remove_encrypt": "Supprimer le chiffrement et stocker le portefeuille sans protection", "tt_remove_pin": "Supprimer le PIN et exiger la phrase secrète pour déverrouiller", diff --git a/res/lang/ja.json b/res/lang/ja.json index 1fef988..717dfb5 100644 --- a/res/lang/ja.json +++ b/res/lang/ja.json @@ -237,6 +237,7 @@ "change_pass_confirm": "新しいパスフレーズ(確認):", "change_pass_current": "現在のパスフレーズ:", "change_pass_new": "新しいパスフレーズ:", + "change_pass_pin_note": "このウォレットにはクイックアンロック PIN が設定されています。変更後は古くなるため、設定で PIN を削除してから再設定してください。", "change_pass_title": "パスフレーズを変更", "characters": "文字", "chat": "チャット", @@ -1688,10 +1689,13 @@ "sec_mode_passphrase": " パスフレーズ", "sec_not_connected_to_daemon": "デーモンに接続されていません", "sec_not_connected_to_daemon_pin": "デーモンに接続されていません", + "sec_passphrase_changed_pin_stale": "パスフレーズを変更しました。PIN は古くなりました", "sec_passphrase_changed_successfully": "パスフレーズを変更しました", "sec_pin_changed_successfully": "PINを変更しました", "sec_pin_removed": "PINを削除しました", "sec_pin_set_successfully": "PINを設定しました", + "sec_pin_stale_msg": "PIN が古くなっています。パスフレーズを使用してください。", + "sec_pin_unreadable": "PIN ファイルが読めません。パスフレーズで解除してください。", "sec_restart_daemon_for_encryption": "暗号化を有効にするには、デーモンを再起動してください。", "sec_too_many_attempts_wait": "試行回数が多すぎます。%.0f秒お待ちください...", "sec_total_elapsed_fmt": "合計経過時間: %d分 %02d秒", @@ -1840,6 +1844,7 @@ "settings_open_log_folder": "ログフォルダを開く", "settings_other": "その他", "settings_pin_active": "PIN", + "settings_pin_out_of_date": "PIN が古くなっています", "settings_privacy": "プライバシー", "settings_quick_unlock_pin": "クイックアンロック PIN", "settings_reduce_transparency": "透明度を下げる", @@ -2097,6 +2102,7 @@ "tt_open_data_dir": "ファイルマネージャーでウォレットとブロックチェーンデータのフォルダを開きます", "tt_open_dir": "クリックしてファイルエクスプローラーで開く", "tt_open_log_folder": "デバッグログとクラッシュログが入ったフォルダを開きます", + "tt_pin_out_of_date": "この PIN の設定後にウォレットのパスフレーズが変更されたため、PIN ではウォレットをアンロックできません。PIN を削除して再設定してください。", "tt_reduce_motion": "アクセシビリティのためにアニメーション遷移と残高補間を無効にする", "tt_remove_encrypt": "暗号化を解除してウォレットを保護なしで保存", "tt_remove_pin": "PIN を削除しアンロックにパスフレーズを要求", diff --git a/res/lang/ko.json b/res/lang/ko.json index 282f59c..d28a1b5 100644 --- a/res/lang/ko.json +++ b/res/lang/ko.json @@ -237,6 +237,7 @@ "change_pass_confirm": "새 암호 확인:", "change_pass_current": "현재 암호:", "change_pass_new": "새 암호:", + "change_pass_pin_note": "이 지갑에는 빠른 잠금 해제 PIN이 있습니다. 변경 후에는 PIN이 오래된 상태가 되므로 설정에서 PIN을 제거한 뒤 다시 설정하세요.", "change_pass_title": "암호 변경", "characters": "문자", "chat": "채팅", @@ -1688,10 +1689,13 @@ "sec_mode_passphrase": " 암호문", "sec_not_connected_to_daemon": "데몬에 연결되지 않음", "sec_not_connected_to_daemon_pin": "데몬에 연결되지 않음", + "sec_passphrase_changed_pin_stale": "암호문이 변경되었습니다. PIN이 오래되었습니다", "sec_passphrase_changed_successfully": "암호문이 성공적으로 변경되었습니다", "sec_pin_changed_successfully": "PIN이 성공적으로 변경되었습니다", "sec_pin_removed": "PIN이 제거되었습니다", "sec_pin_set_successfully": "PIN이 성공적으로 설정되었습니다", + "sec_pin_stale_msg": "PIN이 오래되었습니다. 암호문을 사용하세요.", + "sec_pin_unreadable": "PIN 파일을 읽을 수 없습니다. 암호문을 사용하세요.", "sec_restart_daemon_for_encryption": "암호화를 적용하려면 데몬을 다시 시작하세요.", "sec_too_many_attempts_wait": "시도 횟수가 너무 많습니다. %.0f초 기다리세요...", "sec_total_elapsed_fmt": "총 경과 시간: %d분 %02d초", @@ -1840,6 +1844,7 @@ "settings_open_log_folder": "로그 폴더 열기", "settings_other": "기타", "settings_pin_active": "PIN", + "settings_pin_out_of_date": "PIN이 오래됨", "settings_privacy": "개인 정보", "settings_quick_unlock_pin": "빠른 잠금 해제 PIN", "settings_reduce_transparency": "투명도 줄이기", @@ -2097,6 +2102,7 @@ "tt_open_data_dir": "지갑 및 블록체인 데이터가 있는 폴더를 파일 탐색기에서 엽니다", "tt_open_dir": "파일 탐색기에서 열려면 클릭", "tt_open_log_folder": "디버그 및 충돌 로그가 있는 폴더를 엽니다", + "tt_pin_out_of_date": "이 PIN을 설정한 후 지갑 암호문이 변경되어 PIN으로 더 이상 지갑을 잠금 해제할 수 없습니다. PIN을 제거한 뒤 다시 설정하세요.", "tt_reduce_motion": "접근성을 위해 애니메이션 전환 및 잔액 보간 비활성화", "tt_remove_encrypt": "암호화를 제거하고 지갑을 보호 없이 저장", "tt_remove_pin": "PIN을 제거하고 잠금 해제 시 비밀번호 요구", diff --git a/res/lang/pt.json b/res/lang/pt.json index 4ba83a5..aabe2ee 100644 --- a/res/lang/pt.json +++ b/res/lang/pt.json @@ -237,6 +237,7 @@ "change_pass_confirm": "Confirmar nova:", "change_pass_current": "Senha atual:", "change_pass_new": "Nova senha:", + "change_pass_pin_note": "Esta carteira tem um PIN de desbloqueio rápido. Após a alteração ele ficará desatualizado: remova o PIN em Ajustes e defina-o novamente.", "change_pass_title": "Alterar senha", "characters": "caracteres", "chat": "Chat", @@ -1688,10 +1689,13 @@ "sec_mode_passphrase": " Frase-senha", "sec_not_connected_to_daemon": "Não conectado ao daemon", "sec_not_connected_to_daemon_pin": "Não conectado ao daemon", + "sec_passphrase_changed_pin_stale": "Frase-senha alterada; seu PIN está desatualizado", "sec_passphrase_changed_successfully": "Frase-senha alterada com sucesso", "sec_pin_changed_successfully": "PIN alterado com sucesso", "sec_pin_removed": "PIN removido", "sec_pin_set_successfully": "PIN definido com sucesso", + "sec_pin_stale_msg": "PIN desatualizado. Use sua frase-senha.", + "sec_pin_unreadable": "Arquivo do PIN ilegível. Use sua frase-senha.", "sec_restart_daemon_for_encryption": "Reinicie o daemon para que a criptografia entre em vigor.", "sec_too_many_attempts_wait": "Tentativas em excesso. Aguarde %.0f segundos...", "sec_total_elapsed_fmt": "Tempo total: %dm %02ds", @@ -1840,6 +1844,7 @@ "settings_open_log_folder": "Abrir pasta de logs", "settings_other": "Outros", "settings_pin_active": "PIN", + "settings_pin_out_of_date": "PIN desatualizado", "settings_privacy": "Privacidade", "settings_quick_unlock_pin": "PIN de desbloqueio rápido", "settings_reduce_transparency": "Reduzir transparência", @@ -2097,6 +2102,7 @@ "tt_open_data_dir": "Abrir a pasta com os dados da sua carteira e da blockchain no gerenciador de arquivos", "tt_open_dir": "Clique para abrir no explorador de arquivos", "tt_open_log_folder": "Abre a pasta que contém os logs de depuração e de falhas", + "tt_pin_out_of_date": "A frase-senha da carteira mudou depois que este PIN foi definido, então o PIN não consegue mais desbloqueá-la. Remova o PIN e defina-o novamente.", "tt_reduce_motion": "Desativar transições animadas e lerp de saldo para acessibilidade", "tt_remove_encrypt": "Remover encriptação e armazenar a carteira desprotegida", "tt_remove_pin": "Remover PIN e exigir frase secreta para desbloquear", diff --git a/res/lang/ru.json b/res/lang/ru.json index 2ed0f6a..a12e8be 100644 --- a/res/lang/ru.json +++ b/res/lang/ru.json @@ -237,6 +237,7 @@ "change_pass_confirm": "Подтвердите новый:", "change_pass_current": "Текущий пароль:", "change_pass_new": "Новый пароль:", + "change_pass_pin_note": "У этого кошелька есть PIN для быстрой разблокировки. После смены он устареет: удалите PIN в настройках и установите его заново.", "change_pass_title": "Сменить пароль", "characters": "символов", "chat": "Чат", @@ -1688,10 +1689,13 @@ "sec_mode_passphrase": " Парольная фраза", "sec_not_connected_to_daemon": "Нет соединения с демоном", "sec_not_connected_to_daemon_pin": "Нет соединения с демоном", + "sec_passphrase_changed_pin_stale": "Парольная фраза изменена; PIN устарел", "sec_passphrase_changed_successfully": "Парольная фраза успешно изменена", "sec_pin_changed_successfully": "PIN успешно изменён", "sec_pin_removed": "PIN удалён", "sec_pin_set_successfully": "PIN успешно задан", + "sec_pin_stale_msg": "PIN устарел. Используйте парольную фразу.", + "sec_pin_unreadable": "Файл PIN не читается. Используйте парольную фразу.", "sec_restart_daemon_for_encryption": "Перезапустите демон, чтобы шифрование вступило в силу.", "sec_too_many_attempts_wait": "Слишком много попыток. Подождите %.0f сек...", "sec_total_elapsed_fmt": "Всего прошло: %dм %02dс", @@ -1840,6 +1844,7 @@ "settings_open_log_folder": "Открыть папку журналов", "settings_other": "Прочее", "settings_pin_active": "PIN", + "settings_pin_out_of_date": "PIN устарел", "settings_privacy": "Конфиденциальность", "settings_quick_unlock_pin": "Быстрый PIN-код разблокировки", "settings_reduce_transparency": "Уменьшить прозрачность", @@ -2097,6 +2102,7 @@ "tt_open_data_dir": "Открыть в файловом менеджере папку с данными кошелька и блокчейна", "tt_open_dir": "Нажмите, чтобы открыть в проводнике", "tt_open_log_folder": "Открывает папку с журналами отладки и сбоев", + "tt_pin_out_of_date": "Парольная фраза кошелька изменилась после установки этого PIN, поэтому PIN больше не разблокирует кошелёк. Удалите PIN и установите его заново.", "tt_reduce_motion": "Отключить анимированные переходы и плавное изменение баланса для доступности", "tt_remove_encrypt": "Удалить шифрование и хранить кошелёк без защиты", "tt_remove_pin": "Удалить PIN и требовать пароль для разблокировки", diff --git a/res/lang/zh.json b/res/lang/zh.json index 00a7af7..fa217b0 100644 --- a/res/lang/zh.json +++ b/res/lang/zh.json @@ -237,6 +237,7 @@ "change_pass_confirm": "确认新密码:", "change_pass_current": "当前密码短语:", "change_pass_new": "新密码短语:", + "change_pass_pin_note": "此钱包设有快速解锁 PIN。更改后它将过时:请在设置中移除 PIN 并重新设置。", "change_pass_title": "更改密码短语", "characters": "字符", "chat": "聊天", @@ -1688,10 +1689,13 @@ "sec_mode_passphrase": " 密码短语", "sec_not_connected_to_daemon": "未连接到守护进程", "sec_not_connected_to_daemon_pin": "未连接到守护进程", + "sec_passphrase_changed_pin_stale": "密码短语已更改;PIN 已过时", "sec_passphrase_changed_successfully": "密码短语更改成功", "sec_pin_changed_successfully": "PIN 更改成功", "sec_pin_removed": "PIN 已移除", "sec_pin_set_successfully": "PIN 设置成功", + "sec_pin_stale_msg": "PIN 已过时。请使用密码短语。", + "sec_pin_unreadable": "无法读取 PIN 文件。请使用密码短语。", "sec_restart_daemon_for_encryption": "请重启守护进程以使加密生效。", "sec_too_many_attempts_wait": "尝试次数过多。请等待 %.0f 秒…", "sec_total_elapsed_fmt": "总耗时:%d 分 %02d 秒", @@ -1840,6 +1844,7 @@ "settings_open_log_folder": "打开日志文件夹", "settings_other": "其他", "settings_pin_active": "PIN", + "settings_pin_out_of_date": "PIN 已过时", "settings_privacy": "隐私", "settings_quick_unlock_pin": "快速解锁 PIN", "settings_reduce_transparency": "降低透明度", @@ -2097,6 +2102,7 @@ "tt_open_data_dir": "在文件管理器中打开包含您钱包和区块链数据的文件夹", "tt_open_dir": "点击在文件管理器中打开", "tt_open_log_folder": "打开包含调试和崩溃日志的文件夹", + "tt_pin_out_of_date": "设置此 PIN 后钱包密码短语已更改,因此该 PIN 无法再解锁钱包。请移除 PIN 并重新设置。", "tt_reduce_motion": "禁用动画过渡和余额渐变以提高无障碍性", "tt_remove_encrypt": "移除加密并以未受保护状态存储钱包", "tt_remove_pin": "移除 PIN 并要求密码解锁", diff --git a/res/themes/ui.toml b/res/themes/ui.toml index 55f3f4d..931a729 100644 --- a/res/themes/ui.toml +++ b/res/themes/ui.toml @@ -1350,6 +1350,8 @@ wallet-btn-padding = { size = 24.0 } rpc-label-min-width = { size = 70.0 } rpc-label-width = { size = 85.0 } security-combo-width = { size = 120.0 } +# Wrap width of the Security band's "PIN out of date" tooltip (a long sentence in every language). +pin-tooltip-wrap-width = { size = 420.0 } node-grid-breakpoint = { size = 900.0 } port-input-min-width = { size = 60.0 } port-input-width-ratio = { size = 0.4 } diff --git a/src/app.h b/src/app.h index 6a49af9..ed7adde 100644 --- a/src/app.h +++ b/src/app.h @@ -766,6 +766,11 @@ public: void showPinChangeDialog() { show_pin_change_ = true; pin_status_.clear(); } void showPinRemoveDialog() { show_pin_remove_ = true; pin_status_.clear(); } bool hasPinVault() const; + // The PIN vault seals a passphrase the wallet no longer accepts (C87): changed in this session, or a + // PIN unlock got RPC -14. Session-only; the vault file is kept. Settings shows "PIN out of date". + bool isPinVaultStale() const { return pin_vault_stale_; } + // A wallet encrypt / passphrase change is in flight (PIN controls stay disabled meanwhile). + bool isEncryptInProgress() const { return encrypt_in_progress_; } // Debug-options gate: does revealing the debug dropdown need re-authentication (a PIN vault or // an encrypted wallet)? And verify the entered PIN/passphrase — cb(ok) is invoked on the main @@ -1859,6 +1864,10 @@ private: // Lock screen state bool lock_screen_was_visible_ = false; // tracks lock→unlock transitions for auto-focus bool lock_use_pin_ = true; // true = PIN input, false = passphrase input + // The active wallet's PIN vault holds an outdated passphrase (see isPinVaultStale()). Cleared when a + // PIN is set (which also re-offers PIN entry) or removed and on a wallet switch; a Change PIN keeps it + // (same sealed passphrase). + bool pin_vault_stale_ = false; char lock_pin_buf_[16] = {}; char lock_passphrase_buf_[256] = {}; std::string lock_error_msg_; diff --git a/src/app_network.cpp b/src/app_network.cpp index d5f2541..4cc878a 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -1594,6 +1594,7 @@ void App::switchToWallet(const std::string& walletFile, bool stopDaemonConfirmed // lock-screen attempt/lockout state + wipe the passphrase/PIN entry buffers — the previous wallet's // failed-unlock counter and typed secrets must not apply to the new wallet. if (vault_) vault_->setWalletScope(walletFile); + pin_vault_stale_ = false; // the out-of-date mark was the previous wallet's vault lock_attempts_ = 0; lock_lockout_timer_ = 0.0f; lock_error_msg_.clear(); @@ -1696,6 +1697,7 @@ void App::processWalletSwitchRevert() settings_->setActiveWalletFile(wallet_switch_prev_file_); settings_->save(); if (vault_) vault_->setWalletScope(wallet_switch_prev_file_); + pin_vault_stale_ = false; } // Clear the crash-wedge so the (good) previous wallet's daemon is allowed to start again. if (daemon_controller_) daemon_controller_->resetCrashCount(); diff --git a/src/app_security.cpp b/src/app_security.cpp index acac134..0ea28a7 100644 --- a/src/app_security.cpp +++ b/src/app_security.cpp @@ -6,6 +6,7 @@ // Split from app.cpp for maintainability. #include "app.h" +#include "data/pin_vault_policy.h" #include "rpc/rpc_client.h" #include "rpc/rpc_worker.h" #include "rpc/connection.h" @@ -436,6 +437,8 @@ void App::processDeferredEncryption() { // Finalize PIN settings on main thread if (result.pinProvided) { if (result.pinStored) { + pin_vault_stale_ = false; + lock_use_pin_ = true; settings_->setPinEnabled(true); settings_->save(); ui::Notifications::instance().info(TR("sec_wallet_encrypted_and_pin_set"), 5.0f); @@ -568,13 +571,19 @@ void App::changePassphrase(const std::string& oldPass, const std::string& newPas if (!rpc_ || !rpc_->isConnected() || !worker_) return; encrypt_in_progress_ = true; encrypt_status_ = TR("sec_changing_passphrase"); + // The vault this change applies to, and whether it changes the passphrase at all. The success + // callback decides about the vault with the state at THAT time (C87). + const std::string vaultPath = vault_ ? vault_->getVaultPath() : std::string(); + const bool samePass = (oldPass == newPass); auto* w = (fast_worker_ && fast_worker_->isRunning()) ? fast_worker_.get() : worker_.get(); auto* r = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get(); w->post([this, r, oldPass = std::string(oldPass), - newPass = std::string(newPass)]() mutable -> rpc::RPCWorker::MainCb { + newPass = std::string(newPass), + vaultPath, + samePass]() mutable -> rpc::RPCWorker::MainCb { bool ok = false; std::string err_msg; try { @@ -590,7 +599,9 @@ void App::changePassphrase(const std::string& oldPass, const std::string& newPas return [this, ok, err_msg, - newPass = std::move(cacheNewPass)]() mutable { + newPass = std::move(cacheNewPass), + vaultPath, + samePass]() mutable { encrypt_in_progress_ = false; if (ok) { encrypt_status_.clear(); @@ -602,7 +613,18 @@ void App::changePassphrase(const std::string& oldPass, const std::string& newPas // New key: the old ciphertext is unreadable with it. Write now unless the unlock just did // (keyId() is part of the content signature, so Now skips only if the same key is submitted). storeTransactionHistoryCacheIfAvailable(data::HistoryStoreMode::Now); - ui::Notifications::instance().info(TR("sec_passphrase_changed_successfully")); + const bool vaultNow = vault_ && vault_->hasVault(); + if (data::decideVaultAfterPassphraseChange(true, vaultNow, samePass, vaultPath, + vault_ ? vault_->getVaultPath() : std::string()) == data::VaultAfterChange::MarkStale) { + // The PIN vault still seals the OLD passphrase. Keep the file (the user removes the PIN + // and sets it again), but stop offering the PIN this session and say so. + lock_use_pin_ = false; + pin_vault_stale_ = true; + DEBUG_LOGF("[App] Passphrase changed; the PIN vault is kept but marked out of date\n"); + ui::Notifications::instance().warning(TR("sec_passphrase_changed_pin_stale"), 10.0f); + } else { + ui::Notifications::instance().info(TR("sec_passphrase_changed_successfully")); + } } else { encrypt_status_ = std::string(TR("sec_failed_prefix")) + err_msg; } @@ -1256,23 +1278,30 @@ void App::renderLockScreen() { // Use fast-lane worker for priority unlock. auto* w = (fast_worker_ && fast_worker_->isRunning()) ? fast_worker_.get() : worker_.get(); auto* r = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get(); + // The vault this attempt opens: a wallet switch before the answer must not mark the new + // wallet's PIN out of date. + const std::string vaultPath = vault_ ? vault_->getVaultPath() : std::string(); if (w) { - w->post([this, r, pin, timeout]() -> rpc::RPCWorker::MainCb { + w->post([this, r, pin, timeout, vaultPath]() -> rpc::RPCWorker::MainCb { // Heavy Argon2id derivation runs here (worker thread) std::string passphrase; bool vaultOk = vault_ && vault_->retrieve(pin, passphrase); if (!vaultOk) { bool noVault = !vault_ || !vault_->hasVault(); - return [this, noVault]() { - if (noVault) { - // Vault file missing — switch to passphrase mode. Not a failed - // attempt, so no counter bump / lockout escalation. + // A file no PIN can open (zero-filled by a removeVault() whose unlink failed, + // truncated, unknown version) counts as no vault: failing it is not a wrong PIN. + bool malformed = !noVault && !vault_->isWellFormed(); + return [this, noVault, malformed]() { + if (noVault || malformed) { + // Vault file missing or unreadable — switch to passphrase mode. Not a + // failed attempt, so no counter bump / lockout escalation. lock_unlock_in_progress_ = false; - lock_error_msg_ = TR("pin_not_set"); + lock_error_msg_ = noVault ? TR("pin_not_set") : TR("sec_pin_unreadable"); lock_use_pin_ = false; - lock_error_timer_ = 3.0f; + lock_error_timer_ = noVault ? 3.0f : 6.0f; lock_timer_armed_at_ = std::chrono::steady_clock::now(); + if (malformed) DEBUG_LOGF("[App] PIN unlock: the vault file is malformed; no penalty\n"); } else { applyUnlockFailure(TR("incorrect_pin")); } @@ -1282,6 +1311,7 @@ void App::renderLockScreen() { // Vault decrypted — now unlock wallet via RPC (also on worker thread) bool rpcOk = false; std::string rpcErr; + int rpcCode = 0; try { if (r && r->isConnected()) { r->call("walletpassphrase", {passphrase, timeout}); @@ -1289,6 +1319,8 @@ void App::renderLockScreen() { } else { rpcErr = TR("sec_not_connected_to_daemon"); } + } catch (const rpc::RpcError& e) { + rpcErr = e.what(); rpcCode = e.code; } catch (const std::exception& e) { rpcErr = e.what(); } @@ -1298,6 +1330,22 @@ void App::renderLockScreen() { applyUnlockSuccess(passphrase, timeout); util::SecureVault::secureZero(passphrase.data(), passphrase.size()); }; + } else if (data::isStaleVaultUnlockFailure(/*vaultDecrypted=*/true, rpcCode)) { + // The PIN was right (the vault opened) but the wallet refuses the passphrase it + // seals: it was changed after the PIN was set. Not a failed attempt (no lockout) + // and the vault is kept; use the passphrase and mark the PIN out of date. + util::SecureVault::secureZero(passphrase.data(), passphrase.size()); + return [this, vaultPath]() { + lock_unlock_in_progress_ = false; + lock_use_pin_ = false; + if (vault_ && vault_->getVaultPath() == vaultPath) pin_vault_stale_ = true; + lock_error_msg_ = TR("sec_pin_stale_msg"); + lock_error_timer_ = 6.0f; + lock_timer_armed_at_ = std::chrono::steady_clock::now(); + memset(lock_passphrase_buf_, 0, sizeof(lock_passphrase_buf_)); + DEBUG_LOGF("[App] PIN unlock: the wallet refused the vault's passphrase (-14); " + "PIN marked out of date, no penalty\n"); + }; } else { // Vault decrypt succeeded but the RPC failed — still a failed unlock, // so route through applyUnlockFailure so the lockout curve applies @@ -1519,6 +1567,8 @@ void App::renderEncryptWalletDialog() { if (!pinStr.empty()) util::SecureVault::secureZero(&pinStr[0], pinStr.size()); return [this, ok]() { if (ok) { + pin_vault_stale_ = false; + lock_use_pin_ = true; settings_->setPinEnabled(true); settings_->save(); pin_in_progress_ = false; @@ -1582,6 +1632,14 @@ void App::renderEncryptWalletDialog() { DialogWarningHeader(TR("wiz_encrypt_warning")); ImGui::Spacing(); + // This wallet has a PIN: it keeps the old passphrase and goes out of date (C87). + if (hasPinVault()) { + ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetStyleColorVec4(ImGuiCol_TextDisabled)); + ImGui::TextWrapped("%s", TR("change_pass_pin_note")); + ImGui::PopStyleColor(); + ImGui::Spacing(); + } + ImGui::TextUnformatted(TR("change_pass_current")); ImGui::PushItemWidth(-1); ImGui::InputText("##chg_old", change_old_pass_buf_, sizeof(change_old_pass_buf_), @@ -1611,7 +1669,8 @@ void App::renderEncryptWalletDialog() { // Two-button footer (primary + Cancel) to match the encrypt/decrypt siblings. float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f; - ImGui::BeginDisabled(!valid || encrypt_in_progress_); + // Not while a PIN operation runs: PIN setup could verify and seal the OLD passphrase. + ImGui::BeginDisabled(!valid || encrypt_in_progress_ || pin_in_progress_); if (ui::material::TactileButton(TR("change_pass_title"), ImVec2(btnW, 40))) { changePassphrase(std::string(change_old_pass_buf_), std::string(change_new_pass_buf_)); @@ -2093,7 +2152,8 @@ void App::renderPinDialogs() { // Two-button footer (primary + Cancel) to match the encrypt/decrypt siblings. float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f; - ImGui::BeginDisabled(!valid || pin_in_progress_); + // Not while a passphrase change runs: it could land after the old passphrase is verified. + ImGui::BeginDisabled(!valid || pin_in_progress_ || encrypt_in_progress_); if (ui::material::TactileButton(TR("settings_set_pin"), ImVec2(btnW, 40))) { pin_in_progress_ = true; pin_status_ = TR("sec_verifying_passphrase"); @@ -2135,6 +2195,8 @@ void App::renderPinDialogs() { return [this, storeOk]() { if (storeOk) { + pin_vault_stale_ = false; // the vault now seals the verified passphrase + lock_use_pin_ = true; settings_->setPinEnabled(true); settings_->save(); pin_status_.clear(); @@ -2293,6 +2355,7 @@ void App::renderPinDialogs() { return [this, ok]() { if (ok) { vault_->removeVault(); + pin_vault_stale_ = false; settings_->setPinEnabled(false); settings_->save(); pin_status_.clear(); diff --git a/src/data/pin_vault_policy.h b/src/data/pin_vault_policy.h new file mode 100644 index 0000000..3cf07b8 --- /dev/null +++ b/src/data/pin_vault_policy.h @@ -0,0 +1,45 @@ +#pragma once + +#include + +// Pure decisions for the PIN quick-unlock vault (C87), kept free of App/UI/RPC state so they are +// unit-testable. The vault seals the wallet passphrase under the PIN. None of these paths deletes +// it: a vault whose passphrase the wallet no longer accepts is only marked out of date for the +// session (no lockout penalty), and the user removes it and sets the PIN again. The vault may hold +// the only copy of a passphrase that still opens an older wallet backup. +namespace dragonx { +namespace data { + +// What a completed walletpassphrasechange means for the active wallet's PIN vault. +enum class VaultAfterChange { + Keep, // no vault, the change failed or kept the same passphrase, or the vault is another wallet's + MarkStale, // the vault seals the OLD passphrase: stop offering the PIN this session and warn (keep the file) +}; + +// Decided on the main thread when the change completes, not when it is sent, so a vault created +// while the change was in flight is covered too. `pathAtDispatch` is the vault path captured when +// the change was sent and `pathNow` the active vault path now: a wallet switch in between makes +// them differ, and the other wallet's vault is left alone. +inline VaultAfterChange decideVaultAfterPassphraseChange(bool changeSucceeded, + bool vaultExistsNow, + bool samePassphrase, + const std::string& pathAtDispatch, + const std::string& pathNow) { + if (!changeSucceeded || !vaultExistsNow || samePassphrase) return VaultAfterChange::Keep; + if (pathAtDispatch.empty() || pathAtDispatch != pathNow) return VaultAfterChange::Keep; + return VaultAfterChange::MarkStale; +} + +// dragonxd's RPC_WALLET_PASSPHRASE_INCORRECT ("The wallet passphrase entered was incorrect."). +constexpr int kRpcWalletPassphraseIncorrect = -14; + +// A PIN unlock whose vault opened (so the PIN was right) but whose walletpassphrase call the daemon +// refused with -14: the vault holds a passphrase the wallet no longer accepts (it was changed after +// the PIN was set, e.g. with dragonx-cli walletpassphrasechange). Not a wrong-PIN attempt, so no +// lockout penalty; the PIN is marked out of date and the vault is kept. +inline bool isStaleVaultUnlockFailure(bool vaultDecrypted, int rpcCode) { + return vaultDecrypted && rpcCode == kRpcWalletPassphraseIncorrect; +} + +} // namespace data +} // namespace dragonx diff --git a/src/ui/pages/settings_page.cpp b/src/ui/pages/settings_page.cpp index c4495d8..a25cbe5 100644 --- a/src/ui/pages/settings_page.cpp +++ b/src/ui/pages/settings_page.cpp @@ -1639,26 +1639,53 @@ void RenderSettingsPage(App* app) { ImGui::Dummy(ImVec2(0, Layout::spacingXs())); ImGui::SetCursorScreenPos(ImVec2(secX, ImGui::GetCursorScreenPos().y)); + // Not while a passphrase change runs: PIN setup could seal the old passphrase. + const bool pinBusy = app->isEncryptInProgress(); if (!hasPIN) { + ImGui::BeginDisabled(pinBusy); if (TactileButton(TR("settings_set_pin"), ImVec2(pinBtnW, 0), S.resolveFont("button"))) app->showPinSetupDialog(); - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_set_pin")); + ImGui::EndDisabled(); + if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) material::Tooltip("%s", TR("tt_set_pin")); ImGui::SameLine(0, Layout::spacingMd()); ImGui::TextColored(ImVec4(1,1,1,0.5f), "%s", TR("settings_quick_unlock_pin")); } else { + ImGui::BeginDisabled(pinBusy); if (TactileButton(TR("settings_change_pin"), ImVec2(pinBtnW, 0), S.resolveFont("button"))) app->showPinChangeDialog(); - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_change_pin")); + ImGui::EndDisabled(); + if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) material::Tooltip("%s", TR("tt_change_pin")); ImGui::SameLine(0, Layout::spacingSm()); + ImGui::BeginDisabled(pinBusy); if (TactileButton(TR("settings_remove_pin"), ImVec2(pinBtnW, 0), S.resolveFont("button"))) app->showPinRemoveDialog(); - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_remove_pin")); + ImGui::EndDisabled(); + if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) material::Tooltip("%s", TR("tt_remove_pin")); ImGui::SameLine(0, Layout::spacingMd()); - ImGui::PushFont(Type().iconSmall()); - ImGui::TextColored(ImVec4(0.3f,1.0f,0.5f,1.0f), ICON_MD_DIALPAD); - ImGui::PopFont(); - ImGui::SameLine(0, Layout::spacingXs()); - ImGui::TextColored(ImVec4(0.3f,1.0f,0.5f,1.0f), "%s", TR("settings_pin_active")); + if (app->isPinVaultStale()) { + // The vault seals a passphrase the wallet no longer accepts (C87). + const ImVec4 staleCol = ImGui::ColorConvertU32ToFloat4(Warning()); + ImGui::PushFont(Type().iconSmall()); + ImGui::TextColored(staleCol, ICON_MD_WARNING); + ImGui::PopFont(); + ImGui::SameLine(0, Layout::spacingXs()); + ImGui::TextColored(staleCol, "%s", TR("settings_pin_out_of_date")); + if (ImGui::IsItemHovered()) { + const float wrapW = S.drawElement("components.settings-page", "pin-tooltip-wrap-width") + .sizeOr(420.0f) * Layout::dpiScale(); + material::BeginTooltip(); + ImGui::PushTextWrapPos(wrapW); + ImGui::TextUnformatted(TR("tt_pin_out_of_date")); + ImGui::PopTextWrapPos(); + material::EndTooltip(); + } + } else { + ImGui::PushFont(Type().iconSmall()); + ImGui::TextColored(ImVec4(0.3f,1.0f,0.5f,1.0f), ICON_MD_DIALPAD); + ImGui::PopFont(); + ImGui::SameLine(0, Layout::spacingXs()); + ImGui::TextColored(ImVec4(0.3f,1.0f,0.5f,1.0f), "%s", TR("settings_pin_active")); + } } } else { ImGui::Dummy(ImVec2(0, Layout::spacingSm())); diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index 62e076a..2e9ef9a 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -591,10 +591,13 @@ void I18n::loadBuiltinEnglish() strings_["settings_unlocked"] = "Unlocked"; strings_["settings_quick_unlock_pin"] = "Quick-unlock PIN"; strings_["settings_pin_active"] = "PIN"; + strings_["settings_pin_out_of_date"] = "PIN out of date"; strings_["settings_encrypt_first_pin"] = "Encrypt wallet first to enable PIN"; strings_["incorrect_pin"] = "Incorrect PIN"; strings_["incorrect_passphrase"] = "Incorrect passphrase"; strings_["pin_not_set"] = "PIN not set. Use passphrase to unlock."; + strings_["sec_pin_stale_msg"] = "PIN out of date. Use your passphrase."; + strings_["sec_pin_unreadable"] = "PIN file unreadable. Use your passphrase."; strings_["restarting_after_encryption"] = "Restarting daemon after encryption..."; // Stopping the node on quit: deliver stop, wait, never kill unasked strings_["shutdown_leave_running"] = "Close wallet — leave node running"; @@ -690,6 +693,7 @@ void I18n::loadBuiltinEnglish() strings_["change_pass_current"] = "Current Passphrase:"; strings_["change_pass_new"] = "New Passphrase:"; strings_["change_pass_confirm"] = "Confirm New:"; + strings_["change_pass_pin_note"] = "This wallet has a quick-unlock PIN. After the change it will be out of date: remove the PIN in Settings and set it again."; // --- Remove-encryption (decrypt) dialog (settings) --- strings_["decrypt_title"] = "Remove Wallet Encryption"; strings_["decrypt_warning"] = "This will remove encryption from your wallet. Your private keys will be stored unprotected on disk."; @@ -988,6 +992,7 @@ void I18n::loadBuiltinEnglish() strings_["tt_set_pin"] = "Set a 4-8 digit PIN for quick unlock"; strings_["tt_change_pin"] = "Change your unlock PIN"; strings_["tt_remove_pin"] = "Remove PIN and require passphrase to unlock"; + strings_["tt_pin_out_of_date"] = "The wallet passphrase changed after this PIN was set, so the PIN can no longer unlock the wallet. Remove the PIN and set it again."; strings_["tt_tx_url"] = "Base URL for viewing transactions in a block explorer"; strings_["tt_addr_url"] = "Base URL for viewing addresses in a block explorer"; strings_["tt_custom_fees"] = "Enable manual fee entry when sending transactions"; @@ -2676,6 +2681,7 @@ void I18n::loadBuiltinEnglish() strings_["sec_couldnt_lock_wallet"] = "Couldn't lock the wallet — it is still unlocked. Check the daemon connection."; strings_["sec_changing_passphrase"] = "Changing passphrase..."; strings_["sec_passphrase_changed_successfully"] = "Passphrase changed successfully"; + strings_["sec_passphrase_changed_pin_stale"] = "Passphrase changed; your PIN is now out of date"; strings_["sec_failed_prefix"] = "Failed: "; strings_["sec_encryption_did_not_complete"] = "Wallet encryption did not complete — your wallet is NOT encrypted. Open Settings to finish encrypting it."; strings_["sec_wallet_locked_title"] = "Wallet Locked"; diff --git a/src/util/secure_vault.cpp b/src/util/secure_vault.cpp index 34b4dc6..381ce65 100644 --- a/src/util/secure_vault.cpp +++ b/src/util/secure_vault.cpp @@ -28,6 +28,10 @@ namespace util { // Vault file format version static constexpr uint8_t VAULT_VERSION = 0x01; +// Minimum vault size: 1 (version) + SALTBYTES + NONCEBYTES + MACBYTES + 1 (min plaintext) +static constexpr size_t VAULT_MIN_SIZE = 1 + crypto_pwhash_SALTBYTES + crypto_secretbox_NONCEBYTES + + crypto_secretbox_MACBYTES + 1; + // Argon2id parameters — moderate: ~256 MB, ~3 ops // Tuned for short PINs to make brute-force costly static constexpr unsigned long long ARGON2_MEMLIMIT = crypto_pwhash_MEMLIMIT_MODERATE; @@ -72,6 +76,17 @@ bool SecureVault::hasVault() const { return fs::exists(getVaultPath(), ec); } +bool SecureVault::isWellFormed() const { + std::ifstream in(getVaultPath(), std::ios::binary | std::ios::ate); + if (!in.is_open()) return false; + const std::streamoff size = in.tellg(); + if (size < 0 || static_cast(size) < VAULT_MIN_SIZE) return false; + in.seekg(0); + char version = 0; + if (!in.read(&version, 1)) return false; + return static_cast(version) == VAULT_VERSION; +} + bool SecureVault::isValidPin(const std::string& pin) { if (pin.size() < 4 || pin.size() > 8) return false; return std::all_of(pin.begin(), pin.end(), ::isdigit); @@ -174,10 +189,7 @@ bool SecureVault::retrieve(const std::string& pin, std::string& passphrase) cons size_t fileSize = in.tellg(); in.seekg(0); - // Minimum size: 1 (version) + SALTBYTES + NONCEBYTES + MACBYTES + 1 (min plaintext) - size_t minSize = 1 + crypto_pwhash_SALTBYTES + crypto_secretbox_NONCEBYTES - + crypto_secretbox_MACBYTES + 1; - if (fileSize < minSize) { + if (fileSize < VAULT_MIN_SIZE) { DEBUG_LOGF("[SecureVault] Vault file too small\n"); return false; } diff --git a/src/util/secure_vault.h b/src/util/secure_vault.h index 739fc1d..a2f3fbc 100644 --- a/src/util/secure_vault.h +++ b/src/util/secure_vault.h @@ -43,6 +43,13 @@ public: */ bool hasVault() const; + /** + * @brief Cheap structural check of the vault file (no PIN, no Argon2): at least the minimum + * size and the known version byte first. A zero-filled file (left by a removeVault() whose + * unlink failed) or a truncated one is not well formed, and no PIN can open it. + */ + bool isWellFormed() const; + /** * @brief Encrypt passphrase with PIN and save to vault.dat * @param pin The user's PIN (4-8 digits) diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 55dcf7d..52733e3 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -64,6 +64,7 @@ #include "ui/staleness_badge.h" #include "ui/notifications.h" #include "data/seed_migration_resume.h" +#include "data/pin_vault_policy.h" #include "util/address_validation.h" #include "util/seed_phrase.h" #include "util/local_time.h" @@ -2708,6 +2709,118 @@ void testSecureVaultScope() EXPECT_TRUE(endsWith(vb.getVaultPath(), "vault.dat")); } +// C87: a passphrase change marks the PIN vault out of date (D9(a)) and never removes it; RPC -14 on a +// PIN unlock whose vault opened is "PIN out of date", not a wrong PIN (D10). +void testPinVaultPolicy() +{ + using dragonx::data::VaultAfterChange; + using dragonx::data::decideVaultAfterPassphraseChange; + using dragonx::data::isStaleVaultUnlockFailure; + const std::string pa = "/cfg/vault.dat", pb = "/cfg/vault-wallet-b-1234abcd.dat"; + + // The spec rows: failure, no vault, same passphrase, path mismatch -> Keep; otherwise MarkStale. + EXPECT_TRUE(decideVaultAfterPassphraseChange(false, true, false, pa, pa) == VaultAfterChange::Keep); + EXPECT_TRUE(decideVaultAfterPassphraseChange(true, false, false, pa, pa) == VaultAfterChange::Keep); + EXPECT_TRUE(decideVaultAfterPassphraseChange(true, true, true, pa, pa) == VaultAfterChange::Keep); + EXPECT_TRUE(decideVaultAfterPassphraseChange(true, true, false, pa, pb) == VaultAfterChange::Keep); + EXPECT_TRUE(decideVaultAfterPassphraseChange(true, true, false, "", "") == VaultAfterChange::Keep); + EXPECT_TRUE(decideVaultAfterPassphraseChange(true, true, false, pa, pa) == VaultAfterChange::MarkStale); + + // Full table: MarkStale exactly when all four conditions hold; there is no "remove" outcome. + int stale = 0; + for (int m = 0; m < 16; ++m) { + const bool ok = m & 1, exists = m & 2, same = m & 4, match = m & 8; + const VaultAfterChange r = decideVaultAfterPassphraseChange(ok, exists, same, pa, match ? pa : pb); + const bool want = ok && exists && !same && match; + EXPECT_TRUE(r == (want ? VaultAfterChange::MarkStale : VaultAfterChange::Keep)); + if (r == VaultAfterChange::MarkStale) ++stale; + } + EXPECT_EQ(stale, 1); + + EXPECT_EQ(dragonx::data::kRpcWalletPassphraseIncorrect, -14); + EXPECT_TRUE(isStaleVaultUnlockFailure(true, -14)); + EXPECT_FALSE(isStaleVaultUnlockFailure(true, -28)); // warmup: a transient refusal, not a stale vault + EXPECT_FALSE(isStaleVaultUnlockFailure(false, -14)); // the vault did not open: a wrong PIN + EXPECT_FALSE(isStaleVaultUnlockFailure(true, 0)); // transport / non-RPC failure + EXPECT_FALSE(isStaleVaultUnlockFailure(true, -1)); +} + +#ifndef _WIN32 +// C87 D11: isWellFormed() is a structural check only (size + version byte). A zero-filled file (what a +// removeVault() whose unlink failed leaves) or a truncated one is not well formed, and retrieve() fails +// on it before any key derivation; the PIN unlock then treats it like a missing vault (no penalty). +// Under a temp $HOME: the vault lives in the config dir. +void testSecureVaultWellFormed() +{ + using dragonx::util::SecureVault; + const char* oldHome = std::getenv("HOME"); + const std::string savedHome = oldHome ? oldHome : ""; + const char* oldXdg = std::getenv("XDG_CONFIG_HOME"); + const bool hadXdg = oldXdg != nullptr; + const std::string savedXdg = hadXdg ? oldXdg : ""; + const fs::path tmp = makeTempDir(); + setenv("HOME", tmp.string().c_str(), 1); + setenv("XDG_CONFIG_HOME", (tmp / ".config").string().c_str(), 1); + { + std::error_code ec; + fs::create_directories(dragonx::util::Platform::getConfigDir(), ec); + SecureVault v("wallet-c87.dat"); + const std::string path = v.getVaultPath(); + EXPECT_TRUE(path.rfind(tmp.string(), 0) == 0); // never the real config dir + EXPECT_FALSE(v.hasVault()); + EXPECT_FALSE(v.isWellFormed()); // missing + + EXPECT_TRUE(v.store("1234", "correct horse battery")); + EXPECT_TRUE(v.hasVault()); + EXPECT_TRUE(v.isWellFormed()); + std::string pass; + EXPECT_TRUE(v.retrieve("1234", pass)); + EXPECT_EQ(pass, std::string("correct horse battery")); + const std::string good = [&] { + std::ifstream in(path, std::ios::binary); + return std::string(std::istreambuf_iterator(in), std::istreambuf_iterator()); + }(); + EXPECT_TRUE(good.size() > 41); + + auto rewrite = [&](const std::string& bytes) { + std::ofstream out(path, std::ios::binary | std::ios::trunc); + out.write(bytes.data(), static_cast(bytes.size())); + }; + // Zero-filled, same size. + rewrite(std::string(good.size(), '\0')); + EXPECT_TRUE(v.hasVault()); + EXPECT_FALSE(v.isWellFormed()); + pass.clear(); + EXPECT_FALSE(v.retrieve("1234", pass)); + EXPECT_TRUE(pass.empty()); + // Truncated (a valid version byte, too short to hold salt + nonce + tag). + rewrite(good.substr(0, 20)); + EXPECT_FALSE(v.isWellFormed()); + EXPECT_FALSE(v.retrieve("1234", pass)); + // One byte short of the minimum (version + salt + nonce + tag + 1). + rewrite(good.substr(0, 1 + 16 + 24 + 16)); + EXPECT_FALSE(v.isWellFormed()); + // Full size, unknown version byte. + std::string badVersion = good; + badVersion[0] = '\x02'; + rewrite(badVersion); + EXPECT_FALSE(v.isWellFormed()); + EXPECT_FALSE(v.retrieve("1234", pass)); + // Empty file. + rewrite(std::string()); + EXPECT_FALSE(v.isWellFormed()); + // The original bytes are well formed again (the check is structural only). + rewrite(good); + EXPECT_TRUE(v.isWellFormed()); + EXPECT_TRUE(v.removeVault()); + EXPECT_FALSE(v.hasVault()); + } + if (!savedHome.empty()) setenv("HOME", savedHome.c_str(), 1); else unsetenv("HOME"); + if (hadXdg) setenv("XDG_CONFIG_HOME", savedXdg.c_str(), 1); else unsetenv("XDG_CONFIG_HOME"); + removeTree(tmp); +} +#endif + void testWalletSecurityController() { using dragonx::services::WalletSecurityController; @@ -15951,6 +16064,10 @@ int main() testBalanceDisplayLerp(); testOperationStatusPollParsing(); testSecureVaultScope(); + testPinVaultPolicy(); +#ifndef _WIN32 + testSecureVaultWellFormed(); +#endif testWalletSecurityController(); testWalletSecurityWorkflow(); testWalletSecurityWorkflowExecutor();