Skip to content
← All posts
· Delta1 Labs KeyrightLicensing.NETDeep Dive

Licensing a .NET app on Linux and macOS: no registry, no AppData — where the Keyright lease lives, what the fingerprint anchors to, and the systemd service that never relaunches

Most licensing guidance is written for Windows, and the Keyright SDK quietly hides that: the same Validate() and ActivateAsync() calls work on Linux and macOS. But the three things a licensing SDK depends on — a per-user data folder, a stable machine identity, and an app that relaunches often enough to refresh its lease — all look different off Windows. The lease lands in ~/.local/share, which a sandboxed Mac app or a systemd service with ProtectHome= cannot write. The machine fingerprint anchors on /etc/machine-id or the IOPlatformUUID instead of a registry value, and a golden image that forgot to clear machine-id makes a whole fleet one machine. And a daemon that starts once a quarter never gets the startup refresh a desktop app relies on, so its 14-day lease expires with the process still running. This post walks through where the SDK writes on each platform, what the fingerprint is built from and the two machine-id traps, a systemd unit with StateDirectory= and LoadCredential= that gives the SDK a home, the lease-refresh loop a long-running service needs, the file mode the lease deserves on a multi-user host, and the verification loop that proves it.

The Keyright SDK does not care which operating system it is on. Keyright.NET targets netstandard2.0 and net8.0, Validate() is pure computation over bytes, and ActivateAsync is an HTTPS call. Deploy a licensed worker on Ubuntu or ship a Mac build of your desktop app and the same code runs. That is the good news, and it is also why the three platform differences that do matter tend to surface as a support ticket rather than a compile error.

A licensing SDK depends on three things the platform provides: somewhere per-user to keep a small signed file, a stable identity for the machine, and — less obviously — an application that gets relaunched often enough to refresh what it cached. On Windows those are %LocalAppData%, the registry MachineGuid, and the fact that desktop apps start every morning. Off Windows all three are different, and a Keyright integration that was tested on a developer’s Windows laptop can quietly do the wrong thing on the Linux box it is deployed to. This post is the Linux and macOS version of the story: where the files go, what the fingerprint is made of, how to give a systemd service a home, and the refresh loop a process that never restarts needs.

Where the files go when there is no AppData

A Keyright client keeps up to three files per product, all rooted on Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData):

{LocalApplicationData}/Keyright/{product}/lease.json     signed activation lease (cached after ActivateAsync)
{LocalApplicationData}/Keyright/{product}/state.json     first-seen / last-seen timestamps (trial clock, clock-tamper)
{LocalApplicationData}/Keyright/{product}/license.json   default location for an offline license file

On Unix, .NET resolves LocalApplicationData the XDG way: $XDG_DATA_HOME if it is set, otherwise $HOME/.local/share. And it does that on macOS too — not ~/Library/Application Support, which is where a Mac user would look. So for a user dana the lease sits at:

Linux   /home/dana/.local/share/Keyright/acme-agent/lease.json
macOS   /Users/dana/.local/share/Keyright/acme-studio/lease.json

Three things follow from “it is under $HOME”, and they are the whole first half of this post.

It is per user. Two users on the same machine each have their own cache and each activate once. That is not two seats: both activations fingerprint the same machine and send the same machineId, so the service binds one seat and refreshes it twice — exactly the shared-PC behaviour described in the MSIX post. The cache is per user; the seat was always per machine.

It depends on HOME being real and writable. A systemd unit with ProtectHome=yes sees an empty, read-only /home. A DynamicUser=yes unit runs as a transient UID whose home is /. A hand-made service account is often created with --home /nonexistent or --home /var/empty. In all of those, ~/.local/share/Keyright cannot be created. The SDK does not throw: the lease cache is deliberately best-effort, so a failed write is swallowed, ActivateAsync returns a perfectly valid LicenseInfo, and the service runs licensed — until the next restart, when there is no cached lease and it activates again. Online, that is invisible; it just costs a round-trip. Offline, it is NoLicense and a fail-closed service that worked yesterday.

A sandboxed Mac app has a different HOME. Under App Sandbox, HOME is redirected to the app’s container, so ~/.local/share/Keyright/acme-studio/lease.json lands at ~/Library/Containers/com.acme.studio/Data/.local/share/Keyright/acme-studio/lease.json. Everything works — the sandbox is writable, it survives updates, and it is deleted when the user removes the app and its container. It is the macOS counterpart of MSIX write virtualization: the path the SDK logs is not the path the bytes are in. Make it explicit with LeaseCachePath and friends if a support engineer will ever need the file.

