Licensing enforcement
IntentSharp checks a signed license key offline when a client is built. By default a missing, invalid or expired key never stops the library; it logs. The one hard stop is the version gate: a build released after a paid key's maintenance-until date refuses that key at construction. Evaluation keys are never gated. There is no phone-home and no kill switch.
The terms behind this are in LICENSE and COMMERCIAL-LICENSE.md. The decision is ADR 0010. The plan that built it is workplan-v3-licensing.md.
Where the pieces live
| Path | What | Ships with the library |
|---|---|---|
src/IntentSharp/Licensing/LicenseKey.cs |
LicenseEdition, LicensePayload, LicenseKeyCodec, LicenseKeyStatus, LicenseVerification, LicenseVerifier |
Yes |
src/IntentSharp/Licensing/LicensePublicKeys.cs |
LicensePublicKeys (trusted public keys) and ReleaseInfo (this build's release date and version) |
Yes |
src/IntentSharp/Licensing/LicenseEnforcer.cs |
LicenseEnforcer, the LicenseState enum and the LicenseStatus record it returns |
Yes |
src/IntentSharp/Licensing/LicenseException.cs |
LicenseException, thrown by Build() |
Yes |
src/IntentSharp/Licensing/LicenseNotice.cs |
LicenseInfo (public constants: license name, licensor, contact, purchase URL, free-use summary, purchase instructions) and LicenseNotice (internal and stateless: the text and log level of each state's notice, and the EventId) |
Yes |
src/IntentSharp.Licensing.Signing/ |
LicenseSigner: key pair generation and signing (private-key side) |
No |
tools/IntentSharp.LicenseTool/ |
intentsharp-license, the command-line tool for keys |
No |
services/IntentSharp.LicenseService/ |
The license service: evaluation form, Stripe checkout and webhook | No |
The library only verifies. It contains public keys and LicenseVerifier, never a private key and never a
reference to the signing project. The signing project is referenced by the tool, the service and the test
projects.
Key format
A key is one line of text:
ISK1.<base64url payload JSON>.<base64url signature>
ISK1is the format prefix (LicenseKeyCodec.Prefix). Before the product was renamed to IntentSharp the prefix wasSSK1; no production key was issued with it, and a key that starts withSSK1.is refused asMalformedlike any other wrong prefix (ADR 0010, addendum).- The payload is UTF-8 JSON serialized with web defaults (camelCase), enums as strings and nulls omitted
(
LicenseKeyCodec.JsonOptions). - The signature is ECDSA P-256 over SHA-256 of the exact payload bytes that appear in the key, in IEEE P1363 fixed-field form (64 bytes). Because the signed bytes are the bytes in the key, there is no canonicalization step.
- base64url is unpadded, with
-and_in place of+and/.
The payload (LicensePayload):
| JSON name | Property | Meaning |
|---|---|---|
v |
Version |
Payload version. Must be 1. |
id |
Id |
License id, for example lic_ plus 20 characters (LicenseSigner.NewLicenseId()). Required. |
licensee |
Licensee |
The licensed organization or person. Required. |
email |
Email |
Contact address. Optional. |
edition |
Edition |
Evaluation, Developer, Team, Business, Enterprise or Oem. |
seats |
Seats |
Licensed developers; 0 means unlimited (Enterprise and Oem) and is shown as "unlimited seats". Informational; the library never counts seats. LicenseSigner.Sign rejects a negative count. |
issued |
Issued |
Issue date. |
expires |
Expires |
End of the paid term, or of the evaluation. |
maintenanceUntil |
MaintenanceUntil |
The version gate date (see below). |
features |
Features |
A list of strings. Not interpreted by the library today. |
notes |
Notes |
Free text. Optional. |
Dates are calendar dates in UTC (DateOnly).
LicenseKeyCodec.TryDecode rejects a key that is empty, does not have three dot-separated parts starting
with ISK1, contains invalid base64url, has a signature that is not 64 bytes, has a payload that is not
valid JSON, or has a payload with a version other than 1 or an empty id or licensee. It does not check
the signature.
The verifier
LicenseVerifier is built from one or more SubjectPublicKeyInfo-encoded P-256 public keys (base64). It
needs at least one. Verify(key) returns a LicenseVerification whose Status is:
LicenseKeyStatus |
When |
|---|---|
Missing |
The key is null, empty or whitespace. |
Malformed |
TryDecode failed; Error says why. |
BadSignature |
The key decodes, but no trusted public key verifies the signature. The decoded payload is returned for diagnostics and must not be trusted. |
Valid |
One trusted public key verifies the signature. |
The verifier is thread-safe and does not interpret dates or editions. That is the enforcer's job.
Enforcement states
LicenseEnforcer applies the policy for one client. Its public surface:
| Member | What it does |
|---|---|
constructor (verifier, timeProvider, releaseDate, logger = null, reminderInterval = null) |
The trusted keys, the clock, the build's release date (ReleaseInfo.ReleaseDate in the client), the logger for the notices (null discards them) and the reminder interval (null uses DefaultReminderInterval; zero or less throws). |
Evaluate(key) |
Verifies and classifies the key for today, makes the result the current Status and returns it. Logs nothing. |
Status |
The current LicenseStatus. Set by Evaluate; MaybeRemind refreshes its date-based states (see The reminder). Throws InvalidOperationException before Evaluate. |
ThrowIfGated() |
Throws LicenseException when the state is VersionGated. |
ThrowIfUnlicensed() |
Throws LicenseException when the state is None, Invalid or EvaluationExpired. The builder calls it only under FailWhenUnlicensed. |
LogStatus() |
Writes the current state's notice now (the Build() line) and restarts the reminder interval. |
MaybeRemind() |
The request-path reminder, which also refreshes the date-based state. Returns true when it wrote a notice. Never throws for a license reason. |
ReleaseDate, ReminderInterval |
The values it was constructed with. |
RenewalWindowDays |
30: the days after a paid key's expiry during which it is RenewalDue rather than Lapsed. |
DefaultReminderInterval |
One hour, the default of IntentClientOptions.LicenseReminderInterval. |
LicenseStatus is a record with State (a LicenseState), Payload (the signed payload, or null for None
and Invalid), Message (the notice text) and DaysRemaining (days to the expiry date for Evaluation and
Licensed, to the end of the renewal window for RenewalDue, 0 on the last day, null otherwise).
LicenseState has the values None, Invalid, Evaluation, EvaluationExpired, Licensed,
RenewalDue, Lapsed and VersionGated. Missing maps to None, Malformed and BadSignature map to
Invalid (the untrusted payload of a bad signature is not exposed), and a Valid key maps to one of the
remaining states by its edition and dates:
| State | Condition | Behaviour |
|---|---|---|
| None | no key configured | works; Warning logged at Build() and again at most once per hour on use |
| Invalid | malformed or bad signature | works; Error logged at Build() and hourly, naming the problem |
| Evaluation | edition Evaluation, today <= Expires | works; Information at Build() with days remaining |
| EvaluationExpired | edition Evaluation, today > Expires | works; Error at Build() and hourly: "evaluation ended, purchase required" |
| Licensed | paid edition, today <= Expires | quiet; one Information line at Build() naming licensee and edition |
| RenewalDue | paid edition, Expires < today <= Expires + 30 days | works; Warning at Build() and hourly |
| Lapsed | paid edition, today > Expires + 30 days | works; Error at Build() and hourly |
| VersionGated | paid edition, ReleaseInfo.ReleaseDate > MaintenanceUntil (checked before the date rows) |
Build() throws LicenseException with licensee, dates, and the purchase instructions. Nothing else runs. |
"Today" is the UTC calendar date from the enforcer's TimeProvider, so tests can set the clock. Paid
editions are every edition except Evaluation. An Evaluation key is never VersionGated: it is Evaluation or
EvaluationExpired by its expiry date, whatever the build's release date.
Every notice is written through the injected ILogger with EventId 9100, named "IntentSharp.License",
so operators can filter or route license lines. The level is Information for Evaluation and Licensed,
Warning for None and RenewalDue, and Error for Invalid, EvaluationExpired and Lapsed. When Build() throws
(the gate, or FailWhenUnlicensed) nothing is logged; the exception carries the message. The text and
level of each notice come from the internal, stateless LicenseNotice; when and how often a notice is
written is decided by the client's own LicenseEnforcer. No license state is static: two clients in one
process keep separate reminder clocks.
The version gate
Every build carries its release date in ReleaseInfo.ReleaseDate (2026-10-11 for version
2.0.0-preview.1, the value in ReleaseInfo.Version). Both values are read at run time from the assembly:
<Version> and <ReleaseDate> are set in Directory.Build.props at the repository root, shared by every
project, and src/IntentSharp/IntentSharp.csproj stamps the date into the core assembly as the
AssemblyMetadata entry "ReleaseDate". A valid paid key whose MaintenanceUntil is earlier
than that date is refused by this build: Build() throws a LicenseException that names the licensee and
the dates and says how to buy, and no client is created.
- The gate applies to paid editions only and is checked before their date states. An Evaluation key never
throws at
Build()because of the gate; it reports Evaluation or EvaluationExpired. - It is the only hard failure, and it fires at construction, never during a request. A process that is already running is never stopped.
- A key with no trusted signature has no trustworthy
MaintenanceUntil, so the gate does not apply to None or Invalid; those states log instead (unlessFailWhenUnlicensedis set). - This is what makes the commercial fallback work: after a subscription lapses, every build released on or before the key's maintenance-until date keeps accepting the key, and every later build refuses it. The contract wording is in COMMERCIAL-LICENSE.md section 3 item 5 and FAQ 7.6.
<ReleaseDate>inDirectory.Build.propsmust be bumped together with<Version>for every release, or the gate compares keys against an old date. A build without the metadata fails at type initialization rather than defaulting silently.
The reminder
IntentClient.TransformAsync<T> calls MaybeRemind() at its start. GenerateAsync,
ExecuteIntentAsync, the extension methods and SemanticDatabase all go through TransformAsync<T>.
MaybeRemind() does nothing but read the clock until at least IntentClientOptions.LicenseReminderInterval
(default one hour) has passed since the last notice. It is a timestamp comparison against the
TimeProvider on the request path: no timer and no background thread. One caller per interval does the
rest; concurrent callers return at once. If the clock moves backwards, the interval restarts without a
notice.
When the interval has passed, MaybeRemind() does two things:
- It refreshes the state. It re-applies the date rules to a verified key for today and replaces
Statuswhen the state or the days remaining changed. A long-running client built as Licensed therefore becomes RenewalDue the day after the expiry date and Lapsed the day after the 30-day renewal window ends, and an Evaluation becomes EvaluationExpired the day after its expiry date, without a restart. The change appears on the first request after the date changes once the interval has passed since the last notice. The signature and the version gate are not checked again: they cannot change while the process runs. None, Invalid and VersionGated are never refreshed. - It repeats the notice for the states whose row says "hourly" (None, Invalid, EvaluationExpired, RenewalDue and Lapsed), using the refreshed state. Evaluation and Licensed do not remind, but once a refresh moves a client into a repeating state, that state's Warning or Error is logged from then on.
The refreshed state is what IntentClient.License returns.
FailWhenUnlicensed
IntentClientOptions.FailWhenUnlicensed defaults to false. When it is true, Build() throws a
LicenseException for None, Invalid and EvaluationExpired instead of logging. It is for teams that want a
hard stop in their own pipelines. RenewalDue and Lapsed are not affected; they still work and log.
How the client uses it
IntentClientBuilder.Build()creates the enforcer withLicensePublicKeys.All, evaluatesIntentClientOptions.LicenseKey, throws for the gate or forFailWhenUnlicensed, logs the state, and hands the enforcer to the client. An internal hook lets tests pass their own verifier,TimeProviderand release date.IntentClient.Licensereturns the enforcer's currentLicenseStatus: the state evaluated atBuild(), with its date-based state refreshed at most once perLicenseReminderIntervalwhile the client is used (see The reminder).services.AddIntentSharp(...)builds the client through the same builder, so the same evaluation runs. ALicenseExceptionpropagates unchanged; an application that resolvesIntentClientat startup fails at startup when the gate fires.- The key comes from
IntentClientOptions.LicenseKey:IntentClientBuilder.WithLicenseKey(...)in code, orIntentSharp:Options:LicenseKeyin configuration withAddIntentSharp.
Key rotation
LicensePublicKeys.All lists every trusted public key; today it holds one, LicensePublicKeys.Production2026.
LicenseVerifier accepts a key signed by any listed key, so rotation is additive:
- Generate a new pair with
intentsharp-license keygen --out <dir>. It prints the public key and the C# line to paste intoLicensePublicKeys.cs. - Add the new public key to
LicensePublicKeysand toAll. Keep the old key listed until every license signed with it has expired. - Release a build that trusts the new key.
- Only then switch the tool and the license service to the new private key. A key signed with a private key that a build does not trust is Invalid in that build.
The service side of rotation (configuration, backups) is in ../operations/license-service.md.
The private key
- The production private key is not in the repository and never will be. It is kept outside the repository
by the owner.
.gitignoreexcludes*.pemand*signing-key*. - Nothing logs, stores or returns a private key.
- Tests never need the production key. They generate a pair with
LicenseSigner.GenerateKeyPair()and constructLicenseVerifierwith that public key.
The license tool
tools/IntentSharp.LicenseTool builds the console tool intentsharp-license. It parses its own
arguments and depends only on the signing project.
| Command | What it does |
|---|---|
keygen --out <dir> |
Writes intentsharp-license-signing-key.pem (private key, PKCS#8) and intentsharp-license-public-key.txt (SPKI base64). The private key file is readable by the current user only: mode 0600, or on Windows a protected access list with one full-control rule for the current user and nothing inherited from the folder. Refuses to overwrite. Prints the public key and the C# line for LicensePublicKeys.cs. |
sign --key <pem path> --licensee <name> --edition <edition> [options] |
Signs a key and prints it on stdout only, so it can be piped; a one-line summary goes to stderr. Options: --email, --seats <n\|unlimited> (0 or unlimited means unlimited), --issued, --expires, --maintenance-until, --notes, --id. The key file is read like the service reads it: blocks that hold no key, such as the EC PARAMETERS block openssl ecparam -genkey writes before the key, are skipped. |
verify --public <base64 or path> <key> |
Prints the status and the decoded payload as a table. For a key with a bad signature it still prints the payload, under a line saying it is not trusted. - as <key> reads the key from standard input. Exit code 0 only for a valid key. |
inspect <key> |
Prints the payload without checking the signature, and says so. - as <key> reads the key from standard input. |
verify, inspect and the sign summary print key text with control characters, format characters
(bidirectional overrides, zero-width and other invisible marks) and line separators escaped as \uXXXX, so a
licensee cannot hide or reorder text on the terminal. Zero-width non-joiner and joiner stay as they are.
| help [<command>] | Prints the tool's usage, or one command's. |
sign defaults: issued is today (UTC); seats are 1 for Developer and Evaluation, 10 for Team, 50 for
Business, and unlimited (stored as 0) for Enterprise and Oem; expires is issued plus 29 days for Evaluation (30 calendar days including the issue day)
and issued plus one year otherwise; maintenance-until equals expires; the id is new. Every command takes
--help or -h. Options can be written --name value or --name=value. Exit codes: 0 success, 1 failure,
2 usage error.
The license service
services/IntentSharp.LicenseService is an ASP.NET Core minimal API on net10.0 that issues evaluation keys
from a web form and paid keys from Stripe. The licensor and seller it names is C2Consultants LLC. It runs on
Azure App Service: a test copy on the Stripe sandbox is live, and the production address,
https://license.intentsharp.com, is bound to App Service but not live yet; until it is, email is the way to
buy (COMMERCIAL-LICENSE.md section 4).
| Endpoint | What it does |
|---|---|
GET /, GET /evaluate |
The evaluation form: name, email, company, intended use, and a checkbox accepting the license terms. |
POST /api/evaluation |
Validates the form, allows one evaluation per email domain per 365 days (per address for free-mail domains), rate-limits to 5 requests per IP per hour, signs a 30-day one-seat Evaluation key, stores the lead and the key, and emails the key. The response never shows the key. |
GET /buy |
The buy page: Developer, Team, Business and Enterprise with their prices, one form that posts to /api/checkout. |
POST /api/checkout |
Creates a Stripe Checkout Session in subscription mode for an edition and returns its URL (JSON), or redirects to it (a form post from the buy page). Seat ranges: Developer 1 to 1,000, Team 1 to 10 (the page always sends 10), Business 11 to 50, Enterprise 1 to 100,000 (the key is issued with unlimited seats whatever the count). With Stripe:RequireTermsAcceptance true the session requires the buyer to accept the terms of service. Stripe returns the buyer to Stripe:SuccessUrl (/buy/complete) or Stripe:CancelUrl (/buy). |
GET /buy/complete |
The success page: thanks the buyer and says the key is on its way to the checkout email address, normally within a few minutes, and what to do if it has not come within an hour. It never shows a key, reads no query value (Stripe's session_id is never echoed), calls no Stripe API and sets no cookies; the key comes only from the webhook, by email. |
POST /api/stripe/webhook |
Verifies the Stripe signature, ignores duplicate event ids, and handles checkout.session.completed when the session is paid and checkout.session.async_payment_succeeded (sign and email a paid key for one year; an unpaid session waits for the later event), invoice.paid for a subscription cycle (sign and email a key that ends when the billing period the invoice paid for ends, never before the old dates; one year past the old dates when the invoice carries no period; an invoice whose period is not about one year renews nothing), and customer.subscription.deleted (mark the license lapsed; the version gate does the rest). Answers 400 for a bad or missing signature and 503 when the webhook secret is not configured. |
GET /LICENSE, GET /COMMERCIAL-LICENSE.md |
The license texts, embedded at build time from the repository root files. The evaluation form links to them. |
GET /healthz |
Health check. |
It stores leads, licenses and processed Stripe event ids in SQLite. A lead holds what the evaluation form asked
for (name, email address, company, intended use), when the terms were accepted, the issued key's license id, and
the normalized address and domain the one-per-year rule compares; the key itself, with the company as licensee
and the address, is in the lead's Evaluation row in licenses. A background job (Retention/) deletes each
lead, with the Evaluation license row it names, once its key was issued more than
LicenseService:LeadRetentionDays days ago (default 730, the 24 months of the privacy notice): one minute after
start and then daily, in batches of 100 with one transaction each. It never deletes a paid license, a row with
any Stripe id, or a Stripe event id, and never a lead the one-per-year rule can still read (one younger than 365
days, whatever the setting). A shutdown during a run rolls back the batch in progress and keeps the batches
before it, and the run logs their counts. A request to see or delete one person's records is handled by hand
with SQL commands; there is no endpoint for it. Deleting a lead on request removes what the one-per-year rule
reads, so the rule resets for that domain or free-mail address unless another lead of the last 365 days shares
it; whether to keep a record without personal data instead is an open owner decision (operations guide,
section 9). Copies of the key emails kept by the sending mailbox, and the log store, are outside the database
and outside both deletions.
The service sends mail through one of three providers chosen by Email:Provider: Microsoft Graph sendMail
with an Entra ID app registration (the owner's Office 365 path), SMTP, or a log sender that is allowed only in
Development and Testing unless Email:AllowLogSender is true (then every key is written to the log). Keys bought
through Stripe are stored before their email is queued, so a failed send never loses a key; it is logged with
the license id for a manual resend, and so is every key whose email was not confirmed sent when the host's
shutdown timeout passed.
Every page ends with a footer naming the seller, C2Consultants LLC, and the support address, with "Terms of
sale" and "Privacy" links when LicenseService:TermsUrl and LicenseService:PrivacyUrl are set. Every key
email ends with the support address, the license id to quote and the seller's name; the purchase and renewal
emails also link the terms of sale when LicenseService:TermsUrl is set. The support address is
LicenseService:SupportEmail, else Email:From, else licensing@c2consultants.net
(Configuration/ServiceContact.cs).
Running, configuring, deploying and backing it up are in ../operations/license-service.md; the browser runbook for Stripe and the Entra app is ../operations/stripe-and-microsoft365-setup.md.
What enforcement does not do
- It makes no network call. A key is checked against public keys compiled into the build.
- It never stops a running process and never refuses a request. Expired and lapsed keys log.
- It does not count seats, machines or deployments.
- It cannot stop someone who edits the source. IntentSharp is source-available; removing or circumventing the check is a breach of LICENSE section 5.3, and that is the enforcement for it.