Переглянути джерело

Merge branch 'develop' into feature/3113-network-timeout

Sebastian Stenzel 1 рік тому
батько
коміт
3071410a22
32 змінених файлів з 858 додано та 210 видалено
  1. 2 2
      .github/workflows/appimage.yml
  2. 2 2
      .github/workflows/build.yml
  3. 4 3
      .github/workflows/debian.yml
  4. 2 2
      .github/workflows/get-version.yml
  5. 2 2
      .github/workflows/mac-dmg.yml
  6. 2 2
      .github/workflows/pullrequest.yml
  7. 2 2
      .github/workflows/win-exe.yml
  8. 11 12
      .idea/compiler.xml
  9. 1 1
      .idea/misc.xml
  10. 1 1
      dist/linux/debian/control
  11. 1 1
      dist/linux/debian/rules
  12. 10 10
      pom.xml
  13. 3 2
      src/main/java/org/cryptomator/ui/common/FxmlFile.java
  14. 7 7
      src/main/java/org/cryptomator/ui/health/StartController.java
  15. 2 2
      src/main/java/org/cryptomator/ui/keyloading/hub/AuthFlowController.java
  16. 0 5
      src/main/java/org/cryptomator/ui/keyloading/hub/CreateDeviceDto.java
  17. 0 19
      src/main/java/org/cryptomator/ui/keyloading/hub/HttpHelper.java
  18. 16 1
      src/main/java/org/cryptomator/ui/keyloading/hub/HubConfig.java
  19. 16 5
      src/main/java/org/cryptomator/ui/keyloading/hub/HubKeyLoadingModule.java
  20. 3 3
      src/main/java/org/cryptomator/ui/keyloading/hub/HubKeyLoadingStrategy.java
  21. 84 9
      src/main/java/org/cryptomator/ui/keyloading/hub/JWEHelper.java
  22. 191 0
      src/main/java/org/cryptomator/ui/keyloading/hub/LegacyRegisterDeviceController.java
  23. 138 21
      src/main/java/org/cryptomator/ui/keyloading/hub/ReceiveKeyController.java
  24. 45 0
      src/main/java/org/cryptomator/ui/keyloading/hub/ReceivedKey.java
  25. 97 47
      src/main/java/org/cryptomator/ui/keyloading/hub/RegisterDeviceController.java
  26. 2 2
      src/main/java/org/cryptomator/ui/keyloading/hub/RegisterFailedController.java
  27. 2 2
      src/main/java/org/cryptomator/ui/keyloading/hub/UnauthorizedDeviceController.java
  28. 18 32
      src/main/java/org/cryptomator/ui/mainwindow/ResizeController.java
  29. 1 1
      src/main/resources/fxml/hub_register_device.fxml
  30. 92 0
      src/main/resources/fxml/hub_setup_device.fxml
  31. 4 2
      src/main/resources/i18n/strings.properties
  32. 97 10
      src/test/java/org/cryptomator/ui/keyloading/hub/JWEHelperTest.java

+ 2 - 2
.github/workflows/appimage.yml

@@ -10,8 +10,8 @@ on:
         required: false
 
 env:
-  JAVA_DIST: 'temurin'
-  JAVA_VERSION: 20
+  JAVA_DIST: 'zulu'
+  JAVA_VERSION: 21
 
 jobs:
   get-version:

+ 2 - 2
.github/workflows/build.yml

@@ -6,8 +6,8 @@ on:
     types: [labeled]
 
 env:
-  JAVA_DIST: 'temurin'
-  JAVA_VERSION: 20
+  JAVA_DIST: 'zulu'
+  JAVA_VERSION: 21
 
 defaults:
   run:

+ 4 - 3
.github/workflows/debian.yml

@@ -16,8 +16,9 @@ on:
         type: boolean
 
 env:
