#include "appxi_license.hpp" #include #include #include #include #include #include #include #include #include #if defined(__APPLE__) #include #include #elif defined(_WIN32) #include #endif #ifdef APPXI_USE_CURL #include #endif namespace appxi { namespace { // ---------------------------------------------------------------- a small JSON reader struct Json { enum Type { Null, Bool, Number, String, Array, Object } type = Null; bool b = false; double n = 0; std::string s; std::vector a; std::map o; const Json& operator[](const std::string& k) const { static const Json none; auto it = o.find(k); return type == Object && it != o.end() ? it->second : none; } std::string str() const { return type == String ? s : ""; } double num() const { return type == Number ? n : 0; } }; struct Parser { const std::string& t; size_t i = 0; bool ok = true; explicit Parser(const std::string& text) : t(text) {} void ws() { while (i < t.size() && std::isspace(static_cast(t[i]))) i++; } bool lit(const char* w) { size_t n = std::char_traits::length(w); if (t.compare(i, n, w) == 0) { i += n; return true; } return false; } std::string string() { std::string out; if (i >= t.size() || t[i] != '"') { ok = false; return out; } for (i++; i < t.size() && t[i] != '"'; i++) { if (t[i] != '\\') { out += t[i]; continue; } if (++i >= t.size()) break; switch (t[i]) { case 'n': out += '\n'; break; case 't': out += '\t'; break; case 'r': out += '\r'; break; case 'b': out += '\b'; break; case 'f': out += '\f'; break; case 'u': { unsigned cp = std::stoul(t.substr(i + 1, 4), nullptr, 16); i += 4; if (cp < 0x80) out += static_cast(cp); else if (cp < 0x800) { out += static_cast(0xC0 | (cp >> 6)); out += static_cast(0x80 | (cp & 0x3F)); } else { out += static_cast(0xE0 | (cp >> 12)); out += static_cast(0x80 | ((cp >> 6) & 0x3F)); out += static_cast(0x80 | (cp & 0x3F)); } break; } default: out += t[i]; } } if (i >= t.size()) ok = false; i++; return out; } Json value(int depth = 0) { Json v; ws(); if (i >= t.size() || depth > 32) { ok = false; return v; } char c = t[i]; if (c == '{') { v.type = Json::Object; i++; ws(); if (i < t.size() && t[i] == '}') { i++; return v; } while (ok) { ws(); std::string k = string(); ws(); if (i >= t.size() || t[i] != ':') { ok = false; break; } i++; v.o[k] = value(depth + 1); ws(); if (i < t.size() && t[i] == ',') { i++; continue; } if (i < t.size() && t[i] == '}') { i++; break; } ok = false; } } else if (c == '[') { v.type = Json::Array; i++; ws(); if (i < t.size() && t[i] == ']') { i++; return v; } while (ok) { v.a.push_back(value(depth + 1)); ws(); if (i < t.size() && t[i] == ',') { i++; continue; } if (i < t.size() && t[i] == ']') { i++; break; } ok = false; } } else if (c == '"') { v.type = Json::String; v.s = string(); } else if (lit("true")) { v.type = Json::Bool; v.b = true; } else if (lit("false")) { v.type = Json::Bool; } else if (lit("null")) { v.type = Json::Null; } else { size_t start = i; while (i < t.size() && (std::isdigit(static_cast(t[i])) || t[i] == '-' || t[i] == '+' || t[i] == '.' || t[i] == 'e' || t[i] == 'E')) i++; if (start == i) { ok = false; return v; } v.type = Json::Number; v.n = std::strtod(t.substr(start, i - start).c_str(), nullptr); } return v; } }; Json parse(const std::string& text) { Parser p(text); Json v = p.value(); return p.ok ? v : Json(); } std::string quote(const std::string& s) { std::string out = "\""; for (unsigned char c : s) { if (c == '"' || c == '\\') { out += '\\'; out += static_cast(c); } else if (c < 0x20) { char buf[8]; std::snprintf(buf, sizeof buf, "\\u%04x", c); out += buf; } else out += static_cast(c); } return out + "\""; } // ---------------------------------------------------------------- encoding and crypto std::string b64urlDecode(std::string s) { for (auto& c : s) { if (c == '-') c = '+'; else if (c == '_') c = '/'; } while (s.size() % 4) s += '='; std::string out((s.size() / 4) * 3, '\0'); int n = EVP_DecodeBlock(reinterpret_cast(&out[0]), reinterpret_cast(s.data()), static_cast(s.size())); if (n < 0) return ""; size_t pad = 0; for (size_t k = s.size(); k > 0 && s[k - 1] == '='; k--) pad++; out.resize(static_cast(n) - pad); return out; } std::string sha256Hex(const std::string& s) { unsigned char d[SHA256_DIGEST_LENGTH]; SHA256(reinterpret_cast(s.data()), s.size(), d); static const char* hex = "0123456789abcdef"; std::string out; for (unsigned char c : d) { out += hex[c >> 4]; out += hex[c & 15]; } return out; } bool ed25519Verify(const std::string& pub, const std::string& msg, const std::string& sig) { if (pub.size() != 32 || sig.size() != 64) return false; std::unique_ptr key( EVP_PKEY_new_raw_public_key(EVP_PKEY_ED25519, nullptr, reinterpret_cast(pub.data()), pub.size()), EVP_PKEY_free); if (!key) return false; std::unique_ptr ctx(EVP_MD_CTX_new(), EVP_MD_CTX_free); if (!ctx || EVP_DigestVerifyInit(ctx.get(), nullptr, nullptr, nullptr, key.get()) != 1) return false; return EVP_DigestVerify(ctx.get(), reinterpret_cast(sig.data()), sig.size(), reinterpret_cast(msg.data()), msg.size()) == 1; } // 2026-10-20T07:00:00.123456+00:00 or ...Z, always UTC from AppsXi. std::time_t parseIso(const std::string& s) { int y, mo, d, h, mi, se; if (std::sscanf(s.c_str(), "%d-%d-%dT%d:%d:%d", &y, &mo, &d, &h, &mi, &se) != 6) return 0; // Days from civil (Howard Hinnant), so no timegm/_mkgmtime portability issues. y -= mo <= 2; const long era = (y >= 0 ? y : y - 399) / 400; const unsigned yoe = static_cast(y - era * 400); const unsigned doy = (153 * (mo + (mo > 2 ? -3 : 9)) + 2) / 5 + d - 1; const unsigned doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; const long days = era * 146097 + static_cast(doe) - 719468; return static_cast(days * 86400 + h * 3600 + mi * 60 + se); } std::string machineId() { #if defined(__APPLE__) std::string out; io_service_t svc = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("IOPlatformExpertDevice")); if (svc) { CFTypeRef v = IORegistryEntryCreateCFProperty(svc, CFSTR("IOPlatformUUID"), kCFAllocatorDefault, 0); if (v) { char buf[128]; if (CFStringGetCString(static_cast(v), buf, sizeof buf, kCFStringEncodingUTF8)) out = buf; CFRelease(v); } IOObjectRelease(svc); } return out; #elif defined(_WIN32) char buf[128]; DWORD size = sizeof buf; if (RegGetValueA(HKEY_LOCAL_MACHINE, "SOFTWARE\\Microsoft\\Cryptography", "MachineGuid", RRF_RT_REG_SZ | RRF_SUBKEY_WOW6464KEY, nullptr, buf, &size) == ERROR_SUCCESS) return buf; return ""; #else std::ifstream f("/etc/machine-id"); std::string s; std::getline(f, s); return s; #endif } #ifdef APPXI_USE_CURL size_t collect(char* p, size_t sz, size_t n, void* out) { static_cast(out)->append(p, sz * n); return sz * n; } std::string curlPost(const std::string& url, const std::string& body, const std::string& auth) { std::string out; CURL* c = curl_easy_init(); if (!c) return out; curl_slist* h = nullptr; h = curl_slist_append(h, "Content-Type: application/json"); h = curl_slist_append(h, ("Authorization: " + auth).c_str()); curl_easy_setopt(c, CURLOPT_URL, url.c_str()); curl_easy_setopt(c, CURLOPT_HTTPHEADER, h); curl_easy_setopt(c, CURLOPT_POSTFIELDS, body.c_str()); curl_easy_setopt(c, CURLOPT_TIMEOUT, 15L); curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, collect); curl_easy_setopt(c, CURLOPT_WRITEDATA, &out); if (curl_easy_perform(c) != CURLE_OK) out.clear(); curl_slist_free_all(h); curl_easy_cleanup(c); return out; } #endif const char* platformName() { #if defined(_WIN32) return "windows"; #else return "macos"; #endif } } // namespace License::License(Options options) : o(std::move(options)) { while (!o.apiBase.empty() && o.apiBase.back() == '/') o.apiBase.pop_back(); file = o.storageDir + "/appxi-license.json"; #ifdef APPXI_USE_CURL if (!o.http) o.http = curlPost; #endif } std::string License::deviceId() const { std::string raw = machineId(); if (raw.empty()) raw = "unknown-machine"; return sha256Hex(raw + ":" + o.productKey.substr(0, 8) + ":" + o.appSlug); } Validation License::activate(const std::string& licenseKey) { Validation v; std::string key = licenseKey; key.erase(0, key.find_first_not_of(" \t\r\n")); key.erase(key.find_last_not_of(" \t\r\n") + 1); if (!o.http) { v.reason = "no_http"; return v; } std::string body = "{\"license_key\":" + quote(key) + ",\"device_id\":" + quote(deviceId()) + ",\"platform\":" + quote(platformName()) + "}"; Json j = parse(o.http(o.apiBase + "/api/v1/licenses/validate", body, "Bearer " + o.productKey)); if (j.type != Json::Object) { v.reason = "network"; return v; } v.seatsUsed = static_cast(j["seats_used"].num()); v.seatsMax = static_cast(j["seats_max"].num()); if (!(j["valid"].type == Json::Bool && j["valid"].b)) { v.reason = j["reason"].str().empty() ? "invalid" : j["reason"].str(); if (v.reason == "revoked" || v.reason == "expired" || v.reason == "unknown_license") forget(); return v; } save({key, j["token"].str(), j["trial_expires_at"].str(), j["updates"]["feed_base"].str(), j["updates"]["feed_token"].str()}); v.valid = true; v.entitlement = current(); return v; } Validation License::refresh() { auto c = load(); if (!c) { Validation v; v.reason = "no_license"; return v; } return activate(c->key); } std::string License::verifyToken(const std::string& token, const std::vector& publicKeys) { size_t a = token.find('.'), b = token.rfind('.'); if (a == std::string::npos || a == b) return ""; Json header = parse(b64urlDecode(token.substr(0, a))); if (header["alg"].str() != "EdDSA") return ""; std::string sig = b64urlDecode(token.substr(b + 1)); std::string signedPart = token.substr(0, b); for (const auto& k : publicKeys) if (ed25519Verify(b64urlDecode(k), signedPart, sig)) return b64urlDecode(token.substr(a + 1, b - a - 1)); return ""; } std::optional License::current(std::time_t now) const { auto c = load(); if (!c || c->token.empty()) return std::nullopt; Json claims = parse(verifyToken(c->token, o.publicKeys)); if (claims.type != Json::Object) return std::nullopt; Entitlement e; e.status = claims["status"].str(); e.expiresAt = static_cast(claims["exp"].num()); e.environment = o.productKey.rfind("pk_test_", 0) == 0 ? "sandbox" : "live"; if ((e.status != "active" && e.status != "trial") || e.expiresAt <= now || claims["env"].str() != e.environment) return std::nullopt; e.license = claims["license"].str(); e.buyer = claims["sub"].str(); e.seats = claims["seats"]["max"].type == Json::Number ? static_cast(claims["seats"]["max"].num()) : 1; if (e.status == "trial" && !c->trialExpiresAt.empty()) e.trialExpiresAt = parseIso(c->trialExpiresAt); return e; } bool License::needsRefresh(std::time_t now) const { auto e = current(now); return !e || e->expiresAt - now < 7 * 86400; } TrialStatus License::trialStatus(std::time_t now) const { TrialStatus t; auto e = current(now); if (e && e->status == "trial") { std::time_t end = e->trialExpiresAt ? e->trialExpiresAt : e->expiresAt; t.inTrial = true; t.daysLeft = end > now ? static_cast(std::ceil(static_cast(end - now) / 86400.0)) : 0; t.expired = end <= now; return t; } auto c = load(); t.expired = !e && c && !c->trialExpiresAt.empty() && parseIso(c->trialExpiresAt) <= now; return t; } std::string License::checkoutUrl() const { return o.apiBase + "/checkout/" + o.appSlug; } std::string License::updateFeedUrl(const std::string& platform, const std::string& channel) const { auto c = load(); if (!c || c->feedBase.empty() || c->feedToken.empty()) return ""; return c->feedBase + "/" + platform + "/" + channel + "/appcast.xml?token=" + c->feedToken; } void License::forget() const { std::ofstream f(file, std::ios::trunc); f << "{}"; } std::optional License::load() const { std::ifstream f(file); if (!f) return std::nullopt; std::stringstream ss; ss << f.rdbuf(); Json j = parse(ss.str()); if (j["key"].str().empty()) return std::nullopt; return Cache{j["key"].str(), j["token"].str(), j["trial_expires_at"].str(), j["feed_base"].str(), j["feed_token"].str()}; } void License::save(const Cache& c) const { std::ofstream f(file, std::ios::trunc); f << "{\"key\":" << quote(c.key) << ",\"token\":" << quote(c.token) << ",\"trial_expires_at\":" << quote(c.trialExpiresAt) << ",\"feed_base\":" << quote(c.feedBase) << ",\"feed_token\":" << quote(c.feedToken) << "}"; } } // namespace appxi