What the fingerprint anchors to

The machine fingerprint the SDK sends on activation is a set of salted hashes, KRM1:anchor:soft1:soft2, built from an ordered list of components where index 0 is the anchor that must always match and the rest are soft signals that NodeLockTolerance (default 1) lets drift one at a time. On Windows the anchor is HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid. Off Windows there is no registry, and the SDK reads the platform’s equivalent:

One fingerprint, three anchor sourcesWindows · anchorHKLM\...\Cryptography\MachineGuidregistry, read-onlyLinux · anchor/etc/machine-id/var/lib/dbus/machine-id32 hex chars, mode 0444macOS · anchorIOPlatformUUID(IOPlatformExpertDevice)ioreg, survives upgradesnone readable · fallback~/.local/share/Keyright/machine.id (random, persisted)per user, not per machinesoft · machine-nameEnvironment.MachineName (hostname)soft · osPlatform:Version.Major e.g. Unix:6anchor must match;NodeLockTolerance = 1 letsone soft component changesalted SHA-256 per component, on the machine · only hashes leave itKRM1:7Q3K…(anchor):M2PX…(name):A9F4…(os) DisplayId KR-XXXX-XXXX-XXXXHostname changed? Kernel went 5 → 6? One soft mismatch, still this machine. New /etc/machine-id? A new machine.
  • Linux: /etc/machine-id, the 32-hex-character id systemd generates on first boot, with /var/lib/dbus/machine-id as a fallback for systems that only have the D-Bus one. It is world-readable (mode 0444), so a service running as an unprivileged user can read it without any capability — which is why DynamicUser= units fingerprint correctly.
  • macOS: the IOPlatformUUID of the IOPlatformExpertDevice entry in the I/O registry, read via ioreg. It is the per-machine id Apple’s own services use, and it survives OS upgrades, hostname changes and a user wiping ~/.
  • Nothing readable: the SDK generates a random id, persists it at ~/.local/share/Keyright/machine.id, and anchors on that. On Windows and on any normal Linux or Mac install this file never exists; it is the last resort for a container with no machine-id, or a locked-down sandbox that cannot spawn ioreg. Note what it costs: the fallback is per user, so two users on such a machine would be two machines and two seats.

The two soft components are the host name and Environment.OSVersion.Platform plus the major version — Unix:6 on a Linux 6.x kernel, the macOS major on a Mac. The tolerance is what keeps those honest: rename the host and the anchor still matches, one soft component differs, the lease validates. Upgrade a distribution from a 5.x to a 6.x kernel, same thing. Do both in the same week and you have two soft mismatches, which exceeds the default tolerance — the lease reports MachineMismatch and the next ActivateAsync re-binds, idempotently, because the service matches on the anchor. The design is explained in the fingerprint post; the Linux-specific point is just that your “hardware” anchor is a file.

The two machine-id traps

Because the anchor is a file, it can be copied, and it can be missing. Each is a different failure.

Copied: the golden image. Build a VM image, install your agent, snapshot it, provision forty hosts from the snapshot. Unless something cleared /etc/machine-id before the snapshot was sealed, all forty boot with the same id, hash to the same anchor, and present the same machineId. Keyright does what it does for a machine it has already seen: the first host activates and binds a seat, the other thirty-nine refresh it. The dashboard shows one device. You sold one seat and are running forty — this is the trap that fails open, which is the kind you do not get a ticket about. The fix lives in the image, not the SDK: truncate -s0 /etc/machine-id before sealing (an empty file tells systemd to generate a fresh id on first boot), or run systemd-machine-id-setup from first-boot provisioning. cloud-init does this; templates built by hand often do not. The cloned-VM post covers detecting the duplicates from the service side when you cannot control the images. If you want many hosts to count as one deployment, say so with a custom IMachineComponents, as in the containers post — a decision in code, not an accident in a template.

Missing: the container. Minimal container images ship an empty /etc/machine-id or none at all. The SDK reads an empty string, treats it as unreadable, and falls through to the persisted random fallback — which lives in the container’s writable layer and is gone with the container. Every restart is a new machine and a new seat. That is the same problem the containers post solves by overriding the identity source, and nothing on this page changes it; what this page adds is why the default behaves that way: the file is empty.

A systemd service: giving the SDK a home

Now the half of the post that matters for most Linux deployments: a licensed .NET worker running under systemd. A unit written with modern hardening looks like this, and every line that matters to licensing is annotated:

# /etc/systemd/system/acme-agent.service
[Unit]
Description=Acme Agent
After=network-online.target
Wants=network-online.target

