feat(lite): runtime kill-switch + staged-rollout gate (M5b)

Adds a fail-open, local-only gate that decides whether the lite wallet may run,
so a post-release issue can disable it and rollout can be staged — without any
phone-home (privacy posture: no runtime network fetch; the per-install rollout
bucket is a hashed, never-transmitted local id).

- wallet/lite_rollout_policy.{h,cpp}: a pure decision core. Order — emergency env
  kill-switch (absolute) -> local override -> manifest gates (global enable /
  version floor-ceiling / blocklist / staged-rollout permille) -> fail-open allow.
  Plus a JSON manifest loader (missing/invalid -> fail-open) and FNV-1a bucketing.
- Threads the decision through LiteWalletController -> LiteWalletLifecycleService:
  new availability() reason RolloutDisabled blocks create/open/restore and surfaces
  the gate's user-facing message via the lifecycle status.
- App::rebuildLiteWallet() resolves it from: DRAGONX_LITE_KILL_SWITCH (env), the
  lite_rollout setting (auto/force_on/force_off), and a locally-cached manifest at
  <config-dir>/lite_rollout.json. install id generated once via libsodium.
- Settings: persist lite_rollout override + the install id.

A signed remote fetcher can populate the manifest cache later without touching the
policy. Unit-tested (version compare, bucketing, override/env precedence, manifest
gates, staged rollout, loader fail-open, controller integration) and runtime-verified
on Linux (env kill-switch, manifest disable, control sync). Both variants build;
full suite passes; hygiene clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 12:01:08 -05:00
parent ca14aaddc7
commit b3c2282b53
12 changed files with 584 additions and 8 deletions

View File

@@ -13,7 +13,9 @@
#include "config/settings.h"
#include "wallet/lite_wallet_controller.h"
#include "wallet/lite_wallet_server_selection_adapter.h"
#include "wallet/lite_rollout_policy.h"
#include <cstdlib> // std::getenv for the lite kill-switch env var
#include <sodium.h> // sodium_memzero for the lite unlock-passphrase buffer
#include "daemon/daemon_controller.h"
#include "daemon/embedded_daemon.h"
@@ -370,6 +372,62 @@ void App::preFrame()
}
}
namespace {
// Resolve the lite-wallet rollout / kill-switch decision (see wallet/lite_rollout_policy.h).
// Local-only: reads the override + a stable per-install id from settings, an emergency env var,
// and a locally-cached manifest file (NO network fetch). Fail-open: a missing/invalid manifest
// leaves the wallet enabled.
wallet::LiteRolloutDecision resolveLiteRolloutDecision(config::Settings& settings)
{
using namespace dragonx::wallet;
LiteRolloutInputs inputs;
inputs.appVersion = DRAGONX_VERSION;
// Emergency kill-switch env var: any value other than empty/"0"/"false" disables.
if (const char* env = std::getenv("DRAGONX_LITE_KILL_SWITCH")) {
const std::string v = env;
inputs.killSwitchEnv = !v.empty() && v != "0" && v != "false";
}
inputs.override = liteRolloutOverrideFromString(settings.getLiteRolloutOverride());
// Stable per-install bucket source — generated once, persisted, never transmitted, no PII.
std::string installId = settings.getLiteInstallId();
if (installId.empty()) {
if (sodium_init() >= 0) {
unsigned char buf[16];
randombytes_buf(buf, sizeof(buf));
static const char kHex[] = "0123456789abcdef";
installId.reserve(sizeof(buf) * 2);
for (unsigned char c : buf) {
installId.push_back(kHex[c >> 4]);
installId.push_back(kHex[c & 0x0F]);
}
}
settings.setLiteInstallId(installId);
settings.save();
}
inputs.installBucket = liteRolloutBucketFromInstallId(installId);
// Local manifest cache next to settings.json — no network fetch (a signed remote fetcher can
// populate this later). Absent/unreadable -> fail-open.
try {
const std::filesystem::path cfgDir =
std::filesystem::path(config::Settings::getDefaultPath()).parent_path();
inputs.manifest = loadLiteRolloutManifestFromFile((cfgDir / "lite_rollout.json").string());
} catch (...) {
// leave manifest absent -> fail-open
}
const LiteRolloutDecision decision = evaluateLiteRollout(inputs);
if (!decision.allowed) {
DEBUG_LOGF("[lite-rollout] lite wallet gated OFF: %s — %s\n",
liteRolloutStatusName(decision.status), decision.message.c_str());
}
return decision;
}
} // namespace
void App::rebuildLiteWallet()
{
if (!supportsLiteBackend() || !settings_) return;
@@ -381,7 +439,8 @@ void App::rebuildLiteWallet()
lite_wallet_ = wallet::LiteWalletController::createLinked(
walletCapabilities(),
wallet::liteConnectionSettingsFromAppSettings(*settings_));
wallet::liteConnectionSettingsFromAppSettings(*settings_),
resolveLiteRolloutDecision(*settings_));
lite_wallet_->setPersistCallback([this]() { settings_->save(); });
}