Explorar el Código

register device using a setup code

Sebastian Stenzel hace 2 años
padre
commit
5a18d086e0

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

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

+ 12 - 0
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)

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

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 10 - 4
src/main/java/org/cryptomator/ui/keyloading/hub/ReceiveKeyController.java


+ 208 - 0
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<Scene> registerSuccessScene;
+	private final Lazy<Scene> registerFailedScene;
+	private final String deviceId;
+	private final P384KeyPair deviceKeyPair;
+	private final CompletableFuture<ReceivedKey> 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<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.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<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);
+			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;
+	}
+}

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

@@ -0,0 +1,83 @@
+<?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.SetupDeviceController"
+	  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="TODO: Enter your setup code" 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}" 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>

+ 0 - 1
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;