fix(security): don't penalise a correct PIN after a passphrase change

A passphrase change left the PIN vault sealing the old passphrase. The
next PIN unlock opened the vault, walletpassphrase answered RPC -14, and
the generic catch ran the lockout curve on a correct PIN, while Settings
kept showing the PIN as active.

- Passphrase change: when the active wallet's vault exists at completion
  (same vault path as at dispatch, passphrase actually changed), the
  vault is marked out of date for the session and kept on disk: PIN entry
  is no longer offered, a warning toast says so, and Settings shows
  "PIN out of date" with a tooltip on how to fix it. The Change
  Passphrase dialog notes beforehand that a PIN will go out of date.
- PIN unlock: RPC -14 after the vault opened is treated as "PIN out of
  date": passphrase entry, a message, no attempt counted, no lockout, and
  the vault is not deleted (-14 is only the daemon's answer; the vault may
  hold the only copy of a passphrase that opens an older backup). After a
  restart Settings shows the PIN as active until the first PIN attempt.
- A malformed vault (zero-filled by a removeVault() whose unlink failed,
  truncated, unknown version) is treated as missing: passphrase entry,
  "PIN file unreadable", no penalty. New SecureVault::isWellFormed().
- Race guards: Set/Change/Remove PIN are disabled while a passphrase
  change runs, and Change Passphrase while a PIN operation runs.
- Setting or removing the PIN, or switching wallets, clears the mark;
  setting a PIN offers PIN entry again. Change PIN keeps the mark (the
  vault still seals the same passphrase).
- Pure decisions in data/pin_vault_policy.h, unit-tested. New strings in
  all 8 languages.

No vault, wallet or settings format change.

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 62a5577491
commit f6e5038043
18 changed files with 362 additions and 24 deletions
+6
View File
@@ -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",
+6
View File
@@ -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",
+6
View File
@@ -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",
+6
View File
@@ -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 を削除しアンロックにパスフレーズを要求",
+6
View File
@@ -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을 제거하고 잠금 해제 시 비밀번호 요구",
+6
View File
@@ -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",
+6
View File
@@ -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 и требовать пароль для разблокировки",
+6
View File
@@ -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 并要求密码解锁",
+2
View File
@@ -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 }
+9
View File
@@ -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_;
+2
View File
@@ -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();
+75 -12
View File
@@ -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();
+45
View File
@@ -0,0 +1,45 @@
#pragma once
#include <string>
// 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
+35 -8
View File
@@ -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()));
+6
View File
@@ -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";
+16 -4
View File
@@ -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_t>(size) < VAULT_MIN_SIZE) return false;
in.seekg(0);
char version = 0;
if (!in.read(&version, 1)) return false;
return static_cast<uint8_t>(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;
}
+7
View File
@@ -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)
+117
View File
@@ -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<char>(in), std::istreambuf_iterator<char>());
}();
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<std::streamsize>(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();