The web application can operate the online issuance workflow, but the browser does not perform cryptographic signing. It calls authorized control-plane APIs. Private keys remain in Azure Key Vault or Managed HSM, and the isolated BitzOrcas.LicenseSigner asks KMS/HSM to produce ES256 signatures.
Implemented boundary
Online issuance currently includes:
- UI creation, approval/rejection, issue, status, download, and revoke actions;
- a license-independent bootstrap surface for login, forced password change, password recovery, permission-filtered navigation, and the authorized LicenseManagement control plane;
- durable asynchronous calls from the API to an isolated signer;
- version-pinned Azure key URIs;
- independent public-key verification by the API, including a field-by-field check that only
signaturechanged; - idempotent recovery across network and process failures.
It does not include anonymous customer self-service issuance, keys in the browser, direct browser-to-KMS access, or a public customer lease-download service. Operators currently download the formal envelope and distribute it through a controlled channel. Runtime can also consume a separately implemented online ILicenseLeaseSource adapter.
1. Prepare the Azure key
Create an immutable key version for each rotation and retain:
| Item | Example | Secret |
|---|---|---|
KeyId | key-2026-01 | No; stable protocol id |
| Versioned key URI | https://vault.managedhsm.azure.net/keys/license-key/<version> | No, but controlled |
| ES256 public-key PEM | -----BEGIN PUBLIC KEY-----… | No; distributed to verifiers |
The URI must include a concrete version, never implicit latest. Grant the signer identity only the metadata and sign operations it needs; private-key export is not required. Deliver the API public key through an independent trusted path rather than treating a KMS response as the sole trust source.
For the first local P-256 ES256 key, use Azure CLI:
az loginaz keyvault key create \ --vault-name <vault-name> \ --name bitzorcas-runtime-license \ --kty EC \ --curve P-256 \ --ops sign verify
# Retain the complete versioned kid.az keyvault key show \ --vault-name <vault-name> \ --name bitzorcas-runtime-license \ --query key.kid \ --output tsv
# <version> is the final segment of the preceding kid.az keyvault key download \ --vault-name <vault-name> \ --name bitzorcas-runtime-license \ --version <version> \ --encoding PEM \ --file bitzorcas-runtime-license-public.pemPrefer Azure RBAC for the Key Vault data plane. A local Azure CLI identity can receive Key Vault Crypto User; production should use a dedicated managed identity. Keep key creation/rotation
authority separate from routine signing authority. See
az keyvault key and
Key Vault authentication.
2. Configure local AppHost
Complete the SQL Server, RabbitMQ, Redis, JWT, and AppHost parameters in local infrastructure. Then store development signing configuration under AppHost user secrets:
# Pin the protocol KeyId and immutable KMS key version.dotnet user-secrets set \ "LicenseManagement:Development:KeyId" \ "key-2026-01" \ --project src/Hosts/BitzOrcas.AppHost
dotnet user-secrets set \ "LicenseManagement:Development:VersionedKeyUri" \ "https://<vault>.managedhsm.azure.net/keys/<name>/<version>" \ --project src/Hosts/BitzOrcas.AppHost
# Configure the matching public key independently, then enable the worker.dotnet user-secrets set \ "LicenseManagement:Development:TrustedPublicKeyPem" \ "<ES256 public key PEM>" \ --project src/Hosts/BitzOrcas.AppHost
dotnet user-secrets set \ "LicenseManagement:Development:SigningWorkerEnabled" \ "true" \ --project src/Hosts/BitzOrcas.AppHostTo override the development control-plane tenant:
# Production must use a dedicated vendor control-plane tenant.dotnet user-secrets set \ "LicenseManagement:Development:ControlPlaneTenantId" \ "1000001" \ --project src/Hosts/BitzOrcas.AppHostAppHost generates and persists the service credential shared by API and signer, so developers do not copy a token. DefaultAzureCredential disables interactive-browser fallback. Local development may use an authenticated Azure CLI/IDE identity; production uses workload identity.
3. Start and inspect
scripts/local/bootstrap.sh aspirescripts/local/doctor.sh aspiredotnet run --project src/Hosts/BitzOrcas.AppHostdoctor.sh aspire prints no key or password material. It reports whether the signing side is missing
the versioned key URI, trusted public key, or worker enablement, and whether the local DeploymentId
and Runtime License Envelope exist. Missing signing configuration keeps online issuance fail-closed;
missing the Envelope still permits community.small.v1 while the deployment has at most 30 users.
Ordinary business operations remain denied when that policy is not selected, its authoritative meter
is unavailable, or the deployment is over capacity.
For an empty Development database that needs the admin and operator four-eyes actors, replace
the start command with explicit demo seeding:
BITZORCAS_ASPIRE_SEED_DEMO=true dotnet run --project src/Hosts/BitzOrcas.AppHostThe bootstrap writes the local-only 10-character initial-password hint to the gitignored
.bitzorcas/demo-credentials with mode 0600 when the secret is missing. Demo accounts do not
require a first-login password change. Restarting AppHost does not rotate that secret. To replace a
legacy 44-character value, follow
Demo data and seed reference.
Plaintext reaches only the one-shot schema initializer and is converted to BCrypt before storage; API
and JobHost never receive it. During this explicit AppHost demo seed only, higher-priority blank hash
settings mask stale USER:*:PASSWORD_HASH values that may remain in API user secrets. The original
secrets are not deleted, and non-Aspire seed paths still prefer precomputed hashes.
The user-role CSV keeps readable UserName input. Before authorization rows are written, the
Identity adapter resolves each name to its immutable user id and validates all user and role
references. Any invalid reference rejects the whole step instead of leaving partial bindings.
AppHost initializes the business and Quartz schemas before API and JobHost. Verify:
schema-initializerandquartz-schema-initializercomplete successfully.api,jobhost, andlicense-signerare Running.- A user with
license-management.runtime-license.viewcan inspect signer readiness. - Readiness is healthy before an issue action.
/health/live proves only that the signer process is alive. /health/ready also checks configuration, KMS, and receipt storage and requires the service bearer. Use the control-plane signer-status view rather than putting that bearer in a browser or shell history.
4. Run four-eyes issuance in the UI
Management route:
/license-management/runtime-licensesThis route is implemented in frontend/apps/app; it is not merely a planned screen. Complete the
forced password change before entering it. Without a Runtime License, unauthenticated calls should
still fail as Authentication.Required, rather than being intercepted first by
Licensing.Runtime.Unavailable.
Explicit Development demo seeding binds the control plane to tenant 1000001:
admin/root-admin: create, issue, download, and revoke;operator: view, approve, and reject.
End-to-end flow:
admincreates the request.operatorapproves it; requester and reviewer must differ.admintriggers issuance.- The UI observes
Pending → Processing → Completed. - After status becomes
Issued, download the envelope and compare its displayed SHA-256. - Deliver it through a controlled channel.
A rejected request becomes immutable Rejected. Create a new request rather than editing the old record.
5. Fill the request
| Field | Rule |
|---|---|
| CustomerId | Stable customer id, not the control-plane tenant |
| ProductId | Present in signer AllowedProductIds |
| Edition / Features | Match the contract and delivered Profile |
| VersionRange | Explicit supported version range |
| TenancyModes / Environments | Only the authorized scope |
| DeploymentLimit | 1 through 100000 |
| NotBefore / ExpiresAt | Business-validity window |
| OfflineUntil / GraceUntil | NotBefore ≤ OfflineUntil ≤ GraceUntil |
| DeploymentId | Persistent customer deployment identity, never Pod/MAC/CPU |
| KeyId | Exact match for the versioned KMS URI and public-key ring |
Protocol v1 is for dedicated deployments. Protocol v2 currently admits only community.web.v1; the UI fixes its Profile, Composition, and shared DeploymentId, while the API remains the final validator.
Recommended values for full-product local debugging
| Field | Value |
|---|---|
| ProductId | bitzorcas-modern |
| Edition | Enterprise |
| Features | framework.core, framework.aspnetcore, framework.infrastructure, workflow.runtime |
| VersionRange | [1.0.0,2.0.0) |
| TenancyModes | multi-tenant |
| Environments | development |
| DeploymentLimit | 1 |
| DeploymentId | Contents of .bitzorcas/license/deployment-id |
| KeyId | The stable AppHost alias, for example key-2026-01 |
| ProtocolVersion | 1 |
The time window must satisfy at least NotBefore ≤ ExpiresAt and
NotBefore ≤ OfflineUntil ≤ GraceUntil, while covering the intended debugging period. The submitted
payload is immutable; create a new request if any value is wrong.
Obtain the customer DeploymentId
The customer first configures a persistent Licensing:Runtime:DeploymentIdentityPath. Runtime atomically creates a 32-character lowercase GUID the first time it accesses this store. The customer transfers that id to the issuer through a controlled channel and retains the file on persistent storage.
Deleting or regenerating it after issuance causes a deployment-identity mismatch and an Invalid license.
6. Background signing and retry
The issue button does not synchronously wait for KMS. The API freezes request and idempotency facts in a short transaction. The worker then:
- claims a database lease;
- calls the signer outside the transaction;
- obtains or recovers a durable signing receipt;
- reverifies the signature and full payload;
- commits the envelope in another transaction.
Transient failures use bounded backoff. Eight unsuccessful attempts or corrupted durable payload move the operation to Failed with a safe stable error code. Repair KMS, network, storage, or configuration, then explicitly requeue from the control plane. Do not edit database status.
7. Production configuration
API/control plane:
LicenseManagement:ControlPlane:TenantId=<dedicated vendor tenant>LicenseManagement:Signing:BaseUrl=https://license-signer.internalLicenseManagement:Signing:SignPath=/v1/licenses/signLicenseManagement:Signing:ReadinessPath=/health/readyLicenseManagement:Signing:Provider=production-kmsLicenseManagement:Signing:TimeoutSeconds=10LicenseManagement:Signing:TrustedPublicKeys:<key-id>=<public-key PEM>LicenseManagement:Signing:BearerToken=<short-lived service credential>LicenseManagement:SigningWorker:Enabled=trueLicenseManagement:SigningWorker:PollIntervalSeconds=5LicenseManagement:SigningWorker:BatchSize=10LicenseManagement:SigningWorker:LeaseSeconds=90Isolated signer:
LicenseSigner:AllowedProductIds:0=bitzorcas-modernLicenseSigner:Authentication:BearerToken=<same current value as API>LicenseSigner:Authentication:PreviousBearerToken=<rotation window only>LicenseSigner:AzureKeyVault:Keys:<key-id>=https://<vault>/keys/<name>/<version>LicenseSigner:ReceiptStore:Directory=/var/lib/bitzorcas/license-signing-receiptsProduction requirements:
- API, signer, and KMS use a controlled private network;
- the control plane uses a dedicated tenant, not a business tenant;
- UI permissions separate create, approve, issue, export, and revoke;
- receipt storage is a dedicated durable volume; replicas share atomic-create storage or the signer stays single-replica;
- service credentials, workload identity, and KMS auditing are managed by security operations;
- private keys, bearers, and raw signing requests never enter logs.
8. Rotate credentials and keys
Service bearer:
- Configure the signer with the new
BearerTokenand old value inPreviousBearerToken. - Move every API instance to the new value.
- Confirm the old value is unused.
- Remove
PreviousBearerToken.
Signing key:
- Create a new immutable version and
KeyId. - Distribute the new public key to API and Runtime first.
- Confirm every target trusts it.
- Start issuing with the new
KeyId. - Remove the old public key only after reissue or the contractual window ends.
9. Download and activate for a customer
The download contains a suggested file name, JSON envelope, and SHA-256. Deliver it through an authenticated portal, enterprise file exchange, or equivalent controlled channel. Do not paste it into a public ticket.
Minimum customer Runtime configuration:
Licensing:Runtime:Enabled=trueLicensing:Runtime:ProductId=bitzorcas-modernLicensing:Runtime:ProductVersion=<deployed version>Licensing:Runtime:Environment=<licensed environment>Licensing:Runtime:TenancyMode=<licensed tenancy mode>Licensing:Runtime:DeploymentIdentityPath=/var/lib/bitzorcas/license/deployment-idLicensing:Runtime:CachePath=/var/lib/bitzorcas/license/runtime-license.jsonLicensing:Runtime:OfflineLicensePath=/run/secrets/bitzorcas-license.jsonLicensing:Runtime:TrustedPublicKeys:<key-id>=<public-key PEM>Inspect /health/license after startup. The status becomes Valid only when signature, runtime context, DeploymentId, and time windows all match.
10. Revoke
Only a principal with license-management.runtime-license.revoke may revoke. After a reason and confirmation, the control plane asks the same KMS to sign a verifiable Revoked=true envelope. Deliver that result to the target Runtime or online lease source; readiness becomes Unhealthy and business execution fails closed.
Deleting a database row or customer file is not verifiable revocation.
Troubleshooting
| Status/error | Response |
|---|---|
license-signer.not-configured | Check AllowedProductIds, bearer, versioned URI, receipt directory |
provider-unavailable | Check workload identity, KMS network, and get/sign permission |
receipt-store-unavailable | Check dedicated volume, access, and replica sharing |
invalid-request | Check KeyId, canonical collections, timeline, policy, deployment limit |
idempotency-conflict | One key bound different payloads; stop and investigate the caller |
Long RetryScheduled | Check readiness, worker switch, and next-attempt time |
Runtime Invalid | Compare public key, context, DeploymentId, and file integrity |