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>
182 lines
6.9 KiB
C++
182 lines
6.9 KiB
C++
// 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
|