diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/core/license/LicenseValidatorTest.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/core/license/LicenseValidatorTest.java new file mode 100644 index 00000000..de3bab2c --- /dev/null +++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/core/license/LicenseValidatorTest.java @@ -0,0 +1,87 @@ +package com.cameleer3.server.core.license; + +import org.junit.jupiter.api.Test; + +import java.security.*; +import java.security.spec.NamedParameterSpec; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Base64; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class LicenseValidatorTest { + + private KeyPair generateKeyPair() throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("Ed25519"); + return kpg.generateKeyPair(); + } + + private String sign(PrivateKey key, String payload) throws Exception { + Signature signer = Signature.getInstance("Ed25519"); + signer.initSign(key); + signer.update(payload.getBytes()); + return Base64.getEncoder().encodeToString(signer.sign()); + } + + @Test + void validate_validLicense_returnsLicenseInfo() throws Exception { + KeyPair kp = generateKeyPair(); + String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded()); + LicenseValidator validator = new LicenseValidator(publicKeyBase64); + + Instant expires = Instant.now().plus(365, ChronoUnit.DAYS); + String payload = """ + {"tier":"HIGH","features":["topology","lineage","debugger"],"limits":{"max_agents":50,"retention_days":90},"iat":%d,"exp":%d} + """.formatted(Instant.now().getEpochSecond(), expires.getEpochSecond()).trim(); + String signature = sign(kp.getPrivate(), payload); + String token = Base64.getEncoder().encodeToString(payload.getBytes()) + "." + signature; + + LicenseInfo info = validator.validate(token); + + assertThat(info.tier()).isEqualTo("HIGH"); + assertThat(info.hasFeature(Feature.debugger)).isTrue(); + assertThat(info.hasFeature(Feature.replay)).isFalse(); + assertThat(info.getLimit("max_agents", 0)).isEqualTo(50); + assertThat(info.isExpired()).isFalse(); + } + + @Test + void validate_expiredLicense_throwsException() throws Exception { + KeyPair kp = generateKeyPair(); + String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded()); + LicenseValidator validator = new LicenseValidator(publicKeyBase64); + + Instant past = Instant.now().minus(1, ChronoUnit.DAYS); + String payload = """ + {"tier":"LOW","features":["topology"],"limits":{},"iat":%d,"exp":%d} + """.formatted(past.minus(30, ChronoUnit.DAYS).getEpochSecond(), past.getEpochSecond()).trim(); + String signature = sign(kp.getPrivate(), payload); + String token = Base64.getEncoder().encodeToString(payload.getBytes()) + "." + signature; + + assertThatThrownBy(() -> validator.validate(token)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("expired"); + } + + @Test + void validate_tamperedPayload_throwsException() throws Exception { + KeyPair kp = generateKeyPair(); + String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded()); + LicenseValidator validator = new LicenseValidator(publicKeyBase64); + + String payload = """ + {"tier":"LOW","features":["topology"],"limits":{},"iat":0,"exp":9999999999} + """.trim(); + String signature = sign(kp.getPrivate(), payload); + + // Tamper with payload + String tampered = payload.replace("LOW", "BUSINESS"); + String token = Base64.getEncoder().encodeToString(tampered.getBytes()) + "." + signature; + + assertThatThrownBy(() -> validator.validate(token)) + .isInstanceOf(SecurityException.class) + .hasMessageContaining("signature"); + } +} diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseValidator.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseValidator.java new file mode 100644 index 00000000..3c4f314f --- /dev/null +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseValidator.java @@ -0,0 +1,92 @@ +package com.cameleer3.server.core.license; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.security.*; +import java.security.spec.X509EncodedKeySpec; +import java.time.Instant; +import java.util.*; + +public class LicenseValidator { + + private static final Logger log = LoggerFactory.getLogger(LicenseValidator.class); + private static final ObjectMapper objectMapper = new ObjectMapper(); + + private final PublicKey publicKey; + + public LicenseValidator(String publicKeyBase64) { + try { + byte[] keyBytes = Base64.getDecoder().decode(publicKeyBase64); + KeyFactory kf = KeyFactory.getInstance("Ed25519"); + this.publicKey = kf.generatePublic(new X509EncodedKeySpec(keyBytes)); + } catch (Exception e) { + throw new IllegalStateException("Failed to load license public key", e); + } + } + + public LicenseInfo validate(String token) { + String[] parts = token.split("\\.", 2); + if (parts.length != 2) { + throw new IllegalArgumentException("Invalid license token format: expected payload.signature"); + } + + byte[] payloadBytes = Base64.getDecoder().decode(parts[0]); + byte[] signatureBytes = Base64.getDecoder().decode(parts[1]); + + // Verify signature + try { + Signature verifier = Signature.getInstance("Ed25519"); + verifier.initVerify(publicKey); + verifier.update(payloadBytes); + if (!verifier.verify(signatureBytes)) { + throw new SecurityException("License signature verification failed"); + } + } catch (SecurityException e) { + throw e; + } catch (Exception e) { + throw new SecurityException("License signature verification failed", e); + } + + // Parse payload + try { + JsonNode root = objectMapper.readTree(payloadBytes); + + String tier = root.get("tier").asText(); + + Set features = new HashSet<>(); + if (root.has("features")) { + for (JsonNode f : root.get("features")) { + try { + features.add(Feature.valueOf(f.asText())); + } catch (IllegalArgumentException e) { + log.warn("Unknown feature in license: {}", f.asText()); + } + } + } + + Map limits = new HashMap<>(); + if (root.has("limits")) { + root.get("limits").fields().forEachRemaining(entry -> + limits.put(entry.getKey(), entry.getValue().asInt())); + } + + Instant issuedAt = root.has("iat") ? Instant.ofEpochSecond(root.get("iat").asLong()) : Instant.now(); + Instant expiresAt = root.has("exp") ? Instant.ofEpochSecond(root.get("exp").asLong()) : null; + + LicenseInfo info = new LicenseInfo(tier, features, limits, issuedAt, expiresAt); + + if (info.isExpired()) { + throw new IllegalArgumentException("License expired at " + expiresAt); + } + + return info; + } catch (IllegalArgumentException e) { + throw e; + } catch (Exception e) { + throw new IllegalArgumentException("Failed to parse license payload", e); + } + } +}