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
@@ -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)