feat: implement LicenseValidator with Ed25519 signature verification
- Validates payload.signature license tokens using Ed25519 public key - Parses tier, features, limits, timestamps from JSON payload - Rejects expired and tampered tokens - Unit tests for valid, expired, and tampered license scenarios
This commit is contained in:
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Feature> 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<String, Integer> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user