feat(license): require licenseId + tenantId in validator
Spec §2.1 — both fields are required and the validator rejects a token whose tenantId does not match the server's configured tenant (CAMELEER_SERVER_TENANT_ID). Self-hosted customers cannot strip tenantId because the field is in the signed payload. LicenseBeanConfig and LicenseAdminController updated to pass the expected tenant to the validator constructor. The transient placeholder/TODO from Task 2 is removed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,9 @@ public class LicenseBeanConfig {
|
|||||||
@Value("${cameleer.server.license.publickey:}")
|
@Value("${cameleer.server.license.publickey:}")
|
||||||
private String licensePublicKey;
|
private String licensePublicKey;
|
||||||
|
|
||||||
|
@Value("${cameleer.server.tenant.id:default}")
|
||||||
|
private String tenantId;
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public LicenseGate licenseGate() {
|
public LicenseGate licenseGate() {
|
||||||
LicenseGate gate = new LicenseGate();
|
LicenseGate gate = new LicenseGate();
|
||||||
@@ -42,7 +45,7 @@ public class LicenseBeanConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
LicenseValidator validator = new LicenseValidator(licensePublicKey);
|
LicenseValidator validator = new LicenseValidator(licensePublicKey, tenantId);
|
||||||
LicenseInfo info = validator.validate(token);
|
LicenseInfo info = validator.validate(token);
|
||||||
gate.load(info);
|
gate.load(info);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
|||||||
@@ -20,11 +20,14 @@ public class LicenseAdminController {
|
|||||||
|
|
||||||
private final LicenseGate licenseGate;
|
private final LicenseGate licenseGate;
|
||||||
private final String licensePublicKey;
|
private final String licensePublicKey;
|
||||||
|
private final String tenantId;
|
||||||
|
|
||||||
public LicenseAdminController(LicenseGate licenseGate,
|
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.licenseGate = licenseGate;
|
||||||
this.licensePublicKey = licensePublicKey;
|
this.licensePublicKey = licensePublicKey;
|
||||||
|
this.tenantId = tenantId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@@ -42,7 +45,7 @@ public class LicenseAdminController {
|
|||||||
return ResponseEntity.badRequest().body(Map.of("error", "No license public key configured"));
|
return ResponseEntity.badRequest().body(Map.of("error", "No license public key configured"));
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
LicenseValidator validator = new LicenseValidator(licensePublicKey);
|
LicenseValidator validator = new LicenseValidator(licensePublicKey, tenantId);
|
||||||
LicenseInfo info = validator.validate(request.token());
|
LicenseInfo info = validator.validate(request.token());
|
||||||
licenseGate.load(info);
|
licenseGate.load(info);
|
||||||
return ResponseEntity.ok(info);
|
return ResponseEntity.ok(info);
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ package com.cameleer.server.core.license;
|
|||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import java.security.*;
|
import java.security.KeyPair;
|
||||||
import java.security.spec.NamedParameterSpec;
|
import java.security.KeyPairGenerator;
|
||||||
|
import java.security.PrivateKey;
|
||||||
|
import java.security.Signature;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.temporal.ChronoUnit;
|
import java.time.temporal.ChronoUnit;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
@@ -29,12 +32,12 @@ class LicenseValidatorTest {
|
|||||||
void validate_validLicense_returnsLicenseInfo() throws Exception {
|
void validate_validLicense_returnsLicenseInfo() throws Exception {
|
||||||
KeyPair kp = generateKeyPair();
|
KeyPair kp = generateKeyPair();
|
||||||
String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
|
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);
|
Instant expires = Instant.now().plus(365, ChronoUnit.DAYS);
|
||||||
String payload = """
|
String payload = """
|
||||||
{"tier":"HIGH","limits":{"max_agents":50,"retention_days":90},"iat":%d,"exp":%d}
|
{"licenseId":"%s","tenantId":"acme","label":"HIGH","tier":"HIGH","limits":{"max_agents":50,"retention_days":90},"iat":%d,"exp":%d,"gracePeriodDays":7}
|
||||||
""".formatted(Instant.now().getEpochSecond(), expires.getEpochSecond()).trim();
|
""".formatted(UUID.randomUUID(), Instant.now().getEpochSecond(), expires.getEpochSecond()).trim();
|
||||||
String signature = sign(kp.getPrivate(), payload);
|
String signature = sign(kp.getPrivate(), payload);
|
||||||
String token = Base64.getEncoder().encodeToString(payload.getBytes()) + "." + signature;
|
String token = Base64.getEncoder().encodeToString(payload.getBytes()) + "." + signature;
|
||||||
|
|
||||||
@@ -43,18 +46,20 @@ class LicenseValidatorTest {
|
|||||||
assertThat(info.label()).isEqualTo("HIGH");
|
assertThat(info.label()).isEqualTo("HIGH");
|
||||||
assertThat(info.getLimit("max_agents", 0)).isEqualTo(50);
|
assertThat(info.getLimit("max_agents", 0)).isEqualTo(50);
|
||||||
assertThat(info.isExpired()).isFalse();
|
assertThat(info.isExpired()).isFalse();
|
||||||
|
assertThat(info.tenantId()).isEqualTo("acme");
|
||||||
|
assertThat(info.gracePeriodDays()).isEqualTo(7);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void validate_expiredLicense_throwsException() throws Exception {
|
void validate_expiredLicense_throwsException() throws Exception {
|
||||||
KeyPair kp = generateKeyPair();
|
KeyPair kp = generateKeyPair();
|
||||||
String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
|
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);
|
Instant past = Instant.now().minus(1, ChronoUnit.DAYS);
|
||||||
String payload = """
|
String payload = """
|
||||||
{"tier":"LOW","limits":{},"iat":%d,"exp":%d}
|
{"licenseId":"%s","tenantId":"acme","tier":"LOW","limits":{},"iat":%d,"exp":%d}
|
||||||
""".formatted(past.minus(30, ChronoUnit.DAYS).getEpochSecond(), past.getEpochSecond()).trim();
|
""".formatted(UUID.randomUUID(), past.minus(30, ChronoUnit.DAYS).getEpochSecond(), past.getEpochSecond()).trim();
|
||||||
String signature = sign(kp.getPrivate(), payload);
|
String signature = sign(kp.getPrivate(), payload);
|
||||||
String token = Base64.getEncoder().encodeToString(payload.getBytes()) + "." + signature;
|
String token = Base64.getEncoder().encodeToString(payload.getBytes()) + "." + signature;
|
||||||
|
|
||||||
@@ -67,11 +72,11 @@ class LicenseValidatorTest {
|
|||||||
void validate_tamperedPayload_throwsException() throws Exception {
|
void validate_tamperedPayload_throwsException() throws Exception {
|
||||||
KeyPair kp = generateKeyPair();
|
KeyPair kp = generateKeyPair();
|
||||||
String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
|
String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
|
||||||
LicenseValidator validator = new LicenseValidator(publicKeyBase64);
|
LicenseValidator validator = new LicenseValidator(publicKeyBase64, "acme");
|
||||||
|
|
||||||
String payload = """
|
String payload = """
|
||||||
{"tier":"LOW","limits":{},"iat":0,"exp":9999999999}
|
{"licenseId":"%s","tenantId":"acme","tier":"LOW","limits":{},"iat":0,"exp":9999999999}
|
||||||
""".trim();
|
""".formatted(UUID.randomUUID()).trim();
|
||||||
String signature = sign(kp.getPrivate(), payload);
|
String signature = sign(kp.getPrivate(), payload);
|
||||||
|
|
||||||
// Tamper with payload
|
// Tamper with payload
|
||||||
@@ -82,4 +87,55 @@ class LicenseValidatorTest {
|
|||||||
.isInstanceOf(SecurityException.class)
|
.isInstanceOf(SecurityException.class)
|
||||||
.hasMessageContaining("signature");
|
.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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,16 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
|||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
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.security.spec.X509EncodedKeySpec;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
public class LicenseValidator {
|
public class LicenseValidator {
|
||||||
|
|
||||||
@@ -18,8 +22,13 @@ public class LicenseValidator {
|
|||||||
private static final ObjectMapper objectMapper = new ObjectMapper();
|
private static final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
private final PublicKey publicKey;
|
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 {
|
try {
|
||||||
byte[] keyBytes = Base64.getDecoder().decode(publicKeyBase64);
|
byte[] keyBytes = Base64.getDecoder().decode(publicKeyBase64);
|
||||||
KeyFactory kf = KeyFactory.getInstance("Ed25519");
|
KeyFactory kf = KeyFactory.getInstance("Ed25519");
|
||||||
@@ -27,6 +36,7 @@ public class LicenseValidator {
|
|||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
throw new IllegalStateException("Failed to load license public key", e);
|
throw new IllegalStateException("Failed to load license public key", e);
|
||||||
}
|
}
|
||||||
|
this.expectedTenantId = expectedTenantId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public LicenseInfo validate(String token) {
|
public LicenseInfo validate(String token) {
|
||||||
@@ -38,7 +48,6 @@ public class LicenseValidator {
|
|||||||
byte[] payloadBytes = Base64.getDecoder().decode(parts[0]);
|
byte[] payloadBytes = Base64.getDecoder().decode(parts[0]);
|
||||||
byte[] signatureBytes = Base64.getDecoder().decode(parts[1]);
|
byte[] signatureBytes = Base64.getDecoder().decode(parts[1]);
|
||||||
|
|
||||||
// Verify signature
|
|
||||||
try {
|
try {
|
||||||
Signature verifier = Signature.getInstance("Ed25519");
|
Signature verifier = Signature.getInstance("Ed25519");
|
||||||
verifier.initVerify(publicKey);
|
verifier.initVerify(publicKey);
|
||||||
@@ -52,11 +61,24 @@ public class LicenseValidator {
|
|||||||
throw new SecurityException("License signature verification failed", e);
|
throw new SecurityException("License signature verification failed", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse payload
|
|
||||||
try {
|
try {
|
||||||
JsonNode root = objectMapper.readTree(payloadBytes);
|
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<String, Integer> limits = new HashMap<>();
|
Map<String, Integer> limits = new HashMap<>();
|
||||||
if (root.has("limits")) {
|
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 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(licenseId, tenantId, label, limits, issuedAt, expiresAt, gracePeriodDays);
|
||||||
LicenseInfo info = new LicenseInfo(
|
|
||||||
java.util.UUID.randomUUID(), "placeholder", tier,
|
|
||||||
limits, issuedAt, expiresAt, 0);
|
|
||||||
|
|
||||||
if (info.isExpired()) {
|
if (info.isExpired()) {
|
||||||
throw new IllegalArgumentException("License expired at " + expiresAt);
|
throw new IllegalArgumentException("License expired at " + expiresAt
|
||||||
|
+ " (grace period " + gracePeriodDays + " days)");
|
||||||
}
|
}
|
||||||
|
|
||||||
return info;
|
return info;
|
||||||
@@ -83,4 +107,11 @@ public class LicenseValidator {
|
|||||||
throw new IllegalArgumentException("Failed to parse license payload", e);
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user