From 50b0419dfe7d3a729cf62fe3cb6f52e2c147522f Mon Sep 17 00:00:00 2001 From: DanS Date: Fri, 5 Jun 2026 17:50:53 -0500 Subject: [PATCH] feat(lite): wallet encryption controller layer (encrypt/unlock/lock/decrypt) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the backend passphrase-encryption commands into LiteWalletController: - encryptWallet / decryptWallet (take passphrase by value, securely wipe it, save after), unlockWallet / lockWallet (bring spending keys into/out of memory), and encryptionStatus() -> {encrypted, locked}. All return failure-safe results; errors arrive as {"error":..} or "Error:" (handled). - Fold encryptionstatus into refreshModel() (polled every cycle, available even mid-sync since it reads local wallet state) and apply it in applyLiteRefreshModelToWalletState, so WalletState.isEncrypted()/isLocked() track the backend — which gates the existing locked/auto-lock UI. Backend contracts verified against the SDXL source: encrypt/unlock/decrypt take the passphrase as the single arg; lock takes none; encryptionstatus returns {"encrypted","locked"}; ops return {"result":"success"} / {"error":..}. Tests: testLiteWalletControllerEncryption drives encrypt -> lock -> unlock -> decrypt via encryptionStatus(), checks empty-passphrase + closed-wallet rejection, and that the status folds into WalletState. Fake models the state machine. GUI wiring (encrypt in Settings, unlock prompt / lock action) is the follow-up; the backend create flow remains unencrypted by default until encrypt is run. Co-Authored-By: Claude Opus 4.8 --- src/wallet/lite_wallet_controller.cpp | 130 +++++++++++++++++++++++++- src/wallet/lite_wallet_controller.h | 25 +++++ src/wallet/lite_wallet_state_mapper.h | 3 + tests/fake_lite_backend.h | 26 ++++++ tests/test_phase4.cpp | 73 +++++++++++++++ 5 files changed, 256 insertions(+), 1 deletion(-) diff --git a/src/wallet/lite_wallet_controller.cpp b/src/wallet/lite_wallet_controller.cpp index 1310652..2b71930 100644 --- a/src/wallet/lite_wallet_controller.cpp +++ b/src/wallet/lite_wallet_controller.cpp @@ -181,6 +181,12 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model, state.sync.verification_progress = model.sync.progress; state.sync.syncing = !model.sync.complete; } + + if (model.hasEncryptionStatus) { + // Reflect backend encryption state so isLocked()/isEncrypted() gate the UI correctly. + state.encrypted = model.encrypted; + state.locked = model.locked; + } } LiteWalletController::LiteWalletController(WalletCapabilities capabilities, @@ -267,11 +273,23 @@ std::optional LiteWalletController::refreshModel() // syncstatus is fast (reads shared state the sync thread updates). Poll it every time. const auto syncResult = sync_.pollSyncStatus(LiteSyncStatusRequest{}); + // Encryption status reads local wallet state (available even mid-sync); fold it into every + // model so WalletState.isLocked()/isEncrypted() track the backend. + const auto encStatus = encryptionStatus(); + auto applyEncryption = [&encStatus](LiteWalletAppRefreshModel& m) { + if (encStatus.ok) { + m.hasEncryptionStatus = true; + m.encrypted = encStatus.encrypted; + m.locked = encStatus.locked; + } + }; + if (!syncDone_->load()) { // Sync still running: publish progress only. Data queries (balance/list) would block // until the chain is synced, so don't issue them yet. if (!syncResult.ok) return std::nullopt; LiteWalletAppRefreshModel model; + applyEncryption(model); model.hasSyncStatus = true; model.sync.walletHeight = syncResult.syncStatus.syncedBlocks; model.sync.chainHeight = syncResult.syncStatus.totalBlocks; @@ -296,7 +314,9 @@ std::optional LiteWalletController::refreshModel() } const auto mapped = mapLiteWalletRefreshResult(refreshResult); if (!mapped.ok) return std::nullopt; - return mapped.model; + auto model = mapped.model; + applyEncryption(model); + return model; } LiteNewAddressResult LiteWalletController::newAddress(bool shielded) @@ -510,6 +530,114 @@ bool LiteWalletController::walletExists() const return bridge_ && bridge_->walletExists(chainName_); } +namespace { +// encrypt/unlock/lock/decrypt return {"result":"success"} or {"error":..} (or an "Error:"-prefixed +// string the bridge maps to ok=false). Success = bridge ok and no error field. +LiteEncryptionResult parseEncryptionOpResponse(const LiteBridgeStringResult& bridgeCall) +{ + LiteEncryptionResult out; + if (!bridgeCall.ok) { + out.error = bridgeCall.error.empty() ? "operation failed" : bridgeCall.error; + return out; + } + try { + const auto j = nlohmann::json::parse(bridgeCall.value); + if (j.is_object() && j.contains("error")) { + out.error = extractJsonError(j); + return out; + } + } catch (...) { + // a non-JSON success payload is acceptable + } + out.ok = true; + return out; +} +} // namespace + +LiteEncryptionStatus LiteWalletController::encryptionStatus() +{ + LiteEncryptionStatus out; + if (!walletOpen_.load() || !bridge_) { + out.error = "no wallet is open"; + return out; + } + const auto result = bridge_->execute("encryptionstatus", ""); + if (!result.ok) { + out.error = result.error.empty() ? "encryptionstatus failed" : result.error; + return out; + } + try { + const auto j = nlohmann::json::parse(result.value); + if (j.is_object()) { + if (j.contains("error")) { + out.error = extractJsonError(j); + return out; + } + out.encrypted = j.value("encrypted", false); + out.locked = j.value("locked", false); + out.ok = true; + return out; + } + } catch (...) { + // fall through + } + out.error = "could not parse encryptionstatus response"; + return out; +} + +LiteEncryptionResult LiteWalletController::encryptWallet(std::string passphrase) +{ + LiteEncryptionResult out; + if (!walletOpen_.load() || !bridge_) { + secureWipeLiteSecret(passphrase); + out.error = "no wallet is open"; + return out; + } + if (passphrase.empty()) { + out.error = "passphrase required"; + return out; + } + out = parseEncryptionOpResponse(bridge_->execute("encrypt", passphrase)); + secureWipeLiteSecret(passphrase); + if (out.ok) bridge_->execute("save", ""); // persist the now-encrypted wallet + return out; +} + +LiteEncryptionResult LiteWalletController::decryptWallet(std::string passphrase) +{ + LiteEncryptionResult out; + if (!walletOpen_.load() || !bridge_) { + secureWipeLiteSecret(passphrase); + out.error = "no wallet is open"; + return out; + } + if (passphrase.empty()) { + out.error = "passphrase required"; + return out; + } + out = parseEncryptionOpResponse(bridge_->execute("decrypt", passphrase)); + secureWipeLiteSecret(passphrase); + if (out.ok) bridge_->execute("save", ""); // persist the now-unencrypted wallet + return out; +} + +bool LiteWalletController::unlockWallet(std::string passphrase) +{ + if (!walletOpen_.load() || !bridge_ || passphrase.empty()) { + secureWipeLiteSecret(passphrase); + return false; + } + const bool ok = parseEncryptionOpResponse(bridge_->execute("unlock", passphrase)).ok; + secureWipeLiteSecret(passphrase); + return ok; +} + +bool LiteWalletController::lockWallet() +{ + if (!walletOpen_.load() || !bridge_) return false; + return parseEncryptionOpResponse(bridge_->execute("lock", "")).ok; +} + bool LiteWalletController::refreshWalletState(dragonx::WalletState& state) { auto model = refreshModel(); diff --git a/src/wallet/lite_wallet_controller.h b/src/wallet/lite_wallet_controller.h index 3bf4349..ac92b54 100644 --- a/src/wallet/lite_wallet_controller.h +++ b/src/wallet/lite_wallet_controller.h @@ -106,6 +106,21 @@ struct LiteSeedResult { std::string error; }; +// Wallet encryption state (from the backend `encryptionstatus`). `locked` means the spending +// keys are not in memory (an encrypted wallet loads locked; unlock to spend). +struct LiteEncryptionStatus { + bool ok = false; + bool encrypted = false; + bool locked = false; + std::string error; +}; + +// Result of an encrypt/decrypt operation (the passphrase is wiped by the controller). +struct LiteEncryptionResult { + bool ok = false; + std::string error; +}; + class LiteWalletController { public: LiteWalletController(WalletCapabilities capabilities, @@ -182,6 +197,16 @@ public: // Returns false when no wallet is open or the backend save fails. bool saveWallet(); + // --- Wallet encryption (passphrase). All take the passphrase BY VALUE and securely wipe it. --- + // encrypt: set a passphrase on an unencrypted wallet. unlock/lock: bring the spending keys + // into / out of memory. decrypt: permanently remove encryption. encryptionStatus: query state + // (also folded into the periodic refresh so WalletState.isLocked()/isEncrypted() track it). + LiteEncryptionResult encryptWallet(std::string passphrase); + bool unlockWallet(std::string passphrase); + bool lockWallet(); + LiteEncryptionResult decryptWallet(std::string passphrase); + LiteEncryptionStatus encryptionStatus(); + // Poll sync status + fetch balance/addresses/transactions, and apply the result into the // app's WalletState. Returns true if state was updated. Safe no-op when no wallet is open. // Synchronous (blocks on the backend); used by tests and as the worker's unit of work. diff --git a/src/wallet/lite_wallet_state_mapper.h b/src/wallet/lite_wallet_state_mapper.h index bb9e017..be04cfd 100644 --- a/src/wallet/lite_wallet_state_mapper.h +++ b/src/wallet/lite_wallet_state_mapper.h @@ -104,6 +104,9 @@ struct LiteWalletAppRefreshModel { bool hasSpendableOutputs = false; bool hasTransactions = false; bool hasSyncStatus = false; + bool hasEncryptionStatus = false; + bool encrypted = false; + bool locked = false; std::size_t successfulCommandCount = 0; LiteWalletAppChainModel chain; diff --git a/tests/fake_lite_backend.h b/tests/fake_lite_backend.h index ac82163..56bc58b 100644 --- a/tests/fake_lite_backend.h +++ b/tests/fake_lite_backend.h @@ -36,6 +36,8 @@ inline std::atomic g_liteFakeSyncBlock{false}; // when true, the "sync" c inline std::atomic g_liteFakeBadBalance{false}; // when true, "balance" returns invalid JSON inline std::atomic g_liteFakeSendFails{false}; // when true, "send"/"shield" return {"error":..} inline std::atomic g_liteFakeSaveCount{0}; // counts "save" commands (persistence checks) +inline std::atomic g_liteFakeEncrypted{false}; // wallet-encryption state machine (encrypt/...) +inline std::atomic g_liteFakeLocked{false}; // spending-keys-locked state inline void resetLiteFakeCounters() { @@ -126,6 +128,30 @@ 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}. + if (std::strcmp(c, "encrypt") == 0) { + g_liteFakeEncrypted = true; g_liteFakeLocked = false; + return liteFakeDup("{\"result\":\"success\"}"); + } + if (std::strcmp(c, "unlock") == 0) { + g_liteFakeLocked = false; + return liteFakeDup("{\"result\":\"success\"}"); + } + if (std::strcmp(c, "lock") == 0) { + g_liteFakeLocked = true; + return liteFakeDup("{\"result\":\"success\"}"); + } + if (std::strcmp(c, "decrypt") == 0) { + g_liteFakeEncrypted = false; g_liteFakeLocked = false; + return liteFakeDup("{\"result\":\"success\"}"); + } + if (std::strcmp(c, "encryptionstatus") == 0) { + return liteFakeDup(g_liteFakeEncrypted.load() + ? (g_liteFakeLocked.load() ? "{\"encrypted\":true,\"locked\":true}" + : "{\"encrypted\":true,\"locked\":false}") + : "{\"encrypted\":false,\"locked\":false}"); + } } // Default for any other/unknown command. return liteFakeDup("{\"version\":\"sdxl-fake\"}"); diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 941eec6..35e9c04 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -4758,6 +4758,78 @@ void testLiteWalletControllerM5Persistence() } } +// Encryption: the controller drives encrypt/unlock/lock/decrypt + encryptionstatus through the +// (injected fake) backend, wipes passphrases, and folds status into WalletState (encrypted/locked). +void testLiteWalletControllerEncryption() +{ + using namespace dragonx::wallet; + const auto liteCaps = makeWalletCapabilities(WalletBuildKind::Lite, false, true); + const LiteConnectionSettings conn = defaultLiteConnectionSettings(); + + auto open = [&]() { + 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); + return c; + }; + + // encrypt -> lock -> unlock -> decrypt, observed via encryptionStatus() + { + auto c = open(); + const auto s0 = c->encryptionStatus(); + EXPECT_TRUE(s0.ok); + EXPECT_FALSE(s0.encrypted); + EXPECT_FALSE(s0.locked); + + 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(c->unlockWallet("walletpass")); + const auto s3 = c->encryptionStatus(); + EXPECT_TRUE(s3.encrypted); + EXPECT_FALSE(s3.locked); + + EXPECT_TRUE(c->decryptWallet("walletpass").ok); + const auto s4 = c->encryptionStatus(); + EXPECT_FALSE(s4.encrypted); + EXPECT_FALSE(s4.locked); + } + + // empty passphrase rejected; operations on a closed wallet fail closed + { + auto c = open(); + EXPECT_FALSE(c->encryptWallet("").ok); + + LiteWalletController closed(liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); + EXPECT_FALSE(closed.unlockWallet("x")); + EXPECT_FALSE(closed.lockWallet()); + EXPECT_FALSE(closed.encryptionStatus().ok); + } + + // encryption status folds into WalletState (gates the locked UI) + { + LiteWalletAppRefreshModel model; + model.hasEncryptionStatus = true; + model.encrypted = true; + model.locked = true; + dragonx::WalletState state; + applyLiteRefreshModelToWalletState(model, state); + EXPECT_TRUE(state.isEncrypted()); + EXPECT_TRUE(state.isLocked()); + } +} + // Migration: a saved lite chain_name outside {main,test,regtest} (e.g. the legacy // "DRAGONX" ticker) is rewritten to "main" on load, since the backend hard-panics // on unknown chains. Valid values are preserved. @@ -5092,6 +5164,7 @@ int main() testLiteWalletControllerLifecycle(); testLiteWalletControllerM4(); testLiteWalletControllerM5Persistence(); + testLiteWalletControllerEncryption(); testLiteChainNameMigration(); testLiteRefreshModelAppliesToWalletState(); testLitePerAddressBalances();