diff --git a/src/main/java/org/cryptomator/ui/common/FxmlFile.java b/src/main/java/org/cryptomator/ui/common/FxmlFile.java index 3bec75899..15df40e2d 100644 --- a/src/main/java/org/cryptomator/ui/common/FxmlFile.java +++ b/src/main/java/org/cryptomator/ui/common/FxmlFile.java @@ -22,7 +22,8 @@ public enum FxmlFile { HUB_RECEIVE_KEY("/fxml/hub_receive_key.fxml"), // HUB_REGISTER_DEVICE("/fxml/hub_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"), // diff --git a/src/main/java/org/cryptomator/ui/keyloading/hub/CreateDeviceDto.java b/src/main/java/org/cryptomator/ui/keyloading/hub/CreateDeviceDto.java index dcf9b6458..cf8ffdeec 100644 --- a/src/main/java/org/cryptomator/ui/keyloading/hub/CreateDeviceDto.java +++ b/src/main/java/org/cryptomator/ui/keyloading/hub/CreateDeviceDto.java @@ -1,10 +1,15 @@ package org.cryptomator.ui.keyloading.hub; +import java.time.Instant; + class CreateDeviceDto { public String id; public String name; public final String type = "DESKTOP"; public String publicKey; + public String userKey; + public String creationTime; + public String lastSeenTime; } diff --git a/src/main/java/org/cryptomator/ui/keyloading/hub/HubKeyLoadingModule.java b/src/main/java/org/cryptomator/ui/keyloading/hub/HubKeyLoadingModule.java index ad4ea9408..d6d2087dc 100644 --- a/src/main/java/org/cryptomator/ui/keyloading/hub/HubKeyLoadingModule.java +++ b/src/main/java/org/cryptomator/ui/keyloading/hub/HubKeyLoadingModule.java @@ -134,6 +134,13 @@ public abstract class HubKeyLoadingModule { return fxmlLoaders.createScene(FxmlFile.HUB_REGISTER_FAILED); } + @Provides + @FxmlScene(FxmlFile.HUB_SETUP_DEVICE) + @KeyLoadingScoped + static Scene provideHubSetupDeviceScene(@KeyLoading FxmlLoaderFactory fxmlLoaders) { + return fxmlLoaders.createScene(FxmlFile.HUB_SETUP_DEVICE); + } + @Provides @FxmlScene(FxmlFile.HUB_UNAUTHORIZED_DEVICE) @KeyLoadingScoped @@ -176,6 +183,11 @@ public abstract class HubKeyLoadingModule { @FxControllerKey(RegisterFailedController.class) abstract FxController bindRegisterFailedController(RegisterFailedController controller); + @Binds + @IntoMap + @FxControllerKey(SetupDeviceController.class) + abstract FxController bindSetupDeviceController(SetupDeviceController controller); + @Binds @IntoMap @FxControllerKey(UnauthorizedDeviceController.class) diff --git a/src/main/java/org/cryptomator/ui/keyloading/hub/JWEHelper.java b/src/main/java/org/cryptomator/ui/keyloading/hub/JWEHelper.java index 29bc7a98f..233f2a6df 100644 --- a/src/main/java/org/cryptomator/ui/keyloading/hub/JWEHelper.java +++ b/src/main/java/org/cryptomator/ui/keyloading/hub/JWEHelper.java @@ -2,20 +2,33 @@ 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 { @@ -25,11 +38,43 @@ class JWEHelper { 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 ECPrivateKey decryptUserKey(JWEObject jwe, String setupCode) { + try { + jwe.decrypt(new PasswordBasedDecrypter(setupCode)); + return decodeUserKey(jwe); + } catch (JOSEException e) { + throw new MasterkeyLoadingFailedException("Failed to decrypt JWE", e); + } + } public static ECPrivateKey decryptUserKey(JWEObject jwe, ECPrivateKey deviceKey) { try { jwe.decrypt(new ECDHDecrypter(deviceKey)); - var keySpec = readKey(jwe, JWE_PAYLOAD_KEY_FIELD, PKCS8EncodedKeySpec::new); + return decodeUserKey(jwe); + } catch (JOSEException e) { + LOG.warn("Failed to decrypt JWE: {}", jwe); + throw new MasterkeyLoadingFailedException("Failed to decrypt JWE", 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) { @@ -37,13 +82,10 @@ class JWEHelper { } else { throw new IllegalStateException(EC_ALG + " key factory not generating ECPrivateKeys"); } - } catch (JOSEException e) { - LOG.warn("Failed to decrypt JWE: {}", jwe); - throw new MasterkeyLoadingFailedException("Failed to decrypt JWE", e); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException(EC_ALG + " not supported"); } catch (InvalidKeySpecException e) { - LOG.warn("Unexpected JWE payload: {}", jwe.getPayload()); + LOG.warn("Unexpected JWE payload: {}", decryptedJwe.getPayload()); throw new MasterkeyLoadingFailedException("Unexpected JWE payload", e); } } diff --git a/src/main/java/org/cryptomator/ui/keyloading/hub/ReceiveKeyController.java b/src/main/java/org/cryptomator/ui/keyloading/hub/ReceiveKeyController.java index 630b35e1f..1bb08c44c 100644 --- a/src/main/java/org/cryptomator/ui/keyloading/hub/ReceiveKeyController.java +++ b/src/main/java/org/cryptomator/ui/keyloading/hub/ReceiveKeyController.java @@ -43,6 +43,7 @@ public class ReceiveKeyController implements FxController { private final String deviceId; private final String bearerToken; private final CompletableFuture result; + private final Lazy setupDeviceScene; private final Lazy registerDeviceScene; private final Lazy unauthorizedScene; private final URI vaultBaseUri; @@ -50,12 +51,13 @@ public class ReceiveKeyController implements FxController { private final HttpClient httpClient; @Inject - public ReceiveKeyController(@KeyLoading Vault vault, ExecutorService executor, @KeyLoading Stage window, HubConfig hubConfig, @Named("deviceId") String deviceId, @Named("bearerToken") AtomicReference tokenRef, CompletableFuture result, @FxmlScene(FxmlFile.HUB_REGISTER_DEVICE) Lazy registerDeviceScene, @FxmlScene(FxmlFile.HUB_UNAUTHORIZED_DEVICE) Lazy unauthorizedScene, @FxmlScene(FxmlFile.HUB_INVALID_LICENSE) Lazy invalidLicenseScene) { + public ReceiveKeyController(@KeyLoading Vault vault, ExecutorService executor, @KeyLoading Stage window, HubConfig hubConfig, @Named("deviceId") String deviceId, @Named("bearerToken") AtomicReference tokenRef, CompletableFuture result, @FxmlScene(FxmlFile.HUB_SETUP_DEVICE) Lazy setupDeviceScene, @FxmlScene(FxmlFile.HUB_REGISTER_DEVICE) Lazy registerDeviceScene, @FxmlScene(FxmlFile.HUB_UNAUTHORIZED_DEVICE) Lazy unauthorizedScene, @FxmlScene(FxmlFile.HUB_INVALID_LICENSE) Lazy invalidLicenseScene) { this.window = window; this.hubConfig = hubConfig; this.deviceId = deviceId; this.bearerToken = Objects.requireNonNull(tokenRef.get()); this.result = result; + this.setupDeviceScene = setupDeviceScene; this.registerDeviceScene = registerDeviceScene; this.unauthorizedScene = unauthorizedScene; this.vaultBaseUri = getVaultBaseUri(vault); @@ -127,7 +129,7 @@ public class ReceiveKeyController implements FxController { try { switch (response.statusCode()) { case 200 -> receivedDeviceTokenSuccess(userToken, response.body()); - case 403, 404 -> needsDeviceRegistration(); + case 403, 404 -> needsDeviceSetup(); default -> throw new IOException("Unexpected response " + response.statusCode()); } } catch (IOException e) { @@ -135,6 +137,10 @@ public class ReceiveKeyController implements FxController { } } + private void needsDeviceSetup() { + window.setScene(setupDeviceScene.get()); + } + private void receivedDeviceTokenSuccess(String rawUserToken, String rawDeviceToken) throws IOException { try { var userToken = JWEObject.parse(rawUserToken); @@ -171,7 +177,7 @@ public class ReceiveKeyController implements FxController { case 200 -> receivedLegacyAccessTokenSuccess(response.body()); case 402 -> licenseExceeded(); case 403 -> accessNotGranted(); - case 404 -> needsDeviceRegistration(); + case 404 -> needsLegacyDeviceRegistration(); default -> throw new IOException("Unexpected response " + response.statusCode()); } } catch (IOException e) { @@ -193,7 +199,7 @@ public class ReceiveKeyController implements FxController { window.setScene(invalidLicenseScene.get()); } - private void needsDeviceRegistration() { + private void needsLegacyDeviceRegistration() { window.setScene(registerDeviceScene.get()); } diff --git a/src/main/java/org/cryptomator/ui/keyloading/hub/SetupDeviceController.java b/src/main/java/org/cryptomator/ui/keyloading/hub/SetupDeviceController.java new file mode 100644 index 000000000..a43629246 --- /dev/null +++ b/src/main/java/org/cryptomator/ui/keyloading/hub/SetupDeviceController.java @@ -0,0 +1,208 @@ +package org.cryptomator.ui.keyloading.hub; + +import com.auth0.jwt.JWT; +import com.auth0.jwt.interfaces.DecodedJWT; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.nimbusds.jose.JWEObject; +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.jetbrains.annotations.Nullable; +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.text.ParseException; +import java.time.Instant; +import java.time.format.DateTimeFormatter; +import java.util.Base64; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.atomic.AtomicReference; + +@KeyLoadingScoped +public class SetupDeviceController implements FxController { + + private static final Logger LOG = LoggerFactory.getLogger(SetupDeviceController.class); + private static final Gson GSON = new GsonBuilder().setLenient().create(); + + private final Stage window; + private final HubConfig hubConfig; + private final String bearerToken; + private final Lazy registerSuccessScene; + private final Lazy registerFailedScene; + private final String deviceId; + private final P384KeyPair deviceKeyPair; + private final CompletableFuture result; + private final DecodedJWT jwt; + private final HttpClient httpClient; + private final BooleanProperty deviceNameAlreadyExists = new SimpleBooleanProperty(false); + + public TextField setupCodeField; + public TextField deviceNameField; + public Button registerBtn; + + @Inject + public SetupDeviceController(@KeyLoading Stage window, ExecutorService executor, HubConfig hubConfig, @Named("deviceId") String deviceId, DeviceKey deviceKey, CompletableFuture result, @Named("bearerToken") AtomicReference bearerToken, @FxmlScene(FxmlFile.HUB_REGISTER_SUCCESS) Lazy registerSuccessScene, @FxmlScene(FxmlFile.HUB_REGISTER_FAILED) Lazy registerFailedScene) { + this.window = window; + this.hubConfig = hubConfig; + this.deviceId = deviceId; + 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(); + } + + 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() { + setupCodeField.setDisable(true); + deviceNameField.setDisable(true); + deviceNameAlreadyExists.set(false); + registerBtn.setContentDisplay(ContentDisplay.LEFT); + registerBtn.setDisable(true); + + var apiRootUrl = URI.create(hubConfig.devicesResourceUrl + "/..").normalize(); // TODO: add url to vault config file, only use this as a fallback for legacy vaults + var deviceUri = URI.create(hubConfig.devicesResourceUrl + deviceId); + var deviceKey = deviceKeyPair.getPublic().getEncoded(); + + var userReq = HttpRequest.newBuilder(apiRootUrl.resolve("users/me")) // + .GET() // + .header("Authorization", "Bearer " + bearerToken) // + .header("Content-Type", "application/json") // + .build(); + httpClient.sendAsync(userReq, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)) // + .thenApply(response -> { + if (response.statusCode() == 200) { + return GSON.fromJson(response.body(), UserDto.class); + } else { + throw new RuntimeException("Server answered with unexpected status code " + response.statusCode()); + } + }).thenApply(user -> { + try { + 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 dto = new CreateDeviceDto(); + dto.id = deviceId; + dto.name = deviceNameField.getText(); + dto.publicKey = Base64.getUrlEncoder().withoutPadding().encodeToString(deviceKey); + dto.userKey = jwe.serialize(); + dto.creationTime = Instant.now().toString(); + dto.lastSeenTime = Instant.now().toString(); + var json = GSON.toJson(dto); + var putDeviceReq = HttpRequest.newBuilder(deviceUri) // + .PUT(HttpRequest.BodyPublishers.ofString(json, StandardCharsets.UTF_8)) // + .header("Authorization", "Bearer " + bearerToken) // + .header("Content-Type", "application/json") // + .build(); + return httpClient.sendAsync(putDeviceReq, HttpResponse.BodyHandlers.discarding()); + }).handleAsync((response, throwable) -> { + if (response != null) { + this.handleResponse(response); + } else { + this.registrationFailed(throwable); + } + return null; + }, Platform::runLater); + } + + private void handleResponse(HttpResponse 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); + registerBtn.setContentDisplay(ContentDisplay.TEXT_ONLY); + registerBtn.setDisable(false); + } else { + registrationFailed(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); + } + + @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 class UserDto { + public String id; + public String name; + public @Nullable String publicKey; + public @Nullable String privateKey; + public @Nullable String setupCode; + } +} diff --git a/src/main/resources/fxml/hub_setup_device.fxml b/src/main/resources/fxml/hub_setup_device.fxml new file mode 100644 index 000000000..47237fe0e --- /dev/null +++ b/src/main/resources/fxml/hub_setup_device.fxml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/test/java/org/cryptomator/ui/keyloading/hub/JWEHelperTest.java b/src/test/java/org/cryptomator/ui/keyloading/hub/JWEHelperTest.java index cac307add..2cee9cb44 100644 --- a/src/test/java/org/cryptomator/ui/keyloading/hub/JWEHelperTest.java +++ b/src/test/java/org/cryptomator/ui/keyloading/hub/JWEHelperTest.java @@ -3,7 +3,6 @@ package org.cryptomator.ui.keyloading.hub; import com.nimbusds.jose.JWEObject; import org.cryptomator.cryptolib.api.MasterkeyLoadingFailedException; import org.cryptomator.cryptolib.common.P384KeyPair; -import org.cryptomator.cryptolib.shaded.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test;