fix: remove Ed25519 license signing — replace with UUID token placeholder
Drop JwtConfig dependency from LicenseService; generate license tokens as random UUIDs instead. Add findByToken to LicenseRepository and update verifyLicenseToken to do a DB lookup. Update LicenseServiceTest to match. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,4 +11,5 @@ import java.util.UUID;
|
||||
public interface LicenseRepository extends JpaRepository<LicenseEntity, UUID> {
|
||||
List<LicenseEntity> findByTenantIdOrderByCreatedAtDesc(UUID tenantId);
|
||||
Optional<LicenseEntity> findFirstByTenantIdAndRevokedAtIsNullOrderByCreatedAtDesc(UUID tenantId);
|
||||
Optional<LicenseEntity> findByToken(String token);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
package net.siegeln.cameleer.saas.license;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import net.siegeln.cameleer.saas.audit.AuditAction;
|
||||
import net.siegeln.cameleer.saas.audit.AuditService;
|
||||
import net.siegeln.cameleer.saas.config.JwtConfig;
|
||||
import net.siegeln.cameleer.saas.tenant.TenantEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.security.Signature;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
@@ -21,13 +15,10 @@ import java.util.UUID;
|
||||
public class LicenseService {
|
||||
|
||||
private final LicenseRepository licenseRepository;
|
||||
private final JwtConfig jwtConfig;
|
||||
private final AuditService auditService;
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
public LicenseService(LicenseRepository licenseRepository, JwtConfig jwtConfig, AuditService auditService) {
|
||||
public LicenseService(LicenseRepository licenseRepository, AuditService auditService) {
|
||||
this.licenseRepository = licenseRepository;
|
||||
this.jwtConfig = jwtConfig;
|
||||
this.auditService = auditService;
|
||||
}
|
||||
|
||||
@@ -37,7 +28,7 @@ public class LicenseService {
|
||||
Instant now = Instant.now();
|
||||
Instant expiresAt = now.plus(validity);
|
||||
|
||||
String token = signLicenseJwt(tenant.getId(), tenant.getTier().name(), features, limits, now, expiresAt);
|
||||
String token = UUID.randomUUID().toString();
|
||||
|
||||
var entity = new LicenseEntity();
|
||||
entity.setTenantId(tenant.getId());
|
||||
@@ -61,61 +52,20 @@ public class LicenseService {
|
||||
return licenseRepository.findFirstByTenantIdAndRevokedAtIsNullOrderByCreatedAtDesc(tenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a license token by checking its existence and validity in the database.
|
||||
* Returns the license entity's metadata as a map if found and not expired/revoked,
|
||||
* or empty if the token is unknown or invalid.
|
||||
*/
|
||||
public Optional<Map<String, Object>> verifyLicenseToken(String token) {
|
||||
try {
|
||||
String[] parts = token.split("\\.");
|
||||
if (parts.length != 3) return Optional.empty();
|
||||
|
||||
String signingInput = parts[0] + "." + parts[1];
|
||||
byte[] signatureBytes = Base64.getUrlDecoder().decode(parts[2]);
|
||||
|
||||
Signature sig = Signature.getInstance("Ed25519");
|
||||
sig.initVerify(jwtConfig.getPublicKey());
|
||||
sig.update(signingInput.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
|
||||
if (!sig.verify(signatureBytes)) return Optional.empty();
|
||||
|
||||
byte[] payloadBytes = Base64.getUrlDecoder().decode(parts[1]);
|
||||
Map<String, Object> payload = objectMapper.readValue(payloadBytes, new TypeReference<>() {});
|
||||
|
||||
long exp = ((Number) payload.get("exp")).longValue();
|
||||
if (Instant.now().getEpochSecond() >= exp) return Optional.empty();
|
||||
|
||||
return Optional.of(payload);
|
||||
} catch (Exception e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private String signLicenseJwt(UUID tenantId, String tier, Map<String, Object> features,
|
||||
Map<String, Object> limits, Instant issuedAt, Instant expiresAt) {
|
||||
try {
|
||||
String header = base64UrlEncode(objectMapper.writeValueAsBytes(
|
||||
Map.of("alg", "EdDSA", "typ", "JWT", "kid", "license")));
|
||||
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
payload.put("tenant_id", tenantId.toString());
|
||||
payload.put("tier", tier);
|
||||
payload.put("features", features);
|
||||
payload.put("limits", limits);
|
||||
payload.put("iat", issuedAt.getEpochSecond());
|
||||
payload.put("exp", expiresAt.getEpochSecond());
|
||||
|
||||
String payloadEncoded = base64UrlEncode(objectMapper.writeValueAsBytes(payload));
|
||||
String signingInput = header + "." + payloadEncoded;
|
||||
|
||||
Signature sig = Signature.getInstance("Ed25519");
|
||||
sig.initSign(jwtConfig.getPrivateKey());
|
||||
sig.update(signingInput.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
String signature = base64UrlEncode(sig.sign());
|
||||
|
||||
return signingInput + "." + signature;
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to sign license JWT", e);
|
||||
}
|
||||
}
|
||||
|
||||
private String base64UrlEncode(byte[] data) {
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(data);
|
||||
return licenseRepository.findByToken(token)
|
||||
.filter(e -> e.getRevokedAt() == null)
|
||||
.filter(e -> e.getExpiresAt() == null || Instant.now().isBefore(e.getExpiresAt()))
|
||||
.map(e -> Map.<String, Object>of(
|
||||
"tenant_id", e.getTenantId().toString(),
|
||||
"tier", e.getTier(),
|
||||
"features", e.getFeatures(),
|
||||
"limits", e.getLimits()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package net.siegeln.cameleer.saas.license;
|
||||
|
||||
import net.siegeln.cameleer.saas.audit.AuditAction;
|
||||
import net.siegeln.cameleer.saas.audit.AuditService;
|
||||
import net.siegeln.cameleer.saas.config.JwtConfig;
|
||||
import net.siegeln.cameleer.saas.tenant.Tier;
|
||||
import net.siegeln.cameleer.saas.tenant.TenantEntity;
|
||||
import net.siegeln.cameleer.saas.tenant.TenantStatus;
|
||||
@@ -14,6 +13,7 @@ import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
@@ -30,14 +30,11 @@ class LicenseServiceTest {
|
||||
@Mock
|
||||
private AuditService auditService;
|
||||
|
||||
private JwtConfig jwtConfig;
|
||||
private LicenseService licenseService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
jwtConfig = new JwtConfig();
|
||||
jwtConfig.init(); // generates ephemeral keys for testing
|
||||
licenseService = new LicenseService(licenseRepository, jwtConfig, auditService);
|
||||
void setUp() {
|
||||
licenseService = new LicenseService(licenseRepository, auditService);
|
||||
}
|
||||
|
||||
private TenantEntity createTenant(Tier tier) {
|
||||
@@ -68,14 +65,15 @@ class LicenseServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateLicense_producesValidSignedToken() {
|
||||
void generateLicense_producesUuidToken() {
|
||||
var tenant = createTenant(Tier.MID);
|
||||
when(licenseRepository.save(any(LicenseEntity.class))).thenAnswer(inv -> withGeneratedId(inv.getArgument(0)));
|
||||
|
||||
var license = licenseService.generateLicense(tenant, Duration.ofDays(365), UUID.randomUUID());
|
||||
|
||||
assertThat(license.getToken()).isNotBlank();
|
||||
assertThat(license.getToken().split("\\.")).hasSize(3);
|
||||
// Token must be a valid UUID string
|
||||
assertThat(UUID.fromString(license.getToken())).isNotNull();
|
||||
assertThat(license.getTier()).isEqualTo("MID");
|
||||
}
|
||||
|
||||
@@ -107,6 +105,9 @@ class LicenseServiceTest {
|
||||
when(licenseRepository.save(any(LicenseEntity.class))).thenAnswer(inv -> withGeneratedId(inv.getArgument(0)));
|
||||
|
||||
var license = licenseService.generateLicense(tenant, Duration.ofDays(30), UUID.randomUUID());
|
||||
|
||||
when(licenseRepository.findByToken(license.getToken())).thenReturn(Optional.of(license));
|
||||
|
||||
var payload = licenseService.verifyLicenseToken(license.getToken());
|
||||
|
||||
assertThat(payload).isPresent();
|
||||
@@ -115,14 +116,10 @@ class LicenseServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void verifyLicenseToken_tamperedTokenReturnsEmpty() {
|
||||
var tenant = createTenant(Tier.MID);
|
||||
when(licenseRepository.save(any(LicenseEntity.class))).thenAnswer(inv -> withGeneratedId(inv.getArgument(0)));
|
||||
void verifyLicenseToken_unknownTokenReturnsEmpty() {
|
||||
when(licenseRepository.findByToken(any())).thenReturn(Optional.empty());
|
||||
|
||||
var license = licenseService.generateLicense(tenant, Duration.ofDays(30), UUID.randomUUID());
|
||||
String tampered = license.getToken() + "x";
|
||||
|
||||
var payload = licenseService.verifyLicenseToken(tampered);
|
||||
var payload = licenseService.verifyLicenseToken("unknown-token");
|
||||
|
||||
assertThat(payload).isEmpty();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user