# 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: `io.cameleer:cameleer-license-minter:1.0-SNAPSHOT`
- Maven coordinates of the runtime server (does **not** transitively pull this module): `io.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 `io.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 `io.cameleer.license.minter.cli.LicenseMinterCli`. |

## Public Java API

`io.cameleer.license.minter.LicenseMinter` is the only entry point for the library. It is a final, stateless utility class:

```java
import io.cameleer.license.minter.LicenseMinter;
import io.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 `io.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-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.

```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-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.
