Files
cameleer-server/cameleer-license-minter
hsiegeln 858975f03f
Some checks failed
CI / cleanup-branch (push) Has been skipped
CI / build (push) Failing after 2m57s
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Has been skipped
refactor(license): extract cameleer-license-api module from server-core
Splits the pure license contract types (LicenseInfo, LicenseValidator,
LicenseState, LicenseStateMachine, LicenseLimits, DefaultTierLimits) into a
new cameleer-license-api module under package com.cameleer.license.

Why: cameleer-license-minter previously depended on cameleer-server-core for
these types, dragging cameleer-server-core + cameleer-common onto the
classpath of every minter consumer (notably cameleer-saas). The SaaS
management plane has no business carrying server-runtime types — it only
needs the license contract to mint and verify tokens.

After:
  cameleer-license-minter -> cameleer-license-api  (no server internals)
  cameleer-server-core    -> cameleer-license-api
  cameleer-saas           -> cameleer-license-minter -> cameleer-license-api

Verified: mvn -pl cameleer-license-minter dependency:tree shows the minter
no longer pulls cameleer-server-core or cameleer-common. Full reactor
verify (-DskipITs) green: 371 tests pass.

LicenseGate stays in server-core (server-runtime state holder, not contract).

Closes cameleer/cameleer-server#156

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 20:06:52 +02:00
..

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 Maven test dependency or via the LicenseMinter API in custom tooling)
    • target/cameleer-license-minter-1.0-SNAPSHOT-cli.jar — fat CLI JAR with main class com.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.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 and limits rendered as a sorted object. This makes the byte sequence deterministic given a fixed LicenseInfo.
  • 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 matching Base64.getDecoder().

LicenseValidator.validate(...) (cameleer-license-api/src/main/java/com/cameleer/license/LicenseValidator.java:42) splits on the first ., decodes both halves, verifies the signature, then deserializes the payload.

LicenseInfo schema

Source: cameleer-license-api/src/main/java/com/cameleer/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-license-api/src/main/java/com/cameleer/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:
    1. Generate the new keypair.
    2. Distribute the new public key (CAMELEER_SERVER_LICENSE_PUBLICKEY) to every tenant's server config.
    3. Once tenants confirm they are running with the new public key, re-mint and re-issue every active license under the new key.
    4. 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, wrap LicenseMinter.mint(...) in your own ticketing pipeline.
  • Never commit private keys. .gitignore does not block them by name — use a secrets/ 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-license-api for the pure license contract types (LicenseInfo, LicenseValidator) used by mint + --verify. It deliberately does not depend on cameleer-server-core, so consumers of the minter (e.g. cameleer-saas) do not inherit server-runtime types onto their classpath. 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.