-  JAVA_DIST: 'temurin'
-  JAVA_VERSION: 20
+  JAVA_DIST: 'zulu'
+  JAVA_VERSION: 21
+  COFFEELIBS_JDK_VERSION: 21
   OPENJFX_JMODS_AMD64: 'https://download2.gluonhq.com/openjfx/20.0.2/openjfx-20.0.2_linux-x64_bin-jmods.zip'
   OPENJFX_JMODS_AMD64_HASH: 'f522ac2ae4bdd61f0219b7b8d2058ff72a22f36a44378453bcfdcd82f8f5e08c'
   OPENJFX_JMODS_AARCH64: 'https://download2.gluonhq.com/openjfx/20.0.2/openjfx-20.0.2_linux-aarch64_bin-jmods.zip'
@@ -42,7 +43,7 @@ jobs:
         run: |
           sudo add-apt-repository ppa:coffeelibs/openjdk
           sudo apt-get update
-          sudo apt-get install debhelper devscripts dput coffeelibs-jdk-${{ env.JAVA_VERSION }} libgtk2.0-0
+          sudo apt-get install debhelper devscripts dput coffeelibs-jdk-${{ env.COFFEELIBS_JDK_VERSION }} libgtk2.0-0
       - name: Setup Java
         uses: actions/setup-java@v3
         with:

+ 2 - 2
.github/workflows/get-version.yml

@@ -22,8 +22,8 @@ on:
         value: ${{ jobs.determine-version.outputs.type }}
 
 env:
-  JAVA_DIST: 'temurin'
-  JAVA_VERSION: 20
+  JAVA_DIST: 'zulu'
+  JAVA_VERSION: 21
 
 jobs:
   determine-version:

+ 2 - 2
.github/workflows/mac-dmg.yml

@@ -15,8 +15,8 @@ on:
         type: boolean
 
 env:
-  JAVA_DIST: 'temurin'
-  JAVA_VERSION: 20
+  JAVA_DIST: 'zulu'
+  JAVA_VERSION: 21
 
 jobs:
   get-version:

+ 2 - 2
.github/workflows/pullrequest.yml

@@ -4,8 +4,8 @@ on:
   pull_request:
 
 env:
-  JAVA_DIST: 'temurin'
-  JAVA_VERSION: 20
+  JAVA_DIST: 'zulu'
+  JAVA_VERSION: 21
 
 defaults:
   run:

+ 2 - 2
.github/workflows/win-exe.yml

@@ -14,8 +14,8 @@ on:
 
 
 env:
-  JAVA_DIST: 'temurin'
-  JAVA_VERSION: 20
+  JAVA_DIST: 'zulu'
+  JAVA_VERSION: 21
   OPENJFX_JMODS_AMD64: 'https://download2.gluonhq.com/openjfx/20.0.2/openjfx-20.0.2_windows-x64_bin-jmods.zip'
   OPENJFX_JMODS_AMD64_HASH: '18625bbc13c57dbf802486564247a8d8cab72ec558c240a401bf6440384ebd77'
 

+ 11 - 12
.idea/compiler.xml

@@ -14,10 +14,10 @@
         <option name="dagger.fastInit" value="enabled" />
         <option name="dagger.formatGeneratedSource" value="enabled" />
         <processorPath useClasspath="false">
-          <entry name="$MAVEN_REPOSITORY$/com/google/dagger/dagger-compiler/2.45/dagger-compiler-2.45.jar" />
-          <entry name="$MAVEN_REPOSITORY$/com/google/dagger/dagger/2.45/dagger-2.45.jar" />
+          <entry name="$MAVEN_REPOSITORY$/com/google/dagger/dagger-compiler/2.48/dagger-compiler-2.48.jar" />
+          <entry name="$MAVEN_REPOSITORY$/com/google/dagger/dagger/2.48/dagger-2.48.jar" />
           <entry name="$MAVEN_REPOSITORY$/javax/inject/javax.inject/1/javax.inject-1.jar" />
