.NET
The reference for KeyGrant.Licensing 0.8.0, the KeyGrant SDK for .NET, in the
KeyGrant.Licensing namespace. The rules every SDK shares are in
How every SDK behaves.
- .NET 8 or later: it targets
net8.0. - Runs on Windows, macOS and Linux.
- Depends on
BouncyCastle.Cryptographyonly, which it uses for Ed25519.
Add it
KeyGrant.Licensing is on NuGet.
dotnet add package KeyGrant.Licensing Use it
using KeyGrant.Licensing;
var license = new License(new LicenseConfig{ Product = "my-product", ApiBaseUrl = "https://api.keygrant.dev", PublicJwks = PublicJwk.ParseSet("""{"keys":[...]}"""), // the keys from /pubkey Major = 2,});
LicenseStatus status = await license.StatusAsync();if (status.State is not (LicenseState.Licensed or LicenseState.Trial or LicenseState.Unknown)){ ActivateResult result = await license.ActivateAsync(enteredKey); if (!result.Ok) ShowError(result.Error);} - Every call returns a
Task, and every call exceptUpgradeUrlAsynctakes an optionalCancellationToken. - Create one
Licenseper process. Calls on it run one at a time, exceptHasAsync,RequireAsync,UpgradeUrlAsyncand the twoPickUpAsynccalls, which only read and run beside any call.
LicenseConfig
Required. The product's API ID.
Required. Set it to https://api.keygrant.dev.
The product's public keys. PublicJwk.ParseSet(json) reads the {"keys": [...]} that /pubkey answers and the dashboard's Copy key set copies. Give this or PublicJwk.
- A lease is verified by the key it names, or, naming none, by each key in turn.
One public key, a key set of one: assign a JWK JSON string, which converts to it, or set new PublicJwk { X = "..." }. Give this or PublicJwks.
- A JWK that includes the private part (
d) is rejected.
This build's major version. 0, or anything below it, 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.
A TimeSpan: 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.
- The list is copied when the
Licenseis made, and a null among them throwsArgumentException.
A LicenseAdapters with any of Storage, Registry, Clock, Fingerprint and Http.
Calls
Activates the key on this device. Resolves an ActivateResult with Ok, and with Error when Ok is false.
The same, with an ActivateOptions, for a key only its buyer can activate (below).
Resolves whether the license grants the entitlement code, offline from the stored lease. See Check an entitlement.
EntitlementGrants.answers the same of a status already in hand.Grants( status.Entitlements, code)
Throws MissingEntitlementException, whose Entitlement is the code, when the license does not grant it.
Takes a license file's bytes, or its text in the string overload, and resolves an ImportLicenseFileResult (below). Each overload has a twin that also takes an ActivateOptions.
Drops the stored offline license file.
Resolves a UseResult: Ok with Left, or Error (Metered uses).
Emails a six-digit code to the address, for key pickup. Resolves a PickUpSentResult.
Resolves a PickUpKeysResult with the address's keys.
Confirms the code and activates the key that fits this build. Resolves a RestoreResult.
Resolves a LicenseStatus (below). RefreshAsync() and StartTrialAsync() resolve one too.
Resolves a DeactivateResult with Released.
Resolves the link, or null when no key and activation are stored. See Sell an upgrade.
- Otherwise it throws
ArgumentExceptionfor a link that is not an absolute URL.
LicenseStatus
StatusAsync, RefreshAsync and StartTrialAsync resolve it. The
activation errors are
LicenseError members (LicenseError.InvalidKey).
The states and reasons, as enum members: LicenseState.Licensed, LicenseReason.Upgrade, LicenseReason.RevokedFile.
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.
Never null: empty when the lease grants nothing. Each value has Flag, Number or Text set.
Never null either: 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); null on every status not from a license file.
Exceptions
new License(config), when the configuration is incomplete, gives both PublicJwks and PublicJwk, holds a key that is not an Ed25519 public JWK, or has a null among its RevokedLicenseFiles. Also UpgradeUrlAsync, for a link that is not an absolute URL, when an activation is stored.
new License(config), when HttpTimeout is out of range.
Activation and deactivation, when the license file cannot be read or saved: the file system's exception, UnauthorizedAccessException when access is denied.
DeactivateAsync, while an earlier call is stuck. ImportLicenseFileAsync and RemoveLicenseFileAsync, while an earlier call has not finished.
RequireAsync(code), when the license does not grant code.
A call whose CancellationToken is canceled in time:
ActivateAsync,RestoreByEmailAsync,ImportLicenseFileAsync,DeactivateAsyncand the twoPickUpAsynccalls: before the request is answered.StatusAsync,RefreshAsync,StartTrialAsyncandRemoveLicenseFileAsync: only while the call waits its turn. Once a status call runs, it answers from what the device holds.UseAsync: below.
Keys only their buyer can activate
When a key is locked to its buyer (Who can activate), pass what
the customer typed to ActivateAsync(key, options), as an ActivateOptions with
Email and Code, each optional. The steps and rules are in
How every SDK behaves. Since 0.6.0.
ActivateResult result = await license.ActivateAsync(key, new ActivateOptions { Email = emailBox.Text, Code = codeBox.Text });switch (result.Error){ case LicenseError.UserEmailRequired or LicenseError.UserEmailMismatch: AskForTheAddress(); // then activate again with it break; case LicenseError.UserCodeRequired or LicenseError.UserCodeInvalid: AskForTheCode(); // a code was just mailed to the address break; case LicenseError.TooManyCodes or LicenseError.TooManyAttempts: AskToTryLater(); break; case LicenseError.TryAgain: AskToRetry(); // nothing was activated break;} ActivateAsync(key)sends neither, and answers a locked keyLicenseError.UserEmailRequired.ImportLicenseFileAsync(content, options)passes them on to a key file's key.
The key is locked to its buyer and this device is new: ask for the email the key was sold to and call ActivateAsync(key, options) with it.
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.
The email was right and KeyGrant has just emailed a code to it: ask for the code and call ActivateAsync(key, options) with the email and the code.
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 ActivateAsync(key, options) with the email alone for a new code.
Three codes have been emailed for this key this hour. Use the last one, or wait.
Too many wrong emails or codes for this key this hour. Try again later.
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: PickUpAsync(email) emails the customer
a six-digit code, and RestoreByEmailAsync(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).await license.PickUpAsync(emailBox.Text);
// 2. The customer types the code: restore.RestoreResult restored = await license.RestoreByEmailAsync(emailBox.Text, codeBox.Text);if (!restored.Ok){ if (restored.PickupError == PickupError.WrongCode) AskAgain(); if (restored.PickupError == PickupError.CodeExpired) await license.PickUpAsync(emailBox.Text); // a new code if (restored.NoKey) ShowNoKeyUnderThatAddress();} -
To let the customer choose, call
PickUpAsync(email, code), show each ofPickUpKeysResult.Keysby itsModelName, and activate the one chosen withActivateAsync(picked.Key, new ActivateOptions { Email = email, Code = picked.ActivationCode }). - A
RestoreResultholds a refusal inPickupError,NoKeyorError. - 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. Since 0.5.0.
Warning
Keep every key that signed a file in PublicJwks. A build without it reads such a file as
Invalid with reason UnknownKey.
LicenseFileFormat.Extensionis.kglicense, for an open dialog's filter or a drop handler's test.LicenseFileFormat.MaxBytesis 16,384, the most a file may be, andLicenseFileFormat.MediaTypeisapplication/vnd.keygrant.license.- List the ids of leaked files in
RevokedLicenseFiles(Blocking a leaked file).
var license = new License(new LicenseConfig{ Product = "sluice", ApiBaseUrl = "https://api.keygrant.dev", PublicJwks = PublicJwk.ParseSet(KeygrantKeys), Major = 3, RevokedLicenseFiles = [], // ids of files that leaked: an update adds them here}); ImportLicenseFileAsync takes the file's content, never its path. In WPF
(AllowDrop="True" on the window), a dropped file and an "Open license file..." command look like
this:
private async void Window_Drop(object sender, DragEventArgs e){ if (e.Data.GetData(DataFormats.FileDrop) is not string[] paths) return; var path = paths.FirstOrDefault(p => p.EndsWith(LicenseFileFormat.Extension, StringComparison.OrdinalIgnoreCase)); if (path is not null) await ImportAsync(path);}
private async void OpenLicenseFile_Click(object sender, RoutedEventArgs e){ var dialog = new Microsoft.Win32.OpenFileDialog { Filter = $"License file (*{LicenseFileFormat.Extension})|*{LicenseFileFormat.Extension}" }; if (dialog.ShowDialog(this) == true) await ImportAsync(dialog.FileName);}
private async Task ImportAsync(string path){ try { // Never more than a license file can be: a larger one is refused unread. if (new FileInfo(path).Length > LicenseFileFormat.MaxBytes) { ShowError(LicenseFileError.Malformed); return; } var result = await license.ImportLicenseFileAsync(await File.ReadAllBytesAsync(path)); if (result.Ok) ShowThanks(result.LicensedTo!.Name ?? result.LicensedTo.Email); else if (result.Error is { } refused) ShowError(refused); // the file was not taken else ShowActivationError(result.ActivateError!.Value); // a key file's key did not activate } catch (Exception) { // An async void handler must catch everything, or the app ends: the file unreadable, the // state not saved, or a license call still running. Try again. ShowRetry(); }}
In WinForms, set AllowDrop = true, set e.Effect = DragDropEffects.Copy in
DragEnter, and read e.Data.GetData(DataFormats.FileDrop) in DragDrop.
What ImportLicenseFileAsync answers
It verifies the file offline (Importing a file). The
ImportLicenseFileResult has one of:
Ok, with itsKind(LicenseFileKind.KeyorLicenseFileKind.Offline) andLicensedTo(a record withNameandEmail);Error, aLicenseFileErrorbelow, with nothing stored;ActivateError, for a key file whose key did not activate: whatActivateAsyncanswers.
Not a license file's text, or its token or claims are broken, or it is more than LicenseFileFormat.MaxBytes.
A signed token, but not a license file (a lease, say).
Not signed by the key it names.
Signed by a key this build does not carry (PublicJwks): 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.
Another product's file.
The file's id is listed in RevokedLicenseFiles.
An offline file that does not cover this build: this build is newer than the file covers.
An offline file that does not cover this build: this build is older than the file covers.
- It throws
InvalidOperationExceptionwhen an earlier call has not finished, and, asActivateAsyncdoes, when the state cannot be read or the file cannot be saved. RemoveLicenseFileAsyncthrowsInvalidOperationExceptionin the same case, and, asDeactivateAsyncdoes, when the change cannot be saved.
The status of a stored file
A stored offline file answers every StatusAsync() with Licensed,
Source = LicenseSource.File, the Key, LicensedTo and the file's
Entitlements, and never asks KeyGrant, RefreshAsync() included.
- Show "Licensed to Ada Lovelace" from
LicensedTo. - A status from a file has no seat to deactivate: offer
RemoveLicenseFileAsync()instead.DeactivateAsync()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 UseAsync(meter, n), before the work they count, and does the work only
when the spend is Ok. Since 0.7.0.
UseResult spent = await license.UseAsync("exports"); // UseAsync("exports", 40) spends forty at once, whole or noneif (spent.Ok){ RunExport(); // spent.Left: what is left now, net of this device's unsynced spends}else{ switch (spent.Error) { case UseError.UsedUp: OfferMoreExports(spent.Left); // the server's word: fewer left than asked (null when it gave none) break; case UseError.OfflineLimit: AskToConnect(); // KeyGrant could not be reached, and this is past what this device may spend without it break; default: ShowMeterProblem(spent.Error); // NoMeter, NotLicensed, Revoked, Device, Upgrade, Outdated, Storage, InvalidAmount break; }}
LicenseStatus status = await license.StatusAsync(); // offline, from the leasestatus.Meters["exports"]; // MeterStatus { Left = 950, Total = 1000, Offline = 100, Unsynced = 0, Resets = false } UseResult.Offlineis true when the device spent from its offline allowance.nis along. 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.- A request waits at most 5 seconds for KeyGrant, less when
HttpTimeoutis shorter. DeactivateAsync()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.
It does not throw for an outcome: every one is in the UseResult. The one exception is a CancellationToken:
- Canceled while the call waits its turn, or before its request is sent: it throws
OperationCanceledException, and nothing is spent. - Canceled while the request is out: the call stops waiting, spends as for no answer, and returns its result, so read it even after canceling.
When UseAsync does not spend
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.
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.
The key has no meter with that code, or the code is empty or longer than 32 UTF-16 units.
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 StatusAsync says it.
The stored license (the state file) could not be read just now, or an adapter threw where there is no word for it. Try again shortly.
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 dictionary 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.OpenInBrowser("https://buy.stripe.com/your-payment-link");
// The customer types the key from the delivery email:ActivateResult result = await license.ActivateAsync(enteredKey);if (result.Ok) ShowThanks(); The checkout creates the key and delivers it, and the customer types it into the app. See Buying from the trial.
When StatusAsync resolves a trial, the trial reports its launch to KeyGrant at most once a day,
on a thread-pool thread, so the call never waits for it. The app need not call StartTrialAsync
on each launch for Analytics to count the trial as come back
(Device trials).
Keeping a trial through a reinstall
The default registry stash stores nothing. On Windows, WindowsRegistryStash stores the trial's
start date under HKCU\. It is Windows-only,
so guard it with OperatingSystem.IsWindows():
var license = new License(new LicenseConfig{ Product = "my-product", ApiBaseUrl = "https://api.keygrant.dev", PublicJwks = publicJwks, Adapters = OperatingSystem.IsWindows() ? new LicenseAdapters { Registry = new WindowsRegistryStash("my-product") } : null,});