diff --git a/src/main/java/org/cryptomator/ui/convertvault/HubToLocalConvertController.java b/src/main/java/org/cryptomator/ui/convertvault/HubToLocalConvertController.java index a1639afc8..9ebfd03cb 100644 --- a/src/main/java/org/cryptomator/ui/convertvault/HubToLocalConvertController.java +++ b/src/main/java/org/cryptomator/ui/convertvault/HubToLocalConvertController.java @@ -1,22 +1,66 @@ package org.cryptomator.ui.convertvault; +import com.google.common.base.Preconditions; +import org.cryptomator.common.Passphrase; +import org.cryptomator.common.vaults.Vault; +import org.cryptomator.cryptofs.CryptoFileSystemProperties; +import org.cryptomator.cryptofs.CryptoFileSystemProvider; +import org.cryptomator.cryptofs.common.BackupHelper; +import org.cryptomator.cryptolib.api.CryptoException; +import org.cryptomator.cryptolib.api.MasterkeyLoader; +import org.cryptomator.cryptolib.api.MasterkeyLoadingFailedException; +import org.cryptomator.cryptolib.common.MasterkeyFileAccess; import org.cryptomator.ui.changepassword.NewPasswordController; import org.cryptomator.ui.common.FxController; +import org.cryptomator.ui.recoverykey.RecoveryKeyFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import javax.inject.Inject; +import javafx.application.Platform; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.StringProperty; import javafx.fxml.FXML; import javafx.stage.Stage; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutorService; + +import static org.cryptomator.common.Constants.DEFAULT_KEY_ID; +import static org.cryptomator.common.Constants.MASTERKEY_BACKUP_SUFFIX; +import static org.cryptomator.common.Constants.MASTERKEY_FILENAME; +import static org.cryptomator.common.Constants.VAULTCONFIG_FILENAME; public class HubToLocalConvertController implements FxController { + private static final Logger LOG = LoggerFactory.getLogger(HubToLocalConvertController.class); + private final Stage window; + private final Vault vault; + private final StringProperty recoveryKey; + private final RecoveryKeyFactory recoveryKeyFactory; + private final MasterkeyFileAccess masterkeyFileAccess; + private final ExecutorService backgroundExecutorService; + private final BooleanProperty isConverting; @FXML NewPasswordController newPasswordController; @Inject - public HubToLocalConvertController(@ConvertVaultWindow Stage window) { + public HubToLocalConvertController(@ConvertVaultWindow Stage window, @ConvertVaultWindow Vault vault, @ConvertVaultWindow StringProperty recoveryKey, RecoveryKeyFactory recoveryKeyFactory, MasterkeyFileAccess masterkeyFileAccess, ExecutorService backgroundExecutorService) { this.window = window; + this.vault = vault; + this.recoveryKey = recoveryKey; + this.recoveryKeyFactory = recoveryKeyFactory; + this.masterkeyFileAccess = masterkeyFileAccess; + this.backgroundExecutorService = backgroundExecutorService; + this.isConverting = new SimpleBooleanProperty(false); } @FXML @@ -30,12 +74,64 @@ public class HubToLocalConvertController implements FxController { @FXML public void convert() { + Preconditions.checkState(newPasswordController.isGoodPassword()); + LOG.info("Converting hub vault {} to local", vault.getPath()); + CompletableFuture.runAsync(() -> isConverting.setValue(true), Platform::runLater) // + .thenRunAsync(this::convertInternal, backgroundExecutorService) //TODO: which executor is used? + .whenCompleteAsync((result, exception) -> { + isConverting.setValue(false); + if (exception == null) { //TODO: check, how the exceptions are wrapped + LOG.info("Conversion of vault {} succeeded.", vault.getPath()); + } else { + LOG.error("Conversion of vault {} failed.", vault.getPath(), exception); + } + }, Platform::runLater); // //window.setScene(resetPasswordScene.get()); } + //visible for testing + void convertInternal() throws CompletionException, IllegalArgumentException { + var passphrase = newPasswordController.getNewPassword(); + try { + recoveryKeyFactory.newMasterkeyFileWithPassphrase(vault.getPath(), recoveryKey.get(), passphrase); + LOG.debug("Successfully created masterkey file for vault {}", vault.getPath()); + backupHubConfig(vault.getPath().resolve(VAULTCONFIG_FILENAME)); + replaceWithLocalConfig(passphrase); + } catch (MasterkeyLoadingFailedException e) { + throw new CompletionException(new IOException("Vault conversion failed", e)); + } catch (IOException e) { + throw new CompletionException("Vault conversion failed", e); + } finally { + passphrase.destroy(); + } + } + + //visible for testing + void backupHubConfig(Path hubConfigPath) throws IOException { + byte[] hubConfigBytes = Files.readAllBytes(hubConfigPath); + Path backupPath = vault.getPath().resolve(VAULTCONFIG_FILENAME + BackupHelper.generateFileIdSuffix(hubConfigBytes) + MASTERKEY_BACKUP_SUFFIX); + Files.move(hubConfigPath, backupPath, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); //TODO: should this be an atomic move? + LOG.debug("Successfully created vault config backup {} for vault {}", backupPath.getFileName(), vault.getPath()); + } + + //visible for testing + void replaceWithLocalConfig(Passphrase passphrase) throws IOException, MasterkeyLoadingFailedException { + var unverifiedVaultConfig = vault.getVaultConfigCache().get(); + try (var masterkey = masterkeyFileAccess.load(vault.getPath().resolve(MASTERKEY_FILENAME), passphrase)) { + var vaultConfig = unverifiedVaultConfig.verify(masterkey.getEncoded(), unverifiedVaultConfig.allegedVaultVersion()); + MasterkeyLoader loader = ignored -> masterkey.copy(); + CryptoFileSystemProperties fsProps = CryptoFileSystemProperties.cryptoFileSystemProperties() // + .withCipherCombo(vaultConfig.getCipherCombo()) // + .withKeyLoader(loader) // + .build(); + CryptoFileSystemProvider.initialize(vault.getPath(), fsProps, DEFAULT_KEY_ID); + } + } + /* Getter/Setter */ public NewPasswordController getNewPasswordController() { return newPasswordController; } + } diff --git a/src/test/java/org/cryptomator/ui/convertvault/HubToLocalConvertControllerTest.java b/src/test/java/org/cryptomator/ui/convertvault/HubToLocalConvertControllerTest.java new file mode 100644 index 000000000..5931fa1a5 --- /dev/null +++ b/src/test/java/org/cryptomator/ui/convertvault/HubToLocalConvertControllerTest.java @@ -0,0 +1,140 @@ +package org.cryptomator.ui.convertvault; + +import org.cryptomator.common.Constants; +import org.cryptomator.common.Passphrase; +import org.cryptomator.common.vaults.Vault; +import org.cryptomator.cryptolib.api.MasterkeyLoadingFailedException; +import org.cryptomator.cryptolib.common.MasterkeyFileAccess; +import org.cryptomator.ui.changepassword.NewPasswordController; +import org.cryptomator.ui.recoverykey.RecoveryKeyFactory; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mockito; + +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.StringProperty; +import javafx.stage.Stage; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.Optional; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutorService; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.times; + +public class HubToLocalConvertControllerTest { + + Stage window; + Vault vault; + StringProperty recoveryKey; + RecoveryKeyFactory recoveryKeyFactory; + MasterkeyFileAccess masterkeyFileAccess; + ExecutorService backgroundExecutorService; + BooleanProperty isConverting; + NewPasswordController newPasswordController; + + HubToLocalConvertController inTest; + + @BeforeEach + public void beforeEach() { + window = Mockito.mock(Stage.class); + vault = Mockito.mock(Vault.class); + recoveryKey = Mockito.mock(StringProperty.class); + recoveryKeyFactory = Mockito.mock(RecoveryKeyFactory.class); + masterkeyFileAccess = Mockito.mock(MasterkeyFileAccess.class); + backgroundExecutorService = Mockito.mock(ExecutorService.class); + isConverting = Mockito.mock(BooleanProperty.class); + newPasswordController = Mockito.mock(NewPasswordController.class); + inTest = new HubToLocalConvertController(window, vault, recoveryKey, recoveryKeyFactory, masterkeyFileAccess, backgroundExecutorService); + inTest.newPasswordController = newPasswordController; + } + + @Test + public void testBackupHubConfig(@TempDir Path tmpDir) throws IOException { + Path configPath = tmpDir.resolve(Constants.VAULTCONFIG_FILENAME); + Files.writeString(configPath, "hello config", StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + + Mockito.when(vault.getPath()).thenReturn(tmpDir); + + inTest.backupHubConfig(configPath); + Optional result = Files.list(tmpDir).filter(p -> { + var fileName = p.getFileName().toString(); + return fileName.startsWith(Constants.VAULTCONFIG_FILENAME) && fileName.endsWith(Constants.MASTERKEY_BACKUP_SUFFIX); + }).findAny(); + + Assertions.assertTrue(Files.notExists(configPath)); + Assertions.assertTrue(result.isPresent()); + Assertions.assertEquals("hello config", Files.readString(result.get(), StandardCharsets.UTF_8)); + } + + @Nested + class ConvertInternalTests { + + Passphrase passphrase = Mockito.mock(Passphrase.class); + Path vaultPath = Mockito.mock(Path.class, "/vault/path"); + Path configPath = Mockito.mock(Path.class, "/vault/path/config"); + String actualRecoveryKey = "recoveryKey"; + HubToLocalConvertController inSpy; + + @BeforeEach + public void beforeEach() throws IOException { + inSpy = Mockito.spy(inTest); + Mockito.when(newPasswordController.getNewPassword()).thenReturn(passphrase); + Mockito.when(recoveryKey.get()).thenReturn(actualRecoveryKey); + Mockito.when(vault.getPath()).thenReturn(vaultPath); + Mockito.when(vaultPath.resolve(anyString())).thenReturn(configPath); + Mockito.doNothing().when(recoveryKeyFactory).newMasterkeyFileWithPassphrase(any(), anyString(), any()); + Mockito.doNothing().when(inSpy).backupHubConfig(any()); + Mockito.doNothing().when(inSpy).replaceWithLocalConfig(any()); + Mockito.doNothing().when(passphrase).destroy(); + } + + + @Test + public void testConvertInternal() throws IOException { + inSpy.convertInternal(); + + Mockito.verify(recoveryKeyFactory, times(1)).newMasterkeyFileWithPassphrase(vaultPath, actualRecoveryKey, passphrase); + Mockito.verify(inSpy, times(1)).backupHubConfig(configPath); + Mockito.verify(inSpy, times(1)).replaceWithLocalConfig(passphrase); + Mockito.verify(passphrase, times(1)).destroy(); + } + + @Test + public void testConvertInternalWrapsCryptoException() throws IOException { + Mockito.doThrow(new MasterkeyLoadingFailedException("yadda")).when(inSpy).replaceWithLocalConfig(any()); + + Assertions.assertThrows(CompletionException.class, inSpy::convertInternal); + + Mockito.verify(passphrase, times(1)).destroy(); + } + + @Test + public void testConvertInternalWrapsIOException() throws IOException { + Mockito.doThrow(new IOException("yudu")).when(inSpy).backupHubConfig(any()); + + Assertions.assertThrows(CompletionException.class, inSpy::convertInternal); + + Mockito.verify(passphrase, times(1)).destroy(); + } + + @Test + public void testConvertInternalNotWrapsIAE() throws IOException { + Mockito.doThrow(new IllegalArgumentException("yudu")).when(recoveryKeyFactory).newMasterkeyFileWithPassphrase(any(),anyString(),any()); + + Assertions.assertThrows(IllegalArgumentException.class, inSpy::convertInternal); + + Mockito.verify(passphrase, times(1)).destroy(); + } + } + + +}