-          <entry name="$MAVEN_REPOSITORY$/com/google/dagger/dagger-producers/2.45/dagger-producers-2.45.jar" />
+          <entry name="$MAVEN_REPOSITORY$/com/google/dagger/dagger-producers/2.48/dagger-producers-2.48.jar" />
           <entry name="$MAVEN_REPOSITORY$/com/google/guava/failureaccess/1.0.1/failureaccess-1.0.1.jar" />
           <entry name="$MAVEN_REPOSITORY$/com/google/guava/guava/31.0.1-jre/guava-31.0.1-jre.jar" />
           <entry name="$MAVEN_REPOSITORY$/com/google/guava/listenablefuture/9999.0-empty-to-avoid-conflict-with-guava/listenablefuture-9999.0-empty-to-avoid-conflict-with-guava.jar" />
@@ -26,20 +26,19 @@
           <entry name="$MAVEN_REPOSITORY$/com/google/errorprone/error_prone_annotations/2.7.1/error_prone_annotations-2.7.1.jar" />
           <entry name="$MAVEN_REPOSITORY$/com/google/j2objc/j2objc-annotations/1.3/j2objc-annotations-1.3.jar" />
           <entry name="$MAVEN_REPOSITORY$/org/checkerframework/checker-compat-qual/2.5.5/checker-compat-qual-2.5.5.jar" />
-          <entry name="$MAVEN_REPOSITORY$/com/google/dagger/dagger-spi/2.45/dagger-spi-2.45.jar" />
-          <entry name="$MAVEN_REPOSITORY$/com/google/devtools/ksp/symbol-processing-api/1.7.0-1.0.6/symbol-processing-api-1.7.0-1.0.6.jar" />
-          <entry name="$MAVEN_REPOSITORY$/org/jetbrains/kotlin/kotlin-stdlib/1.7.0/kotlin-stdlib-1.7.0.jar" />
-          <entry name="$MAVEN_REPOSITORY$/org/jetbrains/kotlin/kotlin-stdlib-common/1.7.0/kotlin-stdlib-common-1.7.0.jar" />
+          <entry name="$MAVEN_REPOSITORY$/com/google/dagger/dagger-spi/2.48/dagger-spi-2.48.jar" />
+          <entry name="$MAVEN_REPOSITORY$/com/google/devtools/ksp/symbol-processing-api/1.9.0-1.0.12/symbol-processing-api-1.9.0-1.0.12.jar" />
+          <entry name="$MAVEN_REPOSITORY$/org/jetbrains/kotlin/kotlin-stdlib/1.9.0/kotlin-stdlib-1.9.0.jar" />
+          <entry name="$MAVEN_REPOSITORY$/org/jetbrains/kotlin/kotlin-stdlib-common/1.9.0/kotlin-stdlib-common-1.9.0.jar" />
           <entry name="$MAVEN_REPOSITORY$/org/jetbrains/annotations/13.0/annotations-13.0.jar" />
           <entry name="$MAVEN_REPOSITORY$/com/squareup/javapoet/1.13.0/javapoet-1.13.0.jar" />
-          <entry name="$MAVEN_REPOSITORY$/com/squareup/kotlinpoet/1.11.0/kotlinpoet-1.11.0.jar" />
-          <entry name="$MAVEN_REPOSITORY$/org/jetbrains/kotlin/kotlin-stdlib-jdk8/1.7.0/kotlin-stdlib-jdk8-1.7.0.jar" />
-          <entry name="$MAVEN_REPOSITORY$/org/jetbrains/kotlin/kotlin-stdlib-jdk7/1.7.0/kotlin-stdlib-jdk7-1.7.0.jar" />
-          <entry name="$MAVEN_REPOSITORY$/org/jetbrains/kotlin/kotlin-reflect/1.6.10/kotlin-reflect-1.6.10.jar" />
           <entry name="$MAVEN_REPOSITORY$/com/google/googlejavaformat/google-java-format/1.5/google-java-format-1.5.jar" />
           <entry name="$MAVEN_REPOSITORY$/com/google/errorprone/javac-shaded/9-dev-r4023-3/javac-shaded-9-dev-r4023-3.jar" />
