Skip to content

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), returning insecure_transport.
  • Idempotency. An Idempotency-Key header 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:

RulePolicy
Machine code formatMust be exactly 64 lowercase hex characters, or issuance is refused.
Distinct machines per customerAt most 3 distinct machine codes per email in a rolling 365-day window.
Re-issuing for a known machineAlways allowed — it doesn’t consume a new slot.
A new machine beyond the limitRequires an explicit admin override plus a reason (not available to the service principal).
Trial lengthA trial can be valid for at most 90 days.
Long paid expiryBeyond ~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)

  1. Customer sends their machine codeFrom the License Status dialog (Copy button) via the support channel.
  2. Sign in to the portalWith a Microsoft Entra ID account in the configured LicensingAdmins group.
  3. Issue or renewEnter the machine code and validity; the portal signs (Key Vault in production) and self-verifies.
  4. Deliver the fileDownload modelxcelpro-{email}-{licenseId}.lic and 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).

  1. Create a new Key Vault keyA non-exportable EC P-256 key with a fresh kid.
  2. Export its public metadataVia the admin LicenseGen tool (public coordinates only — no private material).
  3. Embed it and ship the add-inAdd the public key to the add-in’s runtime keyset and release that build.
  4. 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