diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/config/LicenseBeanConfig.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/config/LicenseBeanConfig.java index 41717c88..926c2a52 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/config/LicenseBeanConfig.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/config/LicenseBeanConfig.java @@ -26,6 +26,9 @@ public class LicenseBeanConfig { @Value("${cameleer.server.license.publickey:}") private String licensePublicKey; + @Value("${cameleer.server.tenant.id:default}") + private String tenantId; + @Bean public LicenseGate licenseGate() { LicenseGate gate = new LicenseGate(); @@ -42,7 +45,7 @@ public class LicenseBeanConfig { } try { - LicenseValidator validator = new LicenseValidator(licensePublicKey); + LicenseValidator validator = new LicenseValidator(licensePublicKey, tenantId); LicenseInfo info = validator.validate(token); gate.load(info); } catch (Exception e) { diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/LicenseAdminController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/LicenseAdminController.java index 05557017..039b7e80 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/LicenseAdminController.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/LicenseAdminController.java @@ -20,11 +20,14 @@ public class LicenseAdminController { private final LicenseGate licenseGate; private final String licensePublicKey; + private final String tenantId; public LicenseAdminController(LicenseGate licenseGate, - @Value("${cameleer.server.license.publickey:}") String licensePublicKey) { + @Value("${cameleer.server.license.publickey:}") String licensePublicKey, + @Value("${cameleer.server.tenant.id:default}") String tenantId) { this.licenseGate = licenseGate; this.licensePublicKey = licensePublicKey; + this.tenantId = tenantId; } @GetMapping @@ -42,7 +45,7 @@ public class LicenseAdminController { return ResponseEntity.badRequest().body(Map.of("error", "No license public key configured")); } try { - LicenseValidator validator = new LicenseValidator(licensePublicKey); + LicenseValidator validator = new LicenseValidator(licensePublicKey, tenantId); LicenseInfo info = validator.validate(request.token()); licenseGate.load(info); return ResponseEntity.ok(info); diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/core/license/LicenseValidatorTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/core/license/LicenseValidatorTest.java index cbb8c1a9..54e43835 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/core/license/LicenseValidatorTest.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/core/license/LicenseValidatorTest.java @@ -2,11 +2,14 @@ package com.cameleer.server.core.license; import org.junit.jupiter.api.Test; -import java.security.*; -import java.security.spec.NamedParameterSpec; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.Signature; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Base64; +import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -29,12 +32,12 @@ class LicenseValidatorTest { void validate_validLicense_returnsLicenseInfo() throws Exception { KeyPair kp = generateKeyPair(); String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded()); - LicenseValidator validator = new LicenseValidator(publicKeyBase64); + LicenseValidator validator = new LicenseValidator(publicKeyBase64, "acme"); Instant expires = Instant.now().plus(365, ChronoUnit.DAYS); String payload = """ - {"tier":"HIGH","limits":{"max_agents":50,"retention_days":90},"iat":%d,"exp":%d} - """.formatted(Instant.now().getEpochSecond(), expires.getEpochSecond()).trim(); + {"licenseId":"%s","tenantId":"acme","label":"HIGH","tier":"HIGH","limits":{"max_agents":50,"retention_days":90},"iat":%d,"exp":%d,"gracePeriodDays":7} + """.formatted(UUID.randomUUID(), Instant.now().getEpochSecond(), expires.getEpochSecond()).trim(); String signature = sign(kp.getPrivate(), payload); String token = Base64.getEncoder().encodeToString(payload.getBytes()) + "." + signature; @@ -43,18 +46,20 @@ class LicenseValidatorTest { assertThat(info.label()).isEqualTo("HIGH"); assertThat(info.getLimit("max_agents", 0)).isEqualTo(50); assertThat(info.isExpired()).isFalse(); + assertThat(info.tenantId()).isEqualTo("acme"); + assertThat(info.gracePeriodDays()).isEqualTo(7); } @Test void validate_expiredLicense_throwsException() throws Exception { KeyPair kp = generateKeyPair(); String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded()); - LicenseValidator validator = new LicenseValidator(publicKeyBase64); + LicenseValidator validator = new LicenseValidator(publicKeyBase64, "acme"); Instant past = Instant.now().minus(1, ChronoUnit.DAYS); String payload = """ - {"tier":"LOW","limits":{},"iat":%d,"exp":%d} - """.formatted(past.minus(30, ChronoUnit.DAYS).getEpochSecond(), past.getEpochSecond()).trim(); + {"licenseId":"%s","tenantId":"acme","tier":"LOW","limits":{},"iat":%d,"exp":%d} + """.formatted(UUID.randomUUID(), past.minus(30, ChronoUnit.DAYS).getEpochSecond(), past.getEpochSecond()).trim(); String signature = sign(kp.getPrivate(), payload); String token = Base64.getEncoder().encodeToString(payload.getBytes()) + "." + signature; @@ -67,11 +72,11 @@ class LicenseValidatorTest { void validate_tamperedPayload_throwsException() throws Exception { KeyPair kp = generateKeyPair(); String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded()); - LicenseValidator validator = new LicenseValidator(publicKeyBase64); + LicenseValidator validator = new LicenseValidator(publicKeyBase64, "acme"); String payload = """ - {"tier":"LOW","limits":{},"iat":0,"exp":9999999999} - """.trim(); + {"licenseId":"%s","tenantId":"acme","tier":"LOW","limits":{},"iat":0,"exp":9999999999} + """.formatted(UUID.randomUUID()).trim(); String signature = sign(kp.getPrivate(), payload); // Tamper with payload @@ -82,4 +87,55 @@ class LicenseValidatorTest { .isInstanceOf(SecurityException.class) .hasMessageContaining("signature"); } + + @Test + void validate_missingTenantId_throws() throws Exception { + KeyPair kp = generateKeyPair(); + String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded()); + LicenseValidator validator = new LicenseValidator(publicKeyBase64, "acme"); + + Instant exp = Instant.now().plus(30, ChronoUnit.DAYS); + String payload = """ + {"licenseId":"%s","tier":"X","limits":{},"iat":%d,"exp":%d} + """.formatted(UUID.randomUUID(), Instant.now().getEpochSecond(), exp.getEpochSecond()).trim(); + String token = Base64.getEncoder().encodeToString(payload.getBytes()) + "." + sign(kp.getPrivate(), payload); + + assertThatThrownBy(() -> validator.validate(token)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("tenantId"); + } + + @Test + void validate_tenantIdMismatch_throws() throws Exception { + KeyPair kp = generateKeyPair(); + String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded()); + LicenseValidator validator = new LicenseValidator(publicKeyBase64, "beta"); + + Instant exp = Instant.now().plus(30, ChronoUnit.DAYS); + String payload = """ + {"licenseId":"%s","tenantId":"acme","tier":"X","limits":{},"iat":%d,"exp":%d} + """.formatted(UUID.randomUUID(), Instant.now().getEpochSecond(), exp.getEpochSecond()).trim(); + String token = Base64.getEncoder().encodeToString(payload.getBytes()) + "." + sign(kp.getPrivate(), payload); + + assertThatThrownBy(() -> validator.validate(token)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("tenantId"); + } + + @Test + void validate_missingLicenseId_throws() throws Exception { + KeyPair kp = generateKeyPair(); + String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded()); + LicenseValidator validator = new LicenseValidator(publicKeyBase64, "acme"); + + Instant exp = Instant.now().plus(30, ChronoUnit.DAYS); + String payload = """ + {"tenantId":"acme","tier":"X","limits":{},"iat":%d,"exp":%d} + """.formatted(Instant.now().getEpochSecond(), exp.getEpochSecond()).trim(); + String token = Base64.getEncoder().encodeToString(payload.getBytes()) + "." + sign(kp.getPrivate(), payload); + + assertThatThrownBy(() -> validator.validate(token)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("licenseId"); + } } diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseValidator.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseValidator.java index d4f9210d..09067ae4 100644 --- a/cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseValidator.java +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseValidator.java @@ -5,12 +5,16 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.security.*; +import java.security.KeyFactory; +import java.security.PublicKey; +import java.security.Signature; import java.security.spec.X509EncodedKeySpec; import java.time.Instant; import java.util.Base64; import java.util.HashMap; import java.util.Map; +import java.util.Objects; +import java.util.UUID; public class LicenseValidator { @@ -18,8 +22,13 @@ public class LicenseValidator { private static final ObjectMapper objectMapper = new ObjectMapper(); private final PublicKey publicKey; + private final String expectedTenantId; - public LicenseValidator(String publicKeyBase64) { + public LicenseValidator(String publicKeyBase64, String expectedTenantId) { + Objects.requireNonNull(expectedTenantId, "expectedTenantId is required"); + if (expectedTenantId.isBlank()) { + throw new IllegalArgumentException("expectedTenantId must not be blank"); + } try { byte[] keyBytes = Base64.getDecoder().decode(publicKeyBase64); KeyFactory kf = KeyFactory.getInstance("Ed25519"); @@ -27,6 +36,7 @@ public class LicenseValidator { } catch (Exception e) { throw new IllegalStateException("Failed to load license public key", e); } + this.expectedTenantId = expectedTenantId; } public LicenseInfo validate(String token) { @@ -38,7 +48,6 @@ public class LicenseValidator { 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); @@ -52,11 +61,24 @@ public class LicenseValidator { throw new SecurityException("License signature verification failed", e); } - // Parse payload try { JsonNode root = objectMapper.readTree(payloadBytes); - String tier = root.get("tier").asText(); + String licenseIdStr = textOrThrow(root, "licenseId"); + UUID licenseId; + try { + licenseId = UUID.fromString(licenseIdStr); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("licenseId is not a valid UUID: " + licenseIdStr); + } + + String tenantId = textOrThrow(root, "tenantId"); + if (!tenantId.equals(expectedTenantId)) { + throw new IllegalArgumentException( + "License tenantId '" + tenantId + "' does not match server tenant '" + expectedTenantId + "'"); + } + + String label = root.has("label") ? root.get("label").asText() : null; Map limits = new HashMap<>(); if (root.has("limits")) { @@ -65,15 +87,17 @@ public class LicenseValidator { } 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; + if (!root.has("exp")) { + throw new IllegalArgumentException("exp is required"); + } + Instant expiresAt = Instant.ofEpochSecond(root.get("exp").asLong()); + int gracePeriodDays = root.has("gracePeriodDays") ? root.get("gracePeriodDays").asInt() : 0; - // TODO Task 3 — replace with parsed licenseId/tenantId/gracePeriodDays/label - LicenseInfo info = new LicenseInfo( - java.util.UUID.randomUUID(), "placeholder", tier, - limits, issuedAt, expiresAt, 0); + LicenseInfo info = new LicenseInfo(licenseId, tenantId, label, limits, issuedAt, expiresAt, gracePeriodDays); if (info.isExpired()) { - throw new IllegalArgumentException("License expired at " + expiresAt); + throw new IllegalArgumentException("License expired at " + expiresAt + + " (grace period " + gracePeriodDays + " days)"); } return info; @@ -83,4 +107,11 @@ public class LicenseValidator { throw new IllegalArgumentException("Failed to parse license payload", e); } } + + private static String textOrThrow(JsonNode root, String field) { + if (!root.has(field) || root.get(field).asText().isBlank()) { + throw new IllegalArgumentException(field + " is required"); + } + return root.get(field).asText(); + } }