+          <entry name="$MAVEN_REPOSITORY$/com/squareup/kotlinpoet/1.11.0/kotlinpoet-1.11.0.jar" />
+          <entry name="$MAVEN_REPOSITORY$/org/jetbrains/kotlin/kotlin-stdlib-jdk8/1.6.10/kotlin-stdlib-jdk8-1.6.10.jar" />
+          <entry name="$MAVEN_REPOSITORY$/org/jetbrains/kotlin/kotlin-stdlib-jdk7/1.6.10/kotlin-stdlib-jdk7-1.6.10.jar" />
+          <entry name="$MAVEN_REPOSITORY$/org/jetbrains/kotlin/kotlin-reflect/1.6.10/kotlin-reflect-1.6.10.jar" />
           <entry name="$MAVEN_REPOSITORY$/net/ltgt/gradle/incap/incap/0.2/incap-0.2.jar" />
-          <entry name="$MAVEN_REPOSITORY$/org/jetbrains/kotlinx/kotlinx-metadata-jvm/0.5.0/kotlinx-metadata-jvm-0.5.0.jar" />
         </processorPath>
         <module name="cryptomator" />
       </profile>

+ 1 - 1
.idea/misc.xml

@@ -8,7 +8,7 @@
       </list>
     </option>
   </component>
-  <component name="ProjectRootManager" version="2" languageLevel="JDK_20_PREVIEW" project-jdk-name="20" project-jdk-type="JavaSDK">
+  <component name="ProjectRootManager" version="2" languageLevel="JDK_21_PREVIEW" project-jdk-name="21" project-jdk-type="JavaSDK">
     <output url="file://$PROJECT_DIR$/out" />
   </component>
 </project>

+ 1 - 1
dist/linux/debian/control

@@ -2,7 +2,7 @@ Source: cryptomator
 Maintainer: Cryptobot <releases@cryptomator.org>
 Section: utils
 Priority: optional
-Build-Depends: debhelper (>=10), coffeelibs-jdk-20, libgtk2.0-0, libgtk-3-0,  libxxf86vm1, libgl1
+Build-Depends: debhelper (>=10), coffeelibs-jdk-21, libgtk2.0-0, libgtk-3-0,  libxxf86vm1, libgl1
 Standards-Version: 4.5.0
 Homepage: https://cryptomator.org
 Vcs-Git: https://github.com/cryptomator/cryptomator.git

+ 1 - 1
dist/linux/debian/rules

@@ -4,7 +4,7 @@
 # Uncomment this to turn on verbose mode.
 #export DH_VERBOSE=1
 
-JAVA_HOME = /usr/lib/jvm/java-20-coffeelibs
+JAVA_HOME = /usr/lib/jvm/java-21-coffeelibs
 DEB_BUILD_ARCH ?= $(shell dpkg-architecture -qDEB_BUILD_ARCH)
 ifeq ($(DEB_BUILD_ARCH),amd64)
 JMODS_PATH = jmods/amd64:${JAVA_HOME}/jmods

+ 10 - 10
pom.xml

@@ -26,7 +26,7 @@
 
 	<properties>
 		<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
-		<project.jdk.version>20</project.jdk.version>
+		<project.jdk.version>21</project.jdk.version>
 
 		<!-- Group IDs of jars that need to stay on the class path for now -->
 		<!-- remove them, as soon they got modularized or support is dropped (i.e., WebDAV) -->
@@ -35,22 +35,22 @@
 		<!-- cryptomator dependencies -->
 		<cryptomator.cryptofs.version>2.6.7</cryptomator.cryptofs.version>
 		<cryptomator.integrations.version>1.3.0</cryptomator.integrations.version>
