feat(lite): startup unlock prompt + real-backend encryption verification

Startup lock screen (soft): once the first refresh reveals the auto-opened wallet
is encrypted+locked, show the unlock modal on launch (reusing renderLiteUnlockPrompt,
one-shot per session). Soft by design — balances stay viewable via viewing keys
while locked, so the user may dismiss and browse read-only; only spending needs
the passphrase.

Real-backend verification: add `lite_smoke --encrypt` (create -> encryptionstatus
-> encrypt -> lock -> unlock, checking flags; passphrase never printed). Running it
against the real SDXL backend showed encrypt LOCKS immediately
(after encrypt: encrypted=1, locked=1) — the backend removes spending keys right
after encrypting. The controller already relays encryptionstatus faithfully (UI is
state-driven, so unaffected), but the fake modeled encrypt->unlocked; corrected the
fake (encrypt -> encrypted+locked) and the test sequence (encrypt -> unlock -> lock
-> decrypt) to match real behavior.

Builds clean, tests pass, hygiene clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 18:53:35 -05:00
parent d52d3d1b7f
commit 950d7ace50
5 changed files with 68 additions and 13 deletions

View File

@@ -439,6 +439,15 @@ void App::update()
lite_send_callback_ = nullptr;
}
}
// Startup lock screen: once the first refresh reveals the (auto-opened) wallet is
// encrypted, prompt to unlock if it's locked. Soft by design — balances stay viewable via
// viewing keys while locked; only spending needs the passphrase, so the user may dismiss
// and browse read-only. Fires once per session.
if (!lite_startup_lock_checked_ && state_.encrypted) {
lite_startup_lock_checked_ = true;
if (state_.locked) requestLiteUnlock();
}
}
async_tasks_.reapCompleted();

View File

@@ -431,6 +431,8 @@ private:
bool lite_firstrun_dismissed_ = false;
// Lite send-time unlock: set to show the unlock modal when a spend is attempted while locked.
bool lite_unlock_prompt_ = false;
// One-shot: prompt to unlock on startup once we learn the auto-opened wallet is encrypted+locked.
bool lite_startup_lock_checked_ = false;
std::unique_ptr<daemon::DaemonController> daemon_controller_;
std::unique_ptr<daemon::XmrigManager> xmrig_manager_;
util::AsyncTaskManager async_tasks_;

View File

