Rust
The reference for the keygrant crate 0.8.0, the KeyGrant SDK for Rust. The rules every SDK
shares are in How every SDK behaves.
- Rust 1.86 or later.
- Runs on Windows, macOS and Linux, and forbids
unsafecode. - Depends on
ed25519-dalek, to verify leases, andureqover rustls, for its requests. - Proxy: it uses an
httporhttpsproxy named inALL_PROXY,HTTPS_PROXYorHTTP_PROXY, except for the hosts inNO_PROXY. A SOCKS proxy is not used.
Add it
The keygrant crate is on crates.io.
cargo add keygrant Use it
use keygrant::{License, LicenseConfig};
let license = License::new( LicenseConfig::new("my-product", "https://api.keygrant.dev", PUBLIC_KEYS).major(2),)?;
let status = license.status(false);if !status.permits_use() { if let Err(error) = license.activate(&entered_key) { show_error(error.code()); // "invalid-key", "limit", ... }} - Every call blocks, so call it off the UI thread (in Tauri, inside
spawn_blocking). LicenseisSendandSync; create one per process. Calls on it run one at a time, excepthas,require,upgrade_urland the twopick_upcalls, which only read and run beside any call.
LicenseConfig
The three required settings. public_jwk is the product's key set as a string: the {"keys": [...]} that /pubkey answers and the dashboard's Copy key set copies.
- A JSON list of JWKs, one JWK, or its bare
xalso work. - A lease is verified by the key it names, or, naming none, by each key in turn.
This build's major version. 0 is read as 1, as KeyGrant reads a 0.x version, so a 0.x build can set 0 or 1.
What the license's device list calls this machine.
false turns device hold off, so no license is held to its device. See Device management.
How long one request may take.
The ids of the license files this build refuses, matched exactly: how an app update stops an offline license file that leaked. See License files.
- A stored file listed here reads as
Invalidwith reasonRevokedFile, and importing one is refused.
Adapters, each an Arc of its trait. The default is used for any you leave out.
License::new returns a ConfigError for a product, base URL, public key or storage location it cannot use.
Calls
Returns Ok(()) or an ActivateError: a License error (its code() is one of the activation errors, such as invalid-key), or Storage when the license file cannot be read or saved.
The same call with an ActivateOptions, for a key only its buyer can activate (below).
Returns whether the license grants the entitlement code, offline from the stored lease. See Check an entitlement.
keygrant::answers the same of a status already in hand.grants( &status.entitlements, code)
Returns Err(MissingEntitlement), whose entitlement is the code, when the license does not grant it.
Returns Result<ImportedLicenseFile, ImportError> (below). import_license_file_with(content, &options) also takes an ActivateOptions.
Returns Result<(), Error>.
Returns a UseResult: ok with left, or error (Metered uses).
Emails a six-digit code to the address, for key pickup. Returns Result<(), PickupRefusal>.
Returns the address's keys, Result<Vec<PickedKey>, PickupRefusal>.
Confirms the code and activates the key that fits this build. Returns Result<(), RestoreError>.
Returns a LicenseStatus (below). refresh() and start_trial() return one too.
Returns Result<DeactivateResult, Error>, with released.
Returns Result<Option<String>, Error>: Ok(None) when no key and activation are stored, without reading the link. See Sell an upgrade.
Error is one of these. The enum is #[non_exhaustive].
The license file cannot be read or saved.
From deactivate() or remove_license_file(), while an earlier call is stuck.
From upgrade_url, when an activation is stored: the link is not an absolute http or https URL with a host.
LicenseStatus
The states and reasons, with the reason RevokedFile (revoked-file) among them.
The license key; on a status from a license file, the key the file records.
On a trial: the whole days left, and when it ends, in Unix milliseconds.
true for a test license.
A BTreeMap, empty when the lease grants nothing. Each Entitlement reads with as_bool(), as_f64() or as_str().
A BTreeMap too, empty on every status but a Licensed one from a key's lease that names meters (below).
true when an earlier call has not finished, so the status was read from storage.
On a status from a license file (below); none on every status not from a license file.
True for Licensed, Trial and Unknown: keep the app running.
True for Licensed and Trial only.
keygrant::lease::verify_lease verifies a lease against a KeySet on its own, for code
that does not use License.
Keys only their buyer can activate
When a key is locked to its buyer (Who can activate), pass what
the customer typed to activate_with(&key, &options), as an ActivateOptions
built with ActivateOptions::new().email(...).code(...), each optional. The steps and rules are in
How every SDK behaves. Since 0.6.0.
use keygrant::{ActivateOptions, LicenseError};
let options = ActivateOptions::new().email(&entered_email).code(&entered_code);if let Err(error) = license.activate_with(&entered_key, &options) { match error.license_error() { // Ask for the address, then activate again with it. Some(LicenseError::UserEmailRequired | LicenseError::UserEmailMismatch) => {} // A code was just mailed to the address: ask for it. Some(LicenseError::UserCodeRequired | LicenseError::UserCodeInvalid) => {} Some(LicenseError::TooManyCodes | LicenseError::TooManyAttempts) => {} Some(LicenseError::TryAgain) => {} _ => {} }} activate(&key)sends neither, and answers a locked keyUserEmailRequired.import_license_file_with(content, &options)passes them on to a key file's key.- The refusals are
LicenseErrorcases.license_error()on anActivateErrorgives the case, andcode()gives its word, so a Tauri front end reads them as it reads the others.
Its word is user-email-required. The key is locked to its buyer and this device is new: ask for the email the key was sold to and call activate_with with it.
Its word is user-email-mismatch. That is not the email the key was sold to (or it is longer than 254 UTF-16 units, and no request is made). KeyGrant never says what the right one is.
Its word is user-code-required. The email was right and KeyGrant has just emailed a code to it: ask for the code and call activate_with with the email and the code.
Its word is user-code-invalid. The code is wrong, expired (it lasts 10 minutes) or already used, or is not six digits (no request is made). Ask again, or call activate_with with the email alone for a new code.
Its word is too-many-codes. Three codes have been emailed for this key this hour. Use the last one, or wait.
Its word is too-many-attempts. Too many wrong emails or codes for this key this hour. Try again later.
Its word is try-again. The code could not be emailed just now, and nothing was activated. Try again; it is nobody's mistake.
Restoring a license without the key
For an App Store build, which may not take a typed key: pick_up(email) emails the customer a
six-digit code, and restore_by_email(email, code) activates the key that fits this build. How a
pickup works is in How every SDK behaves. Since 0.8.0.
// 1. Email a code to the address the key was sold to (the answer is the same either way).license.pick_up(&email)?;
// 2. The customer types the code: restore.match license.restore_by_email(&email, &code) { Ok(()) => { /* licensed: read status() */ } Err(error) => match error.code() { "wrong-code" => { /* ask again */ } "code-expired" => { /* license.pick_up(&email) for a new code */ } "too-many-attempts" | "try-again" => { /* wait error.retry_after() seconds */ } "no-key" => { /* no key under that address */ } _ => {} },} -
To let the customer choose, call
pick_up_with_code, show eachPickedKeyby itsmodel_name, and activate the one chosen withactivate_with, the email, and itsactivation_codewhen it has one. - The errors are the pickup errors, in this SDK's own names. The requests are on the endpoints page.
License files
A customer can be sent a .kglicense file: a key file, which activates its key,
or an offline license file, which licenses the app with no network. How each behaves is in
How every SDK behaves, and what each costs under
Delivered as in License models. These are not the
file the SDK stores its own state in (Storage). Since 0.5.0.
Warning
Keep every key that signed a file in public_jwk. A build without it reads such a file as
Invalid with reason UnknownKey.
LICENSE_FILE_EXTENSIONis.kglicense, for an open dialog's filter or a drop handler's test.LICENSE_FILE_MEDIA_TYPEisapplication/vnd.keygrant.license, andlicense_file::MAX_LICENSE_FILE_BYTESis 16,384, the most a file may be.import_license_filetakes the file's content (a&str,String,&[u8]orVec<u8>), never its path. In plain Rust, read the file and hand over its bytes:license.import_license_file(std::fs::read(path)?).- List the ids of leaked files in
revoked_license_files(Blocking a leaked file).
In Tauri, a drop and a command can look like this:
use std::sync::Arc;
use std::io::Read;use std::path::Path;
use keygrant::license_file::MAX_LICENSE_FILE_BYTES;use keygrant::{ImportedLicenseFile, License, LicenseConfig, LICENSE_FILE_EXTENSION};use tauri::{DragDropEvent, Emitter, WindowEvent};
const PUBLIC_KEYS: &str = include_str!("../keygrant-keys.json");
// The front end hands over a file's bytes: from a drop on a web view, or an// <input type="file" accept=".kglicense">, as Array.from(new Uint8Array(await file.arrayBuffer())).#[tauri::command]async fn import_license_file( license: tauri::State<'_, Arc<License>>, bytes: Vec<u8>,) -> Result<ImportedLicenseFile, String> { let license = Arc::clone(&license); tauri::async_runtime::spawn_blocking(move || license.import_license_file(bytes)) .await .map_err(|e| e.to_string())? // "malformed", "not-a-license-file", "bad-signature", "unknown-key", // "wrong-product", "revoked-file", "upgrade", "outdated"; for a key file // what activate() answers; "storage" or "busy": try again. .map_err(|e| e.code().to_owned())}
/// A file's first `limit` bytes at most.fn read_at_most(path: &Path, limit: usize) -> std::io::Result<Vec<u8>> { let mut bytes = Vec::new(); std::fs::File::open(path)?.take(limit as u64).read_to_end(&mut bytes)?; Ok(bytes)}
fn main() { let license = Arc::new( License::new( LicenseConfig::new("sluice", "https://api.keygrant.dev", PUBLIC_KEYS) .major(3) // Ids of files that leaked: an update adds them here. .revoked_license_files(["lf_8Hq2"]), ) .expect("the license configuration is the developer's to fix"), ); let on_drop = Arc::clone(&license); tauri::Builder::default() .manage(license) .invoke_handler(tauri::generate_handler![import_license_file]) .on_window_event(move |window, event| { // A file dropped on the window, read here: the SDK takes content, never a path. if let WindowEvent::DragDrop(DragDropEvent::Drop { paths, .. }) = event { let dropped = paths.iter().find(|path| { path.extension().and_then(|e| e.to_str()).is_some_and(|e| { e.eq_ignore_ascii_case(LICENSE_FILE_EXTENSION.trim_start_matches('.')) }) }); if let Some(path) = dropped.cloned() { let license = Arc::clone(&on_drop); let window = window.clone(); tauri::async_runtime::spawn_blocking(move || { // Never more than a license file can be: a larger one is refused unread. let imported = read_at_most(&path, MAX_LICENSE_FILE_BYTES + 1) .map_err(|e| e.to_string()) .and_then(|bytes| { license.import_license_file(bytes).map_err(|e| e.code().to_owned()) }); let _ = window.emit("license-file", imported); }); } } }) .run(tauri::generate_context!()) .expect("error while running the app");} What import_license_file answers
It verifies the file offline (Importing a file). It returns
Ok(ImportedLicenseFile { kind, licensed_to }), where kind is
LicenseFileKind::Key or Offline, or an ImportError:
The file was refused, and nothing was stored. The error is one of the cases below.
A key file's key did not activate. It is what activate() answers.
A call on this License before it has not finished. Try again.
The state cannot be read or the file cannot be saved. Try again.
ImportError::code() gives the word of any of them: a LicenseFileError's, a
LicenseError's, storage or busy. The enum is
#[non_exhaustive]. The LicenseFileError cases are:
Its word is malformed. Not a license file's text, or its token or claims are broken, or it is more than MAX_LICENSE_FILE_BYTES.
Its word is not-a-license-file. A signed token, but not a license file (a lease, say).
Its word is bad-signature. Not signed by the key it names.
Its word is unknown-key. Signed by a key this build does not carry (the key set in public_jwk): a newer one, after the product rotated its key, which an update of the app carries. A key file's notes print its key, which can be typed meanwhile.
Its word is wrong-product. Another product's file.
Its word is revoked-file. The file's id is listed in revoked_license_files.
Its word is upgrade. An offline file that does not cover this build: this build is newer than the file covers.
Its word is outdated. An offline file that does not cover this build: this build is older than the file covers.
remove_license_file() drops the stored offline file, if there is one, and a key this device holds
keeps working. It returns Err(Error::Busy) when an earlier call has not finished and
Err(Error::Storage(_)) when the change cannot be saved.
The status of a stored file
A stored offline file answers every status() with Licensed,
source: Some(LicenseSource::File), the key, licensed_to and the file's
entitlements, and never asks KeyGrant, refresh() included.
- Show "Licensed to Ada Lovelace" from
licensed_to. - A status from a file has no seat to deactivate: offer
remove_license_file()instead.deactivate()leaves the file alone. - Besides
RevokedFile, a status from a file can beInvalidwith reasonUpgrade,Outdated,TamperedorUnknownKey. What each means, and which wins when the device also holds a key, is in The status of a stored file.
Metered uses
A license model can count uses of what it sells (Metered uses).
The app spends them with use_meter(meter, n) (use is a Rust keyword), before the
work they count, and does the work only when the spend is ok. Since 0.7.0.
use keygrant::UseError;
let spent = license.use_meter("exports", 1); // use_meter("exports", 40) spends forty at once, whole or noneif spent.ok { run_export(); // spent.left: what is left now, net of this device's unsynced spends} else { match spent.error { // The server's word: fewer left than asked (spent.left says how many, when it gave a number). Some(UseError::UsedUp) => offer_more_exports(spent.left), // KeyGrant could not be reached, and this is past what this device may spend without it. Some(UseError::OfflineLimit) => ask_to_connect(), // NoMeter, NotLicensed, Revoked, Device, Upgrade, Outdated, Storage, InvalidAmount. other => show_meter_problem(other), }}
let status = license.status(false); // offline, from the leasestatus.meters["exports"]; // MeterStatus { left: 950, total: 1000, offline: 100, unsynced: 0, resets: false } In a Tauri command, run it on the blocking pool like every other call:
#[tauri::command]async fn export_spend(license: tauri::State<'_, Arc<License>>) -> Result<UseResult, String> { let license = Arc::clone(&license); tauri::async_runtime::spawn_blocking(move || license.use_meter("exports", 1)) .await .map_err(|e| e.to_string())} offlineis true in the result when the device spent from its offline allowance.nis au64. Outside 1 to 1,000,000,000 it isInvalidAmount, and a meter code that is not 1 to 32 UTF-16 units isNoMeter, both without asking.- It never panics for an outcome: every one is in the
UseResult. The call blocks and takes no cancellation: it runs to its bounded answer. - A request waits at most 5 seconds for KeyGrant, less when
http_timeoutis shorter. deactivate()sends the unsynced spends with the release.- How a spend works online and offline, and why a meter never takes the license away, is in How every SDK behaves.
When use_meter does not spend
Its word is used-up. The meter has fewer uses left than asked. left says how many are left, when KeyGrant gave a number, so the app can offer fewer. It is KeyGrant's own word, not a guess.
Its word is offline-limit. KeyGrant could not be reached, and the spend is past this device's offline allowance for the meter or past what it last heard is left. left says what the device may still spend. Ask the customer to connect.
Its word is no-meter. The key has no meter with that code, or the code is empty or longer than 32 UTF-16 units.
Its word is not-licensed. No key's lease licenses this build on this device, so nothing was asked. A device trial and an offline license file have no meters.
KeyGrant's verdict on the key, this build or this device, as status() says it. Their words are revoked, upgrade, outdated and device.
Its word is storage. The stored license (the state file) could not be read just now, or an adapter panicked where there is no word for it. Try again shortly.
Its word is invalid-amount. n is not a whole number from 1 to 1,000,000,000. This is a bug in the app: nothing was asked.
LicenseStatus::meters
A map by meter code, on a Licensed status from a key's lease that names meters, and empty on
every other status. Each MeterStatus has:
What is left, net of this device's unsynced spends.
The uses the key holds, for good or for this billing period.
This device's offline allowance.
Uses this device spent that KeyGrant has not heard of yet.
true for a meter that comes back each billing period.
Buying from the trial
// The Buy button, while the app holds no key.open_in_browser("https://buy.stripe.com/your-payment-link");
// The customer types the key from the delivery email:if license.activate(&entered_key).is_ok() { show_thanks();} The checkout creates the key and delivers it, and the customer types it into the app. See Buying from the trial.
When status answers Trial, the trial reports its launch to KeyGrant at most once a
day, on a thread of its own, so the call never waits for it. The app need not call start_trial()
on each launch for Analytics to count the trial as come back
(Device trials).
Storage
The default storage is the folder every SDK shares, in the same format
(Where the license is stored), so it reads a license the
Electron SDK stored. .storage() on the
LicenseConfig takes your own.
Keeping a trial through a reinstall
The default registry stash stores nothing. On Windows, WindowsRegistryStash stores the trial's
start date under HKCU\. It exists only in
Windows builds:
use keygrant::LicenseConfig;
let config = LicenseConfig::new("my-product", "https://api.keygrant.dev", PUBLIC_KEYS);
#[cfg(windows)]let config = config.registry(std::sync::Arc::new(keygrant::WindowsRegistryStash::new("my-product")));