-		<cryptomator.integrations.win.version>1.2.3</cryptomator.integrations.win.version>
+		<cryptomator.integrations.win.version>1.2.4</cryptomator.integrations.win.version>
 		<cryptomator.integrations.mac.version>1.2.2</cryptomator.integrations.mac.version>
-		<cryptomator.integrations.linux.version>1.3.0</cryptomator.integrations.linux.version>
-		<cryptomator.fuse.version>3.0.0</cryptomator.fuse.version>
+		<cryptomator.integrations.linux.version>1.4.0-beta2</cryptomator.integrations.linux.version>
+		<cryptomator.fuse.version>4.0.0-beta1</cryptomator.fuse.version>
 		<cryptomator.dokany.version>2.0.0</cryptomator.dokany.version>
-		<cryptomator.webdav.version>2.0.4</cryptomator.webdav.version>
+		<cryptomator.webdav.version>2.0.5</cryptomator.webdav.version>
 
 		<!-- 3rd party dependencies -->
 		<commons-lang3.version>3.13.0</commons-lang3.version>
-		<dagger.version>2.48</dagger.version>
+		<dagger.version>2.48.1</dagger.version>
 		<easybind.version>2.2</easybind.version>
-		<guava.version>32.1.2-jre</guava.version>
-		<jackson.version>2.15.2</jackson.version>
+		<guava.version>32.1.3-jre</guava.version>
+		<jackson.version>2.15.3</jackson.version>
 		<javafx.version>20.0.2</javafx.version>
 		<jwt.version>4.4.0</jwt.version>
-		<nimbus-jose.version>9.31</nimbus-jose.version>
+		<nimbus-jose.version>9.36</nimbus-jose.version>
 		<logback.version>1.4.11</logback.version>
 		<slf4j.version>2.0.9</slf4j.version>
 		<tinyoauth2.version>0.6.0</tinyoauth2.version>
@@ -64,7 +64,7 @@
 		<!-- build-time dependencies -->
 		<jetbrains.annotations.version>24.0.1</jetbrains.annotations.version>
 		<dependency-check.version>8.4.0</dependency-check.version>
-		<jacoco.version>0.8.10</jacoco.version>
+		<jacoco.version>0.8.11</jacoco.version>
 		<license-generator.version>2.2.0</license-generator.version>
 		<junit-tree-reporter.version>1.2.1</junit-tree-reporter.version>
 		<mvn-compiler.version>3.11.0</mvn-compiler.version>

+ 3 - 2
src/main/java/org/cryptomator/ui/common/FxmlFile.java

@@ -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"), //

+ 7 - 7
src/main/java/org/cryptomator/ui/health/StartController.java

@@ -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);
 			}
 		}
 	}

+ 2 - 2
src/main/java/org/cryptomator/ui/keyloading/hub/AuthFlowController.java

@@ -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;

+ 0 - 5
src/main/java/org/cryptomator/ui/keyloading/hub/CreateDeviceDto.java

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

+ 0 - 19
src/main/java/org/cryptomator/ui/keyloading/hub/HttpHelper.java

@@ -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);
-		}
-	}
-
-}

+ 16 - 1
src/main/java/org/cryptomator/ui/keyloading/hub/HubConfig.java

@@ -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();
+		}
+	}
 }

+ 16 - 5
src/main/java/org/cryptomator/ui/keyloading/hub/HubKeyLoadingModule.java

@@ -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)

+ 3 - 3
src/main/java/org/cryptomator/ui/keyloading/hub/HubKeyLoadingStrategy.java

@@ -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);

+ 84 - 9
src/main/java/org/cryptomator/ui/keyloading/hub/JWEHelper.java

@@ -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 JWEObject encryptUserKey(ECPrivateKey userKey, ECPublicKey deviceKey) {
+		try {
+			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) {
+			throw new RuntimeException(e);
+		}
+	}
 
