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
+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();