cameleer-license-minter/README.md — vendor-side guide: build, public LicenseMinter API, CLI usage with all flags, token format (standard base64, not url-safe), LicenseInfo schema, Ed25519 key generation, worked example, security guidance, runtime-separation verification. docs/license-enforcement.md — operator guide: install paths and priority (env > file > DB > none), public-key config, REST API, state machine (ABSENT/ACTIVE/GRACE/EXPIRED/INVALID), default tier caps, 403 envelope semantics, retention TTL recompute, daily revalidation, audit + Prometheus surfaces, troubleshooting. docs/handoff/2026-04-26-license-saas-handoff.md — SaaS playbook: trust model, onboarding/renewal/revocation runbooks, key management, cap matrix per plan tier, telemetry, failure modes, testing guidance. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cameleer-license-minter
Standalone vendor-side tool for producing signed Ed25519 license tokens consumed by cameleer-server. The minter is intentionally not a runtime or compile-scope dependency of the server — the server only ships with the matching public key and validates tokens via LicenseValidator. The private signing key never leaves the vendor's environment.
- Module GAV:
com.cameleer:cameleer-license-minter:1.0-SNAPSHOT - Maven coordinates of the runtime server (does not transitively pull this module):
com.cameleer:cameleer-server-app:1.0-SNAPSHOT - Build artifacts (after
mvn -pl cameleer-license-minter package):target/cameleer-license-minter-1.0-SNAPSHOT.jar— plain library JAR (consumable as a Maventestdependency or via theLicenseMinterAPI in custom tooling)target/cameleer-license-minter-1.0-SNAPSHOT-cli.jar— fat CLI JAR with main classcom.cameleer.license.minter.cli.LicenseMinterCli
Table of contents
Audience
Build
Public Java API
CLI usage
Token format
LicenseInfo schema
Limits dictionary
Generating an Ed25519 key pair
Worked example
Security guidance
Compatibility / runtime separation
Audience
Vendors / SaaS operators issuing licenses to customers who run cameleer-server. End-customer operators looking for how to install a token should read docs/license-enforcement.md instead.
Build
# From the repo root
mvn -pl cameleer-license-minter package
Two JARs land in cameleer-license-minter/target/:
| Artifact | Purpose |
|---|---|
cameleer-license-minter-1.0-SNAPSHOT.jar |
Plain library (the repackage execution for the main artifact is disabled; see pom.xml:50-54). Use this when embedding the minter inside your own tooling or a unit test that needs a fresh signed token. |
cameleer-license-minter-1.0-SNAPSHOT-cli.jar |
Fat CLI JAR. Repackaged by Spring Boot's spring-boot-maven-plugin with classifier cli; main class is com.cameleer.license.minter.cli.LicenseMinterCli. |
Public Java API
com.cameleer.license.minter.LicenseMinter is the only entry point for the library. It is a final, stateless utility class:
import com.cameleer.license.minter.LicenseMinter;
import com.cameleer.server.core.license.LicenseInfo;
LicenseInfo info = new LicenseInfo(
java.util.UUID.randomUUID(),
"acme-prod", // tenantId — must match server's CAMELEER_SERVER_TENANT_ID
"Acme Production (Tier B)", // human label, optional
java.util.Map.of(
"max_environments", 3,
"max_apps", 25,
"max_agents", 50,
"max_users", 20,
"max_total_replicas", 30
),
java.time.Instant.now(), // issuedAt
java.time.Instant.parse("2027-01-01T00:00:00Z"), // expiresAt
7 // gracePeriodDays
);
String token = LicenseMinter.mint(info, ed25519PrivateKey);
Source: cameleer-license-minter/src/main/java/com/cameleer/license/minter/LicenseMinter.java:20.
The method is thread-safe; the underlying Jackson ObjectMapper is configured once with ORDER_MAP_ENTRIES_BY_KEYS so canonical-JSON serialization is deterministic across runs and process boundaries.
LicenseMinter.mint will throw IllegalStateException if the JCE provider rejects the private key or the payload cannot be serialized.
CLI usage
The CLI entry point is com.cameleer.license.minter.cli.LicenseMinterCli. Run it from the fat JAR produced by the build:
java -jar cameleer-license-minter/target/cameleer-license-minter-1.0-SNAPSHOT-cli.jar \
--private-key=/secure/keys/cameleer-license-priv.pem \
--tenant=acme-prod \
--label="Acme Production (Tier B)" \
--expires=2027-01-01 \
--grace-days=7 \
--max-environments=3 \
--max-apps=25 \
--max-agents=50 \
--max-users=20 \
--max-total-replicas=30 \
--output=/secure/out/acme-prod.lic \
--public-key=/secure/keys/cameleer-license-pub.b64 \
--verify
Flag reference
Source of truth: cameleer-license-minter/src/main/java/com/cameleer/license/minter/cli/LicenseMinterCli.java:26.
| Flag | Required | Meaning |
|---|---|---|
--private-key=<path> |
yes | Path to a PKCS#8-encoded Ed25519 private key. Both PEM (-----BEGIN PRIVATE KEY-----) and raw base64 are accepted (LicenseMinterCli.readEd25519PrivateKey). |
--tenant=<tenantId> |
yes | The exact tenantId the server will compare against CAMELEER_SERVER_TENANT_ID. Mismatch causes the validator to throw at install / revalidation. |
--expires=<YYYY-MM-DD> |
yes | Expiration date interpreted as midnight UTC. The validator considers tokens expired once now > exp + gracePeriodDays. |
--label=<text> |
no | Human-readable label, surfaced via GET /api/v1/admin/license and /api/v1/admin/license/usage. |
--grace-days=<int> |
no | Number of days the license stays usable after --expires. Defaults to 0. |
--max-<limitkey>=<int> |
no, repeatable | Each --max-foo-bar flag becomes the limit key max_foo_bar. See the limits dictionary below. Unknown keys are accepted by the minter (the server side ignores keys it does not understand and falls through to defaults). |
--output=<path> |
no | Write the token to a file. When omitted, the token is printed to stdout. On --verify failure the file is deleted. |
--public-key=<path> |
no, required for --verify |
Path to the matching base64 X.509 SPKI public key file (one line, no PEM markers). |
--verify |
no | After minting, parse + signature-check the token using --public-key and --tenant. Exits non-zero if verification fails. |
Exit codes: 0 on success, 1 on minting / IO failure, 2 on argument validation failure, 3 on --verify failure.
Token format
A token is the concatenation of two standard base64 segments joined by a literal .:
base64(canonicalJson) + "." + base64(ed25519Signature)
- The canonical JSON payload is produced by
LicenseMinter.canonicalPayload(...)with keys sorted lexicographically andlimitsrendered as a sorted object. This makes the byte sequence deterministic given a fixedLicenseInfo. - The signature is computed with
Signature.getInstance("Ed25519")over the canonical payload bytes (not over the base64-encoded form). - Encoding is
Base64.getEncoder()(RFC 4648 §4 — not base64url). The validator decodes with the matchingBase64.getDecoder().
LicenseValidator.validate(...) (cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseValidator.java:42) splits on the first ., decodes both halves, verifies the signature, then deserializes the payload.
LicenseInfo schema
Source: cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseInfo.java. Field-by-field:
| Field | Type | Required | Semantics |
|---|---|---|---|
licenseId |
UUID |
yes | Stable identifier for this token. The server's audit trail records install/replace transitions by license id; renewals must use a fresh UUID so audit history is non-ambiguous. |
tenantId |
String |
yes | Must equal the server's CAMELEER_SERVER_TENANT_ID. The validator throws IllegalArgumentException on mismatch. Blank values are rejected by the canonical record constructor. |
label |
String |
no | Free-form human label. Surfaced on the admin/usage endpoints and the operator UI. Has no enforcement semantics. |
limits |
Map<String,Integer> |
yes (may be empty) | License-specific overrides. Any key that appears here is unioned over DefaultTierLimits.DEFAULTS to form the effective caps in ACTIVE / GRACE states. Keys not present fall through to defaults. |
issuedAt |
Instant (epoch seconds in JSON iat) |
yes | Stamped by the minter; not currently consulted by the validator beyond informational logging. |
expiresAt |
Instant (epoch seconds in JSON exp) |
yes | The validator throws if now > expiresAt + gracePeriodDays * 86400 at install or revalidation. |
gracePeriodDays |
int |
yes (>= 0) | Window after expiresAt during which the gate transitions to GRACE (license still grants its caps) before flipping to EXPIRED. Negative values are rejected at construction. |
Limits dictionary
Canonical key set: cameleer-server-core/src/main/java/com/cameleer/server/core/license/DefaultTierLimits.java. Any key not listed here is silently ignored by the server's LicenseGate.getEffectiveLimits().
| CLI flag | Key | Default | What the server enforces |
|---|---|---|---|
--max-environments |
max_environments |
1 | EnvironmentService.create(...) consults LicenseEnforcer.assertWithinCap("max_environments", currentCount, 1). |
--max-apps |
max_apps |
3 | AppService.createApp(...) checks total app count across all envs. |
--max-agents |
max_agents |
5 | AgentRegistryService.register(...) checks live agent count. |
--max-users |
max_users |
3 | User creation paths (UserAdminController, UiAuthController self-signup, OidcAuthController first-login). |
--max-outbound-connections |
max_outbound_connections |
1 | OutboundConnectionServiceImpl.create(...). |
--max-alert-rules |
max_alert_rules |
2 | AlertRuleController.create(...). |
--max-total-cpu-millis |
max_total_cpu_millis |
2000 | DeploymentExecutor PRE_FLIGHT compute cap (sum of replicas * cpuLimit over non-stopped deployments). |
--max-total-memory-mb |
max_total_memory_mb |
2048 | DeploymentExecutor PRE_FLIGHT compute cap (sum of replicas * memoryLimitMb). |
--max-total-replicas |
max_total_replicas |
5 | DeploymentExecutor PRE_FLIGHT compute cap (sum of replicas). |
--max-execution-retention-days |
max_execution_retention_days |
1 | ClickHouse TTL cap for executions, processor_executions. Effective TTL = min(cap, env.executionRetentionDays). |
--max-log-retention-days |
max_log_retention_days |
1 | ClickHouse TTL cap for logs. |
--max-metric-retention-days |
max_metric_retention_days |
1 | ClickHouse TTL cap for agent_metrics, agent_events. |
--max-jar-retention-count |
max_jar_retention_count |
3 | EnvironmentAdminController PUT /{envSlug}/jar-retention rejects requests above this cap. Also bounds the daily JarRetentionJob. |
Generating an Ed25519 key pair
The minter and validator both rely on the JCE Ed25519 algorithm shipped with JDK 17+. No external crypto library is needed.
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.util.Base64;
KeyPair kp = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
// 32-byte public key, X.509 SubjectPublicKeyInfo wrapped — this is what the server expects.
String publicKeyB64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
// PKCS#8 private key — the CLI's --private-key reader accepts this either as raw base64
// or PEM-wrapped (`-----BEGIN PRIVATE KEY-----`).
String privateKeyB64 = Base64.getEncoder().encodeToString(kp.getPrivate().getEncoded());
A one-liner using the JDK's keytool is not sufficient — keytool cannot produce raw Ed25519 PKCS#8 in a directly-usable shape for our reader. Generating via the API above (or openssl genpkey -algorithm ed25519) is the supported path.
For OpenSSL:
openssl genpkey -algorithm ed25519 -out cameleer-license-priv.pem
openssl pkey -in cameleer-license-priv.pem -pubout -outform DER \
| base64 -w0 > cameleer-license-pub.b64
The resulting cameleer-license-pub.b64 is the value to put into CAMELEER_SERVER_LICENSE_PUBLICKEY.
Worked example
End-to-end: generate a key pair, mint a license, install it on a running server, verify enforcement.
# 1. Vendor side — generate the keypair
openssl genpkey -algorithm ed25519 -out /secrets/cameleer-priv.pem
openssl pkey -in /secrets/cameleer-priv.pem -pubout -outform DER \
| base64 -w0 > /secrets/cameleer-pub.b64
# 2. Vendor side — distribute the public key (commit to deployment config / Vault / k8s Secret)
cat /secrets/cameleer-pub.b64
# MCowBQYDK2VwAyEAxxxxx...
# 3. Vendor side — mint a license for a customer tenant
mvn -pl cameleer-license-minter package -DskipTests
java -jar cameleer-license-minter/target/cameleer-license-minter-1.0-SNAPSHOT-cli.jar \
--private-key=/secrets/cameleer-priv.pem \
--public-key=/secrets/cameleer-pub.b64 \
--tenant=acme-prod \
--label="Acme Production" \
--expires=2027-01-01 \
--grace-days=14 \
--max-environments=3 \
--max-apps=25 \
--max-agents=50 \
--max-users=20 \
--max-total-replicas=30 \
--max-total-cpu-millis=15000 \
--max-total-memory-mb=16384 \
--max-execution-retention-days=30 \
--max-log-retention-days=14 \
--max-metric-retention-days=14 \
--max-jar-retention-count=10 \
--output=/tmp/acme.lic \
--verify
# 4. Customer side — server boots with public key + tenant id matching the mint
export CAMELEER_SERVER_TENANT_ID=acme-prod
export CAMELEER_SERVER_LICENSE_PUBLICKEY=$(cat /secrets/cameleer-pub.b64)
# 5. Customer side — install via the admin API after boot
curl -X POST https://server.example.com/api/v1/admin/license \
-H "Authorization: Bearer ${ADMIN_JWT}" \
-H "Content-Type: application/json" \
-d "{\"token\": \"$(cat /tmp/acme.lic)\"}"
# 6. Customer side — verify it was accepted
curl https://server.example.com/api/v1/admin/license \
-H "Authorization: Bearer ${ADMIN_JWT}"
# {"state":"ACTIVE","invalidReason":null,"envelope":{...},"lastValidatedAt":"..."}
curl https://server.example.com/api/v1/admin/license/usage \
-H "Authorization: Bearer ${ADMIN_JWT}"
# Shows current/cap/source per limit key
For boot-time installation (preferred for SaaS-managed deployments), set CAMELEER_SERVER_LICENSE_TOKEN instead of POSTing — see docs/license-enforcement.md.
Security guidance
- The Ed25519 private key is the trust root. Anyone who holds it can mint licenses for any tenant. Treat it like a code-signing key.
- Storage. Production private keys belong in an HSM, KMS (e.g. AWS KMS / GCP KMS with non-exportable signing), or a sealed Vault transit backend. A sealed file on a laptop is acceptable for low-volume / pre-production minting only and should never be committed to git or shared via chat.
- Rotation. Rotation is destructive: every customer running with the old public key will reject all new tokens signed with the new private key. The pragmatic procedure is:
- Generate the new keypair.
- Distribute the new public key (
CAMELEER_SERVER_LICENSE_PUBLICKEY) to every tenant's server config. - Once tenants confirm they are running with the new public key, re-mint and re-issue every active license under the new key.
- Decommission the old private key. Practical revocation flows through expiry — keep license terms short enough (12 months or less) that planned rotations stay aligned with renewal cadence.
- Auditing. The server records every install/replace/reject under
AuditCategory.LICENSE. The minter itself does not write audit rows; if you need a vendor-side audit trail of mint operations, wrapLicenseMinter.mint(...)in your own ticketing pipeline. - Never commit private keys.
.gitignoredoes not block them by name — use asecrets/directory excluded by your repository's policy, or store them entirely outside the working tree.
Compatibility / runtime separation
The minter is intentionally absent from cameleer-server-app's production classpath. To verify after a build:
mvn -pl cameleer-server-app dependency:tree | grep license-minter
# expected: empty output (or, in development branches, a single line scoped 'test')
cameleer-license-minter/pom.xml depends on cameleer-server-core for LicenseInfo and the validator round-trip used by --verify. The server app intentionally does not depend on the minter — vendors mint outside the customer-deployed runtime, and a compromised customer cannot leverage server code to forge tokens.