Admin & signing
The administrator’s view of licensing: who signs licenses, how the website asks for one without ever touching a signing key, the policy that governs seats and machine moves, and how signing keys are rotated. This is reference for operators — end users never see any of it.
Two systems, one job
The website (this app) handles commerce and orchestration — Stripe, seats, entitlement state. The licensing portal (a separate C# service) is the only thing that signs licenses. The website never holds a signing key; it asks the portal to issue, and the portal returns a finished, signed .lic.
Who signs, and how the key is protected
The portal picks its signer by environment. In Production it uses Azure Key Vault: it sends a SHA-256 digest to the vault, which returns an ES256 signature. The private key is non-exportable and never leaves the vault — it never exists in the portal’s process memory. In development/staging the portal uses a local key file instead, and that dev signer is hard-blocked from running in Production (it throws on startup if the environment is Production). The website, in every environment, holds no key of any kind.
The website → portal issue contract
When a purchase or trial grant needs a license, the website calls the portal’s service endpoint, POST /api/service/licenses/issue. The contract is deliberately strict:
- Bearer token. The call carries an
Authorization: Bearer <ServiceIssueToken>compared in constant time. The authorizer fails closed — if the secret is unconfigured, every call is rejected. - HTTPS only in production. A transport guard rejects non-HTTPS requests in Production (honoring a trusted proxy’s first-hop
X-Forwarded-Proto), returninginsecure_transport. - Idempotency. An
Idempotency-Keyheader is required; replays within 48 hours return the prior result instead of issuing twice. Reusing a key for a different request is rejected. - Overrides are stripped. Policy overrides (extra machine, long expiry) are ignored for the service principal — those remain human-admin-only. The website does its own Stripe / entitlement / seat checks first, but the portal still enforces every one of its own invariants.
On success the portal returns the license id, customer id, the delivery file name (modelxcelpro-{email}-{licenseId}.lic), the kind, and the signed envelope text. Before persisting, the portal re-verifies its own freshly signed license against the runtime keyset — a license that wouldn’t verify is never delivered.
Seat & machine-move policy
Issuance enforces these invariants regardless of who calls:
| Rule | Policy |
|---|---|
| Machine code format | Must be exactly 64 lowercase hex characters, or issuance is refused. |
| Distinct machines per customer | At most 3 distinct machine codes per email in a rolling 365-day window. |
| Re-issuing for a known machine | Always allowed — it doesn’t consume a new slot. |
| A new machine beyond the limit | Requires an explicit admin override plus a reason (not available to the service principal). |
| Trial length | A trial can be valid for at most 90 days. |
| Long paid expiry | Beyond ~5 years requires an admin override and a reason; since the service path strips overrides, the effective service cap is ~5 years. |
A renewal reuses the prior customer and (if no new machine code is given) the prior machine, and records the previous license id in renewedFromLicenseId. This is how a v3 license “moves”: by issuing a new or rotated license for the new machine, never by a local move counter.
Issuing a license (admin)
- Customer sends their machine codeFrom the License Status dialog (Copy button) via the support channel.
- Sign in to the portalWith a Microsoft Entra ID account in the configured LicensingAdmins group.
- Issue or renewEnter the machine code and validity; the portal signs (Key Vault in production) and self-verifies.
- Deliver the fileDownload
modelxcelpro-{email}-{licenseId}.licand send it back. The customer activates it offline — see Activation.
New licenses are v3 JSON, machine-bound by default. Existing v2 XML licenses remain valid until their signed expiry.
Signing-key rotation
The add-in verifies against a public keyset embedded in the build. That creates one hard ordering rule for rotation:
Ship the public key in the add-in before issuing with it
A license signed with a new key only verifies if the add-in already knows that key’s public half. So an add-in update carrying the new public key must ship first; only then may the portal be configured to issue with the new key id (kid).
- Create a new Key Vault keyA non-exportable EC P-256 key with a fresh kid.
- Export its public metadataVia the admin LicenseGen tool (public coordinates only — no private material).
- Embed it and ship the add-inAdd the public key to the add-in’s runtime keyset and release that build.
- Point the portal at the new kidConfigure the portal to issue with the new key id once the add-in update is out.
Old keys keep verifying until their licenses expire, so rotation is non-disruptive; a key is marked revoked only for an emergency compromise. The portal also serves public key material at runtime through a public-key provider — it never exposes private keys.
Release readiness
A readiness script (Test-LicensingReleaseReadiness.ps1) gates production. It fails if the active runtime key looks like a development key (its kid contains dev), if there’s no active non-dev key or the key lacks lifecycle metadata, if required production settings are blank (Entra config, KeyVaultKeyId, V3KeyId, the runtime keys path, a ServiceIssueToken of at least 32 characters, debug compilation off), or if a dependency vulnerability scan finds anything.
Current beta provisioning — stated plainly
In the current build the embedded runtime key is a development key (kid mxp-v3-dev-2026-05), so the readiness check would not yet pass for production signing. The Key Vault production path is implemented and ready; a production key must be generated, embedded in an add-in build, and configured in the portal before general availability. Until then, treat issued licenses as beta artifacts.
Where to go next