Merge branch 'develop' into feature/vault-volume-type

This commit is contained in:
Jan-Peter Klein
2023-10-20 15:58:52 +02:00
60 changed files with 1054 additions and 313 deletions

View File

@@ -2,6 +2,7 @@ package org.cryptomator.common;
import com.google.common.base.Splitter;
import com.google.common.base.Strings;
import org.jetbrains.annotations.VisibleForTesting;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -31,6 +32,7 @@ public class Environment {
private static final String BUILD_NUMBER_PROP_NAME = "cryptomator.buildNumber";
private static final String PLUGIN_DIR_PROP_NAME = "cryptomator.pluginDir";
private static final String TRAY_ICON_PROP_NAME = "cryptomator.showTrayIcon";
private static final String DISABLE_UPDATE_CHECK_PROP_NAME = "cryptomator.disableUpdateCheck";
private Environment() {}
@@ -43,15 +45,16 @@ public class Environment {
logCryptomatorSystemProperty(SETTINGS_PATH_PROP_NAME);
logCryptomatorSystemProperty(IPC_SOCKET_PATH_PROP_NAME);
logCryptomatorSystemProperty(KEYCHAIN_PATHS_PROP_NAME);
logCryptomatorSystemProperty(P12_PATH_PROP_NAME);
logCryptomatorSystemProperty(LOG_DIR_PROP_NAME);
logCryptomatorSystemProperty(LOOPBACK_ALIAS_PROP_NAME);
logCryptomatorSystemProperty(PLUGIN_DIR_PROP_NAME);
logCryptomatorSystemProperty(MOUNTPOINT_DIR_PROP_NAME);
logCryptomatorSystemProperty(MIN_PW_LENGTH_PROP_NAME);
logCryptomatorSystemProperty(APP_VERSION_PROP_NAME);
logCryptomatorSystemProperty(BUILD_NUMBER_PROP_NAME);
logCryptomatorSystemProperty(PLUGIN_DIR_PROP_NAME);
logCryptomatorSystemProperty(TRAY_ICON_PROP_NAME);
logCryptomatorSystemProperty(P12_PATH_PROP_NAME);
logCryptomatorSystemProperty(DISABLE_UPDATE_CHECK_PROP_NAME);
}
public static Environment getInstance() {
@@ -74,10 +77,6 @@ public class Environment {
return getPaths(SETTINGS_PATH_PROP_NAME);
}
public Stream<Path> getP12Path() {
return getPaths(P12_PATH_PROP_NAME);
}
public Stream<Path> getIpcSocketPath() {
return getPaths(IPC_SOCKET_PATH_PROP_NAME);
}
@@ -86,6 +85,10 @@ public class Environment {
return getPaths(KEYCHAIN_PATHS_PROP_NAME);
}
public Stream<Path> getP12Path() {
return getPaths(P12_PATH_PROP_NAME);
}
public Optional<Path> getLogDir() {
return getPath(LOG_DIR_PROP_NAME);
}
@@ -94,14 +97,14 @@ public class Environment {
return Optional.ofNullable(System.getProperty(LOOPBACK_ALIAS_PROP_NAME));
}
public Optional<Path> getPluginDir() {
return getPath(PLUGIN_DIR_PROP_NAME);
}
public Optional<Path> getMountPointsDir() {
return getPath(MOUNTPOINT_DIR_PROP_NAME);
}
public int getMinPwLength() {
return Integer.getInteger(MIN_PW_LENGTH_PROP_NAME, DEFAULT_MIN_PW_LENGTH);
}
/**
* Returns the app version defined in the {@value APP_VERSION_PROP_NAME} property or returns "SNAPSHOT".
*
@@ -115,20 +118,24 @@ public class Environment {
return Optional.ofNullable(System.getProperty(BUILD_NUMBER_PROP_NAME));
}
public int getMinPwLength() {
return Integer.getInteger(MIN_PW_LENGTH_PROP_NAME, DEFAULT_MIN_PW_LENGTH);
public Optional<Path> getPluginDir() {
return getPath(PLUGIN_DIR_PROP_NAME);
}
public boolean showTrayIcon() {
return Boolean.getBoolean(TRAY_ICON_PROP_NAME);
}
public boolean disableUpdateCheck() {
return Boolean.getBoolean(DISABLE_UPDATE_CHECK_PROP_NAME);
}
private Optional<Path> getPath(String propertyName) {
String value = System.getProperty(propertyName);
return Optional.ofNullable(value).map(Paths::get);
}
// visible for testing
@VisibleForTesting
Stream<Path> getPaths(String propertyName) {
Stream<String> rawSettingsPaths = getRawList(propertyName, System.getProperty("path.separator").charAt(0));
return rawSettingsPaths.filter(Predicate.not(Strings::isNullOrEmpty)).map(Path::of);

View File

@@ -3,6 +3,7 @@ package org.cryptomator.common;
import com.google.common.base.Preconditions;
import com.google.common.base.Strings;
import com.google.common.base.Throwables;
import org.jetbrains.annotations.VisibleForTesting;
import java.util.Locale;
import java.util.Objects;
@@ -114,7 +115,7 @@ public class ErrorCode {
* @param bottomFrames Other stack frames, potentially forming the bottom of the stack of <code>allFrames</code>
* @return The number of additional frames in <code>allFrames</code>. In most cases this should be equal to the difference in size.
*/
// visible for testing
@VisibleForTesting
static int countTopmostFrames(StackTraceElement[] allFrames, StackTraceElement[] bottomFrames) {
if (allFrames.length < bottomFrames.length) {
// if frames had been stacked on top of bottomFrames, allFrames would be larger
@@ -124,7 +125,7 @@ public class ErrorCode {
}
}
// visible for testing
@VisibleForTesting
static <T> int commonSuffixLength(T[] set, T[] subset) {
Preconditions.checkArgument(set.length >= subset.length);
// iterate items backwards as long as they are identical

View File

@@ -62,7 +62,7 @@ public final class OneDriveWindowsLocationPresetsProvider implements LocationPre
ProcessBuilder command = new ProcessBuilder(args);
Process p = command.start();
waitForSuccess(p, 3, "`reg query`");
return p.inputReader(StandardCharsets.UTF_8).lines().filter(outputFilter);
return p.inputReader(StandardCharsets.ISO_8859_1).lines().filter(outputFilter);
}

View File

@@ -1,6 +1,7 @@
package org.cryptomator.common.mount;
import org.apache.commons.lang3.SystemUtils;
import org.jetbrains.annotations.VisibleForTesting;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -66,7 +67,7 @@ public final class MountWithinParentUtil {
}
}
//visible for testing
@VisibleForTesting
static MountPointState getMountPointState(Path path) throws IOException, IllegalMountPointException {
if (Files.notExists(path, LinkOption.NOFOLLOW_LINKS)) {
return MountPointState.NOT_EXISTING;
@@ -82,7 +83,7 @@ public final class MountWithinParentUtil {
return MountPointState.BROKEN_JUNCTION;
}
//visible for testing
@VisibleForTesting
enum MountPointState {
NOT_EXISTING,
@@ -93,7 +94,7 @@ public final class MountWithinParentUtil {
}
//visible for testing
@VisibleForTesting
static void removeResidualHideaway(Path mountPoint, Path hideaway) throws IOException {
checkIsHideawayDirectory(mountPoint, hideaway);
Files.delete(hideaway); //Fails if not empty
@@ -155,7 +156,7 @@ public final class MountWithinParentUtil {
}
}
//visible for testing
@VisibleForTesting
static Path getHideaway(Path mountPoint) {
return mountPoint.resolveSibling(HIDEAWAY_PREFIX + mountPoint.getFileName().toString() + HIDEAWAY_SUFFIX);
}

View File

@@ -8,7 +8,7 @@ package org.cryptomator.common.settings;
import com.google.common.base.CharMatcher;
import com.google.common.base.Strings;
import com.google.common.io.BaseEncoding;
import org.jetbrains.annotations.VisibleForTesting;
import javafx.beans.Observable;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.StringExpression;
@@ -131,7 +131,7 @@ public class VaultSettings {
return json;
}
//visible for testing
@VisibleForTesting
static String normalizeDisplayName(String original) {
if (original.isBlank() || ".".equals(original) || "..".equals(original)) {
return "_";

View File

@@ -6,6 +6,7 @@
*******************************************************************************/
package org.cryptomator.launcher;
import org.jetbrains.annotations.VisibleForTesting;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -48,7 +49,7 @@ class FileOpenRequestHandler {
handleLaunchArgs(FileSystems.getDefault(), args);
}
// visible for testing
@VisibleForTesting
void handleLaunchArgs(FileSystem fs, List<String> args) {
Collection<Path> pathsToOpen = args.stream().map(str -> {
try {

View File

@@ -5,6 +5,7 @@ import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.LoggerContext;
import ch.qos.logback.classic.encoder.PatternLayoutEncoder;
import ch.qos.logback.classic.spi.Configurator;
import ch.qos.logback.classic.spi.ConfiguratorRank;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.Appender;
import ch.qos.logback.core.ConsoleAppender;
@@ -19,6 +20,7 @@ import org.cryptomator.common.Environment;
import java.nio.file.Path;
import java.util.Map;
@ConfiguratorRank(ConfiguratorRank.CUSTOM_NORMAL_PRIORITY)
public class LogbackConfigurator extends ContextAwareBase implements Configurator {
private static final String LOG_PATTERN = "%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n";

View File

@@ -1,5 +1,7 @@
package org.cryptomator.ui.addvaultwizard;
import org.jetbrains.annotations.VisibleForTesting;
import javax.inject.Inject;
import java.util.List;
import java.util.ResourceBundle;
@@ -51,7 +53,7 @@ public class ReadmeGenerator {
resourceBundle.getString("addvault.new.readme.accessLocation.4")));
}
// visible for testing
@VisibleForTesting
String createDocument(Iterable<String> paragraphs) {
StringBuilder sb = new StringBuilder(RTF_HEADER);
for (String p : paragraphs) {
@@ -63,7 +65,7 @@ public class ReadmeGenerator {
return sb.toString();
}
// visible for testing
@VisibleForTesting
String escapeNonAsciiChars(CharSequence input) {
StringBuilder sb = new StringBuilder();
appendEscaped(sb, input);

View File

@@ -20,9 +20,10 @@ public enum FxmlFile {
HUB_AUTH_FLOW("/fxml/hub_auth_flow.fxml"), //
HUB_INVALID_LICENSE("/fxml/hub_invalid_license.fxml"), //
HUB_RECEIVE_KEY("/fxml/hub_receive_key.fxml"), //
HUB_REGISTER_DEVICE("/fxml/hub_register_device.fxml"), //
HUB_LEGACY_REGISTER_DEVICE("/fxml/hub_legacy_register_device.fxml"), //
HUB_REGISTER_SUCCESS("/fxml/hub_register_success.fxml"), //
HUB_REGISTER_FAILED("/fxml/hub_register_failed.fxml"),
HUB_REGISTER_FAILED("/fxml/hub_register_failed.fxml"), //
HUB_SETUP_DEVICE("/fxml/hub_setup_device.fxml"), //
HUB_UNAUTHORIZED_DEVICE("/fxml/hub_unauthorized_device.fxml"), //
LOCK_FORCED("/fxml/lock_forced.fxml"), //
LOCK_FAILED("/fxml/lock_failed.fxml"), //

View File

@@ -16,6 +16,7 @@ import org.cryptomator.ui.common.FxmlFile;
import org.cryptomator.ui.common.FxmlScene;
import org.cryptomator.ui.fxapp.FxApplicationWindows;
import org.cryptomator.ui.recoverykey.RecoveryKeyFactory;
import org.jetbrains.annotations.VisibleForTesting;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -116,7 +117,7 @@ public class HubToPasswordConvertController implements FxController {
}, Platform::runLater); //
}
//visible for testing
@VisibleForTesting
void convertInternal() throws CompletionException, IllegalArgumentException {
var passphrase = newPasswordController.getNewPassword();
var vaultPath = vault.getPath();
@@ -141,7 +142,7 @@ public class HubToPasswordConvertController implements FxController {
}
}
//visible for testing
@VisibleForTesting
void backupHubConfig(Path hubConfigPath) throws IOException {
byte[] hubConfigBytes = Files.readAllBytes(hubConfigPath);
Path backupPath = hubConfigPath.resolveSibling(VAULTCONFIG_FILENAME + BackupHelper.generateFileIdSuffix(hubConfigBytes) + MASTERKEY_BACKUP_SUFFIX);
@@ -149,7 +150,7 @@ public class HubToPasswordConvertController implements FxController {
LOG.debug("Successfully created hub config backup {}", backupPath.getFileName());
}
//visible for testing
@VisibleForTesting
Path createPasswordConfig(Path passwordConfigPath, Path masterkeyFile, Passphrase passphrase) throws IOException, MasterkeyLoadingFailedException {
var unverifiedVaultConfig = vault.getVaultConfigCache().get();
try (var masterkey = masterkeyFileAccess.load(masterkeyFile, passphrase)) {

View File

@@ -31,6 +31,7 @@ import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Comparator;
import java.util.Map;
import java.util.Optional;
@@ -42,7 +43,8 @@ public class ErrorController implements FxController {
private static final ObjectMapper JSON = new ObjectMapper();
private static final Logger LOG = LoggerFactory.getLogger(ErrorController.class);
private static final String ERROR_CODES_URL = "https://api.cryptomator.org/desktop/error-codes.json";
private static final String USER_AGENT_FORMAT = "Cryptomator/%s (Build %s) (%s %s %s)";
private static final String ERROR_CODES_URL_FORMAT = "https://api.cryptomator.org/desktop/error-codes.json?error-code=%s";
private static final String SEARCH_URL_FORMAT = "https://github.com/cryptomator/cryptomator/discussions/categories/errors?discussions_q=category:Errors+%s";
private static final String REPORT_URL_FORMAT = "https://github.com/cryptomator/cryptomator/discussions/new?category=Errors&title=Error+%s&body=%s";
private static final String SEARCH_ERRORCODE_DELIM = " OR ";
@@ -142,11 +144,19 @@ public class ErrorController implements FxController {
@FXML
public void lookUpSolution() {
String userAgent = USER_AGENT_FORMAT.formatted( //
environment.getAppVersion(), //
environment.getBuildNumber().orElse("undefined"), //
System.getProperty("os.name"), //
System.getProperty("os.version"), //
System.getProperty("os.arch"));
isLoadingHttpResponse.set(true);
askedForLookupDatabasePermission.set(true);
HttpClient httpClient = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).build();
HttpRequest httpRequest = HttpRequest.newBuilder()//
.uri(URI.create(ERROR_CODES_URL))//
.header("User-Agent", userAgent)
.timeout(Duration.ofSeconds(10))
.uri(URI.create(ERROR_CODES_URL_FORMAT.formatted(URLEncoder.encode(errorCode.toString(),StandardCharsets.UTF_8))))//
.build();
httpClient.sendAsync(httpRequest, HttpResponse.BodyHandlers.ofInputStream())//
.thenAcceptAsync(this::loadHttpResponse, executorService)//

View File

@@ -1,6 +1,7 @@
package org.cryptomator.ui.fxapp;
import dagger.Lazy;
import org.cryptomator.common.Environment;
import org.cryptomator.common.settings.Settings;
import org.cryptomator.ui.traymenu.TrayMenuComponent;
import org.slf4j.Logger;
@@ -17,6 +18,7 @@ public class FxApplication {
private static final Logger LOG = LoggerFactory.getLogger(FxApplication.class);
private final long startupTime;
private final Environment environment;
private final Settings settings;
private final AppLaunchEventHandler launchEventHandler;
private final Lazy<TrayMenuComponent> trayMenu;
@@ -26,8 +28,9 @@ public class FxApplication {
private final AutoUnlocker autoUnlocker;
@Inject
FxApplication(@Named("startupTime") long startupTime, Settings settings, AppLaunchEventHandler launchEventHandler, Lazy<TrayMenuComponent> trayMenu, FxApplicationWindows appWindows, FxApplicationStyle applicationStyle, FxApplicationTerminator applicationTerminator, AutoUnlocker autoUnlocker) {
FxApplication(@Named("startupTime") long startupTime, Environment environment, Settings settings, AppLaunchEventHandler launchEventHandler, Lazy<TrayMenuComponent> trayMenu, FxApplicationWindows appWindows, FxApplicationStyle applicationStyle, FxApplicationTerminator applicationTerminator, AutoUnlocker autoUnlocker) {
this.startupTime = startupTime;
this.environment = environment;
this.settings = settings;
this.launchEventHandler = launchEventHandler;
this.trayMenu = trayMenu;
@@ -68,7 +71,9 @@ public class FxApplication {
return null;
});
appWindows.checkAndShowUpdateReminderWindow();
if (!environment.disableUpdateCheck()) {
appWindows.checkAndShowUpdateReminderWindow();
}
launchEventHandler.startHandlingLaunchEvents();
autoUnlocker.tryUnlockForTimespan(2, TimeUnit.MINUTES);

View File

@@ -22,23 +22,23 @@ public class UpdateChecker {
private static final Logger LOG = LoggerFactory.getLogger(UpdateChecker.class);
private static final Duration AUTOCHECK_DELAY = Duration.seconds(5);
private final Environment env;
private final Settings settings;
private final String currentVersion;
private final StringProperty latestVersionProperty;
private final Comparator<String> semVerComparator;
private final ScheduledService<String> updateCheckerService;
@Inject
UpdateChecker(Settings settings, Environment env, @Named("latestVersion") StringProperty latestVersionProperty, @Named("SemVer") Comparator<String> semVerComparator, ScheduledService<String> updateCheckerService) {
this.env = env;
this.settings = settings;
this.latestVersionProperty = latestVersionProperty;
this.semVerComparator = semVerComparator;
this.updateCheckerService = updateCheckerService;
this.currentVersion = env.getAppVersion();
}
public void automaticallyCheckForUpdatesIfEnabled() {
if (settings.checkForUpdates.get()) {
if (!env.disableUpdateCheck() && settings.checkForUpdates.get()) {
startCheckingForUpdates(AUTOCHECK_DELAY);
}
}
@@ -63,9 +63,9 @@ public class UpdateChecker {
private void checkSucceeded(WorkerStateEvent event) {
String latestVersion = updateCheckerService.getValue();
LOG.info("Current version: {}, lastest version: {}", currentVersion, latestVersion);
LOG.info("Current version: {}, lastest version: {}", getCurrentVersion(), latestVersion);
if (semVerComparator.compare(currentVersion, latestVersion) < 0) {
if (semVerComparator.compare(getCurrentVersion(), latestVersion) < 0) {
// update is available
latestVersionProperty.set(latestVersion);
} else {
@@ -88,7 +88,7 @@ public class UpdateChecker {
}
public String getCurrentVersion() {
return currentVersion;
return env.getAppVersion();
}
}

View File

@@ -63,6 +63,7 @@ public abstract class UpdateCheckerModule {
return HttpRequest.newBuilder() //
.uri(LATEST_VERSION_URI) //
.header("User-Agent", userAgent) //
.timeout(java.time.Duration.ofSeconds(10))
.build();
}

View File

@@ -101,16 +101,16 @@ public class StartController implements FxController {
}
}
private void loadingKeyFailed(Throwable e) {
switch (e) {
case UnlockCancelledException uce -> {} //ok
case VaultKeyInvalidException vkie -> {
LOG.error("Invalid key"); //TODO: specific error screen
private void loadingKeyFailed(Throwable t) {
switch (t) {
case UnlockCancelledException e -> {} // ok // TODO: rename to _ with JEP 443
case VaultKeyInvalidException e -> { // TODO: rename to _ with JEP 443
LOG.error("Invalid key"); // TODO: specific error screen
appWindows.showErrorWindow(e, window, null);
}
default -> {
LOG.error("Failed to load key.", e);
appWindows.showErrorWindow(e, window, null);
LOG.error("Failed to load key.", t);
appWindows.showErrorWindow(t, window, null);
}
}
}

View File

@@ -35,13 +35,13 @@ public class AuthFlowController implements FxController {
private final String deviceId;
private final HubConfig hubConfig;
private final AtomicReference<String> tokenRef;
private final CompletableFuture<JWEObject> result;
private final CompletableFuture<ReceivedKey> result;
private final Lazy<Scene> receiveKeyScene;
private final ObjectProperty<URI> authUri;
private AuthFlowTask task;
@Inject
public AuthFlowController(Application application, @KeyLoading Stage window, ExecutorService executor, @Named("deviceId") String deviceId, HubConfig hubConfig, @Named("bearerToken") AtomicReference<String> tokenRef, CompletableFuture<JWEObject> result, @FxmlScene(FxmlFile.HUB_RECEIVE_KEY) Lazy<Scene> receiveKeyScene) {
public AuthFlowController(Application application, @KeyLoading Stage window, ExecutorService executor, @Named("deviceId") String deviceId, HubConfig hubConfig, @Named("bearerToken") AtomicReference<String> tokenRef, CompletableFuture<ReceivedKey> result, @FxmlScene(FxmlFile.HUB_RECEIVE_KEY) Lazy<Scene> receiveKeyScene) {
this.application = application;
this.window = window;
this.executor = executor;

View File

@@ -8,6 +8,8 @@ import io.github.coffeelibs.tinyoauth2client.http.response.Response;
import javafx.concurrent.Task;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.time.Duration;
import java.util.function.Consumer;
class AuthFlowTask extends Task<String> {
@@ -34,6 +36,7 @@ class AuthFlowTask extends Task<String> {
protected String call() throws IOException, InterruptedException {
var response = TinyOAuth2.client(hubConfig.clientId) //
.withTokenEndpoint(URI.create(hubConfig.tokenEndpoint)) //
.withRequestTimeout(Duration.ofSeconds(10)) //
.authFlow(URI.create(hubConfig.authEndpoint)) //
.setSuccessResponse(Response.redirect(URI.create(hubConfig.authSuccessUrl + "&device=" + authFlowContext.deviceId()))) //
.setErrorResponse(Response.redirect(URI.create(hubConfig.authErrorUrl + "&device=" + authFlowContext.deviceId()))) //

View File

@@ -1,5 +0,0 @@
package org.cryptomator.ui.keyloading.hub;
record CreateDeviceDto(String id, String name, String publicKey) {
}

View File

@@ -1,19 +0,0 @@
package org.cryptomator.ui.keyloading.hub;
import com.google.common.io.CharStreams;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
class HttpHelper {
public static String readBody(HttpResponse<InputStream> response) throws IOException {
try (var in = response.body(); var reader = new InputStreamReader(in, StandardCharsets.UTF_8)) {
return CharStreams.toString(reader);
}
}
}

View File

@@ -1,6 +1,10 @@
package org.cryptomator.ui.keyloading.hub;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.net.URI;
// needs to be accessible by JSON decoder
@JsonIgnoreProperties(ignoreUnknown = true)
@@ -9,8 +13,19 @@ public class HubConfig {
public String clientId;
public String authEndpoint;
public String tokenEndpoint;
public String devicesResourceUrl;
public String authSuccessUrl;
public String authErrorUrl;
public @Nullable String apiBaseUrl;
@Deprecated // use apiBaseUrl + "/devices/"
public String devicesResourceUrl;
public URI getApiBaseUrl() {
if (apiBaseUrl != null) {
return URI.create(apiBaseUrl);
} else {
// legacy approach
assert devicesResourceUrl != null;
return URI.create(devicesResourceUrl + "/..").normalize();
}
}
}

View File

@@ -1,7 +1,6 @@
package org.cryptomator.ui.keyloading.hub;
import com.google.common.io.BaseEncoding;
import com.nimbusds.jose.JWEObject;
import dagger.Binds;
import dagger.Module;
import dagger.Provides;
@@ -69,7 +68,7 @@ public abstract class HubKeyLoadingModule {
@Provides
@KeyLoadingScoped
static CompletableFuture<JWEObject> provideResult() {
static CompletableFuture<ReceivedKey> provideResult() {
return new CompletableFuture<>();
}
@@ -114,10 +113,10 @@ public abstract class HubKeyLoadingModule {
}
@Provides
@FxmlScene(FxmlFile.HUB_REGISTER_DEVICE)
@FxmlScene(FxmlFile.HUB_LEGACY_REGISTER_DEVICE)
@KeyLoadingScoped
static Scene provideHubRegisterDeviceScene(@KeyLoading FxmlLoaderFactory fxmlLoaders) {
return fxmlLoaders.createScene(FxmlFile.HUB_REGISTER_DEVICE);
static Scene provideHubLegacyRegisterDeviceScene(@KeyLoading FxmlLoaderFactory fxmlLoaders) {
return fxmlLoaders.createScene(FxmlFile.HUB_LEGACY_REGISTER_DEVICE);
}
@Provides
@@ -134,6 +133,13 @@ public abstract class HubKeyLoadingModule {
return fxmlLoaders.createScene(FxmlFile.HUB_REGISTER_FAILED);
}
@Provides
@FxmlScene(FxmlFile.HUB_SETUP_DEVICE)
@KeyLoadingScoped
static Scene provideHubRegisterDeviceScene(@KeyLoading FxmlLoaderFactory fxmlLoaders) {
return fxmlLoaders.createScene(FxmlFile.HUB_SETUP_DEVICE);
}
@Provides
@FxmlScene(FxmlFile.HUB_UNAUTHORIZED_DEVICE)
@KeyLoadingScoped
@@ -166,6 +172,11 @@ public abstract class HubKeyLoadingModule {
@FxControllerKey(RegisterDeviceController.class)
abstract FxController bindRegisterDeviceController(RegisterDeviceController controller);
@Binds
@IntoMap
@FxControllerKey(LegacyRegisterDeviceController.class)
abstract FxController bindLegacyRegisterDeviceController(LegacyRegisterDeviceController controller);
@Binds
@IntoMap
@FxControllerKey(RegisterSuccessController.class)

View File

@@ -36,11 +36,11 @@ public class HubKeyLoadingStrategy implements KeyLoadingStrategy {
private final KeychainManager keychainManager;
private final Lazy<Scene> authFlowScene;
private final Lazy<Scene> noKeychainScene;
private final CompletableFuture<JWEObject> result;
private final CompletableFuture<ReceivedKey> result;
private final DeviceKey deviceKey;
@Inject
public HubKeyLoadingStrategy(@KeyLoading Stage window, @FxmlScene(FxmlFile.HUB_AUTH_FLOW) Lazy<Scene> authFlowScene, @FxmlScene(FxmlFile.HUB_NO_KEYCHAIN) Lazy<Scene> noKeychainScene, CompletableFuture<JWEObject> result, DeviceKey deviceKey, KeychainManager keychainManager, @Named("windowTitle") String windowTitle) {
public HubKeyLoadingStrategy(@KeyLoading Stage window, @FxmlScene(FxmlFile.HUB_AUTH_FLOW) Lazy<Scene> authFlowScene, @FxmlScene(FxmlFile.HUB_NO_KEYCHAIN) Lazy<Scene> noKeychainScene, CompletableFuture<ReceivedKey> result, DeviceKey deviceKey, KeychainManager keychainManager, @Named("windowTitle") String windowTitle) {
this.window = window;
this.keychainManager = keychainManager;
window.setTitle(windowTitle);
@@ -60,7 +60,7 @@ public class HubKeyLoadingStrategy implements KeyLoadingStrategy {
var keypair = deviceKey.get();
showWindow(authFlowScene);
var jwe = result.get();
return JWEHelper.decrypt(jwe, keypair.getPrivate());
return jwe.decryptMasterkey(keypair.getPrivate());
} catch (NoKeychainAccessProviderException e) {
showWindow(noKeychainScene);
throw new UnlockCancelledException("Unlock canceled due to missing prerequisites", e);

View File

@@ -2,35 +2,103 @@ package org.cryptomator.ui.keyloading.hub;
import com.google.common.base.Preconditions;
import com.google.common.io.BaseEncoding;
import com.nimbusds.jose.EncryptionMethod;
import com.nimbusds.jose.JOSEException;
import com.nimbusds.jose.JWEAlgorithm;
import com.nimbusds.jose.JWEHeader;
import com.nimbusds.jose.JWEObject;
import com.nimbusds.jose.Payload;
import com.nimbusds.jose.crypto.ECDHDecrypter;
import com.nimbusds.jose.crypto.ECDHEncrypter;
import com.nimbusds.jose.crypto.PasswordBasedDecrypter;
import com.nimbusds.jose.jwk.Curve;
import com.nimbusds.jose.jwk.gen.ECKeyGenerator;
import com.nimbusds.jose.jwk.gen.JWKGenerator;
import org.cryptomator.cryptolib.api.Masterkey;
import org.cryptomator.cryptolib.api.MasterkeyLoadingFailedException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.security.KeyFactory;
import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.security.interfaces.ECPrivateKey;
import java.security.interfaces.ECPublicKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Arrays;
import java.util.Base64;
import java.util.Map;
import java.util.function.Function;
class JWEHelper {
private static final Logger LOG = LoggerFactory.getLogger(JWEHelper.class);
private static final String JWE_PAYLOAD_MASTERKEY_FIELD = "key";
private static final String JWE_PAYLOAD_KEY_FIELD = "key";
private static final String EC_ALG = "EC";
private JWEHelper(){}
public static Masterkey decrypt(JWEObject jwe, ECPrivateKey privateKey) throws MasterkeyLoadingFailedException {
public static JWEObject encryptUserKey(ECPrivateKey userKey, ECPublicKey deviceKey) {
try {
jwe.decrypt(new ECDHDecrypter(privateKey));
return readKey(jwe);
var encodedUserKey = Base64.getEncoder().encodeToString(userKey.getEncoded());
var keyGen = new ECKeyGenerator(Curve.P_384);
var ephemeralKeyPair = keyGen.generate();
var header = new JWEHeader.Builder(JWEAlgorithm.ECDH_ES, EncryptionMethod.A256GCM).ephemeralPublicKey(ephemeralKeyPair.toPublicJWK()).build();
var payload = new Payload(Map.of(JWE_PAYLOAD_KEY_FIELD, encodedUserKey));
var jwe = new JWEObject(header, payload);
jwe.encrypt(new ECDHEncrypter(deviceKey));
return jwe;
} catch (JOSEException e) {
LOG.warn("Failed to decrypt JWE: {}", jwe);
throw new MasterkeyLoadingFailedException("Failed to decrypt JWE", e);
throw new RuntimeException(e);
}
}
private static Masterkey readKey(JWEObject jwe) throws MasterkeyLoadingFailedException {
public static ECPrivateKey decryptUserKey(JWEObject jwe, String setupCode) throws InvalidJweKeyException {
try {
jwe.decrypt(new PasswordBasedDecrypter(setupCode));
return decodeUserKey(jwe);
} catch (JOSEException e) {
throw new InvalidJweKeyException(e);
}
}
public static ECPrivateKey decryptUserKey(JWEObject jwe, ECPrivateKey deviceKey) throws InvalidJweKeyException {
try {
jwe.decrypt(new ECDHDecrypter(deviceKey));
return decodeUserKey(jwe);
} catch (JOSEException e) {
throw new InvalidJweKeyException(e);
}
}
private static ECPrivateKey decodeUserKey(JWEObject decryptedJwe) {
try {
var keySpec = readKey(decryptedJwe, JWE_PAYLOAD_KEY_FIELD, PKCS8EncodedKeySpec::new);
var factory = KeyFactory.getInstance(EC_ALG);
var privateKey = factory.generatePrivate(keySpec);
if (privateKey instanceof ECPrivateKey ecPrivateKey) {
return ecPrivateKey;
} else {
throw new IllegalStateException(EC_ALG + " key factory not generating ECPrivateKeys");
}
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(EC_ALG + " not supported");
} catch (InvalidKeySpecException e) {
LOG.warn("Unexpected JWE payload: {}", decryptedJwe.getPayload());
throw new MasterkeyLoadingFailedException("Unexpected JWE payload", e);
}
}
public static Masterkey decryptVaultKey(JWEObject jwe, ECPrivateKey privateKey) throws InvalidJweKeyException {
try {
jwe.decrypt(new ECDHDecrypter(privateKey));
return readKey(jwe, JWE_PAYLOAD_KEY_FIELD, Masterkey::new);
} catch (JOSEException e) {
throw new InvalidJweKeyException(e);
}
}
private static <T> T readKey(JWEObject jwe, String keyField, Function<byte[], T> rawKeyFactory) throws MasterkeyLoadingFailedException {
Preconditions.checkArgument(jwe.getState() == JWEObject.State.DECRYPTED);
var fields = jwe.getPayload().toJSONObject();
if (fields == null) {
@@ -39,11 +107,11 @@ class JWEHelper {
}
var keyBytes = new byte[0];
try {
if (fields.get(JWE_PAYLOAD_MASTERKEY_FIELD) instanceof String key) {
if (fields.get(keyField) instanceof String key) {
keyBytes = BaseEncoding.base64().decode(key);
return new Masterkey(keyBytes);
return rawKeyFactory.apply(keyBytes);
} else {
throw new IllegalArgumentException("JWE payload doesn't contain field " + JWE_PAYLOAD_MASTERKEY_FIELD);
throw new IllegalArgumentException("JWE payload doesn't contain field " + keyField);
}
} catch (IllegalArgumentException e) {
LOG.error("Unexpected JWE payload: {}", jwe.getPayload());
@@ -52,4 +120,11 @@ class JWEHelper {
Arrays.fill(keyBytes, (byte) 0x00);
}
}
public static class InvalidJweKeyException extends MasterkeyLoadingFailedException {
public InvalidJweKeyException(Throwable cause) {
super("Invalid key", cause);
}
}
}

View File

@@ -0,0 +1,191 @@
package org.cryptomator.ui.keyloading.hub;
import com.auth0.jwt.JWT;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.fasterxml.jackson.core.JacksonException;
import com.fasterxml.jackson.databind.ObjectMapper;
import dagger.Lazy;
import org.cryptomator.common.settings.DeviceKey;
import org.cryptomator.cryptolib.common.P384KeyPair;
import org.cryptomator.ui.common.FxController;
import org.cryptomator.ui.common.FxmlFile;
import org.cryptomator.ui.common.FxmlScene;
import org.cryptomator.ui.keyloading.KeyLoading;
import org.cryptomator.ui.keyloading.KeyLoadingScoped;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.inject.Inject;
import javax.inject.Named;
import javafx.application.Platform;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.fxml.FXML;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.ContentDisplay;
import javafx.scene.control.TextField;
import javafx.stage.Stage;
import javafx.stage.WindowEvent;
import java.io.IOException;
import java.net.InetAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.atomic.AtomicReference;
@KeyLoadingScoped
public class LegacyRegisterDeviceController implements FxController {
private static final Logger LOG = LoggerFactory.getLogger(LegacyRegisterDeviceController.class);
private static final ObjectMapper JSON = new ObjectMapper().setDefaultLeniency(true);
private static final List<Integer> EXPECTED_RESPONSE_CODES = List.of(201, 409);
private final Stage window;
private final HubConfig hubConfig;
private final String bearerToken;
private final Lazy<Scene> registerSuccessScene;
private final Lazy<Scene> registerFailedScene;
private final String deviceId;
private final P384KeyPair keyPair;
private final CompletableFuture<ReceivedKey> result;
private final DecodedJWT jwt;
private final HttpClient httpClient;
private final BooleanProperty deviceNameAlreadyExists = new SimpleBooleanProperty(false);
public TextField deviceNameField;
public Button registerBtn;
@Inject
public LegacyRegisterDeviceController(@KeyLoading Stage window, ExecutorService executor, HubConfig hubConfig, @Named("deviceId") String deviceId, DeviceKey deviceKey, CompletableFuture<ReceivedKey> result, @Named("bearerToken") AtomicReference<String> bearerToken, @FxmlScene(FxmlFile.HUB_REGISTER_SUCCESS) Lazy<Scene> registerSuccessScene, @FxmlScene(FxmlFile.HUB_REGISTER_FAILED) Lazy<Scene> registerFailedScene) {
this.window = window;
this.hubConfig = hubConfig;
this.deviceId = deviceId;
this.keyPair = Objects.requireNonNull(deviceKey.get());
this.result = result;
this.bearerToken = Objects.requireNonNull(bearerToken.get());
this.registerSuccessScene = registerSuccessScene;
this.registerFailedScene = registerFailedScene;
this.jwt = JWT.decode(this.bearerToken);
this.window.addEventHandler(WindowEvent.WINDOW_HIDING, this::windowClosed);
this.httpClient = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).executor(executor).build();
}
public void initialize() {
deviceNameField.setText(determineHostname());
deviceNameField.textProperty().addListener(observable -> deviceNameAlreadyExists.set(false));
}
private String determineHostname() {
try {
var hostName = InetAddress.getLocalHost().getHostName();
return Objects.requireNonNullElse(hostName, "");
} catch (IOException e) {
return "";
}
}
@FXML
public void register() {
deviceNameAlreadyExists.set(false);
registerBtn.setContentDisplay(ContentDisplay.LEFT);
registerBtn.setDisable(true);
var deviceUri = URI.create(hubConfig.devicesResourceUrl + deviceId);
var deviceKey = keyPair.getPublic().getEncoded();
var dto = new CreateDeviceDto();
dto.id = deviceId;
dto.name = deviceNameField.getText();
dto.publicKey = Base64.getUrlEncoder().withoutPadding().encodeToString(deviceKey);
var json = toJson(dto);
var request = HttpRequest.newBuilder(deviceUri) //
.PUT(HttpRequest.BodyPublishers.ofString(json, StandardCharsets.UTF_8)) //
.header("Authorization", "Bearer " + bearerToken) //
.header("Content-Type", "application/json") //
.build();
httpClient.sendAsync(request, HttpResponse.BodyHandlers.discarding()) //
.thenApply(response -> {
if (EXPECTED_RESPONSE_CODES.contains(response.statusCode())) {
return response;
} else {
throw new RuntimeException("Server answered with unexpected status code " + response.statusCode());
}
}).handleAsync((response, throwable) -> {
if (response != null) {
this.handleResponse(response);
} else {
this.registrationFailed(throwable);
}
return null;
}, Platform::runLater);
}
private String toJson(CreateDeviceDto dto) {
try {
return JSON.writer().writeValueAsString(dto);
} catch (JacksonException e) {
throw new IllegalStateException("Failed to serialize DTO", e);
}
}
private void handleResponse(HttpResponse<Void> voidHttpResponse) {
assert EXPECTED_RESPONSE_CODES.contains(voidHttpResponse.statusCode());
if (voidHttpResponse.statusCode() == 409) {
deviceNameAlreadyExists.set(true);
registerBtn.setContentDisplay(ContentDisplay.TEXT_ONLY);
registerBtn.setDisable(false);
} else {
LOG.debug("Device registration for hub instance {} successful.", hubConfig.authSuccessUrl);
window.setScene(registerSuccessScene.get());
}
}
private void registrationFailed(Throwable cause) {
LOG.warn("Device registration failed.", cause);
window.setScene(registerFailedScene.get());
result.completeExceptionally(cause);
}
@FXML
public void close() {
window.close();
}
private void windowClosed(WindowEvent windowEvent) {
result.cancel(true);
}
/* Getter */
public String getUserName() {
return jwt.getClaim("email").asString();
}
//--- Getters & Setters
public BooleanProperty deviceNameAlreadyExistsProperty() {
return deviceNameAlreadyExists;
}
public boolean getDeviceNameAlreadyExists() {
return deviceNameAlreadyExists.get();
}
private static class CreateDeviceDto {
public String id;
public String name;
public final String type = "DESKTOP";
public String publicKey;
}
}

View File

@@ -1,5 +1,8 @@
package org.cryptomator.ui.keyloading.hub;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nimbusds.jose.JWEObject;
import dagger.Lazy;
import org.cryptomator.common.vaults.Vault;
@@ -8,6 +11,9 @@ import org.cryptomator.ui.common.FxmlFile;
import org.cryptomator.ui.common.FxmlScene;
import org.cryptomator.ui.keyloading.KeyLoading;
import org.cryptomator.ui.keyloading.KeyLoadingScoped;
import org.jetbrains.annotations.NotNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.inject.Inject;
import javax.inject.Named;
@@ -17,14 +23,16 @@ import javafx.scene.Scene;
import javafx.stage.Stage;
import javafx.stage.WindowEvent;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.text.ParseException;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
@@ -33,25 +41,32 @@ import java.util.concurrent.atomic.AtomicReference;
@KeyLoadingScoped
public class ReceiveKeyController implements FxController {
private static final Logger LOG = LoggerFactory.getLogger(ReceiveKeyController.class);
private static final String SCHEME_PREFIX = "hub+";
private static final ObjectMapper JSON = new ObjectMapper().setDefaultLeniency(true);
private static final Duration REQ_TIMEOUT = Duration.ofSeconds(10);
private final Stage window;
private final HubConfig hubConfig;
private final String deviceId;
private final String bearerToken;
private final CompletableFuture<JWEObject> result;
private final Lazy<Scene> registerDeviceScene;
private final CompletableFuture<ReceivedKey> result;
private final Lazy<Scene> setupDeviceScene;
private final Lazy<Scene> legacyRegisterDeviceScene;
private final Lazy<Scene> unauthorizedScene;
private final URI vaultBaseUri;
private final Lazy<Scene> invalidLicenseScene;
private final HttpClient httpClient;
@Inject
public ReceiveKeyController(@KeyLoading Vault vault, ExecutorService executor, @KeyLoading Stage window, @Named("deviceId") String deviceId, @Named("bearerToken") AtomicReference<String> tokenRef, CompletableFuture<JWEObject> result, @FxmlScene(FxmlFile.HUB_REGISTER_DEVICE) Lazy<Scene> registerDeviceScene, @FxmlScene(FxmlFile.HUB_UNAUTHORIZED_DEVICE) Lazy<Scene> unauthorizedScene, @FxmlScene(FxmlFile.HUB_INVALID_LICENSE) Lazy<Scene> invalidLicenseScene) {
public ReceiveKeyController(@KeyLoading Vault vault, ExecutorService executor, @KeyLoading Stage window, HubConfig hubConfig, @Named("deviceId") String deviceId, @Named("bearerToken") AtomicReference<String> tokenRef, CompletableFuture<ReceivedKey> result, @FxmlScene(FxmlFile.HUB_SETUP_DEVICE) Lazy<Scene> setupDeviceScene, @FxmlScene(FxmlFile.HUB_LEGACY_REGISTER_DEVICE) Lazy<Scene> legacyRegisterDeviceScene, @FxmlScene(FxmlFile.HUB_UNAUTHORIZED_DEVICE) Lazy<Scene> unauthorizedScene, @FxmlScene(FxmlFile.HUB_INVALID_LICENSE) Lazy<Scene> invalidLicenseScene) {
this.window = window;
this.hubConfig = hubConfig;
this.deviceId = deviceId;
this.bearerToken = Objects.requireNonNull(tokenRef.get());
this.result = result;
this.registerDeviceScene = registerDeviceScene;
this.setupDeviceScene = setupDeviceScene;
this.legacyRegisterDeviceScene = legacyRegisterDeviceScene;
this.unauthorizedScene = unauthorizedScene;
this.vaultBaseUri = getVaultBaseUri(vault);
this.invalidLicenseScene = invalidLicenseScene;
@@ -61,23 +76,120 @@ public class ReceiveKeyController implements FxController {
@FXML
public void initialize() {
var keyUri = appendPath(vaultBaseUri, "/keys/" + deviceId);
var request = HttpRequest.newBuilder(keyUri) //
requestVaultMasterkey();
}
/**
* STEP 1 (Request): GET vault key for this user
*/
private void requestVaultMasterkey() {
var accessTokenUri = appendPath(vaultBaseUri, "/access-token");
var request = HttpRequest.newBuilder(accessTokenUri) //
.header("Authorization", "Bearer " + bearerToken) //
.GET() //
.timeout(REQ_TIMEOUT) //
.build();
httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofInputStream()) //
.thenAcceptAsync(this::loadedExistingKey, Platform::runLater) //
httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.US_ASCII)) //
.thenAcceptAsync(this::receivedVaultMasterkey, Platform::runLater) //
.exceptionally(this::retrievalFailed);
}
private void loadedExistingKey(HttpResponse<InputStream> response) {
/**
* STEP 1 (Response): GET vault key for this user
*
* @param response Response
*/
private void receivedVaultMasterkey(HttpResponse<String> response) {
LOG.debug("GET {} -> Status Code {}", response.request().uri(), response.statusCode());
switch (response.statusCode()) {
case 200 -> requestUserKey(response.body());
case 402 -> licenseExceeded();
case 403, 410 -> accessNotGranted(); // or vault has been archived, effectively disallowing access - TODO: add specific dialog?
case 404 -> requestLegacyAccessToken();
default -> throw new IllegalStateException("Unexpected response " + response.statusCode());
}
}
/**
* STEP 2 (Request): GET user key for this device
*/
private void requestUserKey(String encryptedVaultKey) {
var deviceTokenUri = URI.create(hubConfig.getApiBaseUrl() + "/devices/" + deviceId);
var request = HttpRequest.newBuilder(deviceTokenUri) //
.header("Authorization", "Bearer " + bearerToken) //
.GET() //
.timeout(REQ_TIMEOUT) //
.build();
httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)) //
.thenAcceptAsync(response -> receivedUserKey(encryptedVaultKey, response), Platform::runLater) //
.exceptionally(this::retrievalFailed);
}
/**
* STEP 2 (Response): GET user key for this device
*
* @param response Response
*/
private void receivedUserKey(String encryptedVaultKey, HttpResponse<String> response) {
LOG.debug("GET {} -> Status Code {}", response.request().uri(), response.statusCode());
try {
switch (response.statusCode()) {
case 200 -> retrievalSucceeded(response);
case 200 -> {
var device = JSON.reader().readValue(response.body(), DeviceDto.class);
receivedBothEncryptedKeys(encryptedVaultKey, device.userPrivateKey);
}
case 404 -> needsDeviceSetup(); // TODO: using the setup code, we can theoretically immediately unlock
default -> throw new IllegalStateException("Unexpected response " + response.statusCode());
}
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
private void needsDeviceSetup() {
window.setScene(setupDeviceScene.get());
}
private void receivedBothEncryptedKeys(String encryptedVaultKey, String encryptedUserKey) throws IOException {
try {
var vaultKeyJwe = JWEObject.parse(encryptedVaultKey);
var userKeyJwe = JWEObject.parse(encryptedUserKey);
result.complete(ReceivedKey.vaultKeyAndUserKey(vaultKeyJwe, userKeyJwe));
window.close();
} catch (ParseException e) {
throw new IOException("Failed to parse JWE", e);
}
}
/**
* LEGACY FALLBACK (Request): GET the legacy access token from Hub 1.x
*/
@Deprecated
private void requestLegacyAccessToken() {
var legacyAccessTokenUri = appendPath(vaultBaseUri, "/keys/" + deviceId);
var request = HttpRequest.newBuilder(legacyAccessTokenUri) //
.header("Authorization", "Bearer " + bearerToken) //
.GET() //
.timeout(REQ_TIMEOUT) //
.build();
httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.US_ASCII)) //
.thenAcceptAsync(this::receivedLegacyAccessTokenResponse, Platform::runLater) //
.exceptionally(this::retrievalFailed);
}
/**
* LEGACY FALLBACK (Response)
*
* @param response Response
*/
@Deprecated
private void receivedLegacyAccessTokenResponse(HttpResponse<String> response) {
try {
switch (response.statusCode()) {
case 200 -> receivedLegacyAccessTokenSuccess(response.body());
case 402 -> licenseExceeded();
case 403, 410 -> accessNotGranted(); // or vault has been archived, effectively disallowing access
case 404 -> needsDeviceRegistration();
case 404 -> needsLegacyDeviceRegistration();
default -> throw new IOException("Unexpected response " + response.statusCode());
}
} catch (IOException e) {
@@ -85,10 +197,11 @@ public class ReceiveKeyController implements FxController {
}
}
private void retrievalSucceeded(HttpResponse<InputStream> response) throws IOException {
@Deprecated
private void receivedLegacyAccessTokenSuccess(String rawToken) throws IOException {
try {
var string = HttpHelper.readBody(response);
result.complete(JWEObject.parse(string));
var token = JWEObject.parse(rawToken);
result.complete(ReceivedKey.legacyDeviceKey(token));
window.close();
} catch (ParseException e) {
throw new IOException("Failed to parse JWE", e);
@@ -99,8 +212,9 @@ public class ReceiveKeyController implements FxController {
window.setScene(invalidLicenseScene.get());
}
private void needsDeviceRegistration() {
window.setScene(registerDeviceScene.get());
@Deprecated
private void needsLegacyDeviceRegistration() {
window.setScene(legacyRegisterDeviceScene.get());
}
private void accessNotGranted() {
@@ -132,14 +246,17 @@ public class ReceiveKeyController implements FxController {
private static URI getVaultBaseUri(Vault vault) {
try {
var kid = vault.getVaultConfigCache().get().getKeyId();
assert kid.getScheme().startsWith(SCHEME_PREFIX);
var hubUriScheme = kid.getScheme().substring(SCHEME_PREFIX.length());
return new URI(hubUriScheme, kid.getSchemeSpecificPart(), kid.getFragment());
var url = vault.getVaultConfigCache().get().getKeyId();
assert url.getScheme().startsWith(SCHEME_PREFIX);
var correctedScheme = url.getScheme().substring(SCHEME_PREFIX.length());
return new URI(correctedScheme, url.getSchemeSpecificPart(), url.getFragment());
} catch (IOException e) {
throw new UncheckedIOException(e);
} catch (URISyntaxException e) {
throw new IllegalStateException("URI constructed from params known to be valid", e);
}
}
@JsonIgnoreProperties(ignoreUnknown = true)
private record DeviceDto(@JsonProperty(value = "userPrivateKey", required = true) String userPrivateKey) {}
}

View File

@@ -0,0 +1,45 @@
package org.cryptomator.ui.keyloading.hub;
import com.nimbusds.jose.JWEObject;
import org.cryptomator.cryptolib.api.Masterkey;
import java.security.interfaces.ECPrivateKey;
@FunctionalInterface
interface ReceivedKey {
/**
* Decrypts the vault key.
*
* @param deviceKey This device's private key.
* @return The decrypted vault key
*/
Masterkey decryptMasterkey(ECPrivateKey deviceKey);
/**
* Creates an unlock response object from the user key + vault key.
*
* @param vaultKeyJwe a JWE containing the symmetric vault key, encrypted for this device's user.
* @param userKeyJwe a JWE containing the user's private key, encrypted for this device.
* @return Ciphertext received by Hub, which can be decrypted using this device's private key.
*/
static ReceivedKey vaultKeyAndUserKey(JWEObject vaultKeyJwe, JWEObject userKeyJwe) {
return deviceKey -> {
var userKey = JWEHelper.decryptUserKey(userKeyJwe, deviceKey);
return JWEHelper.decryptVaultKey(vaultKeyJwe, userKey);
};
}
/**
* Creates an unlock response object from the received legacy "access token" JWE.
*
* @param vaultKeyJwe a JWE containing the symmetric vault key, encrypted for this device.
* @return Ciphertext received by Hub, which can be decrypted using this device's private key.
* @deprecated Only for compatibility with Hub 1.0 - 1.2
*/
@Deprecated
static ReceivedKey legacyDeviceKey(JWEObject vaultKeyJwe) {
return deviceKey -> JWEHelper.decryptVaultKey(vaultKeyJwe, deviceKey);
}
}

View File

@@ -1,7 +1,7 @@
package org.cryptomator.ui.keyloading.hub;
import com.auth0.jwt.JWT;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.JacksonException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.io.BaseEncoding;
@@ -20,6 +20,7 @@ import org.slf4j.LoggerFactory;
import javax.inject.Inject;
import javax.inject.Named;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.fxml.FXML;
@@ -31,14 +32,16 @@ import javafx.stage.Stage;
import javafx.stage.WindowEvent;
import java.io.IOException;
import java.net.InetAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.text.ParseException;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.atomic.AtomicReference;
@@ -47,7 +50,7 @@ public class RegisterDeviceController implements FxController {
private static final Logger LOG = LoggerFactory.getLogger(RegisterDeviceController.class);
private static final ObjectMapper JSON = new ObjectMapper().setDefaultLeniency(true);
private static final List<Integer> EXPECTED_RESPONSE_CODES = List.of(201, 409);
private static final Duration REQ_TIMEOUT = Duration.ofSeconds(10);
private final Stage window;
private final HubConfig hubConfig;
@@ -55,26 +58,27 @@ public class RegisterDeviceController implements FxController {
private final Lazy<Scene> registerSuccessScene;
private final Lazy<Scene> registerFailedScene;
private final String deviceId;
private final P384KeyPair keyPair;
private final CompletableFuture<JWEObject> result;
private final DecodedJWT jwt;
private final P384KeyPair deviceKeyPair;
private final CompletableFuture<ReceivedKey> result;
private final HttpClient httpClient;
private final BooleanProperty deviceNameAlreadyExists = new SimpleBooleanProperty(false);
private final BooleanProperty deviceNameAlreadyExists = new SimpleBooleanProperty(false);
private final BooleanProperty invalidSetupCode = new SimpleBooleanProperty(false);
private final BooleanProperty workInProgress = new SimpleBooleanProperty(false);
public TextField setupCodeField;
public TextField deviceNameField;
public Button registerBtn;
@Inject
public RegisterDeviceController(@KeyLoading Stage window, ExecutorService executor, HubConfig hubConfig, @Named("deviceId") String deviceId, DeviceKey deviceKey, CompletableFuture<JWEObject> result, @Named("bearerToken") AtomicReference<String> bearerToken, @FxmlScene(FxmlFile.HUB_REGISTER_SUCCESS) Lazy<Scene> registerSuccessScene, @FxmlScene(FxmlFile.HUB_REGISTER_FAILED) Lazy<Scene> registerFailedScene) {
public RegisterDeviceController(@KeyLoading Stage window, ExecutorService executor, HubConfig hubConfig, @Named("deviceId") String deviceId, DeviceKey deviceKey, CompletableFuture<ReceivedKey> result, @Named("bearerToken") AtomicReference<String> bearerToken, @FxmlScene(FxmlFile.HUB_REGISTER_SUCCESS) Lazy<Scene> registerSuccessScene, @FxmlScene(FxmlFile.HUB_REGISTER_FAILED) Lazy<Scene> registerFailedScene) {
this.window = window;
this.hubConfig = hubConfig;
this.deviceId = deviceId;
this.keyPair = Objects.requireNonNull(deviceKey.get());
this.deviceKeyPair = Objects.requireNonNull(deviceKey.get());
this.result = result;
this.bearerToken = Objects.requireNonNull(bearerToken.get());
this.registerSuccessScene = registerSuccessScene;
this.registerFailedScene = registerFailedScene;
this.jwt = JWT.decode(this.bearerToken);
this.window.addEventHandler(WindowEvent.WINDOW_HIDING, this::windowClosed);
this.httpClient = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).executor(executor).build();
}
@@ -82,6 +86,13 @@ public class RegisterDeviceController implements FxController {
public void initialize() {
deviceNameField.setText(determineHostname());
deviceNameField.textProperty().addListener(observable -> deviceNameAlreadyExists.set(false));
deviceNameField.disableProperty().bind(workInProgress);
setupCodeField.textProperty().addListener(observable -> invalidSetupCode.set(false));
setupCodeField.disableProperty().bind(workInProgress);
var missingSetupCode = setupCodeField.textProperty().isEmpty();
var missingDeviceName = deviceNameField.textProperty().isEmpty();
registerBtn.disableProperty().bind(workInProgress.or(missingSetupCode).or(missingDeviceName));
registerBtn.contentDisplayProperty().bind(Bindings.when(workInProgress).then(ContentDisplay.LEFT).otherwise(ContentDisplay.TEXT_ONLY));
}
private String determineHostname() {
@@ -95,35 +106,62 @@ public class RegisterDeviceController implements FxController {
@FXML
public void register() {
deviceNameAlreadyExists.set(false);
registerBtn.setContentDisplay(ContentDisplay.LEFT);
registerBtn.setDisable(true);
workInProgress.set(true);
var keyUri = URI.create(hubConfig.devicesResourceUrl + deviceId);
var deviceKey = keyPair.getPublic().getEncoded();
var dto = new CreateDeviceDto(deviceId, deviceNameField.getText(), BaseEncoding.base64Url().omitPadding().encode(deviceKey));
var json = toJson(dto);
var request = HttpRequest.newBuilder(keyUri) //
var apiRootUrl = hubConfig.getApiBaseUrl();
var userReq = HttpRequest.newBuilder(apiRootUrl.resolve("users/me")) //
.GET() //
.timeout(REQ_TIMEOUT) //
.header("Authorization", "Bearer " + bearerToken) //
.header("Content-Type", "application/json").PUT(HttpRequest.BodyPublishers.ofString(json, StandardCharsets.UTF_8)) //
.header("Content-Type", "application/json") //
.build();
httpClient.sendAsync(request, HttpResponse.BodyHandlers.discarding()) //
httpClient.sendAsync(userReq, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)) //
.thenApply(response -> {
if (EXPECTED_RESPONSE_CODES.contains(response.statusCode())) {
return response;
if (response.statusCode() == 200) {
var dto = fromJson(response.body());
return Objects.requireNonNull(dto, "null or empty response body");
} else {
throw new RuntimeException("Server answered with unexpected status code " + response.statusCode());
}
}).handleAsync((response, throwable) -> {
}).thenApply(user -> {
try {
assert user.privateKey != null; // api/vaults/{v}/user-tokens/me would have returned 403, if user wasn't fully set up yet
var userKey = JWEHelper.decryptUserKey(JWEObject.parse(user.privateKey), setupCodeField.getText());
return JWEHelper.encryptUserKey(userKey, deviceKeyPair.getPublic());
} catch (ParseException e) {
throw new RuntimeException("Server answered with unparsable user key", e);
}
}).thenCompose(jwe -> {
var now = Instant.now().toString();
var dto = new CreateDeviceDto(deviceId, deviceNameField.getText(), BaseEncoding.base64().encode(deviceKeyPair.getPublic().getEncoded()), "DESKTOP", jwe.serialize(), now);
var json = toJson(dto);
var deviceUri = apiRootUrl.resolve("devices/" + deviceId);
var putDeviceReq = HttpRequest.newBuilder(deviceUri) //
.PUT(HttpRequest.BodyPublishers.ofString(json, StandardCharsets.UTF_8)) //
.timeout(REQ_TIMEOUT) //
.header("Authorization", "Bearer " + bearerToken) //
.header("Content-Type", "application/json") //
.build();
return httpClient.sendAsync(putDeviceReq, HttpResponse.BodyHandlers.discarding());
}).whenCompleteAsync((response, throwable) -> {
if (response != null) {
this.handleResponse(response);
} else {
this.registrationFailed(throwable);
this.setupFailed(throwable);
}
return null;
workInProgress.set(false);
}, Platform::runLater);
}
private UserDto fromJson(String json) {
try {
return JSON.reader().readValue(json, UserDto.class);
} catch (IOException e) {
throw new IllegalStateException("Failed to deserialize DTO", e);
}
}
private String toJson(CreateDeviceDto dto) {
try {
return JSON.writer().writeValueAsString(dto);
@@ -132,23 +170,26 @@ public class RegisterDeviceController implements FxController {
}
}
private void handleResponse(HttpResponse<Void> voidHttpResponse) {
assert EXPECTED_RESPONSE_CODES.contains(voidHttpResponse.statusCode());
if (voidHttpResponse.statusCode() == 409) {
deviceNameAlreadyExists.set(true);
registerBtn.setContentDisplay(ContentDisplay.TEXT_ONLY);
registerBtn.setDisable(false);
} else {
private void handleResponse(HttpResponse<Void> response) {
if (response.statusCode() == 201) {
LOG.debug("Device registration for hub instance {} successful.", hubConfig.authSuccessUrl);
window.setScene(registerSuccessScene.get());
} else if (response.statusCode() == 409) {
deviceNameAlreadyExists.set(true);
} else {
setupFailed(new IllegalStateException("Unexpected http status code " + response.statusCode()));
}
}
private void registrationFailed(Throwable cause) {
LOG.warn("Device registration failed.", cause);
window.setScene(registerFailedScene.get());
result.completeExceptionally(cause);
private void setupFailed(Throwable cause) {
switch (cause) {
case CompletionException e when e.getCause() instanceof JWEHelper.InvalidJweKeyException -> invalidSetupCode.set(true);
default -> {
LOG.warn("Device setup failed.", cause);
window.setScene(registerFailedScene.get());
result.completeExceptionally(cause);
}
}
}
@FXML
@@ -160,13 +201,6 @@ public class RegisterDeviceController implements FxController {
result.cancel(true);
}
/* Getter */
public String getUserName() {
return jwt.getClaim("email").asString();
}
//--- Getters & Setters
public BooleanProperty deviceNameAlreadyExistsProperty() {
@@ -177,5 +211,21 @@ public class RegisterDeviceController implements FxController {
return deviceNameAlreadyExists.get();
}
public BooleanProperty invalidSetupCodeProperty() {
return invalidSetupCode;
}
public boolean isInvalidSetupCode() {
return invalidSetupCode.get();
}
@JsonIgnoreProperties(ignoreUnknown = true)
private record UserDto(String id, String name, String publicKey, String privateKey, String setupCode) {}
private record CreateDeviceDto(@JsonProperty(required = true) String id, //
@JsonProperty(required = true) String name, //
@JsonProperty(required = true) String publicKey, //
@JsonProperty(required = true, defaultValue = "DESKTOP") String type, //
@JsonProperty(required = true) String userPrivateKey, //
@JsonProperty(required = true) String creationTime) {}
}

View File

@@ -12,10 +12,10 @@ import java.util.concurrent.CompletableFuture;
public class RegisterFailedController implements FxController {
private final Stage window;
private final CompletableFuture<JWEObject> result;
private final CompletableFuture<ReceivedKey> result;
@Inject
public RegisterFailedController(@KeyLoading Stage window, CompletableFuture<JWEObject> result) {
public RegisterFailedController(@KeyLoading Stage window, CompletableFuture<ReceivedKey> result) {
this.window = window;
this.result = result;
}

View File

@@ -15,10 +15,10 @@ import java.util.concurrent.CompletableFuture;
public class UnauthorizedDeviceController implements FxController {
private final Stage window;
private final CompletableFuture<JWEObject> result;
private final CompletableFuture<ReceivedKey> result;
@Inject
public UnauthorizedDeviceController(@KeyLoading Stage window, CompletableFuture<JWEObject> result) {
public UnauthorizedDeviceController(@KeyLoading Stage window, CompletableFuture<ReceivedKey> result) {
this.window = window;
this.result = result;
this.window.addEventHandler(WindowEvent.WINDOW_HIDING, this::windowClosed);

View File

@@ -7,7 +7,6 @@ import org.slf4j.LoggerFactory;
import javax.inject.Inject;
import javafx.beans.binding.BooleanBinding;
import javafx.collections.ObservableList;
import javafx.fxml.FXML;
import javafx.geometry.Rectangle2D;
import javafx.scene.input.MouseEvent;
@@ -67,37 +66,7 @@ public class ResizeController implements FxController {
return (settings.windowHeight.get() == 0) && (settings.windowWidth.get() == 0) && (settings.windowXPosition.get() == 0) && (settings.windowYPosition.get() == 0);
}
private boolean isWithinDisplayBounds() {
// (x1, y1) is the top left corner of the window, (x2, y2) is the bottom right corner
final double slack = 10;
final double width = window.getWidth() - 2 * slack;
final double height = window.getHeight() - 2 * slack;
final double x1 = window.getX() + slack;
final double y1 = window.getY() + slack;
final double x2 = x1 + width;
final double y2 = y1 + height;
final ObservableList<Screen> screens = Screen.getScreensForRectangle(x1, y1, width, height);
// Find the total visible area of the window
double visibleArea = 0;
for (Screen screen : screens) {
Rectangle2D bounds = screen.getVisualBounds();
double xOverlap = Math.min(x2, bounds.getMaxX()) - Math.max(x1, bounds.getMinX());
double yOverlap = Math.min(y2, bounds.getMaxY()) - Math.max(y1, bounds.getMinY());
visibleArea += xOverlap * yOverlap;
}
final double windowArea = width * height;
// Within bounds if the visible area matches the window area
return visibleArea == windowArea;
}
private void checkDisplayBounds(WindowEvent evt) {
// Minimizing a window in Windows and closing it could result in an out of bounds position at (x, y) = (-32000, -32000)
// See https://devblogs.microsoft.com/oldnewthing/20041028-00/?p=37453
// If the position is (-32000, -32000), restore to the last saved position
@@ -108,8 +77,9 @@ public class ResizeController implements FxController {
window.setHeight(settings.windowHeight.get());
}
if (!isWithinDisplayBounds()) {
if (isOutOfDisplayBounds()) {
// If the position is illegal, then the window appears on the main screen in the middle of the window.
LOG.debug("Resetting window position due to insufficient screen overlap");
Rectangle2D primaryScreenBounds = Screen.getPrimary().getBounds();
window.setX((primaryScreenBounds.getWidth() - window.getMinWidth()) / 2);
window.setY((primaryScreenBounds.getHeight() - window.getMinHeight()) / 2);
@@ -119,6 +89,22 @@ public class ResizeController implements FxController {
}
}
private boolean isOutOfDisplayBounds() {
// define a rect which is inset on all sides from the window's rect:
final double x = window.getX() + 20; // 20px left
final double y = window.getY() + 5; // 5px top
final double w = window.getWidth() - 40; // 20px left + 20px right
final double h = window.getHeight() - 25; // 5px top + 20px bottom
return isRectangleOutOfScreen(x, y, 0, h) // Left pixel column
|| isRectangleOutOfScreen(x + w, y, 0, h) // Right pixel column
|| isRectangleOutOfScreen(x, y, w, 0) // Top pixel row
|| isRectangleOutOfScreen(x, y + h, w, 0); // Bottom pixel row
}
private boolean isRectangleOutOfScreen(double x, double y, double width, double height) {
return Screen.getScreensForRectangle(x, y, width, height).isEmpty();
}
private void startResize(MouseEvent evt) {
origX = window.getX();
origY = window.getY();

View File

@@ -20,9 +20,10 @@ import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.value.ObservableValue;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.event.Event;
import javafx.fxml.FXML;
import javafx.geometry.Side;
import javafx.scene.control.Button;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.ListView;
import javafx.scene.input.ContextMenuEvent;
import javafx.scene.input.DragEvent;
@@ -67,6 +68,8 @@ public class VaultListController implements FxController {
public ListView<Vault> vaultList;
public StackPane root;
public Button addVaultBtn;
@FXML
private ContextMenu addVaultContextMenu;
@Inject
VaultListController(@MainWindow Stage mainWindow, //
@@ -140,15 +143,15 @@ public class VaultListController implements FxController {
root.setOnDragOver(this::handleDragEvent);
root.setOnDragDropped(this::handleDragEvent);
root.setOnDragExited(this::handleDragEvent);
addVaultBtn.addEventFilter(ContextMenuEvent.CONTEXT_MENU_REQUESTED, Event::consume);
}
@FXML
private void showMenu() {
double screenX = addVaultBtn.localToScreen(addVaultBtn.getBoundsInLocal()).getMinX();
double screenY = addVaultBtn.localToScreen(addVaultBtn.getBoundsInLocal()).getMaxY();
addVaultBtn.getContextMenu().show(addVaultBtn, screenX, screenY);
private void toggleMenu() {
if (addVaultContextMenu.isShowing()) {
addVaultContextMenu.hide();
} else {
addVaultContextMenu.show(addVaultBtn, Side.BOTTOM, 0.0, 0.0);
}
}
private void deselect(MouseEvent released) {

View File

@@ -1,5 +1,6 @@
package org.cryptomator.ui.preferences;
import org.cryptomator.common.Environment;
import org.cryptomator.ui.common.FxController;
import org.cryptomator.ui.fxapp.UpdateChecker;
import org.slf4j.Logger;
@@ -19,6 +20,7 @@ public class PreferencesController implements FxController {
private static final Logger LOG = LoggerFactory.getLogger(PreferencesController.class);
private final Environment env;
private final Stage window;
private final ObjectProperty<SelectedPreferencesTab> selectedTabProperty;
private final BooleanBinding updateAvailable;
@@ -31,7 +33,8 @@ public class PreferencesController implements FxController {
public Tab aboutTab;
@Inject
public PreferencesController(@PreferencesWindow Stage window, ObjectProperty<SelectedPreferencesTab> selectedTabProperty, UpdateChecker updateChecker) {
public PreferencesController(Environment env, @PreferencesWindow Stage window, ObjectProperty<SelectedPreferencesTab> selectedTabProperty, UpdateChecker updateChecker) {
this.env = env;
this.window = window;
this.selectedTabProperty = selectedTabProperty;
this.updateAvailable = updateChecker.latestVersionProperty().isNotNull();
@@ -42,6 +45,9 @@ public class PreferencesController implements FxController {
window.setOnShowing(this::windowWillAppear);
selectedTabProperty.addListener(observable -> this.selectChosenTab());
tabPane.getSelectionModel().selectedItemProperty().addListener(observable -> this.selectedTabChanged());
if (env.disableUpdateCheck()) {
tabPane.getTabs().remove(updatesTab);
}
}
private void selectChosenTab() {

View File

@@ -8,6 +8,7 @@ import org.cryptomator.cryptolib.api.InvalidPassphraseException;
import org.cryptomator.cryptolib.api.Masterkey;
import org.cryptomator.cryptolib.common.MasterkeyFileAccess;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.VisibleForTesting;
import javax.inject.Inject;
import javax.inject.Singleton;
@@ -58,7 +59,7 @@ public class RecoveryKeyFactory {
}
}
// visible for testing
@VisibleForTesting
String createRecoveryKey(byte[] rawKey) {
Preconditions.checkArgument(rawKey.length == 64, "key should be 64 bytes");
byte[] paddedKey = Arrays.copyOf(rawKey, 66);