fix(lite): require a confirmed 8+ character passphrase to encrypt

The Lite "Encrypt wallet" field and the Create/Restore passphrase had no
confirmation and no minimum length; only an empty passphrase was
refused. Encrypting locks the wallet at once, and a locked wallet cannot
reveal its seed, so a typo there could cost a user without a seed backup
their funds. The full-node encrypt dialog already requires 8 characters
and a confirmation.

This applies to new passphrases only. Opening and unlocking are
unchanged: an existing wallet with a shorter passphrase still opens and
unlocks.

- A pure liteEncryptPassphraseValid(pass, confirm): at least 8 bytes and
  equal to the confirmation. It takes string_views, so the page's secret
  buffers are never copied.
- liteLifecyclePassphraseAcceptable(op, pass, confirm) gates the Settings
  create/open/restore request. Open is never checked, because its
  passphrase field also unlocks an existing wallet. Create and Restore
  are checked only when a passphrase was entered; an empty one still
  creates an unencrypted wallet.
- Encrypt gets a Confirm field. The button stays disabled until the
  passphrase is valid, and a hint says why (too short, with the count,
  or not matching).
- Create/Restore show a Confirm row once a passphrase is typed, with the
  same hint. Validate stays disabled while the passphrase is invalid, so
  a click never wipes a typed seed over a passphrase typo. The request
  builder re-checks the rule.
- The confirmation buffers are wiped with their passphrase: after a run
  or an encrypt, and when the Confirm row is hidden.
- Once the wallet is closed, the Settings page wipes the Encrypt,
  Confirm and Decrypt passphrase buffers whole every time, also when no
  secret was revealed; before, closing the wallet wiped them only while
  clearing a revealed backup or secret. They are zeroed in full because
  an emptied field can still hold the old tail past its first NUL.
- New copy under Encrypt: "Anyone with the wallet file can try
  passphrases offline. Back up your seed phrase first and use a long
  passphrase."
- i18n: three new keys (lite_confirm_label, tt_lite_confirm_pass,
  lite_encrypt_offline_warning) in i18n.cpp and the 8 language files,
  added additively. The hints reuse the wizard's wiz_pass_too_short and
  wiz_pass_mismatch. No new CJK glyphs, so the subset font is unchanged.