-	public static Masterkey decrypt(JWEObject jwe, ECPrivateKey privateKey) 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);
+			return readKey(jwe, JWE_PAYLOAD_KEY_FIELD, Masterkey::new);
 		} catch (JOSEException e) {
-			LOG.warn("Failed to decrypt JWE: {}", jwe);
-			throw new MasterkeyLoadingFailedException("Failed to decrypt JWE", e);
+			throw new InvalidJweKeyException(e);
 		}
 	}
 
-	private static Masterkey readKey(JWEObject jwe) throws MasterkeyLoadingFailedException {
+	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);
+		}
+	}
 }

+ 191 - 0
src/main/java/org/cryptomator/ui/keyloading/hub/LegacyRegisterDeviceController.java

@@ -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;
+
+	}
+
+}

Різницю між файлами не показано, бо вона завелика
+ 138 - 21
src/main/java/org/cryptomator/ui/keyloading/hub/ReceiveKeyController.java


+ 45 - 0
src/main/java/org/cryptomator/ui/keyloading/hub/ReceivedKey.java

@@ -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);
+	}
+
+}

+ 97 - 47
src/main/java/org/cryptomator/ui/keyloading/hub/RegisterDeviceController.java

@@ -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);
-
-		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) //
+		workInProgress.set(true);
+
+		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) {}
 }

+ 2 - 2
src/main/java/org/cryptomator/ui/keyloading/hub/RegisterFailedController.java

@@ -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;
 	}

+ 2 - 2
src/main/java/org/cryptomator/ui/keyloading/hub/UnauthorizedDeviceController.java

@@ -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);

+ 18 - 32
src/main/java/org/cryptomator/ui/mainwindow/ResizeController.java

@@ -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();

+ 1 - 1
src/main/resources/fxml/hub_register_device.fxml

@@ -15,7 +15,7 @@
 <?import org.cryptomator.ui.controls.FontAwesome5Spinner?>
 <HBox xmlns:fx="http://javafx.com/fxml"
 	  xmlns="http://javafx.com/javafx"
-	  fx:controller="org.cryptomator.ui.keyloading.hub.RegisterDeviceController"
+	  fx:controller="org.cryptomator.ui.keyloading.hub.LegacyRegisterDeviceController"
 	  minWidth="400"
 	  maxWidth="400"
 	  minHeight="145"

+ 92 - 0
src/main/resources/fxml/hub_setup_device.fxml

