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>
288 lines
16 KiB
Markdown
288 lines
16 KiB
Markdown
# 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=<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-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.
|
|
|
|
```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.
|