KeyGrant for Flutter
Client-side licensing for Flutter apps on iOS, iPadOS, Android, Windows, macOS and Linux. A port of
the reference Electron SDK (@keygrant/electron), held to the same behaviour by the shared
conformance vectors (conformance/vectors.json). The full guide is at https://keygrant.dev/docs.
A licence is proven to the app by a signed lease (a JWT, EdDSA over Ed25519) that the app checks offline against the product's public key. The app goes online only to activate, to renew a lease near or past its end, to tell the server it runs a newer major version, to spend a metered use or report the uses the device spent while it got no answer, or, at most once a day and in the background, to report a running trial's launch. Licensing fails in favour of the paying customer: an outage, no network, a 5xx, a 429, an ambiguous 4xx or an answer the SDK cannot use never takes away a lease that is still valid. Only an explicit verdict from the server, or the lease's own end, does.
- Flutter 3.47 or later, built and tested with Flutter 3.47.7. Its Android module is Flutter 3.47's (Android Gradle Plugin 9), so an older Flutter cannot build it.
- iOS and iPadOS 15, macOS 12, Android 7 (API 24), Windows 10 and Linux, or later.
- Dependencies:
cryptography(Ed25519),crypto(SHA-256),http,ffi,meta,path_provider.
Install
flutter pub add keygrant
The plugin registers itself: nothing to add to main(). An isolate your app starts itself (a
background task) calls DartPluginRegistrant.ensureInitialized() before it makes a licence there.
Quick start
import 'package:keygrant/keygrant.dart';
/// The product's public keys: the `keys` of
/// `GET https://api.keygrant.dev/v1/products/<product>/pubkey`, fetched when you build and
/// shipped with the app. Public: safe to ship.
final license = KeyGrantLicense(LicenseConfig(
product: 'sluice',
apiBaseUrl: 'https://api.keygrant.dev',
publicJwks: parseJwkSet('{"keys":[{"kty":"OKP","crv":"Ed25519","x":"<the x of your key>"}]}'),
major: 3, // the app's major version: set it, the server checks keys by it
));
Future<void> launch() async {
final status = await license.status(); // offline-first, usually instant
switch (status.state) {
case LicenseState.licensed:
case LicenseState.trial:
case LicenseState.unknown: // the state could not be read just now: keep running
showApp();
case LicenseState.expired:
case LicenseState.invalid:
case LicenseState.unlicensed:
showActivation(status.reason); // say why; offer activate, trial, restore or buy
}
}
Make one KeyGrantLicense per app and share it: its calls run one at a time. The constructor throws
a LicenseConfigError for a configuration it cannot use (no public key, both publicJwk and
publicJwks, a key that is not an Ed25519 public JWK), so the first run shows the mistake.
The API
| Call | Answers |
|---|---|
status({bool forceOnline = false}) |
Future<LicenseStatus>; never throws |
refresh() |
status() forced online: call it when the customer says they have paid, or on a "Try again" |
activate(String key, {String? email, String? code}) |
Activated or ActivateFailed(LicenseError); throws only when the state cannot be read or saved. email and code are what a person typed for a user-locked key |
startTrial() |
Future<LicenseStatus>; never throws |
deactivate() |
DeactivateResult(released): releases the seat and drops the local licence (trial evidence kept) |
importLicenseFile(String text, {email, code}), importLicenseFileBytes(List<int> bytes, {email, code}) |
a licence file's content: an offline file is stored and licenses the app with no network; a key file's key is activated as activate() would |
removeLicenseFile() |
drops the stored offline licence file; a key the device holds keeps working |
has(String code), require(String code) |
whether the licence grants an entitlement, offline; require throws MissingEntitlement |
use(String meter, [int n = 1]) |
UseSpent(left, offline) or UseRefused(UseError, left); never throws |
pickUp(String email) |
PickUpCodeSent or PickUpSendFailed: a code mailed to the address a key was sold to |
confirmPickUp(String email, String code) |
PickUpKeysFound(keys) or PickUpKeysFailed: the address's keys of the product |
restoreByEmail(String email, String code) |
Restored or RestoreFailed(RestoreError): the code confirmed and the key that fits this build activated |
upgradeUrl(String paymentLink) |
the payment link with this device's activation id as client_reference_id (never the key), or null with no activation; with Paddle, your own checkout page, which gives client_reference_id to Paddle.js as custom data |
Every error is an enum whose wire member is the word every KeyGrant SDK uses
(LicenseError.invalidKey.wire == 'invalid-key').
deactivate() drops the local licence whatever the server says, and answers released: true
only when KeyGrant confirmed the release (a 200), or when this device held no seat. released: false means the seat may still be held: say so rather than tell the customer it is free. A state
copied from another device (its lease held to another machine) is only dropped here, and nothing is
asked. deactivate(), importLicenseFile() and removeLicenseFile() throw LicenseCallUnfinished
behind a call on this licence that has not finished, and the storage's error when the state cannot
be saved (the licence is kept then: try again).
LicenseConfig
| Field | Default | |
|---|---|---|
product |
the product slug | |
apiBaseUrl |
https://api.keygrant.dev |
|
publicJwks / publicJwk |
the product's key set (give one of the two); parseJwkSet(text) reads /pubkey's answer, a list of JWKs or one JWK |
|
major |
1 |
the running app's major version; a 0.x build sets 0 and runs as major 1 |
deviceName |
null | a label for the device list (cut to 120 UTF-16 units) |
deviceHold |
true |
false on pooled, non-persistent machines: no lease is then held to its device |
httpTimeout |
15 s | how long any one request may take; the SDK holds every request to it itself |
revokedLicenseFiles |
[] |
the ids of licence files this build refuses, matched exactly |
adapters |
defaults | LicenseAdapters(storage:, registry:, clock:, fingerprinter:, http:), any null for the default |
Statuses
state |
reason |
What to show |
|---|---|---|
licensed |
the app. test: a test licence; entitlements: what it grants; meters: what it counts. With source LicenseSource.file, a stored offline licence file licenses it: show licensedTo |
|
trial |
the app; trialDaysLeft, trialEndsAt (ms), entitlements |
|
expired |
offline |
the lease ran out and the server could not be reached: "connect to renew" |
expired |
trial |
the trial is over: buy, or restore a purchase |
invalid |
revoked |
the key was revoked or has expired, or this device was released |
invalid |
upgrade |
this build is newer than the key covers: buy an upgrade (upgradeUrl) |
invalid |
outdated |
this build is older than the key covers: update the app |
invalid |
device |
the licence was activated on another device: activate again here |
invalid |
tampered |
the stored lease is not this licence's: activate again |
invalid |
unknownKey |
signed by a key this build does not carry: online the server renews it with the key it signs with now; if it stays so, update the app |
invalid |
revokedFile |
the stored offline licence file is one this build lists as revoked |
unlicensed |
nothing held: activate, start a trial or restore | |
unknown |
storage |
the state could not be read just now: keep running and ask again shortly; never show the activation screen for it |
stalled: an earlier call on this licence has not finished, so the answer comes from what is
stored, with no ask and no write. LicenseStatus compares by content.
Restoring a purchase in an App Store build
A build that may not take a typed key (an App Store build, under Apple's guideline 3.1.1) restores a web buyer's licence by the address the key was sold to. Turn on the product's Key pickup in the dashboard, then:
// 1. The customer types the address they bought with.
final sent = await license.pickUp(email);
if (sent case PickUpSendFailed(:final error, :final retryAfter)) {
return showError(error, retryAfter); // invalidEmail, tooManyAttempts, pickupDisabled, tryAgain, network
}
// The answer is the same whether or not the address holds a key: say "if that address bought
// a key, a code is on its way".
// 2. They type the six-digit code that came.
final restored = await license.restoreByEmail(email, code);
switch (restored) {
case Restored():
await license.status(); // licensed
case RestoreFailed(:final error):
showError(error); // wrongCode, codeExpired (ask for a new code), noKey, upgrade, outdated, limit, ...
}
restoreByEmail activates, on this device, the key of that address that fits this build: the one
this device already holds a seat on (a reinstall takes its seat back), else one with a free seat,
else the first. The key is never shown to the customer or handed to the app. A user-locked key
activates with the address, and a key locked by code with the code the pickup proved. To let the
customer choose among several keys, call confirmPickUp(email, code), show each PickedKey by its
modelName, and activate the one picked with activate(picked.key, email: email, code: picked.activationCode). The keys a confirm answered are kept in memory for ten minutes (never
stored), so a restore with the same code right after asks nothing more, and a restore whose
activation got no answer can be retried with the same code.
Fingerprints: what a device is
A lease activated on one device is held to it, so a state file copied to another does not license it. The device is read once per run and kept for the process:
- Windows:
MachineGuidunderHKLM\SOFTWARE\Microsoft\Cryptography, read through the registry API in the 64-bit view. - macOS:
IOPlatformUUID, read through IOKit by the plugin (so a sandboxed Mac App Store build reads it too), else from/usr/sbin/ioreg. - Linux:
/etc/machine-id, else/var/lib/dbus/machine-id; an id on a filesystem in memory, or written at this boot, is none. - On those three, a machine with no id falls back to its host name, and the host name's fingerprint is always offered beside the machine id's, so every KeyGrant SDK on a machine reads the same device.
- iOS and iPadOS: an install id the plugin keeps in the Keychain (service
dev.keygrant.install, this device only, never synced), seeded fromidentifierForVendorthe first time. It survives the app being deleted and installed again. A Keychain that cannot be read (a device locked since it started) gives no fingerprint that time, never another id. - Android:
Settings.Secure.ANDROID_ID, which survives a reinstall and changes with a factory reset. A device with none (or the one value some old builds share) gets an install id of the SDK's own in the app's private storage, and its seats are never held to the device. - A phone has no host name worth the name, so on iOS and Android there is no host-name fallback or alternate. A read that fails or is slow gives no fingerprint this time, and a lease is then not judged by its device: the customer keeps running.
A trial's start is also kept in the iOS Keychain (service dev.keygrant.trial), so deleting the app
does not restart a trial. Android keeps no such thing past the app.
Backups: Android's Auto Backup (on by default) and an iOS backup restore the app's files, the KeyGrant
state file among them. Restored onto the same device, nothing changes. Restored onto a new one, the lease
is held to the old device and reads invalid / device until the customer activates again
(restoreByEmail in an App Store build). To skip that, leave KeyGrant/ out of the app's backup (Android's
dataExtractionRules / fullBackupContent, iOS's isExcludedFromBackup).
Entitlements and metered uses
if (await license.has('export-4k')) enable4k(); // a flag on, a number not 0, a text not empty
await license.require('cloud'); // throws MissingEntitlement('cloud') when not granted
final spent = await license.use('exports'); // before the work it counts
switch (spent) {
case UseSpent(:final left, :final offline):
doExport(); // `left` net of this device's unsynced spends; `offline` when it came from the allowance
case UseRefused(:final error, :final left):
explain(error, left); // usedUp, offlineLimit, noMeter, notLicensed, storage, ...
}
has and require read the stored licence offline, as status() judges it, with no ask and no
write. use is exact online; with no answer it spends from the device's offline allowance for the
meter and reports the spend with the next request. Two calls are two spends.
Licence files
importLicenseFile(text) (or importLicenseFileBytes(bytes)) takes a .kglicense file
(licenseFileExtension) the customer drops onto the app or opens from its licence screen, verified
offline. An offline licence file is stored and licenses the app for good with no network; a key
file's key is activated as a typed key is. List a leaked file's id in revokedLicenseFiles in an
update to stop it.
Storage
The state is one JSON file per product, written atomically (a temp file, flushed, renamed):
- Windows:
%LOCALAPPDATA%\KeyGrant\<product>.json - macOS:
~/Library/Application Support/KeyGrant/<product>.json(inside the app's container when sandboxed) - Linux:
$XDG_CONFIG_HOME/keygrant/<product>.json, else~/.config/keygrant/<product>.json - iOS and Android: the app's support directory (
getApplicationSupportDirectory()) +KeyGrant/<product>.json
The file is portable between KeyGrant SDKs. Give LicenseAdapters(storage: ...) to keep it elsewhere.
License
MIT
Libraries
- keygrant
- KeyGrant licensing for Flutter apps: offline-verified leases, trials, licence files, metered uses and key pickup, on iOS, Android, Windows, macOS and Linux.