[Service]
ExecStart=/opt/acme/acme-agent
User=acme
Group=acme
Restart=on-failure

# Hardening: the filesystem is read-only and /home is empty. $HOME/.local/share is not writable.
ProtectSystem=strict
ProtectHome=yes

# The one directory this service may write. systemd creates /var/lib/acme-agent, chowns it to acme,
# and exports it as $STATE_DIRECTORY. This is where the lease, local state and offline license go.
StateDirectory=acme-agent
StateDirectoryMode=0700

# Site/OEM deployments: hand the service a signed license file nobody else on the host can read.
# Exposed at $CREDENTIALS_DIRECTORY/license.json, readable by this service only, not in the environment.
LoadCredential=license.json:/etc/acme/license.json

# Key-activated deployments instead: the license key, same mechanism. One or the other.
# LoadCredential=license-key:/etc/acme/license.key

[Install]
WantedBy=multi-user.target

StateDirectory= is the answer to the HOME problem. It gives the service a directory that exists, is owned by the service user, survives restarts and upgrades, and is exempt from ProtectSystem=strict. StateDirectoryMode=0700 keeps other users on the host out of it. LoadCredential= is the answer to “where does the license come from on a machine with no human”: systemd copies the file into a per-service tmpfs and tells the process where with CREDENTIALS_DIRECTORY. Unlike an Environment= line, the content never appears in systemctl show, /proc/<pid>/environ, or a child process.

The client reads both:

using Keyright.Client;

static KeyrightClient CreateLicenseClient()
{
    var options = new KeyrightOptions
    {
        Product         = "acme-agent",
        PublicKeyBase64 = AcmeKeys.LicensePublicKey,       // public key only; safe to ship in the binary
        ServiceUrl      = "https://licensing.acme.example",
    };

    // systemd StateDirectory= — may list several directories, colon-separated; the first is ours.
    string? state = Environment.GetEnvironmentVariable("STATE_DIRECTORY")?.Split(':')[0];
    if (!string.IsNullOrEmpty(state))
    {
        string root = Path.Combine(state, "keyright");
        options.LeaseCachePath     = Path.Combine(root, "lease.json");
        options.LocalStatePath     = Path.Combine(root, "state.json");
        options.DefaultLicensePath = Path.Combine(root, "license.json");
    }

    // systemd LoadCredential= — a site/OEM license file. Explicit path = authoritative, no fall-through.
    string? creds = Environment.GetEnvironmentVariable("CREDENTIALS_DIRECTORY");
    if (creds is not null && File.Exists(Path.Combine(creds, "license.json")))
        options.LicenseFilePath = Path.Combine(creds, "license.json");

    return KeyrightClient.Initialize(options);
}

Outside systemd — a developer running the binary from a shell — neither variable is set, the SDK defaults to ~/.local/share, and the same code is right. There is no “am I a service” flag to get wrong.

The LicenseFilePath branch is the same explicit-source rule as everywhere else in the SDK: when it is set, the ambient sources (KEYRIGHT_LICENSE, the config path, the default path, the cached lease) are not consulted, and a missing or invalid file is NoLicense or SignatureInvalid rather than a fall-through. For a deployment where licensing is an operator decision, that is what you want. Note that the license file is not a secret in the way a password is — it is a signed document bound to this machine’s fingerprint, useless anywhere else — but it does carry the license key and the licensee’s name, and LoadCredential= is simply the cleanest way to put a file in front of exactly one process on a Linux host.

The macOS daemon story is the same with different names. A LaunchDaemon running as a dedicated _acme user has a home of /var/empty, which is read-only; HOME for a root daemon is /var/root. Put the three paths under /Library/Application Support/Acme/keyright/ with mode 0700, owned by the daemon’s user, and the SDK is happy. launchd has no LoadCredential=; a 0600 file under /Library/Application Support/Acme/ referenced via LicenseFilePath is the equivalent.

The daemon that never relaunches

Here is the difference that has nothing to do with file paths and bites hardest.

An activation lease is a short-lived signed token — 14 days by default — and the SDK’s recommended refresh pattern is call ActivateAsync at startup. For a desktop app that is a complete solution: the user launches it most days, each launch refreshes the lease when online, and a laptop that stays offline for two weeks is the edge case the window was sized for. A service inverts the frequencies. It launches once, after a deploy, and then runs for months. Nothing calls ActivateAsync again, the lease reaches leaseExpiresUtc, and the next Validate() — in a request handler, on day 15 — returns Expired with the message “reconnect to re-validate”. The process is up, the network is fine, the license is paid for, and the service has failed closed to Free because nobody relaunched it.

