A customer with four hundred desktops does not want to activate four hundred machines, and their IT team will tell you so before the purchase order is signed. The answer every vendor arrives at is a site license: one key for the whole organisation, no per-machine ceremony. The question that rarely gets written down is what such a key is bound to. A node-locked key is bound to a machine fingerprint. A floating key is bound to a seat count on a server. A site key with no binding at all is a file that unlocks your product for anyone who has it — and files travel.
This post builds a site license on Keyright that is bound to something the whole organisation shares and a stranger does not: the directory domain. It is a signed entitlement, not a new license type; the gate is twenty lines; and the threat model is stated honestly, because a domain name is a fence, not a lock.
What the key says
A node-locked Keyright license carries a machineId and the validator refuses to run on any machine whose fingerprint does not match. A site license sets machineId to nothing and instead names the site in the signed entitlement set:
{
"product": "acme-studio",
"licensee": "Contoso Ltd",
"expiryUtc": "2027-10-17T00:00:00Z",
"tier": "pro",
"entitlements": {
"site:domain": "corp.contoso.example",
"site:domain-sid": "S-1-5-21-3623811015-3361044348-30300820",
"export": "true",
"max-projects": "unlimited"
},
"signature": "…"
}
Two things matter here. The site: entries are ordinary entitlements — string values inside the canonical payload that the signature covers — so a customer can no more change corp.contoso.example to home.example than they can push the expiry out a year: one changed byte and LicenseStatus.SignatureInvalid is what the app sees. And the prefix is a convention, not a Keyright feature: it keeps where this key may run visibly separate from what this key unlocks, so nobody later gates a feature on a site claim or vice versa.
site:domain-sid is optional. The DNS domain is the portable, cross-platform binding; the SID is the Windows-only hardening you add when the customer or the contract value justifies it. We will come back to what each one actually proves.
Where the machine's identity comes from
The app has to answer "what domain am I on?" from the machine itself, offline, at startup. There are two tempting APIs and one of them is wrong.
Environment.UserDomainName returns the NetBIOS domain of the user running the process — CONTOSO — and on a machine that is not joined to any domain it returns the machine name instead. That makes it useless for a fail-closed check (you cannot tell "not joined" from "joined to a domain that happens to share the machine's name") and it is flat, so it cannot express that emea.corp.contoso.example is part of corp.contoso.example.
IPGlobalProperties.GetIPGlobalProperties().DomainName returns the host's primary DNS suffix, which domain membership sets: emea.corp.contoso.example on a joined workstation, an empty string on a laptop that was never joined. It is hierarchical, it is cross-platform (on Linux it reads the host's configured domain name), and the empty-string case is exactly the signal you want.
For the SID, WindowsIdentity.GetCurrent().User.AccountDomainSid returns the SID of the domain the current account belongs to — S-1-5-21-… with the final relative identifier stripped. For a domain user on a joined machine that is the forest's domain SID. For a local account it is the local machine's SID, which will not match and should not.
using System.Net.NetworkInformation;
using System.Security.Principal;
public interface ISiteIdentity
{
string? DnsDomain { get; } // "emea.corp.contoso.example"; null when not joined
string? DomainSid { get; } // "S-1-5-21-…"; null off Windows or for a local account
}
public sealed class RealSiteIdentity : ISiteIdentity
{
public string? DnsDomain
{
get
{
try
{
var d = IPGlobalProperties.GetIPGlobalProperties().DomainName;
return string.IsNullOrWhiteSpace(d) ? null : d.TrimEnd('.').ToLowerInvariant();
}
catch { return null; } // no network stack info → treat as unjoined
}
}
public string? DomainSid
{
get
{
if (!OperatingSystem.IsWindows()) return null;
try
{
using var id = WindowsIdentity.GetCurrent();
return id.User?.AccountDomainSid?.Value; // null for well-known/non-domain SIDs
}
catch { return null; }
}
}
}
Every failure path returns null, and null means "not joined". That is deliberate: the gate below treats an unknown identity on a site-licensed key as a refusal, not a pass.
The gate
The gate runs after Validate() and only does anything when the key carries a site claim. Matching the DNS domain is a suffix match on label boundaries: a key issued for corp.contoso.example must accept a workstation in the child domain emea.corp.contoso.example, but must not accept notcorp.contoso.example — hence the leading dot in the comparison.
public enum SiteStatus { Ok, NotSiteLicensed, NotJoined, WrongDomain, WrongDomainSid }
public static class SiteGate
{
public static SiteStatus Check(LicenseInfo info, ISiteIdentity site)
{
var claim = info.Entitlements.GetRaw("site:domain");
if (string.IsNullOrWhiteSpace(claim))
return SiteStatus.NotSiteLicensed; // ordinary key: nothing to check here
var here = site.DnsDomain;
if (here is null)
return SiteStatus.NotJoined; // fail closed: unknown ≠ allowed
claim = claim.Trim().TrimEnd('.').ToLowerInvariant();
bool inDomain = here == claim
|| here.EndsWith("." + claim, StringComparison.Ordinal);
if (!inDomain)
return SiteStatus.WrongDomain;
var sidClaim = info.Entitlements.GetRaw("site:domain-sid");
if (!string.IsNullOrWhiteSpace(sidClaim)
&& !string.Equals(site.DomainSid, sidClaim.Trim(), StringComparison.OrdinalIgnoreCase))
return SiteStatus.WrongDomainSid;
return SiteStatus.Ok;
}
}
Wire it at startup, after the signature and expiry checks the SDK already performs:
static readonly KeyrightClient License = KeyrightClient.Initialize(new KeyrightOptions
{
Product = "acme-studio",
PublicKeyBase64 = AcmeKeys.LicensePublicKey, // public key only; safe to ship
});
LicenseInfo info = License.Validate(); // signature, product, expiry, (no) node-lock
if (!info.IsValid) { RunEvaluation(info); return; }
var site = SiteGate.Check(info, new RealSiteIdentity());
if (site is not (SiteStatus.Ok or SiteStatus.NotSiteLicensed))
{
RunEvaluation(info, reason: site); // "this key is for corp.contoso.example"
return;
}
RunLicensed(info);
Note what the SDK did not do: with machineId absent, Validate() never computed a machine fingerprint, so there is no per-machine state, nothing to "activate", and nothing that drifts when IT re-images a desktop. Four hundred machines, one file, one check.
One deliberate ordering: the site check runs after IsValid. A key with a bad signature never reaches the gate, so the entitlements it carries — including a site:domain an attacker could have typed in — are never read. Entitlements are only trustworthy once the signature over them has verified, and Keyright enforces that by returning an empty entitlement set for anything that did not validate.
Getting the values from the customer
The weak point of any identity-bound license is the hand-off: the customer has to tell you what their domain is, and if they tell you something subtly different from what the machine reports (contoso.example versus corp.contoso.example, a NetBIOS name instead of a DNS one), the first launch after purchase fails with "wrong domain" while everyone involved insists they are on the right domain.
Close that gap by making the product report its own answer, from exactly the code path the gate uses:
// yourapp --site-info
var id = new RealSiteIdentity();
Console.WriteLine($"DNS domain : {id.DnsDomain ?? "(not domain-joined)"}");
Console.WriteLine($"Domain SID : {id.DomainSid ?? "(n/a)"}");
Ask for that output on the order form. Neither value is confidential — the DNS suffix is in every email the company sends, and the domain SID is readable by any authenticated user in the domain — so this is no more sensitive than asking for a company name. The issuing side writes the two strings into the entitlements and signs; what the customer pasted is, by construction, what the check will see. (Issue for the parent domain the customer names, not the child the diagnostic happened to run in: a key for corp.contoso.example covers EMEA and APAC; a key for emea.corp.contoso.example does not.)
What the binding proves — honestly
Here is the part that belongs in your own threat model, not just the marketing page.
A DNS domain name is a policy fence. It is not a secret and it is not a cryptographic identity. Anyone holding a leaked site key can stand up a throwaway forest named corp.contoso.example, join a VM to it, and the name check passes. What the name does stop is the overwhelmingly common failure: the employee who copies license.json to a home PC, the contractor who keeps it after the engagement, the file that ends up in a shared drive. Those machines are not joined to the customer's domain, the gate fails closed, and the product tells them precisely why. For most site licenses — where the customer is a known organisation with a contract — that is the entire job.
A domain SID raises the bar considerably. The SID of a forest is generated at random when the forest is created; there is no supported tooling to choose it, so reproducing S-1-5-21-3623811015-3361044348-30300820 means cloning the customer's actual domain controller, not typing a name into a wizard. An attacker outside the organisation cannot do that; an insider with domain admin rights could, but an insider with domain admin rights is also the person the customer's contract already covers. Pin the SID when the deal is large enough that a renamed forest is a realistic attack, and accept that it is Windows-only — on a Linux workstation DomainSid is null, and the gate above skips the SID comparison only when the key does not carry one, so issue Linux-only sites without it.
Neither is a seat count. A site key says nothing about how many machines run it, because it was designed not to. If a contract needs a hard ceiling — "up to 400 concurrent installs" — the instrument is online activation with per-machine seats and a server that counts them, which is a different Keyright feature with a different operational cost (node-locking and seat management covers it). Trying to get a ceiling out of a domain claim is how vendors end up with a site license that is neither convenient nor enforced.
Tests: a fake site, three machines
The gate is pure given an ISiteIdentity, so the three machines in the figure are three tests:
sealed record FakeSite(string? DnsDomain, string? DomainSid) : ISiteIdentity;
static LicenseInfo SiteKey(string domain, string? sid = null) =>
TestLicenses.Valid(entitlements: new()
{
["site:domain"] = domain,
["site:domain-sid"] = sid!, // null entry is dropped by the test helper
});
[Fact]
public void Child_domain_workstation_passes_by_suffix()
=> Assert.Equal(SiteStatus.Ok, SiteGate.Check(
SiteKey("corp.contoso.example", "S-1-5-21-1-2-3"),
new FakeSite("emea.corp.contoso.example", "S-1-5-21-1-2-3")));
[Fact]
public void Unjoined_laptop_fails_closed()
=> Assert.Equal(SiteStatus.NotJoined, SiteGate.Check(
SiteKey("corp.contoso.example"),
new FakeSite(DnsDomain: null, DomainSid: null)));
[Fact]
public void Lookalike_domain_does_not_suffix_match()
=> Assert.Equal(SiteStatus.WrongDomain, SiteGate.Check(
SiteKey("corp.contoso.example"),
new FakeSite("notcorp.contoso.example", null)));
[Fact]
public void Renamed_forest_fails_the_sid_check()
=> Assert.Equal(SiteStatus.WrongDomainSid, SiteGate.Check(
SiteKey("corp.contoso.example", "S-1-5-21-1-2-3"),
new FakeSite("corp.contoso.example", "S-1-5-21-9-9-9")));
The third test is the one people forget. Without the leading dot in the suffix comparison, notcorp.contoso.example ends with corp.contoso.example and the gate opens for a domain the customer never owned.
Rolling it out
Treat the site claim like any new enforcement: issue the first site keys with the claim present but the gate in audit mode — log SiteStatus without acting on it — for a release, read the logs, then flip to enforcing. The audit-mode rollout pattern applies unchanged, and the thing it will catch is the one this post cannot predict for you: the customer whose "one domain" turns out to be three forests from two acquisitions.
A site license is a promise about where software may run, made to an organisation rather than a machine. Keyright lets you sign that promise into the key; the domain gives the app something real to check it against; and the SID, when you need it, makes the check expensive to lie to. What it does not do is count — and knowing that is what keeps the convenience from quietly becoming a leak.
Top comments (0)