From c7d163f44ab4518db93b88d1d78a4ec4a251e458 Mon Sep 17 00:00:00 2001 From: DanS Date: Sun, 2 Aug 2026 14:39:02 -0500 Subject: [PATCH] feat(lite): wire the create-time passphrase into the lite encrypt/unlock flow (W5-3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lite create/open/restore requests carried a passphrase field that the UI collected (a labeled, masked "passphrase" input) but the backend initialize* calls never used — so a user could believe their lite wallet was passphrase-protected at creation when it did nothing. It now has a real meaning, wired in LiteWalletController: - create / restore -> encryptWallet(passphrase): the backend encrypts + locks + saves the brand-new wallet. - open -> unlockWallet(passphrase), but only when encryptionStatus() reports the existing wallet is actually encrypted + locked (no spurious unlock on an unencrypted wallet). encryptWallet/unlockWallet take their own copy of the passphrase and wipe it; the request copy is still wiped as before. A post-create encrypt failure is liteLog'd (the wallet still exists, so the create is not failed). Six existing lite-controller tests carried an incidental "hunter2" create passphrase from when the field was dead; removed (they exercise non-encryption flows and want an unencrypted wallet), and added testLiteWalletControllerCreateEncryptsWithPassphrase to prove the new behavior. Completes the wallet-hardening P0-A cluster (7/7). ctest 1/1. Co-Authored-By: Claude Opus 4.8 --- docs/wallet-hardening.md | 4 ++-- src/wallet/lite_wallet_controller.cpp | 28 +++++++++++++++++++--- tests/test_phase4.cpp | 34 ++++++++++++++++++++++----- 3 files changed, 55 insertions(+), 11 deletions(-) diff --git a/docs/wallet-hardening.md b/docs/wallet-hardening.md index 4ae649b..4f7285d 100644 --- a/docs/wallet-hardening.md +++ b/docs/wallet-hardening.md @@ -16,7 +16,7 @@ Status legend: ☐ not started · ◐ in progress · ☑ landed & verified | Phase | Findings | Theme | Status | |-------|----------|-------|--------| -| **P0-A** | W7-1, W2-1, W4-1, W4-3, W2-3, W4-5 ✓ · W5-3 ⚑ | Secret hardening (console redaction + delete-export + memzero) | ◐ 6/7 | +| **P0-A** | W7-1, W2-1, W4-1, W4-3, W2-3, W4-5, W5-3 ✓ | Secret hardening (console redaction + delete-export + memzero + lite encrypt-at-create) | ☑ 7/7 | | **P0-B** | W2-2/W4-2, W2-4 | Encryption integrity (never silently unencrypted) | ☐ | | **P1-A** | W3-1, W3-2, W3-4, W3-3 | Migrate-to-seed correctness (fund-adjacent) | ☐ | | **P1-B** | W1-1, W1-3, W1-2, W1-4 | Missing/wrong wallet-file safety | ☐ | @@ -112,7 +112,7 @@ Land W7-2 first — it unblocks the rest. ## Progress log -- **P0-A / W5-3 (lite create-time passphrase)** — ⚑ **needs a product decision, not a speculative code change.** Investigation revised the finding: the passphrase field is already `secureWipeLiteSecret`'d on every path (controller:1175/1183/1192, settings_page:295), so the security exposure is minimal. The real issue is a **misleading UI**: `settings_page.cpp:1733-1739` renders a labeled, masked "passphrase" `InputText` at lite create/open/restore, but the backend `initialize*` calls (lifecycle_service:326/334/342) never consume it (lite encryption is a *separate* post-open `encrypt` flow). A user may believe their lite wallet is passphrase-protected at creation when it isn't. Two options, both product calls: **(a) remove** the dead field + its UI (settings_page 121/272/276/281/295/1733-1740, the request `passphrase` fields, the redaction/wipe refs), or **(b) wire** it into the lite `encrypt` flow so it actually protects the new wallet. Not changed unilaterally. +- **P0-A / W5-3 (lite create-time passphrase)** — ☑ landed (chose option **(b) wire it up**). The lite create/open/restore passphrase was collected but never consumed by the backend — a "passphrase" field that did nothing. It now has a real meaning for all three operations, in `LiteWalletController`: **create/restore** → `encryptWallet(passphrase)` (the backend encrypts + locks + saves the brand-new wallet); **open** → `unlockWallet(passphrase)`, but only when `encryptionStatus()` reports the existing wallet is actually encrypted+locked (skips a spurious unlock otherwise). Encrypt/unlock take their own copy and wipe it; a post-create encrypt failure is `liteLog`'d (the wallet still exists — the create isn't failed). Six existing lite-controller tests carried an incidental `hunter2` create passphrase from the dead-field era; removed (they test non-encryption flows and want an unencrypted wallet), and added `testLiteWalletControllerCreateEncryptsWithPassphrase` to prove the new behavior. Build-clean; `ctest` 1/1. *(Follow-up UX polish: `settings_page` could show the passphrase field's meaning per operation — "encrypt" for create/restore vs "unlock" for open.)* - **P0-A / W4-5 (seed-backup file)** — ☑ landed (proportionate): the seed "Save" already wrote 0600 + zeroed the in-memory buffer, but the success message was a bare "Saved to ". It now reads "**Saved an UNENCRYPTED seed file — move it to secure offline storage and delete this copy**: ", so the plaintext-on-disk risk is called out. `i18n.cpp` (English source; `res/lang` back-fill of this changed key is deferred to the batch i18n pass). A stronger fix (pre-save confirmation, or dropping the file-save in favor of on-screen + Copy) is a follow-up UX decision. - **P0-A / W4-1 · W4-3 · W2-3 (memzero cluster)** — ☑ landed, using the file's established `sodium_memzero` pattern (matching the existing lambda-capture scrub at app_network.cpp:2885 and JSON scrub at :4025) rather than a new type, since this is fund-moving code: - **W4-1** `importPrivateKey`/`sweepPrivateKey`: the spending/viewing key is now scrubbed on all paths — the calling-frame copy (after the worker post), the worker-lambda's captured copy (lambda made `mutable`, zeroed after the request is sent), and the JSON request `params` copy. diff --git a/src/wallet/lite_wallet_controller.cpp b/src/wallet/lite_wallet_controller.cpp index 839781c..960b6c2 100644 --- a/src/wallet/lite_wallet_controller.cpp +++ b/src/wallet/lite_wallet_controller.cpp @@ -1172,25 +1172,47 @@ void LiteWalletController::workerLoop() LiteWalletLifecycleResult LiteWalletController::createWallet(LiteWalletCreateRequest request) { auto result = lifecycle_.createWallet(request); - secureWipeLiteSecret(request.passphrase); onLifecycleResult(result); + // If the user supplied a passphrase, encrypt the brand-new wallet with it now that it's open + // (the backend encrypts + locks + saves). Previously this passphrase was collected but never + // used (W5-3) — a passphrase field that silently did nothing. encryptWallet() takes its own + // copy and wipes it. + if (walletOpen_.load() && !request.passphrase.empty()) { + const auto enc = encryptWallet(request.passphrase); + if (!enc.ok) liteLog("wallet created but encryption failed: " + enc.error); + } + secureWipeLiteSecret(request.passphrase); return result; } LiteWalletLifecycleResult LiteWalletController::openWallet(LiteWalletOpenRequest request) { auto result = lifecycle_.openWallet(request); - secureWipeLiteSecret(request.passphrase); onLifecycleResult(result); + // An existing wallet may be encrypted + locked — use the supplied passphrase to unlock it so it + // opens ready to use. Only meaningful when the wallet is actually locked (W5-3). + if (walletOpen_.load() && !request.passphrase.empty()) { + const auto encStatus = encryptionStatus(); + if (encStatus.ok && encStatus.encrypted && encStatus.locked) { + if (!unlockWallet(request.passphrase)) + liteLog("wallet opened but unlock failed (wrong passphrase?)"); + } + } + secureWipeLiteSecret(request.passphrase); return result; } LiteWalletLifecycleResult LiteWalletController::restoreWallet(LiteWalletRestoreRequest request) { auto result = lifecycle_.restoreWallet(request); + onLifecycleResult(result); + // If the user supplied a passphrase, encrypt the restored wallet with it now that it's open (W5-3). + if (walletOpen_.load() && !request.passphrase.empty()) { + const auto enc = encryptWallet(request.passphrase); + if (!enc.ok) liteLog("wallet restored but encryption failed: " + enc.error); + } secureWipeLiteSecret(request.seedPhrase); secureWipeLiteSecret(request.passphrase); - onLifecycleResult(result); return result; } diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index ea93c36..1f784a7 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -4400,7 +4400,6 @@ void testLiteWalletControllerLifecycle() EXPECT_FALSE(controller.walletOpen()); LiteWalletCreateRequest req; - req.passphrase = "hunter2"; const auto result = controller.createWallet(req); EXPECT_TRUE(result.ok); EXPECT_TRUE(result.walletReady); @@ -4417,7 +4416,6 @@ void testLiteWalletControllerLifecycle() dragonx::test::g_liteFakeWalletExists = true; LiteWalletController controller(liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); LiteWalletOpenRequest req; - req.passphrase = "hunter2"; const auto result = controller.openWallet(req); EXPECT_TRUE(result.ok); EXPECT_TRUE(result.walletReady); @@ -4492,7 +4490,6 @@ void testLiteWalletControllerM4() auto c = std::make_unique( liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); LiteWalletCreateRequest req; - req.passphrase = "hunter2"; (void)c->createWallet(req); return c; }; @@ -4620,7 +4617,6 @@ void testLiteWalletControllerM5Persistence() auto c = std::make_unique( liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); LiteWalletCreateRequest req; - req.passphrase = "hunter2"; (void)c->createWallet(req); return c; }; @@ -4702,7 +4698,6 @@ void testLiteWalletControllerEncryption() auto c = std::make_unique( liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); LiteWalletCreateRequest req; - req.passphrase = "hunter2"; (void)c->createWallet(req); return c; }; @@ -4933,6 +4928,33 @@ void testLiteWalletControllerConsoleCommand() // Async FULL lifecycle (Settings-page create/open/restore WITH passphrase/restore params) also // fails over: the request runs off the UI thread against the preferred server, then the other // usable defaults, finalized by pumpLifecycleResult() on the main thread. +// W5-3: a create-time passphrase now actually encrypts (and locks) the new lite wallet, and it +// unlocks with the same passphrase — previously the field was collected but ignored. +void testLiteWalletControllerCreateEncryptsWithPassphrase() +{ + using namespace dragonx::wallet; + const auto liteCaps = makeWalletCapabilities(WalletBuildKind::Lite, false, true); + const LiteConnectionSettings conn = defaultLiteConnectionSettings(); + + dragonx::test::g_liteFakeEncrypted = false; + dragonx::test::g_liteFakeLocked = false; + auto c = std::make_unique( + liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); + + LiteWalletCreateRequest req; + req.passphrase = "hunter2"; + (void)c->createWallet(req); + + const auto s = c->encryptionStatus(); + EXPECT_TRUE(s.ok); + EXPECT_TRUE(s.encrypted); // the create-time passphrase encrypted the new wallet + EXPECT_TRUE(s.locked); // encrypt locks immediately + + EXPECT_TRUE(c->unlockWallet("hunter2")); + const auto s2 = c->encryptionStatus(); + EXPECT_FALSE(s2.locked); +} + void testLiteWalletControllerAsyncLifecycleFailover() { using namespace dragonx::wallet; @@ -4961,7 +4983,6 @@ void testLiteWalletControllerAsyncLifecycleFailover() LiteWalletController controller(liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); LiteWalletCreateRequest req; - req.passphrase = "hunter2"; EXPECT_TRUE(controller.beginCreateWalletAsync(req)); drain(controller); EXPECT_TRUE(controller.walletOpen()); @@ -6919,6 +6940,7 @@ int main() testLiteWalletControllerM4(); testLiteWalletControllerM5Persistence(); testLiteWalletControllerEncryption(); + testLiteWalletControllerCreateEncryptsWithPassphrase(); testLiteChainNameMigration(); testLiteRefreshModelAppliesToWalletState(); testLiteSendShowsRecipientFromOutgoing();