Who refreshes the 14-day lease?day 1day 8day 15day 20desktop app● = launch → ActivateAsync → lease extended 14 days from here. Never lapses.daemon, no loopExpired → Freeone launch at deploy. Process still running on day 15; nothing refreshed the lease.daemon + loopActivateAsync every 6 h: idempotent, same seat. Amber = service unreachable; cached lease bridges it.The lease is always within hours of fresh, so a 14-day window is 14 days of tolerated outage, not of uptime.

The fix is a refresh loop inside the service. ActivateAsync is safe to call repeatedly: re-activating a machine the service has already seen is idempotent — same seat, fresh lease — and if the licensing service is unreachable the SDK falls back to the still-valid cached lease rather than discarding it, so the loop can only ever extend the window, never shorten it.

using Keyright.Client;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

/// <summary>Keeps the activation lease fresh in a process that may run for months without restarting.</summary>
public sealed class LeaseRefresher(KeyrightClient license, ILogger<LeaseRefresher> log) : BackgroundService
{
    private static readonly TimeSpan Interval = TimeSpan.FromHours(6);

    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        // Key-activated deployment: the key arrives as a systemd credential, never in the environment.
        string? creds = Environment.GetEnvironmentVariable("CREDENTIALS_DIRECTORY");
        string? keyPath = creds is null ? null : Path.Combine(creds, "license-key");
        if (keyPath is null || !File.Exists(keyPath))
        {
            log.LogInformation("No license key credential; running from a license file, nothing to refresh.");
            return;                                   // site/OEM file deployments: the file IS the license
        }
        string key = (await File.ReadAllTextAsync(keyPath, ct)).Trim();

        using var timer = new PeriodicTimer(Interval);
        do
        {
            LicenseInfo info = await license.ActivateAsync(key, ct);   // idempotent: same machine, same seat, new lease
            if (info.IsValid)
                log.LogInformation("License lease refreshed; {Status}, {DaysRemaining} days remaining.", info.Status, info.DaysRemaining);
            else
                log.LogWarning("License refresh did not yield a valid lease: {Status} — {Message}", info.Status, info.Message);
        }
        while (await timer.WaitForNextTickAsync(ct));
    }
}

Register it with services.AddHostedService<LeaseRefresher>() next to the singleton client. Six hours is a reasonable interval: the lease is then always within six hours of fresh, so the 14-day window becomes fourteen days of tolerated outage of your licensing service rather than fourteen days of uptime. The first iteration runs immediately, which doubles as the startup activation. Log the status, never the key — the structured-logging post explains why info.Status and info.DaysRemaining are the right fields.

Two things the loop does not do, on purpose. It does not restart the process when the status goes bad: Validate() already fails closed on its own, and the health-check post shows how to surface a degraded license to the orchestrator without a crash loop. And it does not run for file-based deployments: a signed site license file has its own expiry and nothing to refresh, so the loop checks for the key credential and steps out.

The file mode the lease deserves

On Windows the lease lives under a profile only its user can read. On Linux, File.WriteAllText creates files at 0666 & ~umask, which with the usual 022 is 0644 — readable by every account on the host. The lease is not dangerous to read: it is signed, so editing it yields SignatureInvalid, and it is bound to this machine’s fingerprint, so copying it elsewhere yields MachineMismatch. But it does carry the license key, and a key lets its holder attempt an activation against your seat limit from any machine. On a single-user laptop that is nothing; on a shared build host or a jump box it is a key leak via ls.

StateDirectoryMode=0700 already closes this for a systemd service. For everything else, tighten the directory before the SDK writes into it — KeyrightClient.Initialize does not create the directories, the first write does, so creating them yourself first wins:

using System.IO;

static void SecureLicenseDirectory(string leaseCachePath)
{
    if (OperatingSystem.IsWindows()) return;                  // ACLs on the profile already do this
    string dir = Path.GetDirectoryName(leaseCachePath)!;
    Directory.CreateDirectory(dir, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);   // 0700
    if (File.Exists(leaseCachePath))
        File.SetUnixFileMode(leaseCachePath, UnixFileMode.UserRead | UnixFileMode.UserWrite);                   // 0600
}

Directory.CreateDirectory(path, UnixFileMode) and File.SetUnixFileMode are .NET 7+ APIs; on older targets a chmod P/Invoke does the same. A 0700 directory is enough on its own — a 0644 file inside a directory nobody else can traverse is unreadable — so the file-mode call is belt and braces for a lease that already existed before you tightened the directory.

