feat(lite): wire the create-time passphrase into the lite encrypt/unlock flow (W5-3)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 <path>". It now reads "**Saved an UNENCRYPTED seed file — move it to secure offline storage and delete this copy**: <path>", 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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<LiteWalletController>(
|
||||
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<LiteWalletController>(
|
||||
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<LiteWalletController>(
|
||||
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<LiteWalletController>(
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user