From 48a5035a2c8bf7f1e30c81a1dcc6edb832ba2373 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 5 Apr 2026 12:37:32 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20remove=20Ed25519=20license=20signing=20?= =?UTF-8?q?=E2=80=94=20replace=20with=20UUID=20token=20placeholder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../saas/license/LicenseRepository.java | 1 + .../cameleer/saas/license/LicenseService.java | 82 ++++--------------- .../saas/license/LicenseServiceTest.java | 27 +++--- 3 files changed, 29 insertions(+), 81 deletions(-) diff --git a/src/main/java/net/siegeln/cameleer/saas/license/LicenseRepository.java b/src/main/java/net/siegeln/cameleer/saas/license/LicenseRepository.java index e4067f8..a3087e8 100644 --- a/src/main/java/net/siegeln/cameleer/saas/license/LicenseRepository.java +++ b/src/main/java/net/siegeln/cameleer/saas/license/LicenseRepository.java @@ -11,4 +11,5 @@ import java.util.UUID; public interface LicenseRepository extends JpaRepository { List findByTenantIdOrderByCreatedAtDesc(UUID tenantId); Optional findFirstByTenantIdAndRevokedAtIsNullOrderByCreatedAtDesc(UUID tenantId); + Optional findByToken(String token); } diff --git a/src/main/java/net/siegeln/cameleer/saas/license/LicenseService.java b/src/main/java/net/siegeln/cameleer/saas/license/LicenseService.java index 9e7f8b6..9030eb9 100644 --- a/src/main/java/net/siegeln/cameleer/saas/license/LicenseService.java +++ b/src/main/java/net/siegeln/cameleer/saas/license/LicenseService.java @@ -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> 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 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 features, - Map limits, Instant issuedAt, Instant expiresAt) { - try { - String header = base64UrlEncode(objectMapper.writeValueAsBytes( - Map.of("alg", "EdDSA", "typ", "JWT", "kid", "license"))); - - Map 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.of( + "tenant_id", e.getTenantId().toString(), + "tier", e.getTier(), + "features", e.getFeatures(), + "limits", e.getLimits() + )); } } diff --git a/src/test/java/net/siegeln/cameleer/saas/license/LicenseServiceTest.java b/src/test/java/net/siegeln/cameleer/saas/license/LicenseServiceTest.java index f726914..7e27b8e 100644 --- a/src/test/java/net/siegeln/cameleer/saas/license/LicenseServiceTest.java +++ b/src/test/java/net/siegeln/cameleer/saas/license/LicenseServiceTest.java @@ -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(); }