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 unsafe code.
  • Depends on ed25519-dalek, to verify leases, and ureq over rustls, for its requests.
  • Proxy: it uses an http or https proxy named in ALL_PROXY, HTTPS_PROXY or HTTP_PROXY, except for the hosts in NO_PROXY. A SOCKS proxy is not used.

Release notes

Add it

The keygrant crate is on crates.io.

Terminal
cargo add keygrant

Use it

Rust
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).
  • License is Send and Sync; create one per process. Calls on it run one at a time, except has, require, upgrade_url and the two pick_up calls, which only read and run beside any call.

LicenseConfig

LicenseConfig::new(product, api_base_url, public_jwk)

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 x also work.
  • A lease is verified by the key it names, or, naming none, by each key in turn.
.major(u32)

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.

Default 1

.device_name(name)

What the license's device list calls this machine.

.device_hold(bool)

false turns device hold off, so no license is held to its device. See Device management.

Default true

.http_timeout(Duration)

How long one request may take.

Default 15 seconds

.revoked_license_files(ids)

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 Invalid with reason RevokedFile, and importing one is refused.

Default empty ; Since 0.5.0

.storage(), .registry(), .clock(), .fingerprinter(), .http()

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

activate(&key)

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.

activate_with(&key, &options)

The same call with an ActivateOptions, for a key only its buyer can activate (below).

Since 0.6.0

has(code)

Returns whether the license grants the entitlement code, offline from the stored lease. See Check an entitlement.

  • keygrant::grants(&status.entitlements, code) answers the same of a status already in hand.
require(code)

Returns Err(MissingEntitlement), whose entitlement is the code, when the license does not grant it.

import_license_file(content)

Returns Result<ImportedLicenseFile, ImportError> (below). import_license_file_with(content, &options) also takes an ActivateOptions.

Since 0.5.0

remove_license_file()

Returns Result<(), Error>.

Since 0.5.0

use_meter(meter, n)

Returns a UseResult: ok with left, or error (Metered uses).

Since 0.7.0

pick_up(email)

Emails a six-digit code to the address, for key pickup. Returns Result<(), PickupRefusal>.

Since 0.8.0

pick_up_with_code(email, code)

Returns the address's keys, Result<Vec<PickedKey>, PickupRefusal>.

Since 0.8.0

restore_by_email(email, code)

Confirms the code and activates the key that fits this build. Returns Result<(), RestoreError>.

Since 0.8.0

status(force_online)

Returns a LicenseStatus (below). refresh() and start_trial() return one too.

deactivate()

Returns Result<DeactivateResult, Error>, with released.

upgrade_url(link)

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].

Storage(_)

The license file cannot be read or saved.

Busy

From deactivate() or remove_license_file(), while an earlier call is stuck.

InvalidUrl

From upgrade_url, when an activation is stored: the link is not an absolute http or https URL with a host.

LicenseStatus

state, reason

The states and reasons, with the reason RevokedFile (revoked-file) among them.

key

The license key; on a status from a license file, the key the file records.

trial_days_left, trial_ends_at

On a trial: the whole days left, and when it ends, in Unix milliseconds.

test

true for a test license.

entitlements

A BTreeMap, empty when the lease grants nothing. Each Entitlement reads with as_bool(), as_f64() or as_str().

meters

A BTreeMap too, empty on every status but a Licensed one from a key's lease that names meters (below).

stalled

true when an earlier call has not finished, so the status was read from storage.

source, licensed_to

On a status from a license file (below); none on every status not from a license file.

permits_use()

True for Licensed, Trial and Unknown: keep the app running.

is_entitled()

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.

Rust
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 key UserEmailRequired.
  • import_license_file_with(content, &options) passes them on to a key file's key.
  • The refusals are LicenseError cases. license_error() on an ActivateError gives the case, and code() gives its word, so a Tauri front end reads them as it reads the others.
UserEmailRequired

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.

UserEmailMismatch

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.

UserCodeRequired

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.

UserCodeInvalid

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.

TooManyCodes

Its word is too-many-codes. Three codes have been emailed for this key this hour. Use the last one, or wait.

TooManyAttempts

Its word is too-many-attempts. Too many wrong emails or codes for this key this hour. Try again later.

TryAgain

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.

Rust
// 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 each PickedKey by its model_name, and activate the one chosen with activate_with, the email, and its activation_code when 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_EXTENSION is .kglicense, for an open dialog's filter or a drop handler's test.
  • LICENSE_FILE_MEDIA_TYPE is application/vnd.keygrant.license, and license_file::MAX_LICENSE_FILE_BYTES is 16,384, the most a file may be.
  • import_license_file takes the file's content (a &str, String, &[u8] or Vec<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:

Rust
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:

File(LicenseFileError)

The file was refused, and nothing was stored. The error is one of the cases below.

License(LicenseError)

A key file's key did not activate. It is what activate() answers.

Busy

A call on this License before it has not finished. Try again.

Storage(_)

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:

Malformed

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.

NotALicenseFile

Its word is not-a-license-file. A signed token, but not a license file (a lease, say).

BadSignature

Its word is bad-signature. Not signed by the key it names.

UnknownKey

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.

WrongProduct

Its word is wrong-product. Another product's file.

RevokedFile

Its word is revoked-file. The file's id is listed in revoked_license_files.

Upgrade

Its word is upgrade. An offline file that does not cover this build: this build is newer than the file covers.

Outdated

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 be Invalid with reason Upgrade, Outdated, Tampered or UnknownKey. 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.

Rust
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:

Rust
#[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())}
  • offline is true in the result when the device spent from its offline allowance.
  • n is a u64. Outside 1 to 1,000,000,000 it is InvalidAmount, and a meter code that is not 1 to 32 UTF-16 units is NoMeter, 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_timeout is 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

UsedUp

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.

OfflineLimit

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.

NoMeter

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.

NotLicensed

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.

Revoked, Upgrade, Outdated, Device

KeyGrant's verdict on the key, this build or this device, as status() says it. Their words are revoked, upgrade, outdated and device.

Storage

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.

InvalidAmount

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:

left

What is left, net of this device's unsynced spends.

total

The uses the key holds, for good or for this billing period.

offline

This device's offline allowance.

unsynced

Uses this device spent that KeyGrant has not heard of yet.

resets

true for a meter that comes back each billing period.

Buying from the trial

Rust
// 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\Software\KeyGrant\<product>. It exists only in Windows builds:

Rust
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")));