diff --git a/cameleer-license-minter/src/main/java/com/cameleer/license/minter/LicenseMinter.java b/cameleer-license-minter/src/main/java/com/cameleer/license/minter/LicenseMinter.java new file mode 100644 index 00000000..72c3caf8 --- /dev/null +++ b/cameleer-license-minter/src/main/java/com/cameleer/license/minter/LicenseMinter.java @@ -0,0 +1,52 @@ +package com.cameleer.license.minter; + +import com.cameleer.server.core.license.LicenseInfo; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import java.security.PrivateKey; +import java.security.Signature; +import java.util.Base64; +import java.util.TreeMap; + +public final class LicenseMinter { + + private static final ObjectMapper MAPPER = new ObjectMapper() + .configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true); + + private LicenseMinter() {} + + public static String mint(LicenseInfo info, PrivateKey ed25519PrivateKey) { + byte[] payload = canonicalPayload(info); + try { + Signature signer = Signature.getInstance("Ed25519"); + signer.initSign(ed25519PrivateKey); + signer.update(payload); + byte[] sig = signer.sign(); + return Base64.getEncoder().encodeToString(payload) + "." + Base64.getEncoder().encodeToString(sig); + } catch (Exception e) { + throw new IllegalStateException("Failed to sign license", e); + } + } + + static byte[] canonicalPayload(LicenseInfo info) { + ObjectNode root = MAPPER.createObjectNode(); + root.put("exp", info.expiresAt().getEpochSecond()); + root.put("gracePeriodDays", info.gracePeriodDays()); + root.put("iat", info.issuedAt().getEpochSecond()); + if (info.label() != null) { + root.put("label", info.label()); + } + root.put("licenseId", info.licenseId().toString()); + ObjectNode limits = MAPPER.createObjectNode(); + new TreeMap<>(info.limits()).forEach(limits::put); + root.set("limits", limits); + root.put("tenantId", info.tenantId()); + try { + return MAPPER.writeValueAsBytes(root); + } catch (Exception e) { + throw new IllegalStateException("Failed to serialize license payload", e); + } + } +} diff --git a/cameleer-license-minter/src/test/java/com/cameleer/license/minter/LicenseMinterTest.java b/cameleer-license-minter/src/test/java/com/cameleer/license/minter/LicenseMinterTest.java new file mode 100644 index 00000000..c2fddcd6 --- /dev/null +++ b/cameleer-license-minter/src/test/java/com/cameleer/license/minter/LicenseMinterTest.java @@ -0,0 +1,53 @@ +package com.cameleer.license.minter; + +import com.cameleer.server.core.license.LicenseInfo; +import com.cameleer.server.core.license.LicenseValidator; +import org.junit.jupiter.api.Test; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.time.Instant; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class LicenseMinterTest { + + @Test + void roundTrip_validatorAcceptsMintedToken() throws Exception { + KeyPair kp = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + String publicB64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded()); + + LicenseInfo info = new LicenseInfo( + UUID.randomUUID(), "acme", "ACME prod", + Map.of("max_apps", 50, "max_agents", 100), + Instant.now(), Instant.now().plusSeconds(86400), 7); + + String token = LicenseMinter.mint(info, kp.getPrivate()); + + LicenseInfo parsed = new LicenseValidator(publicB64, "acme").validate(token); + assertThat(parsed.licenseId()).isEqualTo(info.licenseId()); + assertThat(parsed.tenantId()).isEqualTo("acme"); + assertThat(parsed.limits().get("max_apps")).isEqualTo(50); + assertThat(parsed.gracePeriodDays()).isEqualTo(7); + } + + @Test + void canonicalJson_isStableAcrossRuns() throws Exception { + KeyPair kp = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + UUID id = UUID.randomUUID(); + Instant now = Instant.parse("2026-04-25T10:00:00Z"); + Instant exp = Instant.parse("2027-04-25T10:00:00Z"); + LinkedHashMap limits = new LinkedHashMap<>(); + limits.put("max_apps", 5); + limits.put("max_agents", 10); + LicenseInfo info = new LicenseInfo(id, "acme", "label", limits, now, exp, 0); + + String t1 = LicenseMinter.mint(info, kp.getPrivate()); + String t2 = LicenseMinter.mint(info, kp.getPrivate()); + assertThat(t1).isEqualTo(t2); + } +}