Skip to content
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
64 changes: 64 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,64 @@
package org.cryptomator.hub.license;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.FormParam;
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;

import java.io.IOException;
import java.io.UncheckedIOException;
import java.util.Base64;

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

@GET
@Path("/hub/challenge")
@Produces(MediaType.APPLICATION_JSON)
Challenge generateTrialChallenge();

@POST
@Path("/hub/trial")
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
@Produces(MediaType.APPLICATION_JSON)
TrialLicenseResponse generateTrialLicense(@FormParam("captcha") String captcha);

record Challenge(@JsonProperty("algorithm") String algorithm,
@JsonProperty("challenge") String challenge,
@JsonProperty("maxnumber") int maxnumber,
@JsonProperty("salt") String salt,
@JsonProperty("signature") String signature) {
public Solution solve(int number, long took) {
return new Solution(algorithm, challenge, number, salt, signature, took);
}
}

record Solution(@JsonProperty("algorithm") String algorithm,
@JsonProperty("challenge") String challenge,
@JsonProperty("number") int number,
@JsonProperty("salt") String salt,
@JsonProperty("signature") String signature,
@JsonProperty("took") long took) {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
public String toCaptcha() {
try {
var serialized = OBJECT_MAPPER.writer().writeValueAsBytes(this);
return Base64.getEncoder().encodeToString(serialized);
} catch (IOException e) {
throw new UncheckedIOException("Failed to encode captcha", e);
}
}
}

record TrialLicenseResponse(@JsonProperty("hubId") String hubId,
@JsonProperty("licenseKey") String licenseKey) {}


}
116 changes: 77 additions & 39 deletions backend/src/main/java/org/cryptomator/hub/license/LicenseHolder.java
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
package org.cryptomator.hub.license;

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 @@ -20,14 +20,17 @@
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.HexFormat;
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,49 +49,95 @@ 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 challenge = licenseApi.generateTrialChallenge();
var solution = solveChallenge(challenge);
var trialResponse = licenseApi.generateTrialLicense(solution.toCaptcha()); // FIXME: is enterprise?
this.license = licenseValidator.validate(trialResponse.licenseKey(), trialResponse.hubId());
settings.setLicenseKey(trialResponse.licenseKey());
settings.setHubId(trialResponse.hubId());
settingsRepo.persistAndFlush(settings);
LOG.info("Successfully retrieved trial license.");
}

// visible for testing
LicenseApi.Solution solveChallenge(LicenseApi.Challenge challenge) {
HexFormat hex = HexFormat.of();
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);
}
long start = System.nanoTime();
for (int i = 0; i < challenge.maxnumber(); i++) {
var saltedSecret = challenge.salt() + i;
sha256.update(saltedSecret.getBytes(StandardCharsets.US_ASCII));
var attempt = hex.formatHex(sha256.digest());
if (challenge.challenge().equals(attempt)) {
long took = System.nanoTime() - start;
return challenge.solve(i, took / 1_000_000);
}
}
throw new IllegalArgumentException("Unsolvable challenge");
}

/**
* Parses, verifies and persists the given token as the license in the database.
*
Expand Down Expand Up @@ -116,12 +165,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 @@ -146,49 +195,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 @@ -204,4 +241,5 @@ static class LicenseRefreshFailedException extends RuntimeException {
this.body = body;
}
}

}
Loading