Offline license activation
How software checks a license without internet: signed offline leases, signed license files, grace periods, clock tampering and what each one trusts.
C# / .NET
To validate a license key in a C# application, send the key and a device identifier to your license server at startup, verify the Ed25519 signature on the answer with a public key compiled into your app, and only then read the license status, plan and features. Keep a signed offline lease for when the server is unreachable, and treat every failure as unlicensed. This guide shows how with Velsigil.Client, the .NET SDK included with Velsigil.
Last updated
Velsigil.Client targets netstandard2.0 and net8.0. Through the .NET Standard build, its README lists .NET Framework 4.7.2 and later, .NET Core 3.1 and .NET 5 or later, Mono and Unity 2021.3 or later. Its own test suite runs on .NET 8, so test the SDK on the runtime you ship. It uses BouncyCastle.Cryptography for Ed25519 (plus System.Text.Json and Microsoft.Win32.Registry on .NET Standard) and no reflection-based serialization, so it is designed to work with trimming, Native AOT and IL2CPP; test your trimmed, AOT or IL2CPP build before you ship.
The SDK is included with Velsigil as source code; it is not on nuget.org. Add it through a local NuGet feed:
# In the Velsigil source tree: build a package into a local folder
dotnet pack sdks/csharp/src/Velsigil.Client -c Release -o ./nupkgs
# In your application
dotnet add package Velsigil.Client --version 1.0.0 --source ../path/to/nupkgs
Or reference the project directly:
<ItemGroup>
<ProjectReference Include="..\license-panel\sdks\csharp\src\Velsigil.Client\Velsigil.Client.csproj" />
</ItemGroup>
ValidateAsync sends the license key, the device’s hardware ID, a fresh 32-byte random nonce and a timestamp to POST /api/client/v1/validate. The server answers with an envelope of data, sig and kid, and the SDK:
data with the public key you compiled in, never with a key chosen by kid;VelsigilResult whose Ok is true only if all of that succeeded.The requests themselves are not signed: any secret built into a client can be extracted. Integrity comes from the signed answer, the nonce and timestamp, and a device secret the server issues on the first activation.
Copy the API URL, product ID and public key from the product’s Integration tab in the panel and compile them in as constants. Create one long-lived client per product: it is thread-safe and owns a pooled HttpClient.
using Velsigil.Client;
using Velsigil.Client.Storage;
const string ApiUrl = "https://licenses.example.com"; // from the product's Integration tab
const string ProductId = "<product id>";
const string PublicKey = "<Ed25519 public key, base64>"; // compiled in
// One long-lived client per product; throws ArgumentException etc. only for bad configuration.
var client = new VelsigilClient(ApiUrl, ProductId, PublicKey,
new VelsigilClientOptions { Store = FileStore.CreateDefault("MyApp") });
var result = await client.ValidateWithOfflineFallbackAsync(licenseKey,
new ValidateOptions { Version = "1.2.0", DeviceName = Environment.MachineName });
if (!result.Ok)
{
Console.WriteLine($"{result.Message} ({result.Code}) ref {result.RequestId}");
return;
}
var plan = result.License?.Plan ?? result.LeaseClaims?.Plan; // offline: LeaseClaims, not License
Console.WriteLine($"{plan} offline={result.Offline} expires={result.ExpiresAt} days={result.DaysRemaining}");
if (result.HasFeature("pro")) EnablePro();
License failures never throw. The constructor throws ArgumentException for a bad URL, product ID, key or HardwareId override, ArgumentOutOfRangeException for a timeout outside 1 second to 10 minutes, and PlatformNotSupportedException when no machine ID can be read and you did not set HardwareId. After that, calls throw only for programming errors: ArgumentException for invalid arguments (for example a null grant passed to DownloadFileAsync), ObjectDisposedException after Dispose and OperationCanceledException for your own cancellation token. Timeouts, TLS errors, HTTP errors and bad signatures all come back as a result with Ok == false.
A result from the offline lease carries LeaseClaims instead of License, which is why the example reads the plan from either. The result-level helpers HasFeature, ExpiresAt, IsLifetime and DaysRemaining work in both cases.
Code is a stable snake_case string, and ResultCodes has a constant for each, so a switch expression maps them to your own wording. Message is a signed server message or fixed SDK text; text from unsigned responses is never shown.
static string ExplainLicenseProblem(VelsigilResult result) => result.Code switch
{
ResultCodes.InvalidKey => "That license key was not found. Please check it and try again.",
ResultCodes.LicenseExpired => "Your license has expired. Renew it to keep using the app.",
ResultCodes.DeviceLimitReached => "This license is active on too many devices. Free one in the customer portal.",
ResultCodes.DeviceVerificationFailed => "This device has to be reset in the customer portal.",
ResultCodes.OutdatedVersion => "This version is no longer supported. Please update.",
ResultCodes.NetworkError => "The license server could not be reached, and no offline lease is available.",
ResultCodes.InvalidResponse => "The license check failed. Please contact support.",
_ => result.Message,
};
Treat invalid_response as an attack signal, not as a network glitch: it means an answer failed signature, nonce, product or device checks.
On the first activation, the server issues a device secret and stores only its hash. The SDK saves it and sends it with every later validation, deactivation and download. If it is lost, the server records a mismatch and, with strict device binding, refuses the device until it is reset.
The default store is a FileStore in the per-user data folder (%LOCALAPPDATA%\Velsigil on Windows); FileStore.CreateDefault("MyApp") adds a sub-folder for your application. Writes are atomic, and in the .NET 8 build the folder and files are readable only by the current user. To keep the secret in DPAPI or the macOS Keychain instead, implement IVelsigilStore. If no per-user folder exists, the client falls back to memory and reports it through StoreErrorHandler.
ValidateWithOfflineFallbackAsync uses the stored lease only on network_error. A server that answers “revoked” is final, and a tampered answer is invalid_response, never an offline pass. If a lease is stored but expired or invalid, you get lease_expired or lease_invalid; with no lease at all, the original network_error.
A license carries its plan name and a list of features from the plan, which you can override per license in the panel. Gate each feature where it is used rather than with one flag set at startup; HasFeature is false whenever Ok is false, so a failed or forged result never unlocks anything.
// Keep the result, not a copied bool, and check it where each feature lives.
exportMenuItem.Enabled = result.HasFeature("export");
if (result.IsExpiringWithin(TimeSpan.FromDays(7)))
ShowRenewalReminder(result.ExpiresAt);
var check = await client.CheckUpdateAsync(currentVersion: "1.2.0");
if (check.Ok && check.Update is { UpdateAvailable: true } update)
{
Console.WriteLine($"Version {update.LatestVersion} is available{(update.Mandatory ? " (required)" : "")}.");
var grant = await client.GetDownloadAsync(licenseKey); // latest published release
if (grant.Ok && grant.Download is { } download)
{
var target = Path.Combine(Path.GetTempPath(), "MyApp-" + download.Version + ".zip");
var file = await client.DownloadFileAsync(download, target);
if (file.Ok) LaunchInstaller(target); // size and SHA-256 verified
else Console.WriteLine(file.Message); // integrity_mismatch, download_failed, ...
}
}
CheckUpdateAsync sends no license data. GetDownloadAsync returns a short-lived, signed grant for an activated device, and DownloadFileAsync writes to a temporary file next to the destination and replaces the destination only after the size and SHA-256 match the signed values. Treat the download URL as a secret and do not log it. When you pass ValidateOptions.Version, validation also enforces the product’s minimum version (outdated_version).
// Licensing.cs: one long-lived client per product, shared by every form.
public static class Licensing
{
public static readonly VelsigilClient Client = new VelsigilClient(
"https://licenses.example.com", "<product id>", "<Ed25519 public key, base64>",
new VelsigilClientOptions { Store = FileStore.CreateDefault("MyApp") });
}
// MainForm.cs
private async void MainForm_Load(object sender, EventArgs e)
{
var result = await Licensing.Client.ValidateWithOfflineFallbackAsync(
storedKey, new ValidateOptions { Version = Application.ProductVersion });
if (!result.Ok)
{
MessageBox.Show(this, result.Message, "License", MessageBoxButtons.OK, MessageBoxIcon.Warning);
ShowActivationDialog();
return;
}
exportMenuItem.Enabled = result.HasFeature("export");
statusLabel.Text = result.Offline ? "Licensed (offline)" : "Licensed";
}
Use async event handlers and never block the UI thread with .Result or .Wait(). In WPF, use the same pattern in Application.OnStartup or the main window’s Loaded handler. Store the key the user typed with DPAPI (ProtectedData.Protect with the current-user scope) rather than in plain settings.
Validate at startup and then every few hours with ValidateWithOfflineFallbackAsync, honor RetryAfter on rate_limited, and pass a CancellationToken tied to your host’s shutdown.
Assets/Plugins.link.xml that preserves Velsigil.Client and BouncyCastle.Cryptography.Application.persistentDataPath. On mobile and consoles there is no machine ID to read, so set HardwareId to a stable per-install value.HttpClient built on a handler that wraps UnityWebRequest.var options = new VelsigilClientOptions
{
Store = new FileStore(Path.Combine(Application.persistentDataPath, "velsigil")),
// Mobile and consoles have no machine id: set a stable per-install value instead.
HardwareId = SystemInfo.deviceUniqueIdentifier,
};
NuGet selects the .NET Standard build. Legacy packages.config projects need AutoGenerateBindingRedirects. Do not pin ServicePointManager.SecurityProtocol to older TLS versions.
.NET assemblies decompile to readable C# with freely available tools, so assume that an attacker can read and patch your license check.
VelsigilResult rather than a copied bool, so a single patched branch does not unlock everything.ToString().The license key security guide explains what each defense protects against.
The file sdks/test-vectors.json holds signed envelopes, leases and hardware-ID examples that every Velsigil SDK must classify the same way. The .NET tests check every vector and drive the client against an in-process mock server that signs its answers: business failures, signature and nonce mismatches, clock skew, timeouts, offline fallback with a fake clock and verified downloads. The vector key pair is for tests only; never use it for a real product.
dotnet test sdks/csharp/tests/Velsigil.Client.Tests -c Release
ValidateWithOfflineFallbackAsync on one long-lived client per product.Ok == true.FileStore.CreateDefault is the default.Code with the ResultCodes constants, and gate features with HasFeature where they are used.Keep reading
How software checks a license without internet: signed offline leases, signed license files, grace periods, clock tampering and what each one trusts.
How hardware-locked licenses bind a key to a device: machine IDs per OS, why hardware IDs can be spoofed, device secrets, limits and customer resets.
How license keys get shared, forged or replayed, and the defenses that work: signed answers, replay protection, hashed keys, device secrets, rate limits.
Validate license keys in C++17: Ed25519-signed answers via libsodium, device binding, offline leases and static Windows builds with CMake and vcpkg.
Early access
Early access is free. In exchange, we ask for your feedback while we prepare the paid launch. Spots are limited, and we reply to every request by email.
Early access ends when paid subscriptions launch; we will tell members by email before then. To keep using Velsigil after that, you need a subscription. The 30% discount applies to the first 12 months of either plan, paid monthly or yearly.
Opens your email app with a short template: company, what you sell, your platforms and languages, expected license volume and how you plan to deploy. You can also write to hello@velsigil.com.