feat(lite): wallet encryption controller layer (encrypt/unlock/lock/decrypt)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -181,6 +181,12 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model,
|
|||||||
state.sync.verification_progress = model.sync.progress;
|
state.sync.verification_progress = model.sync.progress;
|
||||||
state.sync.syncing = !model.sync.complete;
|
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,
|
LiteWalletController::LiteWalletController(WalletCapabilities capabilities,
|
||||||
@@ -267,11 +273,23 @@ std::optional<LiteWalletAppRefreshModel> LiteWalletController::refreshModel()
|
|||||||
// syncstatus is fast (reads shared state the sync thread updates). Poll it every time.
|
// syncstatus is fast (reads shared state the sync thread updates). Poll it every time.
|
||||||
const auto syncResult = sync_.pollSyncStatus(LiteSyncStatusRequest{});
|
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()) {
|
if (!syncDone_->load()) {
|
||||||
// Sync still running: publish progress only. Data queries (balance/list) would block
|
// Sync still running: publish progress only. Data queries (balance/list) would block
|
||||||
// until the chain is synced, so don't issue them yet.
|
// until the chain is synced, so don't issue them yet.
|
||||||
if (!syncResult.ok) return std::nullopt;
|
if (!syncResult.ok) return std::nullopt;
|
||||||
LiteWalletAppRefreshModel model;
|
LiteWalletAppRefreshModel model;
|
||||||
|
applyEncryption(model);
|
||||||
model.hasSyncStatus = true;
|
model.hasSyncStatus = true;
|
||||||
model.sync.walletHeight = syncResult.syncStatus.syncedBlocks;
|
model.sync.walletHeight = syncResult.syncStatus.syncedBlocks;
|
||||||
model.sync.chainHeight = syncResult.syncStatus.totalBlocks;
|
model.sync.chainHeight = syncResult.syncStatus.totalBlocks;
|
||||||
@@ -296,7 +314,9 @@ std::optional<LiteWalletAppRefreshModel> LiteWalletController::refreshModel()
|
|||||||
}
|
}
|
||||||
const auto mapped = mapLiteWalletRefreshResult(refreshResult);
|
const auto mapped = mapLiteWalletRefreshResult(refreshResult);
|
||||||
if (!mapped.ok) return std::nullopt;
|
if (!mapped.ok) return std::nullopt;
|
||||||
return mapped.model;
|
auto model = mapped.model;
|
||||||
|
applyEncryption(model);
|
||||||
|
return model;
|
||||||
}
|
}
|
||||||
|
|
||||||
LiteNewAddressResult LiteWalletController::newAddress(bool shielded)
|
LiteNewAddressResult LiteWalletController::newAddress(bool shielded)
|
||||||
@@ -510,6 +530,114 @@ bool LiteWalletController::walletExists() const
|
|||||||
return bridge_ && bridge_->walletExists(chainName_);
|
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)
|
bool LiteWalletController::refreshWalletState(dragonx::WalletState& state)
|
||||||
{
|
{
|
||||||
auto model = refreshModel();
|
auto model = refreshModel();
|
||||||
|
|||||||
@@ -106,6 +106,21 @@ struct LiteSeedResult {
|
|||||||
std::string error;
|
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 {
|
class LiteWalletController {
|
||||||
public:
|
public:
|
||||||
LiteWalletController(WalletCapabilities capabilities,
|
LiteWalletController(WalletCapabilities capabilities,
|
||||||
@@ -182,6 +197,16 @@ public:
|
|||||||
// Returns false when no wallet is open or the backend save fails.
|
// Returns false when no wallet is open or the backend save fails.
|
||||||
bool saveWallet();
|
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
|
// 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.
|
// 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.
|
// Synchronous (blocks on the backend); used by tests and as the worker's unit of work.
|
||||||
|
|||||||
@@ -104,6 +104,9 @@ struct LiteWalletAppRefreshModel {
|
|||||||
bool hasSpendableOutputs = false;
|
bool hasSpendableOutputs = false;
|
||||||
bool hasTransactions = false;
|
bool hasTransactions = false;
|
||||||
bool hasSyncStatus = false;
|
bool hasSyncStatus = false;
|
||||||
|
bool hasEncryptionStatus = false;
|
||||||
|
bool encrypted = false;
|
||||||
|
bool locked = false;
|
||||||
std::size_t successfulCommandCount = 0;
|
std::size_t successfulCommandCount = 0;
|
||||||
|
|
||||||
LiteWalletAppChainModel chain;
|
LiteWalletAppChainModel chain;
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ inline std::atomic<bool> g_liteFakeSyncBlock{false}; // when true, the "sync" c
|
|||||||
inline std::atomic<bool> g_liteFakeBadBalance{false}; // when true, "balance" returns invalid JSON
|
inline std::atomic<bool> g_liteFakeBadBalance{false}; // when true, "balance" returns invalid JSON
|
||||||
inline std::atomic<bool> g_liteFakeSendFails{false}; // when true, "send"/"shield" return {"error":..}
|
inline std::atomic<bool> g_liteFakeSendFails{false}; // when true, "send"/"shield" return {"error":..}
|
||||||
inline std::atomic<long> g_liteFakeSaveCount{0}; // counts "save" commands (persistence checks)
|
inline std::atomic<long> g_liteFakeSaveCount{0}; // counts "save" commands (persistence checks)
|
||||||
|
inline std::atomic<bool> g_liteFakeEncrypted{false}; // wallet-encryption state machine (encrypt/...)
|
||||||
|
inline std::atomic<bool> g_liteFakeLocked{false}; // spending-keys-locked state
|
||||||
|
|
||||||
inline void resetLiteFakeCounters()
|
inline void resetLiteFakeCounters()
|
||||||
{
|
{
|
||||||
@@ -126,6 +128,30 @@ inline char* liteFakeExecute(const char* command, const char* args)
|
|||||||
++g_liteFakeSaveCount;
|
++g_liteFakeSaveCount;
|
||||||
return liteFakeDup("{\"result\":\"success\"}");
|
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.
|
// Default for any other/unknown command.
|
||||||
return liteFakeDup("{\"version\":\"sdxl-fake\"}");
|
return liteFakeDup("{\"version\":\"sdxl-fake\"}");
|
||||||
|
|||||||
@@ -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<LiteWalletController>(
|
||||||
|
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
|
// 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
|
// "DRAGONX" ticker) is rewritten to "main" on load, since the backend hard-panics
|
||||||
// on unknown chains. Valid values are preserved.
|
// on unknown chains. Valid values are preserved.
|
||||||
@@ -5092,6 +5164,7 @@ int main()
|
|||||||
testLiteWalletControllerLifecycle();
|
testLiteWalletControllerLifecycle();
|
||||||
testLiteWalletControllerM4();
|
testLiteWalletControllerM4();
|
||||||
testLiteWalletControllerM5Persistence();
|
testLiteWalletControllerM5Persistence();
|
||||||
|
testLiteWalletControllerEncryption();
|
||||||
testLiteChainNameMigration();
|
testLiteChainNameMigration();
|
||||||
testLiteRefreshModelAppliesToWalletState();
|
testLiteRefreshModelAppliesToWalletState();
|
||||||
testLitePerAddressBalances();
|
testLitePerAddressBalances();
|
||||||
|
|||||||
Reference in New Issue
Block a user