register device using a setup code

This commit is contained in:
Sebastian Stenzel
2023-06-22 17:16:00 +02:00
parent 918ace2eb6
commit 5a18d086e0
8 changed files with 367 additions and 11 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -43,6 +43,7 @@ public class ReceiveKeyController implements FxController {
private final String deviceId;
private final String bearerToken;
private final CompletableFuture<ReceivedKey> result;
private final Lazy<Scene> setupDeviceScene;
private final Lazy<Scene> registerDeviceScene;
private final Lazy<Scene> 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<String> tokenRef, CompletableFuture<ReceivedKey> result, @FxmlScene(FxmlFile.HUB_REGISTER_DEVICE) Lazy<Scene> registerDeviceScene, @FxmlScene(FxmlFile.HUB_UNAUTHORIZED_DEVICE) Lazy<Scene> unauthorizedScene, @FxmlScene(FxmlFile.HUB_INVALID_LICENSE) Lazy<Scene> invalidLicenseScene) {
public ReceiveKeyController(@KeyLoading Vault vault, ExecutorService executor, @KeyLoading Stage window, HubConfig hubConfig, @Named("deviceId") String deviceId, @Named("bearerToken") AtomicReference<String> tokenRef, CompletableFuture<ReceivedKey> result, @FxmlScene(FxmlFile.HUB_SETUP_DEVICE) Lazy<Scene> setupDeviceScene, @FxmlScene(FxmlFile.HUB_REGISTER_DEVICE) Lazy<Scene> registerDeviceScene, @FxmlScene(FxmlFile.HUB_UNAUTHORIZED_DEVICE) Lazy<Scene> unauthorizedScene, @FxmlScene(FxmlFile.HUB_INVALID_LICENSE) Lazy<Scene> invalidLicenseScene) {
this.window = window;
this.hubConfig = hubConfig;
this.deviceId = deviceId;
this.bearerToken = Objects.requireNonNull(tokenRef.get());
this.result = result;
this.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());
}

View File

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