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

@@ -0,0 +1,181 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#include "lite_rollout_policy.h"
#include <nlohmann/json.hpp>
#include <algorithm>
#include <cstdint>
#include <fstream>
#include <sstream>
namespace dragonx {
namespace wallet {
namespace {
using json = nlohmann::json;
// Numeric dotted core of a version, dropping any "-prerelease" / "+build" suffix.
std::vector<long long> parseVersionCore(const std::string& v)
{
const std::string core = v.substr(0, v.find_first_of("-+"));
std::vector<long long> parts;
std::stringstream ss(core);
std::string token;
while (std::getline(ss, token, '.')) {
if (token.empty()) { parts.push_back(0); continue; }
try { parts.push_back(std::stoll(token)); }
catch (...) { parts.push_back(0); }
}
return parts;
}
const char* defaultMessageFor(LiteRolloutStatus s)
{
switch (s) {
case LiteRolloutStatus::DisabledByKillSwitchEnv:
return "The lite wallet is disabled by an emergency kill-switch on this machine.";
case LiteRolloutStatus::DisabledByLocalOverride:
return "The lite wallet is turned off in this app's settings.";
case LiteRolloutStatus::DisabledByManifest:
return "The lite wallet is currently disabled by the rollout policy.";
case LiteRolloutStatus::DisabledUnsupportedVersion:
return "This app version is not supported by the lite wallet; please update.";
case LiteRolloutStatus::DisabledBlockedVersion:
return "This app version has a known issue and the lite wallet is disabled; please update.";
case LiteRolloutStatus::DisabledByStagedRollout:
return "The lite wallet is rolling out gradually and is not yet enabled for this install.";
case LiteRolloutStatus::Allowed:
default:
return "";
}
}
LiteRolloutDecision disabledDecision(LiteRolloutStatus s, const std::string& manifestMessage)
{
LiteRolloutDecision d;
d.allowed = false;
d.status = s;
d.message = !manifestMessage.empty() ? manifestMessage : defaultMessageFor(s);
return d;
}
} // namespace
int compareLiteVersions(const std::string& a, const std::string& b)
{
const auto pa = parseVersionCore(a);
const auto pb = parseVersionCore(b);
const std::size_t n = std::max(pa.size(), pb.size());
for (std::size_t i = 0; i < n; ++i) {
const long long va = i < pa.size() ? pa[i] : 0;
const long long vb = i < pb.size() ? pb[i] : 0;
if (va < vb) return -1;
if (va > vb) return 1;
}
return 0;
}
int liteRolloutBucketFromInstallId(const std::string& installId)
{
if (installId.empty()) return 0;
// FNV-1a (64-bit) → 0..999. Stable across runs for a given id.
std::uint64_t h = 1469598103934665603ULL;
for (unsigned char c : installId) {
h ^= static_cast<std::uint64_t>(c);
h *= 1099511628211ULL;
}
return static_cast<int>(h % 1000ULL);
}
LiteRolloutOverride liteRolloutOverrideFromString(const std::string& value)
{
if (value == "force_on") return LiteRolloutOverride::ForceOn;
if (value == "force_off") return LiteRolloutOverride::ForceOff;
return LiteRolloutOverride::Auto;
}
const char* liteRolloutOverrideToString(LiteRolloutOverride o)
{
switch (o) {
case LiteRolloutOverride::ForceOn: return "force_on";
case LiteRolloutOverride::ForceOff: return "force_off";
case LiteRolloutOverride::Auto: default: return "auto";
}
}
const char* liteRolloutStatusName(LiteRolloutStatus s)
{
switch (s) {
case LiteRolloutStatus::Allowed: return "Allowed";
case LiteRolloutStatus::DisabledByKillSwitchEnv: return "DisabledByKillSwitchEnv";
case LiteRolloutStatus::DisabledByLocalOverride: return "DisabledByLocalOverride";
case LiteRolloutStatus::DisabledByManifest: return "DisabledByManifest";
case LiteRolloutStatus::DisabledUnsupportedVersion: return "DisabledUnsupportedVersion";
case LiteRolloutStatus::DisabledBlockedVersion: return "DisabledBlockedVersion";
case LiteRolloutStatus::DisabledByStagedRollout: return "DisabledByStagedRollout";
}
return "Unknown";
}
LiteRolloutDecision evaluateLiteRollout(const LiteRolloutInputs& in)
{
// 1. Emergency env kill-switch is absolute — not even ForceOn bypasses it.
if (in.killSwitchEnv)
return disabledDecision(LiteRolloutStatus::DisabledByKillSwitchEnv, {});
// 2/3. Local override.
if (in.override == LiteRolloutOverride::ForceOff)
return disabledDecision(LiteRolloutStatus::DisabledByLocalOverride, {});
if (in.override == LiteRolloutOverride::ForceOn)
return LiteRolloutDecision{true, LiteRolloutStatus::Allowed, {}};
// 4. Manifest gates — fail-open: only enforced when a manifest is present AND valid.
const LiteRolloutManifest& m = in.manifest;
if (m.present && m.valid) {
if (!m.globalEnabled)
return disabledDecision(LiteRolloutStatus::DisabledByManifest, m.message);
if (!m.minVersion.empty() && compareLiteVersions(in.appVersion, m.minVersion) < 0)
return disabledDecision(LiteRolloutStatus::DisabledUnsupportedVersion, m.message);
if (!m.maxVersion.empty() && compareLiteVersions(in.appVersion, m.maxVersion) > 0)
return disabledDecision(LiteRolloutStatus::DisabledUnsupportedVersion, m.message);
for (const auto& bad : m.blockedVersions)
if (compareLiteVersions(in.appVersion, bad) == 0)
return disabledDecision(LiteRolloutStatus::DisabledBlockedVersion, m.message);
if (in.installBucket >= m.rolloutPermille)
return disabledDecision(LiteRolloutStatus::DisabledByStagedRollout, m.message);
}
// 5. Fail-open default.
return LiteRolloutDecision{true, LiteRolloutStatus::Allowed, {}};
}
LiteRolloutManifest loadLiteRolloutManifestFromFile(const std::string& path)
{
LiteRolloutManifest m; // present=false, valid=false (fail-open)
std::ifstream in(path);
if (!in.is_open()) return m; // no file -> no manifest
m.present = true;
try {
json j;
in >> j;
if (j.contains("global_enabled")) m.globalEnabled = j["global_enabled"].get<bool>();
if (j.contains("min_version")) m.minVersion = j["min_version"].get<std::string>();
if (j.contains("max_version")) m.maxVersion = j["max_version"].get<std::string>();
if (j.contains("blocked_versions"))
m.blockedVersions = j["blocked_versions"].get<std::vector<std::string>>();
if (j.contains("rollout_permille")) {
const int p = j["rollout_permille"].get<int>();
m.rolloutPermille = std::max(0, std::min(1000, p));
}
if (j.contains("message")) m.message = j["message"].get<std::string>();
m.valid = true;
} catch (...) {
m.valid = false; // parse error -> fail-open
}
return m;
}
} // namespace wallet
} // namespace dragonx