Activation gets all the attention: fingerprints, seats, signed leases. Deactivation — giving a seat back so it can be used somewhere else — is treated as the trivial inverse. It is not, for one reason: an activation is something the server grants, but a deactivation is something the client claims. "I have stopped using this license on this machine" is a statement from the one party in the system you cannot trust, and the design of a safe deactivation is entirely about how much of that statement you can verify and how much you can bound.
There are three cases, and they need three different answers. The machine is online and cooperative: the problem is ordering and retries. The machine is offline — air-gapped, or a field laptop with no signal: the problem is proof. The machine is gone — stolen, dead, reimaged without deactivating: the problem is abuse, because every forced release is an opportunity to turn one license into two. This post walks the Keyright SDK and server policy for each.
Online: release on the server, then wipe, and make it repeatable
The cooperative case still has a bug most first implementations ship: the order of the two side effects. A deactivation has to do two things — free the seat on the server and destroy the license on the client — and exactly one of them can fail after the other has succeeded.
Wipe first, then call the server, and a failed call leaves the customer with no license on this machine and a seat still consumed. That is the worst outcome: a stuck seat, an unlicensed app, and a ticket. Call the server first, then wipe, and a failed wipe leaves the app holding a cached signed lease — but that lease has a TTL, the server has already freed the seat, and the next heartbeat returns an activation-lost result. The machine drops to unlicensed on its own inside one lease period. The failure is bounded and self-healing. So: server first.
public async Task<DeactivationOutcome> DeactivateAsync(CancellationToken ct)
{
var current = await _store.ReadAsync(ct);
if (current is null)
return DeactivationOutcome.NothingToRelease;
// The nonce is generated ONCE and persisted before the call, so a retry after
// a crash or timeout replays the same idempotency key instead of minting a new one.
var nonce = current.PendingDeactivationNonce ??= Guid.NewGuid().ToString("N");
await _store.WriteAsync(current, ct);
var result = await _client.FreeSeatAsync(new FreeSeatRequest(
LicenseId: current.LicenseId,
ActivationId: current.ActivationId,
InstanceSecret: current.InstanceSecret,
Nonce: nonce), ct); // POST /v1/free-seat
if (!result.Released && !result.AlreadyReleased)
return DeactivationOutcome.ServerRefused(result.Reason); // store is intact; nothing lost
await _store.WipeAsync(ct); // only after the server has freed the seat
_state.Clear(); // drop the in-memory lease; app is now unlicensed
return DeactivationOutcome.Released;
}
Two details carry the weight. The nonce is written to the store before the request goes out, so if the process dies mid-call, the next attempt sends the same (ActivationId, Nonce) key. And the server treats that key as idempotent: a second request with the same key returns the outcome of the first.
public async Task<FreeSeatResult> FreeSeatAsync(FreeSeatRequest req, CancellationToken ct)
{
await using var _ = await _store.LockActivationAsync(req.ActivationId, ct);
// Replay: same activation + same nonce → return the recorded outcome, do not error.
if (await _store.FindReleaseAsync(req.ActivationId, req.Nonce, ct) is { } prior)
return FreeSeatResult.AlreadyReleased(prior.ReleasedAt);
var activation = await _store.FindActivationAsync(req.ActivationId, ct);
if (activation is null || !activation.SecretMatches(req.InstanceSecret))
return FreeSeatResult.Refused("unknown activation or bad instance secret");
activation.Release(ReleaseReason.SelfOnline, _clock.GetUtcNow());
await _store.RecordReleaseAsync(req.ActivationId, req.Nonce, activation.ReleasedAt!.Value, ct);
await _store.UpsertActivationAsync(activation, ct);
return FreeSeatResult.Released(activation.ReleasedAt.Value);
}
Note what is being verified: the InstanceSecret. A deactivation request is not authenticated by the license key — anyone who has the key could otherwise free a seat they do not hold — but by the opaque secret the server handed this activation when it was created. That same secret is what makes the offline case possible.
Offline: a receipt the server can check without talking to the client
An air-gapped machine cannot call /v1/free-seat. What it can do is produce something the customer carries to the portal — a short string — that convinces the server the seat was given up. The question is what that string can honestly prove.
It cannot prove the wipe happened. The client is untrusted code on a machine you do not control; a modified client could emit the receipt and keep the license files. So do not design around "proof of deletion." Design around proof of surrender: the receipt proves that the activated instance — the only holder of the instance secret — chose to release. Everything else follows from what the server does with that.
public sealed record DeactivationReceipt(
string ActivationId,
string LicenseId,
string Fingerprint,
DateTimeOffset IssuedAt,
string Nonce,
string Mac); // HMAC-SHA256 over the canonical fields, keyed by InstanceSecret
public async Task<string> DeactivateOfflineAsync(CancellationToken ct)
{
var current = await _store.ReadAsync(ct) ?? throw new InvalidOperationException("not activated");
var body = new DeactivationReceipt(
current.ActivationId, current.LicenseId, ComputeFingerprint(),
_clock.GetUtcNow(), Guid.NewGuid().ToString("N"), Mac: "");
var canonical = $"{body.ActivationId}|{body.LicenseId}|{body.Fingerprint}|{body.IssuedAt:O}|{body.Nonce}";
using var hmac = new HMACSHA256(Convert.FromBase64String(current.InstanceSecret));
var mac = Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes(canonical)));
// Wipe BEFORE the receipt is ever displayed. If the wipe throws, no receipt exists,
// the machine is still licensed, and the customer can try again.
await _store.WipeAsync(ct);
_state.Clear();
return Base64Url.Encode(JsonSerializer.SerializeToUtf8Bytes(body with { Mac = mac }));
}
The ordering is reversed from the online path, deliberately. Online, the server acts first because the server is the source of truth and the local wipe is cleanup. Offline, nothing can act on the server until the customer gets to a browser, so the local wipe comes first and the receipt is the product of a successful wipe: the user never sees a receipt for a license that is still on disk. If the wipe fails, no receipt, no harm.
The server verifies the receipt with the instance secret it stored at activation:
public async Task<ReceiptResult> RedeemReceiptAsync(string encoded, CancellationToken ct)
{
if (JsonSerializer.Deserialize<DeactivationReceipt>(Base64Url.Decode(encoded)) is not { } r)
return ReceiptResult.Invalid("malformed");
await using var _ = await _store.LockActivationAsync(r.ActivationId, ct);
var activation = await _store.FindActivationAsync(r.ActivationId, ct);
if (activation is null || activation.LicenseId != r.LicenseId)
return ReceiptResult.Invalid("unknown activation");
var canonical = $"{r.ActivationId}|{r.LicenseId}|{r.Fingerprint}|{r.IssuedAt:O}|{r.Nonce}";
using var hmac = new HMACSHA256(activation.InstanceSecretBytes);
var expected = hmac.ComputeHash(Encoding.UTF8.GetBytes(canonical));
if (!CryptographicOperations.FixedTimeEquals(expected, Convert.FromBase64String(r.Mac)))
return ReceiptResult.Invalid("bad mac");
if (await _store.NonceSeenAsync(r.ActivationId, r.Nonce, ct))
return ReceiptResult.AlreadyRedeemed(activation.ReleasedAt);
if (_clock.GetUtcNow() - r.IssuedAt > _policy.ReceiptMaxAge) // e.g. 30 days
return ReceiptResult.Invalid("expired");
activation.Release(ReleaseReason.OfflineReceipt, _clock.GetUtcNow());
await _store.RememberNonceAsync(r.ActivationId, r.Nonce, ct);
await _store.UpsertActivationAsync(activation, ct);
return ReceiptResult.Released;
}
Now the honest accounting. Suppose the client lied — it emitted a valid receipt and kept its license files. What did it gain? The server has marked the activation released. The cached signed lease on the machine is still cryptographically valid, but it carries an expiry, and the machine has no way to renew it: it is offline, and even if it came online, a heartbeat against a released activation returns activation-lost. So the stolen time is bounded above by one lease TTL — the remaining life of the lease at the moment of the receipt. For an air-gapped deployment where you issue 30-day offline leases, that is the price of offline deactivation, and you set it when you set the TTL. There is no design that makes it zero without a network, and any product that claims otherwise is trusting the client.
The receipt also deliberately contains the fingerprint. It is not needed for verification — the HMAC already binds the activation — but it lets the portal show the customer which machine they are releasing, and lets your support staff spot a receipt being redeemed for a machine that does not match the activation record.
Dead machines: the forced path, and why it has a cooling window
The third case has no client at all. The laptop was stolen; the VM was deleted; the disk was reimaged by someone who did not know the license was on it. The customer needs the seat back and there is nothing to call, nothing to sign. The only option is a forced release: an admin in your dashboard, or the customer in the portal, declares the machine gone.
This is the path that turns licensing into a seat generator if you are not careful. "My machine died" is free to say, and a customer who says it once a week has, in effect, an unlimited license. Two mechanisms keep it honest, and neither relies on believing the claim.
The first is the cooling window. The dead machine might not be dead. It might be a machine that is simply offline, running on a cached lease the server already issued — a lease that is valid until its expiry regardless of what the server now thinks. If the forced release freed the seat immediately, the customer could activate a new machine while the "dead" one keeps running on its cache, and the seat is live in two places. So a forced release does not free the seat; it moves it to cooling, and the seat becomes available only after the maximum remaining lease life has elapsed.
public ForcedReleaseResult ForceRelease(Activation activation, ReleaseReason reason, DateTimeOffset now)
{
// The last lease we issued to this activation is the longest anything on the
// "dead" machine can still be running on. The seat is not free until it is gone.
var coolingUntil = activation.LastIssuedLease.ExpiresAt;
activation.Release(reason, now);
activation.SeatAvailableAt = coolingUntil > now ? coolingUntil : now;
return new ForcedReleaseResult(activation.SeatAvailableAt);
}
For a desktop app heart-beating every hour on a 24-hour lease, cooling is at most a day. For an air-gapped 30-day lease it is up to 30 days, which is the right answer: that license may genuinely still be in use. If the machine really is dead, the customer waits; if it is not, the cooling window is what keeps your seat count true. Your portal should show the countdown so the customer is not left guessing.
The second mechanism is the rolling transfer limit. Forced releases are counted, self-service and receipt releases are not — because those two paths actually stopped the old instance, while the forced path only claimed to.
public sealed record TransferPolicy(int MaxForcedReleases, TimeSpan Window); // e.g. 3 per 30 days
public bool CanForceRelease(License license, TransferPolicy policy, DateTimeOffset now)
{
var recent = license.Releases.Count(r =>
r.Reason is ReleaseReason.AdminForced or ReleaseReason.CustomerForced &&
r.ReleasedAt > now - policy.Window);
return recent < policy.MaxForcedReleases;
}
Three per thirty days is generous for honest customers — hardware fails, but not weekly — and ruinous for anyone trying to run a seat farm through the "lost machine" button. When the limit is hit the portal says so, and the customer talks to you, which is exactly the conversation you want to have at that point. Keyright lets you set the window and count per product, because a fleet of ruggedized field devices legitimately dies more often than a desk full of workstations.
Every release is a ledger entry
All three paths end in the same place: activation.Release(reason, now). The reason is not decoration. It is what makes the history of a license readable six months later, when a customer with a 5-seat license has somehow activated forty machines and you need to know whether that was forty honest replacements or something else.
public enum ReleaseReason
{
SelfOnline, // client called /v1/free-seat with its instance secret — seat really stopped
OfflineReceipt, // HMAC receipt redeemed — activated instance surrendered; bounded by TTL
CustomerForced, // customer declared the machine lost in the portal — counts toward limit
AdminForced, // you did it from the dashboard — counts toward limit, with a note
Reaped // lease expired with no heartbeat; the seat aged out on its own
}
The dashboard's per-license activations view shows these alongside the device metadata, so the honest story — a machine replaced, a receipt redeemed from the field, one forced release in March — reads at a glance, and the dishonest one — nothing but forced releases, every nine days — reads just as clearly.
What not to do
Do not free the seat from an uninstaller. Uninstallers run without network, without consent prompts you control, and often as part of a reimage the customer did not intend to be a deactivation. A seat freed by an uninstall hook is a seat freed by accident, and a reinstall then consumes a second seat when it reactivates. Make deactivation an explicit action with an explicit result.
Do not accept "I deleted it" without the instance secret. A deactivation request authenticated only by the license key lets anyone who has seen the key — a shared key, a leaked key, a former employee — free seats they do not hold. The instance secret is the one thing only the activated client has, and it is the only credential a release should accept.
And do not let the forced path be free. The cooling window and the rolling limit are not there because you distrust your customers; they are there because without them the seat count is an opinion. With them, a seat is free when the server can prove nothing is running on it — and that is the difference between a node-lock that counts and one that merely hopes.
Top comments (0)