Tests: the validator rejects empty, short (7) and mismatched or
unconfirmed passphrases, and accepts 8 or more with a match. The request
gate always accepts Open, even "abc" with no confirmation, accepts
Create/Restore without a passphrase, and applies the rule to them
otherwise. An encrypted, locked wallet still opens and unlocks through
the controller with the 3-character passphrase "abc".

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 e52c9e1b92
commit eac0993512
13 changed files with 262 additions and 13 deletions
+86
View File
@@ -87,6 +87,7 @@
#include "wallet/lite_owned_string.h"
#include "wallet/lite_rollout_policy.h"
#include "wallet/lite_wallet_controller.h"
#include "wallet/lite_wallet_lifecycle_ui_adapter.h"
#include "wallet/lite_wallet_gateway.h"
#include "wallet/lite_wallet_state_mapper.h"
#include "config/settings.h"
@@ -8783,6 +8784,90 @@ void testLiteWalletControllerAsyncAppliesPassphrase()
dragonx::test::g_liteFakeWarmupServerSubstr.clear();
}
// C2 (s1-D8, X7): a NEW Lite passphrase (Settings "Encrypt wallet", or Create/Restore with one entered)
// must be 8+ characters and confirmed. Opening and unlocking an existing wallet never apply the rule, so
// a wallet encrypted earlier with a short passphrase still opens and unlocks.
void testLiteNewPassphraseRules()
{
using namespace dragonx::wallet;
using Op = LiteWalletLifecycleOperation;
EXPECT_EQ(kLiteMinNewPassphraseLength, static_cast<std::size_t>(8));
// The validator: empty, short or mismatched is rejected; 8+ and confirmed is accepted.
EXPECT_FALSE(liteEncryptPassphraseValid("", ""));
EXPECT_FALSE(liteEncryptPassphraseValid("a", "a"));
EXPECT_FALSE(liteEncryptPassphraseValid("1234567", "1234567")); // 7 < 8
EXPECT_FALSE(liteEncryptPassphraseValid("", "12345678"));
EXPECT_FALSE(liteEncryptPassphraseValid("12345678", "")); // not confirmed
EXPECT_FALSE(liteEncryptPassphraseValid("12345678", "12345679")); // mismatch
EXPECT_FALSE(liteEncryptPassphraseValid("12345678", "12345678 ")); // a trailing space is a mismatch
EXPECT_TRUE(liteEncryptPassphraseValid("12345678", "12345678")); // exactly 8
EXPECT_TRUE(liteEncryptPassphraseValid("correct horse battery staple", "correct horse battery staple"));
{
// The page passes its fixed char buffers straight in (no copy of the secret).
char pass[128] = "hunter2hunter2";
char confirm[128] = "hunter2hunter2";
EXPECT_TRUE(liteEncryptPassphraseValid(pass, confirm));
confirm[0] = 'H';
EXPECT_FALSE(liteEncryptPassphraseValid(pass, confirm));
}
// The Settings request gate. OpenExisting is never validated: a short or unconfirmed passphrase
// for an existing wallet passes.
EXPECT_TRUE(liteLifecyclePassphraseAcceptable(Op::OpenExisting, "abc", ""));
EXPECT_TRUE(liteLifecyclePassphraseAcceptable(Op::OpenExisting, "hunter2", "different"));
EXPECT_TRUE(liteLifecyclePassphraseAcceptable(Op::OpenExisting, "", ""));
// Create/Restore without a passphrase make an unencrypted wallet: nothing to confirm.
EXPECT_TRUE(liteLifecyclePassphraseAcceptable(Op::CreateNew, "", ""));
EXPECT_TRUE(liteLifecyclePassphraseAcceptable(Op::RestoreFromSeed, "", ""));
// With a passphrase they set a NEW one: 8+ characters and confirmed.
for (const Op op : {Op::CreateNew, Op::RestoreFromSeed}) {
EXPECT_FALSE(liteLifecyclePassphraseAcceptable(op, "abc", "abc"));
EXPECT_FALSE(liteLifecyclePassphraseAcceptable(op, "1234567", "1234567"));
EXPECT_FALSE(liteLifecyclePassphraseAcceptable(op, "12345678", ""));
EXPECT_FALSE(liteLifecyclePassphraseAcceptable(op, "12345678", "1234567X"));
EXPECT_TRUE(liteLifecyclePassphraseAcceptable(op, "12345678", "12345678"));
}
// The controller's open and unlock paths take an existing short passphrase as is: an encrypted,
// locked wallet opens and unlocks with "abc" (as the Settings Open request sends it), and unlocks
// with it again after a lock.
{
const auto liteCaps = makeWalletCapabilities(WalletBuildKind::Lite, false, true);
LiteConnectionSettings conn;
conn.chainName = "main";
conn.servers = { LiteServerEndpoint{"https://good.example", "Good", true} };
conn.selectionMode = LiteServerSelectionMode::Sticky;
conn.stickyServerUrl = "https://good.example";
dragonx::test::resetLiteFakeCounters();
dragonx::test::g_liteFakeWalletExists = true;
dragonx::test::g_liteFakeEncrypted = true;
dragonx::test::g_liteFakeLocked = true;
LiteWalletController controller(liteCaps, conn,
LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
LiteWalletOpenRequest req;
req.passphrase = "abc";
EXPECT_TRUE(liteLifecyclePassphraseAcceptable(Op::OpenExisting, req.passphrase, ""));
EXPECT_TRUE(controller.beginOpenWalletAsync(req));
for (int i = 0; i < 400 && controller.lifecycleRequestInProgress(); ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(5));
controller.pumpLifecycleResult();
EXPECT_TRUE(controller.walletOpen());
EXPECT_TRUE(controller.encryptionStatus().encrypted);
EXPECT_FALSE(controller.encryptionStatus().locked); // the short passphrase unlocked it
EXPECT_TRUE(controller.lockWallet());
EXPECT_TRUE(controller.encryptionStatus().locked);
EXPECT_TRUE(controller.unlockWallet("abc"));
EXPECT_FALSE(controller.encryptionStatus().locked);
}
dragonx::test::g_liteFakeWalletExists = false;
dragonx::test::g_liteFakeEncrypted = false;
dragonx::test::g_liteFakeLocked = false;
}
// WalletState::tx_revision (the History / Mining caches key on it): sameTransactionInfo covers every
// TransactionInfo field and compares `amount` bit-exactly; setTransactionsIfChanged bumps only on a real change
// and otherwise keeps the old list.
@@ -15713,6 +15798,7 @@ int main()
testLiteWalletControllerEncryption();
testLiteWalletControllerCreateEncryptsWithPassphrase();
testLiteWalletControllerAsyncAppliesPassphrase();
testLiteNewPassphraseRules();
testLiteChainNameMigration();
testLiteRefreshModelAppliesToWalletState();
testSameTransactionList();