Every post in this series ends up recommending the same diagnostic, because it is the thing that turns a ticket into one line: a --license-info flag that prints the paths and the fingerprint from the code path the gate uses, so what it shows is what the SDK does.

using Keyright.Client;
using Keyright.NodeLock;

// acme-agent --license-info
static void PrintLicenseInfo(KeyrightClient license)
{
    var fp = MachineFingerprint.Current();
    LicenseInfo info = license.Validate(out LicenseSourceKind source);

    Console.WriteLine($"Machine id    : {fp.ToBoundString()}");              // what the dashboard's Devices panel lists
    Console.WriteLine($"Display id    : {fp.DisplayId}");                    // short form for a support conversation
    Console.WriteLine($"Anchor source : {AnchorSource()}");
    Console.WriteLine($"Lease cache   : {license.LeaseCachePath}  (exists: {File.Exists(license.LeaseCachePath)})");
    Console.WriteLine($"Local state   : {license.LocalStatePath}");
    Console.WriteLine($"License file  : {license.DefaultPath}");
    Console.WriteLine($"Status        : {info.Status} via {source}  — {info.Message}");
}

static string AnchorSource()
{
    if (OperatingSystem.IsWindows()) return "registry MachineGuid";
    if (OperatingSystem.IsMacOS())   return "IOPlatformUUID";
    foreach (var p in new[] { "/etc/machine-id", "/var/lib/dbus/machine-id" })
        if (File.Exists(p) && File.ReadAllText(p).Trim().Length > 0) return p;
    return "persisted fallback (~/.local/share/Keyright/machine.id) — per user, not per machine";
}

The Anchor source line is the one that catches the golden-image and container traps before they reach production: /etc/machine-id on a real host, the fallback in a bare container. The via {source} suffix — Lease, ExplicitFile, EnvironmentVariable, DefaultPath — tells you which of the sources won, which is the first question when a service that should be reading a credential file is somehow activating online instead.

Verify it

The whole argument is testable on one host in a few minutes. The important trick is in step 2: run the diagnostic inside the unit’s environment, not from your own shell as the service user — sudo -u acme gives you the user but not STATE_DIRECTORY or the credentials, and the paths it prints are the wrong ones.

# 1. The anchor. Non-empty, and different on every host built from the same image.
cat /etc/machine-id
#    → e.g. 8c3f1a2b...   (empty? the image was not cleared: truncate -s0 /etc/machine-id and reboot)

# 2. Activate once, as the unit would. systemd-run builds a transient unit with the same properties.
sudo systemd-run --pty --wait -p User=acme -p StateDirectory=acme-agent \
    -p LoadCredential=license-key:/etc/acme/license.key \
    /opt/acme/acme-agent license activate
sudo systemd-run --pty --wait -p User=acme -p StateDirectory=acme-agent \
    /opt/acme/acme-agent --license-info
#    → Lease cache : /var/lib/acme-agent/keyright/lease.json (exists: True), Status: Valid via Lease
#    Dashboard: the license shows 1 device, the KRM1:… machine id from the line above.

# 3. Owner and mode: the service user, nobody else.
sudo ls -la /var/lib/acme-agent/keyright/
#    drwx------ acme acme .     -rw------- acme acme lease.json

# 4. Restart: the lease survives, no second seat. Dashboard: still 1 device.
sudo systemctl restart acme-agent && sudo journalctl -u acme-agent -n 3
#    → "License lease refreshed; Valid, 364 days remaining."

# 5. The loop. Block the licensing service for an hour and watch the warning line; unblock and watch it recover.
#    Then confirm with the dashboard's last-seen time that the lease is refreshed every 6 hours without a restart.

Add the one that matters for a fleet: provision a second host from the same image and run step 1 on both. If the two ids match, stop and fix the image; nothing after that is meaningful until they differ.

What to take away

Keyright treats Linux and macOS as first-class, and the SDK calls are identical, but the platform underneath a licensing SDK is not. The data folder is ~/.local/share on both and is per user, so a hardened service needs an explicit home — StateDirectory= gives it one, and LoadCredential= hands it a license file or key without a single secret in the environment. The fingerprint anchors on /etc/machine-id or the IOPlatformUUID instead of a registry value, which is just as stable — as long as your images do not ship one id to forty hosts, and your containers do not ship none. And the lease is refreshed by relaunching, which a daemon never does, so give it a loop. Make the paths explicit, print them and the anchor source in a diagnostic, and the day a support engineer asks why a licensed service went Free on day 15, the answer is already in the log.

Try Nebula.NET

Harden your .NET code in minutes — start with the free edition.