# 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 ```bash # 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: ```java 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: ```bash 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=` | yes | Path to a PKCS#8-encoded Ed25519 private key. Both PEM (`-----BEGIN PRIVATE KEY-----`) and raw base64 are accepted (`LicenseMinterCli.readEd25519PrivateKey`). | | `--tenant=` | yes | The exact `tenantId` the server will compare against `CAMELEER_SERVER_TENANT_ID`. Mismatch causes the validator to throw at install / revalidation. | | `--expires=` | yes | Expiration date interpreted as midnight UTC. The validator considers tokens expired once `now > exp + gracePeriodDays`. | | `--label=` | no | Human-readable label, surfaced via `GET /api/v1/admin/license` and `/api/v1/admin/license/usage`. | | `--grace-days=` | no | Number of days the license stays usable after `--expires`. Defaults to `0`. | | `--max-=` | 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=` | no | Write the token to a file. When omitted, the token is printed to stdout. On `--verify` failure the file is deleted. | | `--public-key=` | 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-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` | 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. ```java 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: ```bash 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. ```bash # 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: ```bash 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.