.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.Cryptography only, which it uses for Ed25519.

Release notes

Add it

KeyGrant.Licensing is on NuGet.

Terminal
dotnet add package KeyGrant.Licensing

Use it

C#
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 except UpgradeUrlAsync takes an optional CancellationToken.
  • Create one License per process. Calls on it run one at a time, except HasAsync, RequireAsync, UpgradeUrlAsync and the two PickUpAsync calls, which only read and run beside any call.

LicenseConfig

Product

Required. The product's API ID.

ApiBaseUrl

Required. Set it to https://api.keygrant.dev.

PublicJwks

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

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

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.

Default 1

DeviceName

What the license's device list calls this machine.

DeviceHold

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

Default true

HttpTimeout

A TimeSpan: how long one request may take.

Range greater than zero, at most one day ; Default 15 seconds

RevokedLicenseFiles

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 License is made, and a null among them throws ArgumentException.

Since 0.5.0

Adapters

A LicenseAdapters with any of Storage, Registry, Clock, Fingerprint and Http.

Calls

ActivateAsync(key)

Activates the key on this device. Resolves an ActivateResult with Ok, and with Error when Ok is false.

ActivateAsync(key, options)

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

Since 0.6.0

HasAsync(code)

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

  • EntitlementGrants.Grants(status.Entitlements, code) answers the same of a status already in hand.
RequireAsync(code)

Throws MissingEntitlementException, whose Entitlement is the code, when the license does not grant it.

ImportLicenseFileAsync(content)

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.

Since 0.5.0

RemoveLicenseFileAsync()

Drops the stored offline license file.

Since 0.5.0

UseAsync(meter, n = 1)

Resolves a UseResult: Ok with Left, or Error (Metered uses).

Since 0.7.0

PickUpAsync(email)

Emails a six-digit code to the address, for key pickup. Resolves a PickUpSentResult.

Since 0.8.0

PickUpAsync(email, code)

Resolves a PickUpKeysResult with the address's keys.

Since 0.8.0

RestoreByEmailAsync(email, code)

Confirms the code and activates the key that fits this build. Resolves a RestoreResult.

Since 0.8.0

StatusAsync(forceOnline = false)

Resolves a LicenseStatus (below). RefreshAsync() and StartTrialAsync() resolve one too.

DeactivateAsync()

Resolves a DeactivateResult with Released.

UpgradeUrlAsync(paymentLinkUrl)

Resolves the link, or null when no key and activation are stored. See Sell an upgrade.

  • Otherwise it throws ArgumentException for a link that is not an absolute URL.

LicenseStatus

StatusAsync, RefreshAsync and StartTrialAsync resolve it. The activation errors are LicenseError members (LicenseError.InvalidKey).

State, Reason

The states and reasons, as enum members: LicenseState.Licensed, LicenseReason.Upgrade, LicenseReason.RevokedFile.

Key

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

TrialDaysLeft, TrialEndsAt

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

Test

true for a test license.

Entitlements

Never null: empty when the lease grants nothing. Each value has Flag, Number or Text set.

Meters

Never null either: 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, LicensedTo

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

Exceptions

ArgumentException

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.

ArgumentOutOfRangeException

new License(config), when HttpTimeout is out of range.

IOException, UnauthorizedAccessException

Activation and deactivation, when the license file cannot be read or saved: the file system's exception, UnauthorizedAccessException when access is denied.

InvalidOperationException

DeactivateAsync, while an earlier call is stuck. ImportLicenseFileAsync and RemoveLicenseFileAsync, while an earlier call has not finished.

MissingEntitlementException

RequireAsync(code), when the license does not grant code.

OperationCanceledException

A call whose CancellationToken is canceled in time:

  • ActivateAsync, RestoreByEmailAsync, ImportLicenseFileAsync, DeactivateAsync and the two PickUpAsync calls: before the request is answered.
  • StatusAsync, RefreshAsync, StartTrialAsync and RemoveLicenseFileAsync: 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.

C#
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 key LicenseError.UserEmailRequired.
  • ImportLicenseFileAsync(content, options) passes them on to a key file's key.
LicenseError.UserEmailRequired

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.

LicenseError.UserEmailMismatch

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.

LicenseError.UserCodeRequired

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.

LicenseError.UserCodeInvalid

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.

LicenseError.TooManyCodes

Three codes have been emailed for this key this hour. Use the last one, or wait.

LicenseError.TooManyAttempts

Too many wrong emails or codes for this key this hour. Try again later.

LicenseError.TryAgain

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.

C#
// 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 of PickUpKeysResult.Keys by its ModelName, and activate the one chosen with ActivateAsync(picked.Key, new ActivateOptions { Email = email, Code = picked.ActivationCode }).
  • A RestoreResult holds a refusal in PickupError, NoKey or Error.
  • 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.Extension is .kglicense, for an open dialog's filter or a drop handler's test.
  • LicenseFileFormat.MaxBytes is 16,384, the most a file may be, and LicenseFileFormat.MediaType is application/vnd.keygrant.license.
  • List the ids of leaked files in RevokedLicenseFiles (Blocking a leaked file).
C#
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:

C#
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 its Kind (LicenseFileKind.Key or LicenseFileKind.Offline) and LicensedTo (a record with Name and Email);
  • Error, a LicenseFileError below, with nothing stored;
  • ActivateError, for a key file whose key did not activate: what ActivateAsync answers.
LicenseFileError.Malformed

Not a license file's text, or its token or claims are broken, or it is more than LicenseFileFormat.MaxBytes.

LicenseFileError.NotALicenseFile

A signed token, but not a license file (a lease, say).

LicenseFileError.BadSignature

Not signed by the key it names.

LicenseFileError.UnknownKey

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.

LicenseFileError.WrongProduct

Another product's file.

LicenseFileError.RevokedFile

The file's id is listed in RevokedLicenseFiles.

LicenseFileError.Upgrade

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

LicenseFileError.Outdated

An offline file that does not cover this build: this build is older than the file covers.

  • It throws InvalidOperationException when an earlier call has not finished, and, as ActivateAsync does, when the state cannot be read or the file cannot be saved.
  • RemoveLicenseFileAsync throws InvalidOperationException in the same case, and, as DeactivateAsync does, 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 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 UseAsync(meter, n), before the work they count, and does the work only when the spend is Ok. Since 0.7.0.

C#
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.Offline is true when the device spent from its offline allowance.
  • n is a long. 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.
  • A request waits at most 5 seconds for KeyGrant, less when HttpTimeout is 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

UseError.UsedUp

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.

UseError.OfflineLimit

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.

UseError.NoMeter

The key has no meter with that code, or the code is empty or longer than 32 UTF-16 units.

UseError.NotLicensed

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.

UseError.Revoked, Upgrade, Outdated, Device

KeyGrant's verdict on the key, this build or this device, as StatusAsync says it.

UseError.Storage

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.

UseError.InvalidAmount

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:

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

C#
// 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\Software\KeyGrant\<product>. It is Windows-only, so guard it with OperatingSystem.IsWindows():

C#
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,});