Skip to content

non-null license keys #318

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 7 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions backend/src/main/java/org/cryptomator/hub/Main.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package org.cryptomator.hub;

import io.quarkus.runtime.Quarkus;
import io.quarkus.runtime.QuarkusApplication;
import io.quarkus.runtime.annotations.QuarkusMain;
import jakarta.inject.Inject;
import org.cryptomator.hub.license.LicenseHolder;
import org.jboss.logging.Logger;

@QuarkusMain
public class Main implements QuarkusApplication {

private static final Logger LOG = Logger.getLogger(Main.class);

@Inject
LicenseHolder license;

@Override
public int run(String... args) throws Exception {
try {
license.ensureLicenseExists();
} catch (RuntimeException e) {
LOG.error("Failed to validate license, shutting down...", e);
return 1;
}
Quarkus.waitForExit();
return 0;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ public class AuditLogResource {
@APIResponse(responseCode = "402", description = "Community license used or license expired")
@APIResponse(responseCode = "403", description = "requesting user does not have admin role")
public List<AuditEventDto> getAllEvents(@QueryParam("startDate") Instant startDate, @QueryParam("endDate") Instant endDate, @QueryParam("type") List<String> type, @QueryParam("paginationId") Long paginationId, @QueryParam("order") @DefaultValue("desc") String order, @QueryParam("pageSize") @DefaultValue("20") int pageSize) {
if (!license.isSet() || license.isExpired()) {
if (!license.isSet() || license.isExpired()) { // TODO change to license.getClaim("auditLog") != null
throw new PaymentRequiredException("Community license used or license expired");
}

Expand Down
12 changes: 2 additions & 10 deletions backend/src/main/java/org/cryptomator/hub/api/BillingResource.java
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,8 @@ public class BillingResource {
public BillingDto get() {
int usedSeats = (int) effectiveVaultAccessRepo.countSeatOccupyingUsers();
boolean isManaged = licenseHolder.isManagedInstance();
return Optional.ofNullable(licenseHolder.get())
.map(jwt -> BillingDto.fromDecodedJwt(jwt, usedSeats, isManaged))
.orElseGet(() -> {
var hubId = settingsRepo.get().getHubId();
return BillingDto.create(hubId, (int) licenseHolder.getSeats(), usedSeats, isManaged);
});
var licenseToken = licenseHolder.get();
return BillingDto.fromDecodedJwt(licenseToken, usedSeats, isManaged);
}

@PUT
Expand All @@ -75,10 +71,6 @@ public record BillingDto(@JsonProperty("hubId") String hubId, @JsonProperty("has
@JsonProperty("licensedSeats") Integer licensedSeats, @JsonProperty("usedSeats") Integer usedSeats,
@JsonProperty("issuedAt") Instant issuedAt, @JsonProperty("expiresAt") Instant expiresAt, @JsonProperty("managedInstance") Boolean managedInstance) {

public static BillingDto create(String hubId, int noLicenseSeatCount, int usedSeats, boolean isManaged) {
return new BillingDto(hubId, false, null, noLicenseSeatCount, usedSeats, null, null, isManaged);
}

public static BillingDto fromDecodedJwt(DecodedJWT jwt, int usedSeats, boolean isManaged) {
var id = jwt.getId();
var email = jwt.getSubject();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package org.cryptomator.hub.api;

import com.auth0.jwt.interfaces.DecodedJWT;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
Expand All @@ -14,7 +13,6 @@
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;

import java.time.Instant;
import java.util.Optional;

@Path("/license")
public class LicenseResource {
Expand Down Expand Up @@ -42,7 +40,7 @@ public record LicenseUserInfoDto(@JsonProperty("licensedSeats") Integer licensed

public static LicenseUserInfoDto create(LicenseHolder licenseHolder, int usedSeats) {
var licensedSeats = (int) licenseHolder.getSeats();
var expiresAt = Optional.ofNullable(licenseHolder.get()).map(DecodedJWT::getExpiresAtAsInstant).orElse(null);
var expiresAt = licenseHolder.get().getExpiresAtAsInstant();
return new LicenseUserInfoDto(licensedSeats, usedSeats, expiresAt);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ public Response legacyUnlock(@PathParam("vaultId") UUID vaultId, @PathParam("dev
var access = legacyAccessTokenRepo.unlock(vaultId, deviceId, jwt.getSubject());
eventLogger.logVaultKeyRetrieved(jwt.getSubject(), vaultId, VaultKeyRetrievedEvent.Result.SUCCESS, ipAddress, deviceId);
var subscriptionStateHeaderName = "Hub-Subscription-State";
var subscriptionStateHeaderValue = license.isSet() ? "ACTIVE" : "INACTIVE"; // license expiration is not checked here, because it is checked in the ActiveLicense filter
var subscriptionStateHeaderValue = license.isSet() ? "ACTIVE" : "INACTIVE"; // license expiration is not checked here, because it is checked in the ActiveLicense filter // FIXME: we need to refactor this header
return Response.ok(access.getJwe()).header(subscriptionStateHeaderName, subscriptionStateHeaderValue).build();
} catch (NoResultException e) {
eventLogger.logVaultKeyRetrieved(jwt.getSubject(), vaultId, VaultKeyRetrievedEvent.Result.UNAUTHORIZED, ipAddress, deviceId);
Expand Down Expand Up @@ -334,7 +334,7 @@ public Response unlock(@PathParam("vaultId") UUID vaultId, @QueryParam("evenIfAr
if (access != null) {
eventLogger.logVaultKeyRetrieved(jwt.getSubject(), vaultId, VaultKeyRetrievedEvent.Result.SUCCESS, ipAddress, deviceId);
var subscriptionStateHeaderName = "Hub-Subscription-State";
var subscriptionStateHeaderValue = license.isSet() ? "ACTIVE" : "INACTIVE"; // license expiration is not checked here, because it is checked in the ActiveLicense filter
var subscriptionStateHeaderValue = license.isSet() ? "ACTIVE" : "INACTIVE"; // license expiration is not checked here, because it is checked in the ActiveLicense filter // FIXME: we need to refactor this header
return Response.ok(access.getVaultKey(), MediaType.TEXT_PLAIN_TYPE).header(subscriptionStateHeaderName, subscriptionStateHeaderValue).build();
} else if (vaultRepo.findById(vaultId) == null) {
throw new NotFoundException("No such vault.");
Expand Down
31 changes: 31 additions & 0 deletions backend/src/main/java/org/cryptomator/hub/license/LicenseApi.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package org.cryptomator.hub.license;

import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;

@RegisterRestClient(configKey = "license-api")
public interface LicenseApi {

@GET
@Path("/trial/challenge")
@Produces(MediaType.APPLICATION_JSON)
Challenge generateTrialChallenge(@QueryParam("hubId") String hubId);

@POST
@Path("/trial/verify")
@Produces(MediaType.TEXT_PLAIN)
String verifyTrialChallenge(@QueryParam("hubId") String hubId, @QueryParam("solution") int solution);

record Challenge(@JsonProperty("salt") byte[] salt, @JsonProperty("counter") int counter, @JsonProperty("digest") byte[] digest, @JsonProperty("minCounter") int minCounter, @JsonProperty("maxCounter") int maxCounter) {
public Challenge withoutSolution() {
return new Challenge(salt, 0, digest, minCounter, maxCounter);
}
}

}
124 changes: 75 additions & 49 deletions backend/src/main/java/org/cryptomator/hub/license/LicenseHolder.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,16 @@
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.interfaces.Claim;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.cronutils.utils.Preconditions;
import io.quarkus.scheduler.Scheduled;
import io.quarkus.scheduler.ScheduledExecution;
import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.validation.constraints.NotNull;
import org.cryptomator.hub.entities.Settings;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.eclipse.microprofile.rest.client.inject.RestClient;
import org.jboss.logging.Logger;

import java.io.IOException;
Expand All @@ -19,15 +21,19 @@
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.Arrays;
import java.util.Optional;
import java.util.UUID;

@ApplicationScoped
public class LicenseHolder {

private static final int SELFHOSTED_NOLICENSE_SEATS = 5;
private static final int MANAGED_NOLICENSE_SEATS = 0;
private static final Logger LOG = Logger.getLogger(LicenseHolder.class);

@Inject
@ConfigProperty(name = "hub.managed-instance", defaultValue = "false")
Expand All @@ -46,47 +52,90 @@ public class LicenseHolder {

@Inject
RandomMinuteSleeper randomMinuteSleeper;

@Inject
Settings.Repository settingsRepo;

private static final Logger LOG = Logger.getLogger(LicenseHolder.class);
@RestClient
LicenseApi licenseApi;

private DecodedJWT license;

/**
* Loads the license from the database or from init props, if present
* Makes sure a valid (but possibly expired) license exists.
* <p>
* Called during {@link org.cryptomator.hub.Main application startup}.
*
* @throws JWTVerificationException if the license is invalid
*/
@PostConstruct
void init() {
@Transactional
public void ensureLicenseExists() throws JWTVerificationException{
var settings = settingsRepo.get();
if (settings.getLicenseKey() != null && settings.getHubId() != null) {
validateOrResetExistingLicense(settings);
validateExistingLicense(settings);
} else if (initialLicenseToken.isPresent() && initialId.isPresent()) {
validateAndApplyInitLicense(settings, initialLicenseToken.get(), initialId.get());
} else {
requestAnonTrialLicense(settings);
}
}

@Transactional
void validateOrResetExistingLicense(Settings settings) {
@Transactional(Transactional.TxType.MANDATORY)
void validateExistingLicense(Settings settings) throws JWTVerificationException {
try {
this.license = licenseValidator.validate(settings.getLicenseKey(), settings.getHubId());
LOG.info("Verified existing license.");
} catch (JWTVerificationException e) {
LOG.warn("License in database is invalid or does not match hubId", e);
LOG.warn("Deleting license entry. Please add the license over the REST API again.");
settings.setLicenseKey(null);
settingsRepo.persistAndFlush(settings);
throw e;
}
}

@Transactional
void validateAndApplyInitLicense(Settings settings, String initialLicenseToken, String initialHubId) {
@Transactional(Transactional.TxType.MANDATORY)
void validateAndApplyInitLicense(Settings settings, String initialLicenseToken, String initialHubId) throws JWTVerificationException {
try {
this.license = licenseValidator.validate(initialLicenseToken, initialHubId);
settings.setLicenseKey(initialLicenseToken);
settings.setHubId(initialHubId);
settingsRepo.persistAndFlush(settings);
LOG.info("Successfully imported license from property hub.initial-license.");
} catch (JWTVerificationException e) {
LOG.warn("Provided initial license is invalid or does not match inital hubId.", e);
throw e;
}
}

@Transactional(Transactional.TxType.MANDATORY)
void requestAnonTrialLicense(Settings settings) {
LOG.info("No license found. Requesting trial license...");
var hubId = UUID.randomUUID().toString();
var challenge = licenseApi.generateTrialChallenge(hubId);
int solution = solveChallenge(challenge);
var trialLicense = licenseApi.verifyTrialChallenge(hubId, solution);
this.license = licenseValidator.validate(trialLicense, hubId);
settings.setLicenseKey(trialLicense);
settings.setHubId(hubId);
settingsRepo.persistAndFlush(settings);
LOG.info("Successfully retrieved trial license.");
}

// visible for testing
int solveChallenge(LicenseApi.Challenge challenge) {
MessageDigest sha256;
try {
sha256 = MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException e) {
throw new AssertionError("Every implementation of the Java platform is required to support [...] SHA-256", e);
}
for (int i = challenge.minCounter(); i < challenge.maxCounter(); i++) {
sha256.update(challenge.salt());
sha256.update(ByteBuffer.allocate(Integer.BYTES).putInt(0, i));
if (Arrays.equals(challenge.digest(), sha256.digest())) {
return i;
}
}
throw new IllegalArgumentException("Unsolvable challenge");
}

/**
Expand All @@ -106,7 +155,7 @@ public void set(String token) throws JWTVerificationException {
/**
* Attempts to refresh the Hub licence every day between 01:00:00 and 02:00:00 AM UTC if claim refreshURL is present.
*/
@Scheduled(cron = "0 0 1 * * ?", timeZone = "UTC", concurrentExecution = Scheduled.ConcurrentExecution.SKIP, skipExecutionIf = LicenseHolder.LicenseRefreshSkipper.class)
@Scheduled(cron = "0 0 1 * * ?", timeZone = "UTC", concurrentExecution = Scheduled.ConcurrentExecution.SKIP)
void refreshLicense() throws InterruptedException {
randomMinuteSleeper.sleep(); // add random sleep between [0,59]min to reduce infrastructure load
var refreshUrlClaim = get().getClaim("refreshUrl");
Expand All @@ -115,12 +164,12 @@ void refreshLicense() throws InterruptedException {
var refreshUrl = URI.create(refreshUrlClaim.asString());
var refreshedLicense = requestLicenseRefresh(refreshUrl, get().getToken());
set(refreshedLicense);
} catch (LicenseRefreshFailedException lrfe) {
LOG.errorv("Failed to refresh license token. Request to {0} was answerd with response code {1,number,integer}", refreshUrlClaim, lrfe.statusCode);
} catch (LicenseRefreshFailedException e) {
LOG.errorv("Failed to refresh license token. Request to {0} was answerd with response code {1,number,integer}", refreshUrlClaim, e.statusCode);
} catch (IllegalArgumentException | IOException e) {
LOG.error("Failed to refresh license token", e);
} catch (JWTVerificationException jve) {
LOG.error("Failed to refresh license token. Refreshed token is invalid.", jve);
} catch (JWTVerificationException e) {
LOG.error("Failed to refresh license token. Refreshed token is invalid.", e);
}
}
}
Expand All @@ -144,49 +193,37 @@ String requestLicenseRefresh(URI refreshUrl, String licenseToken) throws Interru
}
}

@NotNull
public DecodedJWT get() {
return license;
return Preconditions.checkNotNull(license);
}

/**
* Checks if the license is set.
*
* @return {@code true}, if the license _is not null_. Otherwise false.
*/
@Deprecated // FIXME remove this method!
public boolean isSet() {
return license != null;
}

/**
* Checks if the license is expired.
*
* @return {@code true}, if the license _is not nul and expired_. Otherwise false.
* @return {@code true}, if the license expired, {@code false} otherwise.
*/
public boolean isExpired() {
return Optional.ofNullable(license) //
.map(l -> l.getExpiresAt().toInstant().isBefore(Instant.now())) //
.orElse(false);
return Preconditions.checkNotNull(license).getExpiresAt().toInstant().isBefore(Instant.now());
}

/**
* Gets the number of seats in the license
*
* @return Number of seats of the license, if license is not null. Otherwise {@value SELFHOSTED_NOLICENSE_SEATS}.
* @return Number of seats of the license
*/
public long getSeats() {
return Optional.ofNullable(license) //
.map(l -> l.getClaim("seats")) //
.map(Claim::asLong) //
.orElseGet(this::seatsOnNotExisingLicense);
}

//visible for testing
public long seatsOnNotExisingLicense() {
if (!managedInstance) {
return SELFHOSTED_NOLICENSE_SEATS;
} else {
return MANAGED_NOLICENSE_SEATS;
}
return Preconditions.checkNotNull(license).getClaim("seats").asLong();
}

public boolean isManagedInstance() {
Expand All @@ -203,15 +240,4 @@ static class LicenseRefreshFailedException extends RuntimeException {
}
}

@ApplicationScoped
public static class LicenseRefreshSkipper implements Scheduled.SkipPredicate {

@Inject
LicenseHolder licenseHolder;

@Override
public boolean test(ScheduledExecution execution) {
return licenseHolder.license == null;
}
}
}
Loading