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:
@@ -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_;
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
|
||||
@@ -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()));
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user