KeyGrant.Licensing
0.8.1
dotnet add package KeyGrant.Licensing --version 0.8.1
NuGet\Install-Package KeyGrant.Licensing -Version 0.8.1
<PackageReference Include="KeyGrant.Licensing" Version="0.8.1" />
<PackageVersion Include="KeyGrant.Licensing" Version="0.8.1" />
<PackageReference Include="KeyGrant.Licensing" />
paket add KeyGrant.Licensing --version 0.8.1
#r "nuget: KeyGrant.Licensing, 0.8.1"
#:package KeyGrant.Licensing@0.8.1
#addin nuget:?package=KeyGrant.Licensing&version=0.8.1
#tool nuget:?package=KeyGrant.Licensing&version=0.8.1
KeyGrant.Licensing (.NET)
KeyGrant licensing for .NET desktop apps (WPF, WinForms, Avalonia, MAUI, console): a licence is a signed lease (a JWT, EdDSA / Ed25519) the app verifies offline against the product's public key. The app only talks to KeyGrant to activate, to renew a lease near or past its end, to report a newer major version, and to report a running trial's launch (at most once a day, in the background).
The rule every line serves: licensing failure fails in favour of the paying customer. No network, an outage, a timeout, a 5xx, a 429, an unrecognised 4xx, an answer that cannot be used, or a licence file that cannot be read just now never takes a licence away while the device holds a valid lease. Only the server's explicit verdicts (revoked, upgrade or update required, another device) and the lease's own end do.
This is a port of the Electron SDK (@keygrant/electron), which is the reference: it behaves
the same, reads and writes the same state file, computes the same fingerprints, and its test suite
reproduces every answer in conformance/vectors.json. See Parity with the reference.
Install
dotnet add package KeyGrant.Licensing
Targets net8.0 (runs on .NET 8 and later, on Windows, macOS and Linux). One dependency:
BouncyCastle.Cryptography (MIT), for Ed25519, which .NET 8 does not have.
Quick start
Create one License per app process and share it (calls on it run one at a time).
using KeyGrant.Licensing;
var license = new License(new LicenseConfig
{
Product = "sluice", // the product slug in KeyGrant
ApiBaseUrl = "https://api.keygrant.dev",
PublicJwks = PublicJwk.ParseSet(KeyGrantKeys.Sluice), // the product's PUBLIC keys, embedded (below)
Major = 3, // this build's major version: set it (a 0.x build is 1)
DeviceName = Environment.MachineName, // optional: a label in the customer's device list
});
// At launch: offline-first, never throws for storage.
LicenseStatus status = await license.StatusAsync();
// The customer pastes a key:
ActivateResult result = await license.ActivateAsync(keyTextBox.Text);
if (!result.Ok) ShowError(result.Error); // LicenseError.InvalidKey, Limit, Revoked, Network, ...
// "I've paid" / "Try again" on a lockout screen: ask the server now.
status = await license.RefreshAsync();
// Free this device's seat (the local licence is dropped either way):
DeactivateResult released = await license.DeactivateAsync();
if (!released.Released) ShowSeatMayStillBeHeld(); // KeyGrant did not confirm it: say so, never "the seat is free"
// A trial, begun once (StatusAsync reports a running trial's launch by itself):
status = await license.StartTrialAsync(); // State Trial, TrialDaysLeft, TrialEndsAt
Console
var status = await license.StatusAsync();
switch (status.State)
{
case LicenseState.Licensed:
case LicenseState.Trial:
case LicenseState.Unknown: // the state file is busy just now: keep running
RunTheApp();
break;
default:
Console.WriteLine($"Not licensed ({status.State}, {status.Reason}). Enter a key:");
var result = await license.ActivateAsync(Console.ReadLine() ?? "");
Console.WriteLine(result.Ok ? "Activated." : $"Could not activate: {result.Error}");
break;
}
WPF
public partial class App : Application
{
public static License License { get; } = new(new LicenseConfig
{
Product = "sluice",
ApiBaseUrl = "https://api.keygrant.dev",
PublicJwks = PublicJwk.ParseSet(KeyGrantKeys.Sluice),
Major = 3,
Adapters = new LicenseAdapters { Registry = new WindowsRegistryStash("sluice") }, // optional
});
protected override async void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
var status = await License.StatusAsync(); // safe to await on the UI thread
Window window = status.State is LicenseState.Licensed or LicenseState.Trial or LicenseState.Unknown
? new MainWindow()
: new ActivationWindow(status);
window.Show();
}
}
WinForms
internal static class Program
{
public static readonly License License = new(new LicenseConfig { /* as above */ });
[STAThread]
private static void Main()
{
ApplicationConfiguration.Initialize();
Application.Run(new MainForm());
}
}
public partial class MainForm : Form
{
protected override async void OnLoad(EventArgs e)
{
base.OnLoad(e);
var status = await Program.License.StatusAsync();
if (status.State is not (LicenseState.Licensed or LicenseState.Trial or LicenseState.Unknown))
{
using var dialog = new ActivationDialog(Program.License, status);
dialog.ShowDialog(this);
}
}
}
The SDK's own code never captures a synchronization context (ConfigureAwait(false) throughout), so
it does not deadlock a UI thread that blocks on it; awaiting it is still the right way.
Configuration
LicenseConfig |
Meaning |
|---|---|
Product (required) |
The product slug, matching the KeyGrant product. |
ApiBaseUrl (required) |
The API base, e.g. https://api.keygrant.dev (one trailing slash is dropped). |
PublicJwks |
The product's Ed25519 public keys as JWKs: the key that signs its leases, and the next one once the product has one staged (below). PublicJwk.ParseSet(json) reads a JWK set. Give this or PublicJwk. |
PublicJwk |
One public key, a key set of one: a JSON string converts implicitly, or new PublicJwk { X = "..." }. Give this or PublicJwks. |
Major |
This build's major version (default 1). Keys are bound to, and cover, majors: set it. Read as KeyGrant's server reads a version: a 0.x build (Major = 0) is major 1, and so is any value below 1. |
DeviceName |
A label for this device in the customer's device list; cut to 120 characters. |
DeviceHold |
Whether a licence is held to the device it was activated on (default true). See below. |
HttpTimeout |
How long a request may take before it is abandoned (default 15 s; at most a day). The SDK keeps it whatever the HTTP adapter does. An adapter's token is cancelled only when the SDK gives up on its call (its bound passed, or the caller stopped waiting), never after it answered: an answer stands, whatever the token's callbacks do. |
RevokedLicenseFiles |
The ids of licence files this build refuses (an offline file that leaked), matched exactly; copied when the License is made. A null among them throws ArgumentException. |
Adapters |
Overrides for Storage, Registry, Clock, Fingerprint and Http; any left null uses the default. |
TimeProvider |
What the SDK's timers run on (default TimeProvider.System); for tests. |
The configuration is checked when the License is created: an empty product or API, a bound that is
not positive, no public key, an empty key set, both PublicJwks and PublicJwk, or a public key that is
not an Ed25519 public JWK throws ArgumentException.
Trials: what the app calls
var status = await license.StatusAsync();
if (status.State == LicenseState.Unlicensed) status = await license.StartTrialAsync(); // begins the trial, once
A running trial reports its launch to KeyGrant by itself: StatusAsync() sends it at most once a day,
on a thread-pool thread, answering without waiting for the request and never failing offline (it
saves the day's stamp first), so the dashboard's trial funnel sees the trial come back. The app does not need to call StartTrialAsync() on every launch for
that. StartTrialAsync() still re-syncs the trial when called: a change to the trial's length or
entitlements reaches a running trial there.
Licence files: drag and drop
A licence can come as a file (.kglicense) the customer drops onto the app or opens from its licence
screen. A key file carries their key: importing it activates the key online, exactly as typing it
would. An offline licence file licenses the app with no network at all, for good, on machines that
never connect; it has no seat limit and cannot be revoked, so the only way to stop one that leaked is an
app update that lists its id:
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
});
In WPF (AllowDrop="True" on the window), a dropped file and an "Open licence file..." command:
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 licence 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 licence call still running. Try again.
ShowRetry();
}
}
WinForms is the same with AllowDrop = true, DragEnter setting e.Effect = DragDropEffects.Copy, and
DragDrop reading e.Data.GetData(DataFormats.FileDrop).
ImportLicenseFileAsync takes the file's content (bytes, or its text in the string overload), never its
path, and verifies it offline: signed by a key of PublicJwks, for this product, and, for an offline
file, covering this build's major. It answers an ImportLicenseFileResult: Ok with its Kind
(Key or Offline) and LicensedTo; or Error, a LicenseFileError (Malformed,
NotALicenseFile, BadSignature, UnknownKey, WrongProduct, RevokedFile, Upgrade,
Outdated), nothing stored; or, for a key file whose key did not activate, ActivateError, what
ActivateAsync answers. It throws InvalidOperationException when a licence call before it has not
finished, and, as ActivateAsync does, when the state cannot be read or the file cannot be saved.
UnknownKey means the file was signed by a key this build does not carry: 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.
An offline file is kept and checked again on every StatusAsync(), which then answers Licensed with
Source = LicenseSource.File, the Key, LicensedTo and the file's Entitlements, never asking
the server, 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). If the device also holds a key, a file that covers this build wins, and the key keeps
working once the file is removed, revoked or outgrown. A file that does not cover this build answers
only when no key and no running trial does.
Keep every key that signed a file in your builds. A file names the key that signed it and never
expires, so a key that has signed one stays in the product's key set, and in PublicJwks, after it
stops signing leases. A build without it reads such a file as Invalid / UnknownKey.
User-locked keys
A licence model can let only the person a key was sold to activate a NEW device on it: the activation
names the address on the key and, for a key that mails a code to that address, the code too. Only a new
device is ever asked; StatusAsync(), RefreshAsync(), a renewal, a seat the key already holds and
everything offline are as they were, so an app that is activated keeps running through a KeyGrant or mail
outage.
ActivateAsync(key, options) carries what the person typed (ActivateOptions; each member is optional,
and neither is ever stored):
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;
}
LicenseError |
Means | The app |
|---|---|---|
UserEmailRequired |
the key is locked and no address came | ask for the address |
UserEmailMismatch |
not the key's address (nothing is said of the real one) | ask again |
UserCodeRequired |
the address was right and a code was just mailed to it | ask for the code |
UserCodeInvalid |
the code is wrong, expired (10 minutes) or used, or the key's codes are void for the hour after five wrong ones | ask again, or activate with the address alone for a new code |
TooManyCodes |
three codes were mailed for the key this hour | wait, or use the code sent |
TooManyAttempts |
ten wrong addresses this hour, or the key's codes void | try again later |
TryAgain |
the code could not be mailed: nothing was activated | retry; the one error here that is nobody's mistake |
The address is trimmed (it is compared without regard to case) and a blank is none. The code has its ends
trimmed and its spaces dropped (123 456 is 123456), and must be exactly six ASCII digits. An address
longer than 254 UTF-16 units, or a code that is not six digits, answers UserEmailMismatch or
UserCodeInvalid at once, for any key, asking the server nothing and so costing the key none of its wrong
tries; the key is judged first, then the address, then the code. A key that is not locked ignores both once
they reach the server. ImportLicenseFileAsync(content, options) passes them on to a key file's key.
DeactivateAsync(), RefreshAsync() and StartTrialAsync() take neither.
Metered uses: counting what a key may do
A licence model can count uses of what it sells: 1,000 exports for good, or 50 renders each billing
period. The app spends them with UseAsync(meter, n), before the work they count, and does the work only
when the spend is Ok:
UseResult spent = await license.UseAsync("exports"); // UseAsync("exports", 40) spends forty at once, whole or none
if (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 lease
status.Meters["exports"]; // MeterStatus { Left = 950, Total = 1000, Offline = 100, Unsynced = 0, Resets = false }
- Online it is exact. The server takes
nonly while the meter has room for all of it. Two calls are two spends; the SDK's own retries and reports never count one twice. - With no answer it spends from an allowance. No network, a timeout, a 5xx, a 429 or an answer the SDK
cannot use: the device spends from its offline allowance for that meter (set on the licence model; a
tenth of the meter by default, 0 for online only), never past what it last heard is left, and reports
the spend with its next check-in (
UseResult.Offlineis true). Past either,OfflineLimit. A spend the allowance would refuse asks the server first, whatever the last outage left behind, so a customer who is back online is not refused for it (once a minute while KeyGrant stays out of reach); one the allowance covers is not asked again for a minute after an outage, growing to an hour. The unsynced spends are kept with the licence (StoredState.Spends), so they survive a restart and a second instance of the app, and a spend the SDK cannot save yet is kept in memory: the customer always gets the use they were told they had, and it is reported once it has been saved (a spend in doubt, and the take-back of one refused, are reported with the next request). - It needs a key's lease that licenses this build on this device (
NotLicensedotherwise, without asking). A device trial and an offline licence file have no meters. DeactivateAsync()clears the unsynced spends with the licence. It sends them with the release, but clears them locally even when KeyGrant could not be reached (Releasedis then false), so what the server did not hear is lost: at most the offline allowance of each meter.- It does not throw for an outcome: every one is in the
UseResult, and what no adapter can tell it (one threw where there is no word for it) isStorage. Auserequest waits at most 5 seconds for the server (less whenHttpTimeoutis shorter). The one exception is aCancellationTokencancelled while the call waits its turn, or before its request is sent: it throwsOperationCanceledExceptionand nothing is spent. Cancelled while the request is out, the call stops waiting and spends as for no answer (the spend is in doubt, so it is reported), never an exception: it RETURNS its result, so read it even after cancelling. No wait is recorded for the ask it cut short, but its journal entry is, and the journal's wait (for a spend the allowance refuses, its void's minute) runs from that entry. nis along: anything outside 1 to 1,000,000,000 isInvalidAmount, and a meter code that is not 1 to 32 UTF-16 units isNoMeter, both without asking anything. A code is matched exactly, case included.LicenseStatus.Metersis there on aLicensedstatus from a key's lease that names meters, and empty on every other status:Leftis net of this device's unsynced spends,Totalis what the key holds (for good, or this period),Offlineis this device's allowance,Unsyncedwhat it has spent and not reported, andResetsis true for a meter that comes back each billing period.- A meter never takes the licence away. Nothing reads one to decide whether the app is licensed: a meter that is used up refuses spends, never the app.
- An app built with an SDK from before meters licenses a metered key as any other and cannot spend.
Restoring a licence without the key (App Store builds)
A build that may not take a typed licence key (an App Store build) restores a web buyer's licence by the address the key was sold to. The product's Key pickup must be on. The app asks for a code, the customer types the code that comes to that address, and the SDK activates the key that fits this build:
// 1. Mail a code to the address the key was sold to (the answer is the same whether or not the address holds a key).
PickUpSentResult sent = await license.PickUpAsync(emailBox.Text);
// 2. The customer types the code; restore.
RestoreResult restored = await license.RestoreByEmailAsync(emailBox.Text, codeBox.Text);
if (!restored.Ok)
{
switch (restored.PickupError)
{
case PickupError.WrongCode: AskAgain(); break;
case PickupError.CodeExpired: await license.PickUpAsync(emailBox.Text); break; // a new code
case PickupError.TooManyAttempts or PickupError.TryAgain: WaitSeconds(restored.RetryAfter); break;
}
if (restored.NoKey) ShowNoKeyUnderThatAddress();
if (restored.Error is LicenseError.Upgrade) OfferAnUpgrade(); // the keys are for an older major
if (restored.Error is LicenseError.Outdated) AskToUpdateTheApp(); // the keys start at a later major
}
PickUpAsync(email, code) hands back the address's keys instead (PickUpKeysResult.Keys: show them by
ModelName, never the key) for an app that lets the customer choose; activate one with
ActivateAsync(picked.Key, new ActivateOptions { Email = address, Code = picked.ActivationCode }).
RestoreByEmailAsync chooses for the customer, among the keys that cover this build's major: the key this
device already holds a seat on, else one with a free seat, else the first.
PickupError |
Means | The app |
|---|---|---|
InvalidEmail |
the address is blank or longer than any | ask again |
WrongCode |
not six digits, or not the address's live code | ask again |
CodeExpired |
no live code (expired after 10 minutes, used, burnt by its fifth wrong try, or never asked for) | PickUpAsync(email) again |
TooManyAttempts |
thirty wrong codes for the address this hour, or too many asks or confirms from this network | wait RetryAfter seconds |
PickupDisabled |
the product's Key pickup is off | offer another way (support) |
TryAgain |
a count could not be made | retry |
Network |
no answer, or an answer that is not a pickup's | retry |
RestoreResult holds a refusal in one of three members: PickupError (the code was not taken), NoKey (the
address holds no key of the product) or Error, a LicenseError: Upgrade (the keys are for an older major
than this build) or Outdated (their lowest version is newer), activating nothing, then ActivateAsync's own
errors (Limit, Revoked, ...) as they come, and Network behind a call that has not finished. The address and the
code are prepared as an activation's are (trimmed, a code's spaces dropped) and refused at once, asking nothing,
when the address is blank or the code is not six digits, so a mistyped code costs the address none of its wrong
tries. RetryAfter is read from the server's answer. The first confirm spends the code, so the keys it answered are
kept in memory for 10 minutes (a code's life) by the address and code as sent: a confirm or a restore with the same
pair answers from them and asks nothing, so a restore whose activation got no answer can be retried with the same
code, and a restore after PickUpAsync(email, code) needs no new one. A restore that activates drops them. Nothing
about a pickup is stored (not the address, the code, the keys or a refusal): the two PickUpAsync calls write nothing
and run beside any call, and RestoreByEmailAsync stores only the activation, as ActivateAsync does, and runs one at
a time with it. A pickup never takes a licence away. The rules are in the SDK contract's section on key pickup, which
the conformance vectors pickupAsks, pickupRefusals, pickedKeys and pickupChoices hold this SDK to.
Buying from the app
Give a customer who holds no key a Buy button that opens your Stripe Payment Link. The checkout emails them their key, and they type it into the app:
using System.Diagnostics;
Process.Start(new ProcessStartInfo("https://buy.stripe.com/your-link") { UseShellExecute = true });
// Once they have the key from the email:
ActivateResult result = await license.ActivateAsync(keyTextBox.Text);
For a customer who already holds a key, UpgradeUrlAsync(link) adds this install's activation to the
link, so an upgrade sale finds the key it upgrades (below).
What each status means
StatusAsync() answers a LicenseStatus (State, Reason, Key, TrialDaysLeft, TrialEndsAt,
Test, Entitlements, Meters, Stalled, and Source and LicensedTo on a status from a licence file):
| State | Reason | Meaning | What to show |
|---|---|---|---|
Licensed |
A valid lease licenses this build on this device; with Source File, a stored offline licence file does. |
The app, in the edition its Entitlements name (below). Check Test (below). From a file, "Licensed to" its LicensedTo. |
|
Trial |
A trial is running on its signed trial lease: TrialEndsAt (ms) is the end that lease was signed to, TrialDaysLeft the days to it, rounded up. |
The app, with a trial banner; its Entitlements are the trial lease's. |
|
Unknown |
Storage |
The state file could not be read just now (an antivirus scan holding it). Nothing is known either way. | Keep running as before; never the activation screen for it. Ask again shortly. |
Expired |
Offline |
The lease ran out and the server could not be reached to renew it. | "Connect to the internet to renew your licence", and RefreshAsync() on a retry. |
Expired |
Trial |
The trial has ended, or its trial lease is not valid here. A trial end stored in the state file is unsigned and never runs a trial on its own. | Buy / enter a key. |
Invalid |
Revoked |
The key was revoked, refunded or expired, or this device was released. | Buy / enter a key; RefreshAsync() once they say they renewed. |
Invalid |
Upgrade |
This build is NEWER than the key covers (with Source File: than the file covers). |
Offer the upgrade (UpgradeUrlAsync), or the older version; from a file, a file (or a key) for this version. |
Invalid |
Outdated |
This build is OLDER than the key's (or the file's) lowest version. | "Update the app": the key is for a later version. |
Invalid |
Device |
The licence was activated on another device (this machine changed, or the state file was copied). | "Activate again on this device" (ActivateAsync(key)). |
Invalid |
Tampered |
The stored lease is not for this licence at all (another key, activation or product, a bad signature); with Source File, the stored file is not one (the state file was edited). |
Enter a key; from a file, import it again or remove it. |
Invalid |
UnknownKey |
The stored lease is signed by a key this build does not carry. Online, the server renews it with the key it signs with now. With Source File, no renewal comes. |
"Update the app" when it stays so online: this build does not carry the product's signing key. From a file: update the app, or ask for the same licence's file signed again. |
Invalid |
RevokedFile |
The stored offline licence file is one this build lists as revoked (Source File). |
Enter a key, or contact the vendor. |
Unlicensed |
No licence and no trial on this device. | Enter a key or start a trial. |
Stalled is true when an earlier call on this License has not finished (a storage adapter stuck on a
dead disk, say): the answer then comes from what is stored, with no ask and no write.
ActivateAsync answers ActivateResult (Ok, Error): InvalidKey (the server knows no such key,
or what was entered cannot be one: nothing, or over 120 characters), Limit (activated on as many
devices as it allows), Revoked, Upgrade, Outdated, Network (no answer, or an answer it could
not use, or the device could not be identified in time, or an earlier call has not finished: try
again), InvalidLease (the server's lease failed verification: a wrong public key, usually), and the
seven errors of a user-locked key (above). The key
is trimmed and upper-cased before it is sent and stored. ActivateAsync and DeactivateAsync THROW
when the state file cannot be read or saved (the customer asked, and can try again);
DeactivateAsync also throws InvalidOperationException behind a call that has not finished.
DeactivateAsync drops the local licence (keeping only trial evidence) and answers DeactivateResult:
Released is true when KeyGrant confirmed the release (a 200), or when the device held no activation;
false when it could not be asked, or answered anything else (a 5xx, a 429, a proxy's page). Then the
seat may still be held: tell the customer so (the developer's dashboard, or support, can release it),
never that it is free. The local licence is dropped either way.
It never releases another device's seat. A state file copied or restored from another PC (a roaming
profile, a backup, a cloned disk) carries that PC's activation: its lease, valid or run out, is held to
that machine's id, or a device refusal stands for this one. Then DeactivateAsync sends nothing, only
drops it here, and answers Released true, as this device holds no seat. Releasing it would lock the
other PC out at its next check-in.
When it goes online
StatusAsync() answers from what the device holds, and asks the server only:
- on a valid lease, when this build is a newer major than the server last heard (on an open-ended lease), or the lease is near its end (less than 7 days left, or less than half its life): bounded to 5 s, and the valid lease is the answer when that gets no verdict;
- on a lease past its end or not valid here, so the server can issue a fresh one;
- on a stored refusal that still stands for this build, so a renewed or reinstated key heals;
- on a trial whose lease does not verify here while its stored end is still ahead (a lease signed
before the product's key was rotated, or one signed to end short of the trial's end), so the server
can sign it again: asked as
StartTrialAsync()asks, bounded to 5 s, and its answer saved as that saves one. Until a trial lease this build can verify comes back, it isExpired/Trial;
and in each case only once the wait after the last ask is over: 1 minute after an ask nothing
answered (offline), 1 hour after one the server answered without a lease (a refusal, a 5xx, a 429)
or without a trial this build can verify. RefreshAsync() asks at once. A new lease ends the wait.
Besides those asks, a running trial (its trial lease valid, no key held) reports its launch at most
once a day: its stamp saved first, then the report sent on a thread-pool thread, bounded to 5 s, never
awaited and its answer not read. Since it runs beside the calls, a custom IHttpAdapter or
IFingerprinter may be called from more than one thread at once (the defaults may).
Calls on one License run one at a time. A call waits for the one before it, but at most its
HttpTimeout + 15 s from when that one started; then it runs read-only (Stalled).
Every call but UpgradeUrlAsync takes an optional CancellationToken. A call cancelled while it waits
its turn throws OperationCanceledException and steps out of the queue. Once it runs, cancelling
StatusAsync, RefreshAsync or StartTrialAsync only stops it waiting on the server or the device: it
answers from what the device holds, as when nothing answers, and records nothing of the ask it cut
short. A licensed answer never becomes an exception. ActivateAsync and DeactivateAsync throw
OperationCanceledException until the server has answered, and save nothing.
Embedding the public keys
The product's public keys are the keys of GET https://api.keygrant.dev/v1/products/<slug>/pubkey,
which is public and needs no API key, and the dashboard's Signing key tab copies the same set (Copy key
set). Fetch them when you build and ship them with the app, e.g.
curl -s https://api.keygrant.dev/v1/products/sluice/pubkey
# {"publicJwk":{"kty":"OKP","crv":"Ed25519","x":"..."},"keys":[{"kty":"OKP","crv":"Ed25519","x":"...","kid":"..."}]}
and embed that response, or the copied set, at build time:
internal static class KeyGrantKeys
{
public const string Sluice = """{"keys":[{"kty":"OKP","crv":"Ed25519","x":"Ohg4h8SrYQ0m1su_zVacJLLwf_6rNsvOtV5oWY6CzJs"}]}""";
}
var license = new License(new LicenseConfig
{
Product = "sluice",
ApiBaseUrl = "https://api.keygrant.dev",
PublicJwks = PublicJwk.ParseSet(KeyGrantKeys.Sluice),
Major = 2,
});
(that x is the conformance vectors' TEST key: use your product's own). An embedded resource works as
well. Never ship the private key: a JWK carrying d is refused. The public keys only verify; holding
them lets nobody sign a lease.
Why a set. A lease names the key that signed it (kid in its header, the key's RFC 7638
thumbprint, which the SDK computes from the key itself). It is verified by that key; a lease that names
none (every lease signed before key sets) by each key in turn; and one naming a key the build does not
carry is refused (UnknownKey). When the product rotates its signing key, it first stages the next
key, published beside the current one while every lease is still signed with the current key. Ship
builds carrying both; once they are out, the product switches, and those builds verify every lease
signed after it. Embed the whole set each time you build, so a build always carries the key that signs
now and the one staged next. A PublicJwk alone is a set of one, as before.
Each activation and validate reports the ids of the keys the build carries (kids), so the
dashboard can count the devices still on builds without the staged key before you switch.
DeviceHold: holding a licence to its device
By default a lease whose seat is keyed by the operating system's machine id is held to that device:
a state file copied to another machine reads Invalid/Device there, and the server refuses its
renewal. An honest device is never refused for how it is identified: a host-name seat, a seat of
unknown kind, and a run that cannot read the machine id in time are not judged.
Set DeviceHold = false for a product deployed on non-persistent, pooled VDI (or a home folder
shared between machines), where every session may be another machine with the same state file: no
fingerprint kind is reported, no lease is judged by its device, and no fingerprints are sent to the
server to check. The device limit still counts seats.
The default fingerprint is the machine id, hashed so it never leaves the machine:
MachineGuid (Windows, 64-bit registry view), IOPlatformUUID (macOS), /etc/machine-id or
/var/lib/dbus/machine-id (Linux, unless on a tmpfs/ramfs or written at this boot). With none to read,
the host name, platform and architecture, hashed. The same machine gives the same fingerprint under
this SDK and the Electron one. A custom IFingerprinter with only GetAsync is never held to its
device; implement IdentifyAsync and report DeviceKind.Machine for a stable machine id that should be.
Test licences
A test licence (made from the dashboard, a kg_test_ API key, or a Stripe test-mode checkout) is
signed with the product's real key and licenses the app like any other; it lapses within 30 days and
is never billed. LicenseStatus.Test is true for one. Refuse them in a release build if you want:
#if !DEBUG
if (status.Test) status = status with { State = LicenseState.Invalid, Reason = LicenseReason.Tampered };
#endif
Entitlements
A licence can grant more than the base product: an edition, a number of seats, an add-on. The lease
signs them (its ent claim) and the status hands them to the app as Entitlements, read offline like
everything else on the lease:
var status = await license.StatusAsync();
var pro = status.Entitlements.TryGetValue("edition", out var edition) && edition.Text == "pro";
var seats = status.Entitlements.TryGetValue("seats", out var granted) ? granted.Number ?? 1 : 1;
var beta = status.Entitlements.TryGetValue("beta", out var flag) && flag.Flag == true;
- Type:
IReadOnlyDictionary<string, Entitlement>, never null. AnEntitlementis exactly one of a flag (Flag, abool?), a number (Number, adouble?) or a text (Text, astring?); the accessor of any other kind is null. Two are equal when they are the same kind with the same value (numbers as doubles, so5and5.0alike), andToString()writes the value. The map is read-only, and each status holds its own copy: change a copy, not the status. - When it holds them: on a
Licensedstatus (from the licence's lease: straight from the device, or after an activation, a renewal orRefreshAsync(), and on aStalledanswer from the lease stored) and on aTrialstatus (from the trial's lease), when that lease grants at least one. - When it is empty: on every other status (
Expired,Invalidfor any reason, a stored refusal included,Unlicensed,Unknown), and when the lease grants none.LicenseStatuscompares its entitlements by content, not by reference, so two statuses from the same lease are equal. - A malformed claim never costs the licence: a member that is not a flag, a number or a text (an
object, a list, null) is left out, and an
entthat is not an object at all reads as none. The SDK enforces none of the server's caps on them (how many, how long); the signature is what it trusts.
To ask whether the licence grants an entitlement, name its code:
if (await license.HasAsync("cloud_sync")) EnableCloudSync();
await license.RequireAsync("export"); // throws MissingEntitlementException (its Entitlement is "export")
HasAsync(code) is true for a flag that is on, a number that is not zero, or a text that is not empty;
false for anything else, and for a code the lease does not grant (matched exactly, case included). It is
answered offline from the stored lease only, as StatusAsync() would answer from what is stored, with no
request and no write: a licence that does not license this build, or none at all, grants nothing; a
running trial grants what its trial lease does. RequireAsync(code) throws
MissingEntitlementException, naming the code, when HasAsync would be false.
EntitlementGrants.Grants(status.Entitlements, code) answers the same of a status already in hand.
Where the state is kept
Unless the app gives its own Storage adapter, one <product>.json per product, per user, on this
machine (FileStorage.DefaultPath(product)):
- Windows:
%LOCALAPPDATA%\KeyGrant\(else%USERPROFILE%\AppData\Local\KeyGrant\); never the roaming AppData. - macOS:
~/Library/Application Support/KeyGrant/ - Linux and others:
$XDG_CONFIG_HOME/keygrant/(else~/.config/keygrant/)
It is the Electron SDK's file, field for field, so an Electron and a .NET build of the same product
on one machine share one licence. new FileStorage(path, new FileStorageOptions { MigrateFrom = old })
keeps it elsewhere (copying the state at old over the first time the new path is missing).
- Written atomically: a temp file beside it, flushed to the disk, renamed over it (a rename Windows refuses as busy is tried again for about 1.5 s); the folder flushed after on macOS and Linux. A write that lands clears temp files a crash left (over a minute old).
- Only a MISSING file is no state, and then the newest whole temp file beside it is read first. Any
other read failure is tried again for about a second, then
StatusAsyncanswersUnknown/Storage. - A file that does not parse is set aside as
<product>.json.<ms>.corrupt(the newest three kept) and an empty state written in its place. - Two processes (two instances of the app) never lose each other's saves: each save stamps a revision
(
rev), checks it again just before the rename and merges onto a newer state (up to 5 times, thenStateConflictException); answers are ordered by when they were asked for, so an older answer never replaces a newer one. - A call running read-only writes nothing at all.
The trial start also goes to the Registry stash when one is given: WindowsRegistryStash (opt-in,
HKCU\Software\KeyGrant\<product>, value TrialStart) survives an uninstall, so a reinstall does not
reset the trial. It keeps the format every KeyGrant SDK keeps, so a trial another SDK stashed for the
product counts: milliseconds since the epoch, written as a REG_QWORD; read from a REG_QWORD or a
REG_SZ of decimal digits. Anything else is no evidence: another type (a REG_DWORD read as a time is
1970), 0 or less, or 2^53 or more. The stash can cost the licence nothing: one that cannot be read is
no evidence, and one that cannot be written is ignored (the server's trial is still saved).
A custom IStorageAdapter must return null only for a state that is truly missing, write atomically,
and write nothing on a readOnly read; implementing UpdateAsync (merge onto the state as stored at
the moment of saving) makes saves from several processes safe.
Upgrades
await license.UpgradeUrlAsync(paymentLink) returns the upgrade offer's payment link with this
device's ACTIVATION id as client_reference_id (never the key), or null without an activation; a
checkout through it extends the key the customer holds instead of minting another. The link's other
parameters and its fragment are kept as written, and any client_reference_id already on it is
replaced.
Selling through Paddle? Pass your own checkout page's address instead of a payment link: the page reads client_reference_id from its address and gives it to Paddle.js as custom data (customData: { client_reference_id }), which is where KeyGrant reads it. KeyGrant's docs page "Selling through Paddle" has the page.
Building and testing
dotnet build sdk-dotnet/KeyGrant.Licensing.sln -c Release
dotnet test sdk-dotnet/KeyGrant.Licensing.sln
The suite loads conformance/vectors.json from the repo root (or the file KEYGRANT_VECTORS names) and
answers every group from the vector's own JSON as the reference does, through the same functions the
engine itself calls: lease verification (canonical base64url, the claims as parsed) and checkLease,
trial leases, fingerprints, claims, machine and host fingerprints, kept majors, early renewal, asks,
standing refusals, trial statuses, refusal and lease patches, keyedBy, merges, the clock guard, its
correction and the time a save records as seen, the wire (error words, refusals, keys, device names,
no-answer statuses, lease and trial bodies), mountinfo, the machine-id reads, what a renewal's answer
saves and says, when a status asks on a valid lease, who a device is (install memory, DeviceHold),
what the default fingerprinter answers for each machine-id reading, the status a stored lease gives with
no verdict, a trial's status from its start and its trial lease (entitlements included in every status),
the bits of the double each entitlement number reads as, the major a build runs as, the link
UpgradeUrlAsync builds, when a running trial reports its launch, a key's id and a lease against a key
set, what entitlements grant, and licence files (the token a file's content holds, a file verified,
judged and given as a status, and a file given where a lease goes), user-locked keys (what an
activation sends for the address and code a person typed, and the error each word of a refusal gives), and key
pickup (what a pickup sends for what a person typed, the error of each refusal, the keys a confirm hands back as
read, and the key a restore activates).
That is every group and every vector but the Windows machineIds
cases, which script reg.exe's output, which this SDK does not parse (it reads the registry), and the
majors cases holding a fraction, which an int major cannot hold. A group the file holds that the
harness does not answer fails the suite.
Its behaviour tests cover activation, offline status, renewal, refusals and their waits, outages that
never lock out, the clock guard, trials, deactivation, DeviceHold, storage that cannot be read, calls
stuck behind one another, entitlements on every status that carries them, the launch report, a 0.x
major, licence files (import, refusals, a stored file beside a key and a trial, key files), user-locked
keys (the address and code sent, each refusal, nothing stored, a key file's key asked too), key pickup (the
requests, every refusal, nothing written by PickUpAsync, a restore's activation with the address and the activation
code, NoKey, Upgrade and Outdated, the keys kept in memory, a pickup behind an unfinished call), metered uses
(online and with no answer, the waits, what is asked, a spend held in memory after a failed save, two
processes over one state file, the allowance judged again where a spend lands, what a validate and a
deactivate report and what clears the report, the journal's limits and its horizon), the file storage
(atomic writes, temp recovery, corrupt files, revision conflicts between two processes) and the
machine-id readers.
A property test (UsePropertyTests) runs seeded random sequences of spends, check-ins, releases and
restarts from two processes over one state file against a server that loses, delays and refuses answers
(or loses them after recording the spend), a disk that refuses its writes and a clock that jumps, and holds
the contract's invariants at every step and at the end. PROPERTY_RUNS and PROPERTY_SEED choose how many
sequences run and from which seed (1500 from seed 1 by default); a case it finds is shrunk and printed as
data, to be kept with the cases beside it.
SystemTests drive the real readers and writers on the system the suite runs on: the registry, a
file another process holds and a rename over it (Windows); /etc/machine-id, /proc and the folder
fsync (Linux); ioreg (macOS); symbolic and hard links everywhere. A test for another system, or for
something this machine cannot do (symbolic links on Windows need Developer Mode or an elevated shell;
the host-name check needs a node of this architecture), is reported skipped with its reason.
github-workflow-example.yml runs the suite on Linux, macOS and Windows
(copy it to .github/workflows/ to enable it). On a Windows machine, the Linux half runs in Docker,
from the repo root:
docker run --rm -v "$PWD:/repo:ro" mcr.microsoft.com/dotnet/sdk:8.0 bash -c \
"mkdir /work && cp -r /repo/conformance /repo/sdk-dotnet /work && cd /work/sdk-dotnet && rm -rf */bin */obj && dotnet test KeyGrant.Licensing.sln"
Parity with the reference
Behaviour is the Electron SDK's; these are the differences, each on purpose:
| Difference | Why |
|---|---|
Windows' MachineGuid is read with the registry API (RegistryView.Registry64), not by running reg.exe. Same value and view; only a REG_SZ counts and the first word is taken, as reg query's output gives it. Access denied is "no id" (as reg exiting 1); any other registry error is "cannot tell this time", where the reference read any non-zero reg exit as "no id". |
No child process, no %SystemRoot% question. "Cannot tell" never flips a device's fingerprint; "no id" switches it to the host name for good. |
The host name is GetHostNameW on Windows and gethostname elsewhere (what Node's os.hostname() calls), the platform and architecture are Node's names (win32/darwin/linux, x64/arm64/ia32/arm) for this PROCESS. |
So the host-name fingerprint is the Electron SDK's on the same machine (checked against node in the tests when it is installed). An app built for another architecture than its Electron twin hashes another arch, as two Electron builds would. |
The public key is checked when the License is created (ArgumentException); a JWK holding d is refused. |
The reference imports it lazily and a bad key makes every status() reject. A configuration error belongs at startup. |
The default storage does not migrate <working directory>/.keygrant/<product>.json. |
That path is the legacy location older Electron SDK versions used. FileStorage.LegacyPath(product) + MigrateFrom reads it if you need to. |
Every call (but UpgradeUrlAsync) takes a CancellationToken. Cancelled while it waits its turn, a call throws OperationCanceledException; once running, StatusAsync, RefreshAsync and StartTrialAsync stop waiting and answer what the device holds (never an exception, and no wait recorded for the ask they cut short), while ActivateAsync and DeactivateAsync throw until the server has answered. |
.NET convention; the reference cannot be cancelled. A status is awaited at launch, so a cancellation never turns a licensed answer into an exception. |
UseAsync(meter, long n = 1, cancellationToken) answers a UseResult (Ok, Left, Offline, Error), where the reference's use(meter, n) answers one object: Left is a nullable long (null where the reference gives none), Offline a flag, Error a UseError. Cancelled while it waits its turn or before its request is sent, it throws OperationCanceledException with nothing spent; cancelled while the request is out, it stops waiting and spends as for no answer and returns its result (read it even after cancelling), never an exception. No wait is recorded for the ask it cut short, but its journal entry is, and the journal's wait runs from it. |
.NET convention: a typed result for the reference's union, and a token every call takes. A spend whose request was sent may be held by the server, so cutting the wait short leaves it in doubt (reported with the next request), as a timeout does. |
StoredState.Spends and MeterFigures are typed lists and maps, read leniently as the reference reads them: a journal that is not a list, an entry or a figure of another shape, is read as empty or left out, and so are the unknown members of a valid entry, when the state is read, so any save writes the state without them (the reference drops a malformed entry or figure, and a figure's unknown members, only when a journal change lands, and keeps a valid entry's unknown members). |
The journal is the device's own, never the server's word. |
HttpTimeout must be positive and at most a day. |
.NET timers take no more; an unbounded request would defeat the bound. |
Major is an int: a fractional major cannot be configured (the reference cuts one to its whole number), so the majors vectors holding a fraction are skipped. A negative one runs as 1, as in the reference. |
Idiomatic C#; a version's major is a whole number. |
A payment link is read by System.Uri, whose normal form differs from the WHATWG parser's in two ways that keep the URL the same: a percent-encoded letter, digit or -._~ in the query is written as that character, and ' in the query is left as it is (WHATWG writes %27). |
The link is read as the platform's URL parser reads it; the query is then edited as text as the reference edits it. |
UpgradeUrlAsync throws ArgumentException for a link that is not an absolute URL (while the device holds an activation), where the reference rejects with a TypeError. |
.NET convention. |
The launch report runs on a thread-pool thread (Task.Run), so the HTTP adapter and the fingerprinter may be called from two threads at once; the tests wait for it through an internal LaunchReportSettled. |
An SDK with blocking I/O sends it on a thread of its own, never on the caller's. |
State files are read leniently: a known field of the wrong type (a refusal word from a later SDK, a number written as text, a string holding an escaped lone surrogate, a licenseFile that is not text, which the reference reads as Invalid/Tampered) reads as absent, a byte-order mark is forgiven, and fractional milliseconds are floored. What such a field held is written back as it was, unless a save sets or clears it; unknown fields are kept and written back, as the reference does. A field NAME holding a lone surrogate is dropped. |
The reference keeps whatever JSON.parse gives, so a damaged field misbehaves later; reading it as absent keeps the rest of the licence, and writing it back keeps what an Electron build of the same product stored. The reference sets a file with a byte-order mark aside as corrupt. |
JSON is read to any depth JSON.parse reads, in one pass, but a member nested more than 64 levels deep is never held as a JsonElement: a state file's is kept as its text and written back (it is not in StoredState.AdditionalFields), a server answer's is left out of the body, and a lease's unknown claim is never read, nor a member of ent that is not a flag, a number or a text (dropped, however deep; any other claim the schema checks cannot nest, so one that deep is bad-claims there too). |
System.Text.Json's JsonDocument takes time that grows with the square of the nesting: a state file with a member 100 000 levels deep would take seconds to read, and a million minutes, which would hang a status at launch. Nothing the SDK reads nests. |
A trial body's trialStart and trialEndsAt are floored to whole milliseconds, and one that is not finite (1e400) is no trial body: the trial evidence the device holds is answered. |
Times are long here. JSON.parse reads 1e400 as Infinity, which the reference would save as null. The server sends whole milliseconds. |
On macOS and Linux, ioreg exiting with a code above 128 is read as killed by a signal ("cannot tell this time"), where execFile tells an exit code ("no id") from a signal. |
.NET reports a signal as exit code 128 + the signal and cannot tell the two apart. Reading it as "cannot tell" keeps the device's fingerprint (it is read again next time); "no id" would switch it to the host name for good. |
WindowsRegistryStash is this SDK's own (the Electron SDK ships only a no-op stash), in the format every KeyGrant SDK keeps: REG_QWORD milliseconds written; a REG_QWORD or a decimal REG_SZ read; another type, 0 or less, or 2^53 or more is no evidence. |
So a reinstall cannot reset a trial on Windows, whichever SDK stashed it. A value that cannot be a time would start the trial in 1970 on the server, or have it refused. |
A string in a server body, or in a lease claim the SDK reads (sub, aid, ...), holding an escaped lone surrogate is no string: no verdict, or bad-claims. Anywhere else in a body (a member the SDK does not read) it costs nothing else in the answer; unknown claims are fine. An entitlement whose name or text holds one is dropped, as a malformed member is: the licence and the other entitlements stay, where the reference would keep it. The default HTTP adapter reads a body as UTF-8 whatever charset the response names, as fetch does. |
System.Text.Json cannot hold one, and an exception there would fail a status. The server never signs an entitlement name or text holding one. |
ActivateAsync(key, options) and ImportLicenseFileAsync(content, options) are overloads, with the options before the CancellationToken, where the reference takes an optional second argument; a code is text here, where the reference also reads a code kept as a number. |
.NET convention: the existing calls keep their shape. A typed port takes text. |
PickUpAsync(email) and PickUpAsync(email, code) are overloads, as the reference's pickUp is, and a pickup's refusal is PickupError (with RetryAfter) where the reference's one error holds it. RestoreByEmailAsync holds its refusal in three members (PickupError, NoKey, Error), where the reference's one error holds any of the words. Cancelled before its request is answered, a PickUpAsync call throws OperationCanceledException. |
.NET convention: a typed result for the reference's union, and a token every call takes. |
ImportLicenseFileAsync answers a refusal (Error) and a key file's failed activation (ActivateError) in two members, where the reference's one error holds either word; a whole number in a licence file's claims past a long is held at its end. |
Idiomatic C#: each typed. Majors are compared as the reference compares doubles, so every build is judged alike. |
A key is upper-cased with ToUpperInvariant (simple case mapping); JavaScript's toUpperCase maps fully (ß to SS). Trimming uses JavaScript's white space exactly. |
The key alphabet is ASCII, where they agree. |
Test and Stalled are false rather than absent, and Entitlements empty rather than absent, with LicenseStatus comparing it by content; times are long milliseconds; HttpResult.Json is a JsonNode; IStorageAdapter.UpdateAsync and IFingerprinter.IdentifyAsync are default interface methods (returning false / null for "not implemented"). |
Idiomatic C# for the reference's optional members. |
| The default storage opens files sharing read, write and delete, as libuv opens every file on Windows. | A reader of ours never refuses another process its own reads and writes. It does not let a rename over the file through: Windows refuses a rename over a file anyone holds open, whatever its sharing (access denied), and that is tried again as busy for about 1.5 s, as the reference tries EPERM again. |
Dependencies
BouncyCastle.Cryptography (MIT) for Ed25519 verification; everything else is in .NET 8.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net8.0
- BouncyCastle.Cryptography (>= 2.6.2)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.