feat(license-minter): implement LicenseMinter library
Pure signing primitive: serialises LicenseInfo to canonical JSON (sorted top-level keys via ORDER_MAP_ENTRIES_BY_KEYS plus a TreeMap for the limits sub-object) then signs with Ed25519. Round-trips through LicenseValidator and is byte-stable across runs for identical inputs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String, Integer> 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user