@@ -128,10 +128,11 @@ inline char* liteFakeExecute(const char* command, const char* args)
++g_liteFakeSaveCount;
return liteFakeDup("{\"result\":\"success\"}");
}
// Encryption state machine. encrypt -> encrypted (keys stay in memory this session);
// lock/unlock toggle locked; decrypt clears it; encryptionstatus reports {encrypted,locked}.
// Encryption state machine. encrypt -> encrypted AND locked (matches the real backend,
// which removes keys from memory right after encrypting — verified via lite_smoke --encrypt);
// unlock/lock toggle locked; decrypt clears it; encryptionstatus reports {encrypted,locked}.
if (std::strcmp(c, "encrypt") == 0) {
g_liteFakeEncrypted = true; g_liteFakeLocked = false;
g_liteFakeEncrypted = true; g_liteFakeLocked = true;
return liteFakeDup("{\"result\":\"success\"}");
}
if (std::strcmp(c, "unlock") == 0) {

View File

@@ -4777,7 +4777,8 @@ void testLiteWalletControllerEncryption()
return c;
};
// encrypt -> lock -> unlock -> decrypt, observed via encryptionStatus()
// encrypt -> unlock -> lock -> decrypt, observed via encryptionStatus(). NOTE: encrypt LOCKS
// immediately (the real backend removes keys after encrypting — verified via lite_smoke).
{
auto c = open();
const auto s0 = c->encryptionStatus();
@@ -4788,17 +4789,17 @@ void testLiteWalletControllerEncryption()
EXPECT_TRUE(c->encryptWallet("walletpass").ok);
const auto s1 = c->encryptionStatus();
EXPECT_TRUE(s1.encrypted);
EXPECT_FALSE(s1.locked);
EXPECT_TRUE(c->lockWallet());
const auto s2 = c->encryptionStatus();
EXPECT_TRUE(s2.encrypted);
EXPECT_TRUE(s2.locked);
EXPECT_TRUE(s1.locked); // encrypt locks immediately
EXPECT_TRUE(c->unlockWallet("walletpass"));
const auto s2 = c->encryptionStatus();
EXPECT_TRUE(s2.encrypted);
EXPECT_FALSE(s2.locked);
EXPECT_TRUE(c->lockWallet());
const auto s3 = c->encryptionStatus();
EXPECT_TRUE(s3.encrypted);
EXPECT_FALSE(s3.locked);
EXPECT_TRUE(s3.locked);
EXPECT_TRUE(c->decryptWallet("walletpass").ok);
const auto s4 = c->encryptionStatus();

View File

@@ -7,7 +7,7 @@
// against a real lightwalletd server. The "real backend smoke test" the plan gates behind
// passing fake-backend tests.
//
// lite_smoke [server-url] [--create] [--refresh] [--full] [--restore-recent] [--keys]
// lite_smoke [server-url] [--create] [--refresh] [--full] [--restore-recent] [--keys] [--encrypt]
//
// Read-only by default. --create initializes a new wallet. --refresh runs the refresh
// commands through the parsers (shape check). --full also does a (slow) full sync first.
@@ -126,12 +126,36 @@ static void runLiteKeysShapeChecks(LiteClientBridge& bridge)
}
}
// Exercise the encryption commands (encrypt/lock/unlock + encryptionstatus) against the real
// backend. SECRET-SAFE: the throwaway passphrase is never printed; only encrypted/locked flags.
static void runLiteEncryptionChecks(LiteClientBridge& bridge)
{
const char* kPass = "smoke-encryption-passphrase"; // throwaway; isolated HOME only
auto status = [&](const char* label) {
auto r = bridge.execute("encryptionstatus", "");
auto j = nlohmann::json::parse(r.value, nullptr, false);
const bool enc = j.is_object() && j.value("encrypted", false);
const bool lck = j.is_object() && j.value("locked", false);
std::printf("[lite-smoke] encstatus %-13s bridge_ok=%d encrypted=%d locked=%d\n",
label, r.ok, enc, lck);
};
status("(initial)");
std::printf("[lite-smoke] encrypt bridge_ok=%d\n", bridge.execute("encrypt", kPass).ok);
status("(after encrypt)");
std::printf("[lite-smoke] lock bridge_ok=%d\n", bridge.execute("lock", "").ok);
status("(after lock)");
std::printf("[lite-smoke] unlock bridge_ok=%d\n", bridge.execute("unlock", kPass).ok);
status("(after unlock)");
bridge.execute("save", "");
}
int main(int argc, char** argv)
{
std::setvbuf(stdout, nullptr, _IONBF, 0); // unbuffered so output survives a timeout kill
std::string server = "https://lite.dragonx.is";
bool doCreate = false, doRefresh = false, doFull = false, doRestoreRecent = false, doKeys = false;
bool doCreate = false, doRefresh = false, doFull = false, doRestoreRecent = false, doKeys = false,
doEncrypt = false;
for (int i = 1; i < argc; ++i) {
const std::string arg = argv[i];
if (arg == "--create") doCreate = true;
@@ -139,6 +163,7 @@ int main(int argc, char** argv)
else if (arg == "--full") doFull = true;
else if (arg == "--restore-recent") doRestoreRecent = true;
else if (arg == "--keys") doKeys = true;
else if (arg == "--encrypt") doEncrypt = true;
else server = arg;
}
@@ -201,6 +226,23 @@ int main(int argc, char** argv)
return 0;
}
if (doEncrypt) {
// Encryption command check against the real backend (fast, no sync). Isolated HOME.
std::printf("[lite-smoke] initializeNew() for encryption check...\n");
auto cr = bridge.initializeNew(false, server);
std::printf("[lite-smoke] initializeNew ok=%d\n", cr.ok);
if (!cr.ok) {
std::printf("[lite-smoke] error = %s\n", cr.error.c_str());
bridge.shutdown();
return 1;
}
std::printf("[lite-smoke] --- encryption check (encrypt/lock/unlock + status) ---\n");
runLiteEncryptionChecks(bridge);
bridge.shutdown();
std::printf("[lite-smoke] done\n");
return 0;
}
if (doCreate) {
std::printf("[lite-smoke] initializeNew() ... (real network + writes wallet state)\n");
auto result = bridge.initializeNew(false, server);