CI License: MIT C++17 Conformance
Open-source C++ SDK for Keylight — license your native apps, game engine plugins, and audio tools with online activation and offline Ed25519 license verification.
In one line: a software-licensing SDK for C++ — license-key activation and validation, entitlement/feature gating, trials, and tamper-resistant offline license verification (signed
v3lease, Ed25519 + clock-skew tolerance) for desktop apps, Unreal Engine 5 plugins, and JUCE audio applications. Header-only core, C++17, no third-party dependencies.
Licensing shouldn't mean bolting a heavyweight, phone-home-or-die SDK onto your app.
- Works offline. The license is a signed lease your app verifies locally with Ed25519 — no network round-trip to gate a feature, no lockout when the machine is offline.
- Tamper-resistant by design. Entitlements live inside the signature; a forged or hand-edited lease can't pass verification without the tenant's private key.
- Audio-thread safe.
state()reads atomics only — safe to call from JUCE audio callbacks or game-thread hot paths with no lock taken and nothing allocated. - One SDK family. Verifies licenses identically to the Swift, Rust, JavaScript, and C# SDKs, proven by shared conformance vectors.
- Header-only core. Drop in
keylight_single.hppor use CMake FetchContent — zero mandatory external dependencies for the verifier and state machine.
- Why Keylight
- Install
- Quick Start
- Unreal Engine
- JUCE
- License Lifecycle
- License States
- Trials
- Entitlements
- Upgrade, revalidation and lifecycle
- Keyless heartbeat
- Offline & Security
- Configuration Reference
- Cross-SDK Conformance Vectors
- Documentation
- Other SDKs
- License
include(FetchContent) FetchContent_Declare( keylight GIT_REPOSITORY https://github.com/keylight-dev/keylight-cpp.git GIT_TAG v0.2.2 ) FetchContent_MakeAvailable(keylight) target_link_libraries(my_app PRIVATE keylight::keylight)
The core library (keylight::keylight) is interface-only and pulls in no third-party
dependencies. It does link two system frameworks so it can read the OS machine identifier for
free-tier device de-duplication: IOKit + CoreFoundation on macOS and advapi32 on Windows
(nothing on Linux). CMake adds these for you. If you use the
single header you must add that link line yourself. To enable
the bundled cpp-httplib transport (requires OpenSSL):
FetchContent_Declare(keylight ...) set(KEYLIGHT_BUILD_HTTPLIB_TRANSPORT ON CACHE BOOL "" FORCE) FetchContent_MakeAvailable(keylight) target_link_libraries(my_app PRIVATE keylight::keylight keylight::httplib_transport)
For projects that don't use CMake, copy keylight_single.hpp (the
pre-generated amalgamation of all core headers) into your source tree and #include it directly.
No build system changes needed; the core verifier and state machine are entirely self-contained.
#include "keylight_single.hpp"
To include the optional httplib transport alongside it, also copy
include/keylight/transport/httplib.hpp (which itself needs OpenSSL link flags).
vcpkg install keylight # with the optional httplib transport: vcpkg install "keylight[httplib-transport]"
vcpkg port submission is planned for a future release. Until then, use FetchContent or the single-header drop-in.
conan install keylight/0.2.2@
Conan Center submission is planned for a future release.
#include <keylight/keylight.hpp> #include <keylight/keyset.hpp> #include <keylight/store.hpp> #include <keylight/transport/httplib.hpp> // opt-in; requires OpenSSL int main() { // 1. Build a Config with your tenant/product credentials. keylight::Config cfg; cfg.tenantId = "your-tenant"; cfg.productId = "your-product"; cfg.sdkKey = "sdk_live_..."; // sent as X-Keylight-SDK-Key on every call cfg.trialDurationDays = 14; // local trial length (0 = trials disabled) cfg.maxOfflineDays = 7; // optional offline grace window // 2. Fetch the tenant's trusted Ed25519 keyset so leases verify offline. // (You can also pin keys explicitly via cfg.trustedKeys["kid"] = base64_pub.) keylight::HttplibTransport transport; auto ks = keylight::fetchKeyset(transport, cfg.apiBaseUrl, cfg.tenantId); if (ks.is_ok()) { cfg.trustedKeys = ks.value(); } // 3. Create the store (persists the verified lease between launches). // EncryptedFileStore binds the blob to this machine, so it cannot be // edited in a text editor or usefully copied to another machine. // migrating() also imports a pre-0.2.0 plaintext store once and then // deletes it. For a new integration that never had one, plain // `EncryptedFileStore store(keylight::default_store_path(cfg));` is enough. auto store = keylight::EncryptedFileStore::migrating( keylight::default_store_path(cfg), keylight::legacy_plaintext_path(cfg)); // 4. Construct the Client — primes state from the persisted store immediately. keylight::Client client(cfg, transport, store); // 5. On launch: revalidate a stored license, or resolve an already-started // local trial offline. Never starts a trial by itself. client.checkOnLaunch(); // 5b. Start the local trial — only when the user asks for one. if (client.checkTrial() == keylight::TrialStatus::NotStarted) { client.startTrial(); // → State::Trial for the next 14 days } // 6. Activate a license key (online). The returned lease is Ed25519-verified // *before* anything is persisted. auto res = client.activate("USER-LICENSE-KEY"); if (res.is_ok() && res.value() == keylight::State::Licensed) { // seat locked to this device } // 7. Gate features on entitlements — mutex-guarded, NOT audio-thread safe. if (client.hasEntitlement("pro")) { // unlock pro features } // 8. Current high-level state (no network call). keylight::State s = client.state(); // 9. Release the seat on uninstall / device switch. client.deactivate(); }
No background threads by default. Call
checkOnLaunch()on startup andrefreshIfNeeded()on meaningful events (window focus, purchase, resume). The state machine applies a 5-minute debounce and refreshes automatically when the cached lease is stale or within 24 hours of expiry. An optional background thread is available viaclient.startAutoValidation()for daemon or headless applications. Both it andstopAutoValidation()are safe from any thread, including from a state-change listener, and neither blocks. NotestopAutoValidation()retires the worker rather than waiting for it: one more tick can land after it returns.~Client()is what joins. It usually returns at once — it wakes the worker before joining, so a parked one exits without another cycle — but if the worker is mid-cycle it waits for up to a round trip, plus every queued listener callback if that worker is delivering. Since listeners are your code, that upper bound is unbounded; destroy the client somewhere that can afford to wait.
An Unreal Engine 5 plugin lives in integrations/unreal/Keylight/.
It provides:
UKeylightSubsystem— aUGameInstanceSubsystemwith Blueprint-callableActivate/Validate/Deactivate/HasEntitlement/GetStatemethods andFOnKeylightResultasync delegates.FHttpTransport— akeylight::Transportadapter over UE'sFHttpModulethat blocks a background thread viaFEvent, never the game thread.UELicenseStore— persists the lease underSaved/Keylight/in the project directory.
The plugin depends only on Core, CoreUObject, Engine, HTTP, and Json — no extra
dependencies beyond what ships with Unreal Engine.
Manual build required. No UE toolchain is available in CI. A developer with UE 5.x installed must build the plugin and smoke-test before shipping. See
integrations/unreal/README.md.
A JUCE adapter lives in integrations/juce/ and provides:
keylight::juce_integration::JuceUrlTransport— akeylight::Transportadapter overjuce::URL::createInputStreamwith no OpenSSL dependency and no cpp-httplib.keylight::juce_integration::Licensing— owns theClient,FileStore, and transport; exposesactivate/validate/deactivate/checkOnLaunch/startTrialwith message-thread callbacks viajuce::MessageManager::callAsync.state()andhasFeature()read astd::atomicsnapshot — safe to call from the audio thread.
Compiles against JUCE 7 and JUCE 8 with zero extra dependencies beyond juce_core.
Compiled in CI (
.github/workflows/juce.yml) against JUCE 8.0.6 on Linux, macOS and Windows, and JUCE 7.0.12 on Linux and Windows, with an offline smoke test of the query API. A live plugin round-trip in a DAW is still manual. Seeintegrations/juce/README.md.
┌─────────────┐ ┌─────────────┐ ┌──────────────┐
│ activate │────▶│ validate │────▶│ deactivate │
└─────────────┘ └─────────────┘ └──────────────┘
▲さんかく
│ on launch / on events (no background threads by default)
┌─────────────────────┐
│ refreshIfNeeded │
└─────────────────────┘
| Method | Description |
|---|---|
activate(key) → Result<State> |
Activates a key on this device. Verifies the returned lease before persisting. |
validate() → Result<State> |
Re-checks the stored license online. Network failures are non-fatal (grace window applies). |
deactivate() → Result<void> |
Releases the seat and clears local license state. The local cache is cleared either way, but a server rejection is returned as an error: the seat is still consumed and only you can decide to retry. |
refreshIfNeeded() → Result<State> |
Validates only if due (debounce 5 min, stale 6 h, within 24 h of expiry). With no stored license it re-resolves the local trial offline. Safe to call often. |
checkOnLaunch() → Result<State> |
Revalidates a stored license; otherwise resolves the persisted local trial offline. Never starts a trial. |
startTrial() → Result<State> |
Explicitly begins the local trial (idempotent; never restarts one). No network call. |
checkTrial() → TrialStatus |
NotStarted / Active / Expired for the local trial. |
trialDaysLeft() → int |
Whole days left in the local trial (0 when disabled, not started, or elapsed). |
fetchConfig() → Result<void> |
Explicitly refreshes the server-owned product settings (trial length, free tier) from GET /config and re-resolves state. Never called automatically — the same fields already ride on validate and keyless responses. |
effectiveTrialDurationDays() → int |
Trial length actually in force: cached server value, else the Config seed, else 0. |
effectiveFreeTierEnabled() → bool |
Free tier as the server sees it, falling back to the Config seed. |
state() resolves a single high-level status from the cached, Ed25519-verified lease (no network
call). It reads atomics only and is safe to call from any thread.
| State | Meaning |
|---|---|
Licensed |
Current, signature-valid active lease. |
Trial |
No license, but a local trial is active. |
Limited |
Trusted lease with status "fallback": the server could not mint a full lease, so run degraded rather than locked. |
Expired |
Trusted lease expired, or lease status is "expired". |
Invalid |
No trusted lease, no active trial, and no free tier. Also what state() reports when the system clock has been rolled back more than an hour since the last server contact. |
FreeTier |
No license and no active trial, but freeTierEnabled is set. Also where an elapsed trial and a deactivate() land. |
switch (client.state()) { case keylight::State::Licensed: /* full access */ break; case keylight::State::Trial: /* show trial UI */ break; case keylight::State::Limited: /* degraded, not locked */ break; case keylight::State::FreeTier: /* reduced features */ break; case keylight::State::Expired: case keylight::State::Invalid: /* prompt activate */ break; }
state() is noexcept and audio-thread safe, and it calls the clock function you pass to the
Client constructor. If you supply your own, it must be non-throwing, non-blocking and
allocation-free — an exception escaping it is std::terminate, on whichever thread called
state(). The default (std::time) already satisfies this.
Subscribers registered with subscribe() receive the same value state() would return, so a
paywall driven by events and one driven by the query API cannot disagree. Events are delivered
in order, with no SDK lock held — your callback may take your own locks and may call back into
the Client; a re-entrant call queues its event rather than recursing, so it may be delivered
by a different thread. Two things not to do: destroy the Client from a callback, and throw
from one — an exception has nowhere to go, so it is caught and swallowed and the remaining
listeners still get the event. Note also that unsubscribe() does not fence a delivery already
in flight on another thread; keep whatever your callback captures alive across that window.
The clock-rollback guard raises its own event in both directions — once when the rollback is
detected, and again when the clock becomes honest. Because a moving clock changes no underlying
state, that event comes from refreshIfNeeded() or validate(). Call startAutoValidation()
(or one of those on focus/resume) in a long-running host, or a mid-session rollback will reach
state() and never reach your callback.
Trials are local and offline-first. startTrial() persists a start timestamp
next to the lease; the window is then measured against the local clock with no
API call at all. (The free-tier / keyless reporting feature is separate — trial
validity never depends on it.)
Since 0.2.0 the trial LENGTH is a dashboard setting, not a compiled-in value.
Config::trialDurationDays is the seed used before this install has ever
reached the server; once a server value arrives it wins. Resolution order is
server → seed → 0. Read what is actually in force with
effectiveTrialDurationDays().
keylight::Config cfg; cfg.tenantId = "your-tenant"; cfg.productId = "your-product"; cfg.sdkKey = "sdk_live_..."; cfg.trialDurationDays = 14; // SEED only — the dashboard value wins keylight::Client client(cfg, transport, store); // Explicit — nothing starts a trial implicitly. client.startTrial(); // → State::Trial client.checkTrial(); // TrialStatus::Active client.trialDaysLeft(); // 14 // On the next launch: resolves the persisted trial with no network call. client.checkOnLaunch(); // → State::Trial (or Expired once elapsed)
Rules the state machine guarantees:
-
An effective duration of 0 means no trial is granted —
checkTrial()staysNotStartedandtrialDaysLeft()is 0.It does not mean nothing is written. Since 0.2.0
startTrial()records the start timestamp regardless, because a duration of 0 is indistinguishable from "the dashboard value has not arrived yet", and refusing to stamp left no clock for it to measure — which is what made a dashboard-set trial do nothing at all. The stamp grants nothing on its own; it only fixes when the window starts if a duration later arrives. -
checkOnLaunch()never starts a trial. It only resolves one the user already started — important for JUCE plugins, since a DAW may scan or instantiate a plugin without the user ever asking for a trial. -
startTrial()is idempotent. An existing start timestamp is never overwritten, so an elapsed trial cannot be restarted — and enabling trials in the dashboard later does not retroactively grant one to an install that already started. -
A running trial elapses on its own.
state()reads a cached snapshot, so it never recomputes;checkOnLaunch()andrefreshIfNeeded()re-resolve the trial (offline, no request) and notify subscribers, andstartAutoValidation()ticks the latter for long-running hosts. -
Paid licensing always wins. Activating during a trial resolves
Licensed; deactivating later returns to whatever the original trial has become (Trialif still running,Expiredif it elapsed meanwhile,Invalidif there never was one). Deactivation does not reset the trial clock. -
checkTrial()andtrialDaysLeft()do not apply the clock-rollback guard. They are local arithmetic over the persisted start timestamp, so on a rolled-back clockcheckTrial()can reportActivewhilestate()reportsInvalid. Gate access onstate(); use these two for display.
State priority: valid paid license → active local trial → elapsed local trial →
Invalid.
Not tamper-proof. The trial start lives in the same on-disk JSON blob as the lease. Clearing that file (or a fresh install on a clean machine) starts the user over — the store makes no reinstall-proof or anti-tamper claim. The Ed25519 signature is the security boundary for paid licenses; the local trial is a convenience, and a backwards clock jump is clamped rather than credited.
Set freeTierEnabled and a device with no license and no active trial resolves
State::FreeTier rather than Invalid:
cfg.freeTierEnabled = true;Resolution order is: valid paid license → active trial → free tier → elapsed trial → Invalid.
Two consequences, both matching the Rust and Swift SDKs:
- An elapsed trial resolves
FreeTier, notExpired— a lapsed trial drops to the free tier rather than the paywall. deactivate()lands onFreeTierfor the same reason. Releasing a paid seat returns the user to the tier they are still entitled to.
reportKeylessState() sends an anonymous funnel signal so Keylight can show trials started →
converted / in free tier / expired:
client.reportKeylessState(keylight::KeylessState::FreeTier); // or ::Trial / ::Expired- Nothing calls it for you.
checkOnLaunch()still makes no network request when no license is stored, so a DAW scanning your plugin does not phone home. The JUCE adapter opts in on your behalf and reports on every state transition; the core never does. - Debounced to one request per 24 hours per state; a state change always sends. The debounce is recorded only on HTTP 200, so a failed beacon retries instead of going quiet for a day.
- Fire-and-forget. Errors are swallowed, nothing is thrown, and the resolved state is unchanged.
- Blocking — never call it from an audio thread.
What it sends: a random per-install id (freeTierInstanceId(), persisted alongside the lease), the
state string, and — only where the OS exposes a stable machine identifier — machine_hash, a
one-way SHA-256 of it. Never a license key, never a raw device id. Where no such identifier exists
the field is omitted entirely rather than substituting a random value.
Entitlements are feature keys carried inside the signed lease and checked offline:
if (client.hasEntitlement("cloud-sync")) { enableCloudSync(); }
hasEntitlement returns true only when the cached lease is signature-valid, unexpired, and not
expired-status — so offline feature gating never disagrees with the resolved Expired state.
The offline artifact is a signed v3 lease issued by the Keylight API. The SDK reconstructs
the exact signed payload (entitlements sorted, pipe-delimited) and verifies it with Ed25519
against the tenant's trusted keyset, applying a 300-second clock-skew tolerance.
- The trusted keyset is fetched once from
GET /{tenant}/.well-known/keylight-keys(fetchKeyset) or pinned at build time viacfg.trustedKeys["kid"] = base64_pub. hasEntitlementandstate()only read from the in-memory verified-lease cache — no network call, no disk I/O, safe from the audio thread.- The security boundary is the Ed25519 signature. A tampered or forged lease cannot pass verification without the tenant's private key. At-rest encryption (below) is a second layer, not the boundary.
The server-owned product settings (trial_duration_days, free_tier_enabled) arrive on
GET /config, on validate responses and on keyless-beacon replies. Without a signature a
hosts-file redirect can mint an unlimited trial even though it cannot mint a licence, so the
worker Ed25519-signs them over the canonical payload
cfg1|{kid}|{tenant}|{product}|{issued_at}|{expires_at}|{days}|{free_tier} — the same bytes
every Keylight SDK verifies.
cfg.trustedKeys["k1"] = "base64-public-key"; // compiled in cfg.requireSignedConfig = true; // OFF by default
- Off by default, and it is the only switch. With it off the settings are applied as sent
and the signature fields are not consulted at all — the same rule as the Swift, JS, C# and
Rust SDKs. With it on, a body must carry both fields, a
kidintrustedKeysand a signature that verifies inside itsissued_at/expires_atwindow (300 s skew); anything else is dropped, andfetchConfig()returns an error. - Only enable it when you know your product is signed. The worker signs for products that have a trial length configured in the dashboard. Enable it for a product that is not signed and every install silently stops learning its settings.
- Keys are compiled in, so rotation freezes old builds. A build that only trusts a rotated-
out
kidkeeps the settings it last verified (the cache deliberately outlives its window) and learns nothing new until it ships with the new key.
Since 0.2.0 the default store is EncryptedFileStore: ChaCha20-Poly1305
(RFC 8439, vendored — no external dependency) under a key derived from this
machine's stable id.
auto store = keylight::EncryptedFileStore::migrating( keylight::default_store_path(cfg), // ~/.keylight/<tenant>-<product>.bin keylight::legacy_plaintext_path(cfg)); // ~/.keylight/<tenant>-<product>.lease
migrating() imports a pre-0.2.0 plaintext store once on the first read, then
deletes it — the plaintext file is unlinked only after the encrypted copy is
written, so an interrupted upgrade cannot lose state. For a new integration
that never had a plaintext store, EncryptedFileStore store(path); is enough.
FileStore is still supported and unchanged, for integrators supplying their
own storage.
What encryption buys, and what it does not. It makes the blob tamper-evident
— editing trialStart in a text editor no longer extends a trial, because any
edit fails to authenticate and reads as "no data". It makes what this SDK writes
non-portable: a store copied to another machine will not open.
It does not stop seat sharing. A license key shared between machines still
yields working installs, because the API authorizes a validate on
(license_key, instance_id) without checking which machine is asking. Closing
that is a server-side change, tracked separately.
Two operational consequences worth knowing before you ship it:
- If a machine's stable id changes — hardware swap, OS reinstall, a
regenerated
/etc/machine-id— the existing store no longer opens and the app sees a first run. The user pays one reactivation. That is the intended cost of the store not being portable. - On a machine that exposes no stable id at all (a Linux image with neither
/etc/machine-idnor the dbus fallback), the key derives from a constant. Tamper resistance still holds everywhere; machine binding degrades only there, and nobody is locked out.
// Foregrounded the app? Re-check the licence. Debounced to 60s, so calling it // on every focus event is fine. Per-session: a relaunch always revalidates. client.activeRevalidate(); // After an in-app purchase, poll until the new entitlements land rather than // making the user relaunch. Bounded — payment webhooks lag, UIs must not hang. client.refreshAfterUpgrade(); // blocking, up to 30s by default auto fut = client.refreshAfterUpgradeAsync(); // A link to the customer portal for the stored licence. if (auto url = client.upgradeUrl()) openInBrowser(*url); // Catch an obvious typo before it costs a round trip. Shape only — it knows // nothing about whether the key exists. Driven by Config::keyPrefix. if (!client.isValidKeyFormat(typed)) showFormatHint(); // What is stored, for display. cachedLease() does NOT re-verify — use state() // or hasEntitlement() to decide what to unlock. client.hasStoredLicense(); client.cachedLicenseKey(); client.cachedLease();
Lifecycle events are the subset of transitions worth telling a customer
about, as opposed to subscribe(), which fires on every transition:
auto sub = client.onLifecycle([](keylight::LifecycleEvent e) { switch (e) { case keylight::LifecycleEvent::Renewed: /* thank them */ break; case keylight::LifecycleEvent::Cancelled: /* offer to resubscribe */ break; case keylight::LifecycleEvent::Expired: /* paywall */ break; case keylight::LifecycleEvent::Restored: /* welcome back */ break; } });
Same contract as subscribe(): delivered with no SDK lock held, a throwing
callback costs no other listener its event, and you must not destroy the
Client from inside one.
A background beacon reports anonymous state (trial / free tier / expired) so the conversion funnel reflects devices that change between launches. On by default at six hours:
cfg.keylessHeartbeatIntervalMs = 6 * 60 * 60 * 1000; // default cfg.keylessHeartbeatIntervalMs = 0; // disable entirely
- Beacon only. It never revalidates a licence, and a
LicensedorLimiteddevice never beacons at all — it counts keyless devices. - The interval is not the traffic. The beacon is debounced to one report per 24h per state; the interval only decides how promptly a change is noticed.
- Plugin scanning pays nothing. The thread is spawned on first state
resolution rather than in the constructor, and its first tick is at
+interval, so aClientbuilt and discarded during an AU/VST3 scan is born and joined without ever emitting.
Populate a keylight::Config struct:
| Field | Type | Default | Description |
|---|---|---|---|
tenantId |
std::string |
— | Your Keylight tenant (required). |
productId |
std::string |
— | Your product (required). |
sdkKey |
std::string |
— | Tenant SDK key, sent as X-Keylight-SDK-Key on every API call. Required — the API answers 401 without it. |
trustedKeys |
map<string,string> |
empty | Trusted Ed25519 public keys (kid → base64) for offline verification. |
maxOfflineDays |
int |
15 |
Offline grace window since last online validation. Set 0 to run offline as long as the lease itself is current. |
keyPrefix |
std::string |
— | Client-side key-format check (e.g. "PROD"). |
trialDurationDays |
int |
0 |
Local trial length in days (0 = trials disabled). See Trials. |
freeTierEnabled |
bool |
false |
Resolve State::FreeTier instead of Invalid/Expired when there is no license and no active trial. See Free Tier. |
apiBaseUrl |
std::string |
https://api.keylight.dev |
Keylight API base URL. |
appVersion |
std::string |
— | Reported in activation/validation telemetry. |
autoValidationIntervalMs |
int |
1800000 |
Background auto-validation interval (ms); used only when startAutoValidation() is called. A non-positive value is clamped to 1 ms rather than busy-spinning. |
requireSignedConfig |
bool |
false |
Require the server-owned settings (trial length, free tier) to arrive signed by a key in trustedKeys, on every route that carries them. Off: the signature fields are ignored. Only enable when your product is signed. See Signed server config. |
keylessHeartbeatIntervalMs |
int |
21600000 |
Keyless-beacon heartbeat interval (ms), 6 h. 0 disables it. Traffic is debounced to one report per 24 h per state regardless. See Keyless heartbeat. |
The security-critical lease verifier is gated by Keylight's frozen cross-SDK conformance
vectors (tests/test_conformance.cpp). The C++ verifier must agree with every vector on
{ kid_known, signature_valid, expired }, which keeps offline verification behavior
byte-identical across the Keylight SDK family (Swift, Rust, JavaScript, C#, C++).
cmake -B build && cmake --build build && ctest --test-dir build --output-on-failure
The conformance suite runs as part of the CI matrix on Ubuntu, macOS, and Windows.
- C++17 or later
- Supported compilers: GCC 9+, Clang 10+, MSVC 2019+
- Supported platforms: Linux, macOS, Windows (all CI-tested)
- The core library has zero runtime dependencies — the opt-in httplib transport adds a dependency on cpp-httplib and OpenSSL
- Platform docs: docs.keylight.dev
- Website: keylight.dev
- API host:
https://api.keylight.dev
| Platform | Status | Repository |
|---|---|---|
| Swift (macOS/iOS) | Available | keylight-swift |
| Rust (CLIs/daemons/Tauri) | Available | keylight-rust |
| JavaScript/TypeScript | Available | keylight-js |
| C# (.NET/Godot/Unity) | Available | keylight-csharp |
| C++ (this repo) | Available | keylight-cpp |
Keylight is the licensing layer for desktop apps. You keep your own Stripe account, your own pricing, and your own customers — Keylight issues the licenses and tells your app who is allowed to run it.
- License keys issued automatically when a payment completes
- Device activations with limits you set, and self-serve deactivation
- Offline validation — signed Ed25519 leases your app verifies locally
- Feature entitlements signed into the lease, so tiers work offline too
keylight.dev · Documentation · Pricing
- Offline Licensing for Your VST/AU Plugin Without a Backend
- License Your Unreal Engine Game Offline in an Afternoon
MIT License. See LICENSE for details.
Keylight C++ SDK — software licensing for C++: license-key activation & validation, offline Ed25519 lease verification, entitlement/feature gating, trials, and pluggable transport/storage — for desktop apps, Unreal Engine 5 plugins, and JUCE audio applications.