Crates.io Documentation License: MIT Edition MSRV Conformance
Open-source Rust SDK and Tauri plugin for Keylight — license your Rust apps, CLIs, daemons, and Tauri desktop apps with online activation and offline Ed25519 license verification.
In one line: a software-licensing SDK for Rust — license-key activation and validation, entitlement/feature gating, trials and free tiers, and tamper-resistant offline license verification (signed
v3lease, Ed25519 + clock-skew tolerance) for CLIs, daemons, and desktop apps. Synchronous and runtime-free — noasync/Tokio required.
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.
- Synchronous & runtime-free. No
async/Tokio, no background threads — you callactivate/refresh_if_neededon launch and on events, and decide exactly when to check in. Ideal for CLIs and daemons. - One SDK family. Verifies licenses identically to the Swift and JavaScript SDKs, proven by shared conformance vectors.
- Why Keylight
- Features
- Packages
- Quick Start
- License Lifecycle
- License States
- Entitlements
- Offline Validation
- Refresh, Trials & Free Tier
- Lifecycle Events
- Configuration Reference
- CLI & Demo
- Conformance
- Documentation
- Other SDKs
- License
- License Lifecycle — Activate, validate, and deactivate license keys with a small, explicit API.
- Offline Verification — The single offline artifact is a signed
v3lease, verified with Ed25519 and a 300-second clock-skew tolerance. An optionalmax_offline_daysgrace caps how long a device may run without checking in. - Synchronous & runtime-free — Blocking HTTP (
ureq); noasync/Tokio, no background threads. You callrefresh_if_needed()/check_on_launch()on launch and on app events. Ideal for CLIs and daemons. - Entitlements — Feature gating from the cached lease:
has_entitlement("pro"). - Trials & Free Tier — Built-in local trial timer, free-tier mode, and an anonymous "keyless" usage beacon.
- Lifecycle Events — Optional callback fires
Renewed/Cancelled/Expired/Restoredas the resolved state changes. - Clock-Manipulation Detection — Flags backward/forward system-clock tampering.
- Device Telemetry — Auto-attaches
sdk_versionandplatform. To also report your app version, you must set it with.app_version(env!("CARGO_PKG_VERSION"))on the builder — the SDK can't infer it, so it's omitted (and the dashboard shows a blank version) until you do. - Network Resilience — Automatic retry with exponential backoff + jitter; honors
Retry-After. - Secure by Default — TLS via rustls (no OpenSSL), ChaCha20-Poly1305 device-bound
encrypted on-disk storage, and no
unsafein the SDK crate. - Pluggable — Swap the storage backend (
LicenseStore) or HTTP transport (Transport) via traits for tests or custom platforms.
This workspace contains:
| Crate | Description | Distribution |
|---|---|---|
keylight |
Core Rust SDK for any Rust application | crates.io docs |
keylight-sdk-demo |
Reference CLI / template for white-labeled yourapp activate commands (also a dev/ops & CI utility). Ships as keylight-demo — not the Keylight management CLI, which is keylight. |
Prebuilt binaries on GitHub Releases |
tauri-plugin-keylight |
Tauri v2 plugin (Rust side) with capability permissions | crates.io |
tauri-plugin-keylight-api |
Tauri v2 plugin JS/TS bindings (ESM/CJS + .d.ts) |
npm |
keylight-notes-demo |
"Keylight Notes" example app | Example (not published) |
cargo add keylight
use keylight::{Keylight, KeylightConfig}; fn main() -> Result<(), Box<dyn std::error::Error>> { // Build a config. Fetch the tenant's trusted Ed25519 keyset so leases can be // verified offline. (You can also pin keys explicitly with `.trusted_key(kid, pub_b64)`.) let mut cfg = KeylightConfig::builder("your-tenant", "your-product", "sdk_live_...") .key_prefix("PROD") // optional client-side key-format check .max_offline_days(7) // optional offline grace window .app_version(env!("CARGO_PKG_VERSION")) // report app version (omitted if unset) .build(); if let Some((_, keys)) = keylight::keyset::fetch_keyset( &keylight::http::ureq_transport::UreqTransport::default(), &cfg.base_url, &cfg.tenant_id, ) { cfg.trusted_keys.extend(keys); } let kl = Keylight::new(cfg)?; // Activate a license key (online). The returned lease is Ed25519-verified // *before* anything is persisted. let res = kl.activate("USER-LICENSE-KEY")?; println!("activated: {}", res.activated); // Gate features on entitlements — works offline from the cached lease. if kl.has_entitlement("pro") { println!("Pro features unlocked"); } // Release the seat when uninstalling / switching devices. kl.deactivate()?; Ok(()) }
Note the synchronous API — there is no
.awaitand no async runtime to set up.
Add the Rust-side Tauri v2 plugin and the JS bindings:
# Rust side cargo add tauri-plugin-keylight # JavaScript side npm add tauri-plugin-keylight-api
Register it with a prebuilt KeylightConfig (your app supplies tenant/product/keys):
// src-tauri/src/main.rs use keylight::KeylightConfig; fn main() { let cfg = KeylightConfig::builder("your-tenant", "your-product", "sdk_live_...").build(); tauri::Builder::default() .plugin(tauri_plugin_keylight::init(cfg)) .run(tauri::generate_context!()) .expect("error while running tauri application"); }
Grant the plugin's default permission set in your capability file:
// src-tauri/capabilities/default.json { "permissions": ["keylight:default"] }
keylight:default allows activate, validate, and has_entitlement (per-command permissions
keylight:allow-activate etc. are also generated).
Use the typed JS/TS bindings (tauri-plugin-keylight-api,
ESM/CJS + .d.ts) from your frontend:
import { activate, validate, hasEntitlement } from 'tauri-plugin-keylight-api'; await activate('USER-LICENSE-KEY'); const ok = await validate(); if (await hasEntitlement('pro')) { // unlock pro features }
┌─────────────┐ ┌─────────────┐ ┌──────────────┐
│ activate │────▶│ validate │────▶│ deactivate │
└─────────────┘ └─────────────┘ └──────────────┘
▲さんかく
│ on launch / on events (no background threads)
┌───────────────────┐
│ refresh_if_needed │
└───────────────────┘
| Method | Description |
|---|---|
activate(key) -> ActivationResult |
Activates a key on this device. Verifies the returned lease before persisting; returns instance_id, the lease, and expiry. |
validate() -> ValidationResult |
Re-checks the stored license online. Decodes hard-expiry (422) responses and preserves fallback/expired leases so state can resolve. |
deactivate() |
Releases the seat and clears local license state. Call on uninstall or device switch. |
refresh_if_needed() -> Option<ValidationResult> |
Validates only if due (debounce 5 min, stale 6 h, or within 24 h of expiry). Safe to call often. |
check_on_launch() |
Convenience: refresh if a license is stored, else no-op. |
active_revalidate() -> Option<ValidationResult> |
Forces a validate on active use (foreground / focus / popover), debounced 60 s in memory. Catches a revoke mid-session; a network blip never downgrades. |
state() resolves a single high-level status from the cached lease, trial, and free-tier config
(no network):
| State | Meaning |
|---|---|
Licensed |
Current, signature-valid active lease. |
Limited |
Signature-valid fallback lease (grace mode). |
Trial { days_left } |
No license, but a local trial is active. |
FreeTier |
No license, free tier enabled. |
Expired |
Lease expired, or a license was stored but is no longer current. |
Invalid |
No license, no trial, no free tier. |
Entitlements are feature keys carried inside the signed lease and checked offline:
if kl.has_entitlement("cloud-sync") { enable_cloud_sync(); }
has_entitlement 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:
use keylight::KeylightConfig; let cfg = KeylightConfig::builder("your-tenant", "your-product", "sdk_live_...") // Pin trusted keys explicitly instead of fetching them: .trusted_key("k1", "<raw ed25519 public key, base64>") .max_offline_days(7) // None = run offline as long as the lease itself is current .build();
- The trusted keyset can be fetched once from
GET /{tenant}/.well-known/keylight-keys(keylight::keyset::fetch_keyset) or pinned at build time. cached_lease()returns the lease only when it iskid-known, signature-valid, unexpired, and (if set) withinmax_offline_daysof the last online validation.- Key rotation: every lease carries a
kid, and a lease whosekidis absent from the keyset is rejected (VerifyResult { kid_known: false })..trusted_key()is chainable (andtrusted_keys.extend(...)bulk-adds), so when the tenant rotates its signing key you bump thekid(k1→k2) and register both the outgoing and incoming keys for a deprecation window — leases signed by either still verify, so unexpired leases don't flip to invalid mid-session. If youfetch_keysetinstead of pinning, the well-known endpoint already returns every activekid, so rotation is transparent. (See your Keylight tenant'ssecurity.md→ Ed25519 key rotation.) - Encrypted lease/key material is stored device-bound with ChaCha20-Poly1305 (key derived from a per-device identity via BLAKE3) — copying the files to another machine won't decrypt them.
There are no background threads. The host drives refresh on launch and on meaningful events:
kl.check_on_launch()?; // validate if due, on startup kl.refresh_if_needed()?; // call again on window-focus / purchase / resume kl.active_revalidate(); // force a check when the user brings the app forward
refresh_if_needed() respects a staleness policy (5 min debounce, 6 h stale), so with a long-lived
lease a revoke can sit unnoticed until the next launch. active_revalidate() is the prompt
counterpart: it always calls the server (debounced 60 s, in memory only), downgrades immediately on a
definitive rejection, and leaves the session untouched on a transient failure.
Trials and free tier are local and offline-first:
kl.start_trial()?; // begins the trial clock once match kl.check_trial() { // NotStarted | Active { days_left } | Expired keylight::TrialStatus::Active { days_left } => println!("{days_left} days left"), _ => {} } // Anonymous, debounced usage beacon for trial / free-tier / expired devices. // It carries `sdk_version` and `platform` automatically; to also see your app // version on these devices in the dashboard, set `.app_version(...)` on the // builder (see the client setup above) — it is omitted otherwise. kl.report_keyless_state(keylight::KeylessState::Trial); // Tamper check and a pre-filled hosted upgrade link: // `state()` already forces `Invalid` if the clock was rolled back past tolerance // (the offline vector for reviving an expired lease). `is_clock_manipulated()` // additionally surfaces large forward jumps if you want to react to them too. let tampered = kl.is_clock_manipulated(); if let Some(url) = kl.upgrade_url() { println!("Upgrade: {url}"); }
Register a handler to react when the resolved state crosses a transition:
use keylight::{Keylight, LicenseLifecycleEvent}; let kl = Keylight::new(cfg)? .with_event_handler(|event| match event { LicenseLifecycleEvent::Renewed => println!("license renewed"), LicenseLifecycleEvent::Cancelled => println!("dropped to limited/expired"), LicenseLifecycleEvent::Expired => println!("license expired"), LicenseLifecycleEvent::Restored => println!("license restored"), });
| Event | Fires when |
|---|---|
Renewed |
Stayed Licensed and the expiry moved later. |
Cancelled |
Licensed → Limited or Expired. |
Expired |
Any state → Expired. |
Restored |
Expired/Limited/Invalid → Licensed. |
Events are evaluated during validate() and re-derive the previous state from the persisted lease,
so a transition won't re-fire across restarts.
Built with KeylightConfig::builder(tenant_id, product_id, sdk_key):
| Option | Type | Default | Description |
|---|---|---|---|
tenant_id |
String |
— | Your Keylight tenant (required). |
product_id |
String |
— | Your product (required). |
sdk_key |
String |
— | Tenant SDK key (required), sent as X-Keylight-SDK-Key on every call. |
trusted_keys |
map<kid, pub> |
empty | Trusted Ed25519 public keys for offline verification (.trusted_key() or fetch_keyset). |
max_offline_days |
Option<u32> |
Some(15) |
Offline grace window since last online validation. None = until the lease itself expires. |
require_signed_config |
bool |
false |
Require server-owned product settings to carry a valid Ed25519 signature before they are cached. See Signed product settings. |
trial_duration_days |
u32 |
0 |
Local trial-length seed (days). 0 = no trial unless the dashboard says otherwise. |
free_tier_enabled |
bool |
false |
Resolve to FreeTier when there's no license/trial. |
app_version |
Option<String> |
None |
Reported in telemetry. |
base_url |
String |
https://api.keylight.dev |
API base URL. |
key_prefix |
Option<String> |
None |
Client-side key-format check (e.g. "PROD"). |
The trial length and free-tier flag are server-owned: the Keylight worker delivers them on
/config, on validate, and on the keyless beacon, and the SDK caches whatever it last heard.
require_signed_config makes the SDK verify an Ed25519 signature over those settings before caching
them; settings that do not verify are dropped and the compiled-in seed is kept.
let cfg = KeylightConfig::builder("your-tenant", "your-product", "sdk_live_...") .trusted_key("k1", "<raw ed25519 public key, base64>") .require_signed_config(true) .build();
- Off by default — leave it off unless your product is signed. The worker signs a product's settings only once that product has a trial length configured in the dashboard; every other product is served unsigned, and enabling this against one of those rejects every legitimate response and pins the install to its seed.
- Keys are compiled in via
trusted_keys, never fetched at runtime. A keyset fetched over the same connection that serves the settings would let anyone able to forge one forge the other, so the SDK deliberately does not do that. - Rotation freezes, it does not break. A build that only knows the old
kidstops accepting new settings and stays on its last cached values until it ships with the new key. Register both keys for a deprecation window, exactly as for lease verification. verify_config(&fields, tenant_id, product_id, &trusted_keys, now_seconds, skew_seconds) -> boolis the check itself, exported for hosts that want to verify aProductConfigFieldspayload themselves.tenant_id/product_idcome from your config, not the payload, so a config signed for another product fails rather than validating against its own claim. Freshness (issued_at/expires_at, withskew_secondstolerance,keylight::SKEW_SECONDS= 300) applies to the wire only — an already-cached config stays usable past its window.
refresh_after_upgrade(timeout: Duration, poll_interval: Duration) -> bool briefly poll-revalidates
after a customer completes an upgrade, so new entitlements (or a mid-flight rejection) show up in the
running app without waiting for the normal refresh cadence. It covers payment-webhook lag: checkout
can finish in the browser slightly before the provider's webhook reaches Keylight.
use std::time::Duration; let kl = kl.clone(); // Arc<Keylight> std::thread::spawn(move || { if kl.refresh_after_upgrade(Duration::from_secs(30), Duration::from_secs(2)) { // entitlement set or state changed — refresh the UI } });
- Re-validates every
poll_interval(clamped to a 100 ms floor) until the entitlement set or the resolved state changes, ortimeoutelapses. Returnstrueon a change,falseon timeout or when no license is stored (no network call in that case). - Blocking. It sleeps between attempts and can take up to
timeoutto return (each sleep is capped to the time remaining, so it never overshoots). Run it on a background thread, never on a UI/main thread. The Tauri plugin'srefreshAfterUpgrade()already does this for you. - A seat-only upgrade with no entitlement/state change is invisible to it and runs to
timeout; the normal refresh cadence still picks that up.
keylight-sdk-demo is a reference implementation: a thin clap wrapper
around the SDK (see keylight-sdk-demo/src/main.rs). It installs as
keylight-demo, deliberately not keylight — that name belongs to the
Keylight management CLI. Its main purpose is
to be the worked example for adding white-labeled licensing commands to your own CLI.
You don't ship this binary renamed — you embed the keylight library in your tool, bake in
your tenant/product, and expose your own branded subcommand. The end user then just runs
yourapp activate <KEY> (no --tenant/--product to pass):
// In your CLI `mole`: `mole activate <KEY>` Cmd::Activate { key } => { let kl = Keylight::new(KeylightConfig::builder("mole-co", "mole", "sdk_live_...").build())?; let unlocked = kl.activate(&key)?.activated; println!("{}", if unlocked { "Mole Pro unlocked 🎉" } else { "Invalid key" }); } // gate features elsewhere: if kl.has_entitlement("pro") { /* ... */ }
You can also run the generic binary as-is — useful for local development, testing a tenant, or exit-code gating in scripts/CI (it is not a customer-facing tool):
cargo install --git https://github.com/keylight-dev/keylight-rust keylight-sdk-demo keylight --tenant your-tenant --product your-product --fetch-keys activate USER-LICENSE-KEY keylight --tenant your-tenant --product your-product validate || echo "license invalid"
The demo app shows entitlement gating end-to-end (free = 3 notes; the pro
entitlement unlocks unlimited notes + export) against the live public demo tenant:
cargo run -p keylight-notes-demo -- add "first note" cargo run -p keylight-notes-demo -- activate NOTES-PRO0-0000-0001 cargo run -p keylight-notes-demo -- export /tmp/notes.txt # pro-only
The security-critical lease verifier is gated by Keylight's frozen cross-SDK conformance vectors
(keylight/tests/conformance.rs). The Rust verifier must agree with every vector on
{ kid_known, signature_valid, expired }, which keeps offline verification behavior identical
across the Keylight SDK family (Swift, Rust, ...).
cargo test -p keylight --test conformance- API docs (docs.rs): docs.rs/keylight
- Platform docs: docs.keylight.dev
- Website: keylight.dev
- API host:
https://api.keylight.dev
| Platform | Status | Repository |
|---|---|---|
| Swift (macOS/iOS) | Available | keylight-swift |
| Rust (this repo) | Available | keylight-rust |
| JavaScript/TypeScript | Available | keylight-js |
| C# · C++ | Planned | unified by the same cross-SDK conformance vectors |
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 License Validation in Rust
- How to Add License Keys to a Tauri App
- What Is Inside a Keylight Lease: The Ed25519 Format Explained
MIT License. See LICENSE for details.
Keylight Rust SDK — software licensing for Rust: license-key activation & validation, offline Ed25519 lease verification, entitlement/feature gating, trials and free tiers, device-bound encrypted storage, and a Tauri v2 plugin for CLIs, daemons, and desktop apps.