@@ -0,0 +1,92 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<?import org.cryptomator.ui.controls.FontAwesome5IconView?>
+<?import javafx.geometry.Insets?>
+<?import javafx.scene.control.Button?>
+<?import javafx.scene.control.ButtonBar?>
+<?import javafx.scene.control.Label?>
+<?import javafx.scene.control.TextField?>
+<?import javafx.scene.Group?>
+<?import javafx.scene.layout.HBox?>
+<?import javafx.scene.layout.Region?>
+<?import javafx.scene.layout.StackPane?>
+<?import javafx.scene.layout.VBox?>
+<?import javafx.scene.shape.Circle?>
+<?import org.cryptomator.ui.controls.FontAwesome5Spinner?>
+<HBox xmlns:fx="http://javafx.com/fxml"
+	  xmlns="http://javafx.com/javafx"
+	  fx:controller="org.cryptomator.ui.keyloading.hub.RegisterDeviceController"
+	  minWidth="400"
+	  maxWidth="400"
+	  minHeight="145"
+	  spacing="12"
+	  alignment="TOP_LEFT">
+	<padding>
+		<Insets topRightBottomLeft="12"/>
+	</padding>
+	<children>
+		<Group>
+			<StackPane>
+				<padding>
+					<Insets topRightBottomLeft="6"/>
+				</padding>
+				<Circle styleClass="glyph-icon-primary" radius="24"/>
+				<FontAwesome5IconView styleClass="glyph-icon-white" glyph="INFO" glyphSize="24"/>
+			</StackPane>
+		</Group>
+
+		<VBox HBox.hgrow="ALWAYS">
+			<Label styleClass="label-large" text="%hub.register.message" wrapText="true" textAlignment="LEFT">
+				<padding>
+					<Insets bottom="6" top="6"/>
+				</padding>
+			</Label>
+			<Label text="%hub.register.description" wrapText="true"/>
+			<HBox spacing="6" alignment="CENTER_LEFT">
+				<padding>
+					<Insets top="12"/>
+				</padding>
+				<Label text="%hub.register.setupCodeLabel" labelFor="$setupCodeField"/>
+				<TextField fx:id="setupCodeField" HBox.hgrow="ALWAYS"/>
+			</HBox>
+			<HBox spacing="6" alignment="CENTER_LEFT">
+				<padding>
+					<Insets top="12"/>
+				</padding>
+				<Label text="%hub.register.nameLabel" labelFor="$deviceNameField"/>
+				<TextField fx:id="deviceNameField" HBox.hgrow="ALWAYS"/>
+			</HBox>
+			<HBox alignment="TOP_RIGHT">
+				<Label text="%hub.register.occupiedMsg" textAlignment="RIGHT" alignment="CENTER_RIGHT" visible="${controller.deviceNameAlreadyExists}" managed="${controller.deviceNameAlreadyExists}" graphicTextGap="6">
+					<padding>
+						<Insets top="6"/>
+					</padding>
+					<graphic>
+						<FontAwesome5IconView glyph="TIMES" styleClass="glyph-icon-red"/>
+					</graphic>
+				</Label>
+
+				<Label text="%hub.register.invalidSetupCode" textAlignment="RIGHT" alignment="CENTER_RIGHT" visible="${controller.invalidSetupCode}" managed="${controller.invalidSetupCode}" graphicTextGap="6">
+					<padding>
+						<Insets top="6"/>
+					</padding>
+					<graphic>
+						<FontAwesome5IconView glyph="TIMES" styleClass="glyph-icon-red"/>
+					</graphic>
+				</Label>
+			</HBox>
+
+			<Region VBox.vgrow="ALWAYS" minHeight="18"/>
+			<ButtonBar buttonMinWidth="120" buttonOrder="+CU">
+				<buttons>
+					<Button text="%generic.button.cancel" ButtonBar.buttonData="CANCEL_CLOSE" cancelButton="true" onAction="#close"/>
+					<Button fx:id="registerBtn" text="%hub.register.registerBtn" ButtonBar.buttonData="OTHER" defaultButton="true" onAction="#register" contentDisplay="TEXT_ONLY" >
+						<graphic>
+							<FontAwesome5Spinner glyphSize="12" />
+						</graphic>
+					</Button>
+				</buttons>
+			</ButtonBar>
+		</VBox>
+	</children>
+</HBox>

+ 4 - 2
src/main/resources/i18n/strings.properties

@@ -154,9 +154,11 @@ hub.auth.loginLink=Not redirected? Click here to open it.
 hub.receive.message=Processing response…
 hub.receive.description=Cryptomator is receiving and processing the response from Hub. Please wait.
 ### Register Device
-hub.register.message=Device name required
-hub.register.description=This seems to be the first Hub access from this device. In order to identify it for access authorization, you need to name this device.
+hub.register.message=New Device
+hub.register.description=This is the first Hub access from this device. Please authorize it using your setup code.
 hub.register.nameLabel=Device Name
+hub.register.setupCodeLabel=Setup Code
+hub.register.invalidSetupCode=Invalid Setup Code
 hub.register.occupiedMsg=Name already in use
 hub.register.registerBtn=Confirm
 ### Registration Success

Різницю між файлами не показано, бо вона завелика
+ 97 - 10
src/test/java/org/cryptomator/ui/keyloading/hub/JWEHelperTest.java