Browse Source

Merge pull request #3041 from cryptomator/feature/new-hub-keyloading

Adjusted to Hub 1.3.x API
Sebastian Stenzel 1 year ago
parent
commit
8dd8b93656

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

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

File diff suppressed because it is too large
+ 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);

+ 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

File diff suppressed because it is too large
+ 97 - 10
src/test/java/org/cryptomator/ui/keyloading/hub/JWEHelperTest.java