diff --git a/src/main/java/org/cryptomator/cli/CryptomatorCli.java b/src/main/java/org/cryptomator/cli/CryptomatorCli.java new file mode 100644 index 0000000..e43129b --- /dev/null +++ b/src/main/java/org/cryptomator/cli/CryptomatorCli.java @@ -0,0 +1,90 @@ +package org.cryptomator.cli; + +import org.cryptomator.cryptofs.CryptoFileSystemProperties; +import org.cryptomator.cryptofs.CryptoFileSystemProvider; +import org.cryptomator.cryptolib.api.Masterkey; +import org.cryptomator.cryptolib.common.MasterkeyFileAccess; +import org.cryptomator.integrations.mount.UnmountFailedException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import picocli.CommandLine; +import picocli.CommandLine.Command; +import picocli.CommandLine.Parameters; + +import java.io.IOException; +import java.net.URI; +import java.nio.CharBuffer; +import java.nio.file.Path; +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.concurrent.Callable; + +@Command(name = "cryptomator-cli", + mixinStandardHelpOptions = true, + version = "1.0.0", + description = "Unlocks a cryptomator vault and mounts it into the system.") +public class CryptomatorCli implements Callable { + + private static final Logger LOG = LoggerFactory.getLogger(CryptomatorCli.class); + private static final byte[] PEPPER = new byte[0]; + + + @Parameters(index = "0", paramLabel = "/path/to/vaultDirectory", description = "Path to the vault directory") + Path pathToVault; + + @CommandLine.ArgGroup(multiplicity = "1") + PasswordSource passwordSource; + + @CommandLine.ArgGroup(exclusive = false, multiplicity = "1") + MountOptions mountOptions; + + private SecureRandom csrpg = null; + + @Override + public Integer call() throws Exception { + csrpg = SecureRandom.getInstanceStrong(); + CryptoFileSystemProperties fsProps = CryptoFileSystemProperties.cryptoFileSystemProperties() // + .withKeyLoader(this::loadMasterkey) // + //TODO: shortening Threshold + //TODO: maxCleartextname + .build(); + try (var fs = CryptoFileSystemProvider.newFileSystem(pathToVault, fsProps); + var mount = mountOptions.mount(fs)) { + + while (true) { + int c = System.in.read(); + if (c == -1) { //END OF STREAM + //TODO: terminate with error? + mount.unmount(); + return 1; + } else if (c == 0x03) {//Ctrl+C + mount.unmount(); + break; + } + } + } catch (UnmountFailedException e) { + LOG.error("Regular unmount failed. Just terminating...", e); + } + return 0; + } + + private Masterkey loadMasterkey(URI keyId) { + try { + char[] passphrase = passwordSource.readPassphrase(); + Path filePath = pathToVault.resolve("masterkey.cryptomator"); + + var masterkey = new MasterkeyFileAccess(PEPPER, csrpg).load(filePath, CharBuffer.wrap(passphrase)); + Arrays.fill(passphrase, '\u0000'); + return masterkey; + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public static void main(String... args) { + int exitCode = new CommandLine(new CryptomatorCli()) + .setPosixClusteredShortOptionsAllowed(false) + .execute(args); + System.exit(exitCode); + } +} diff --git a/src/main/java/org/cryptomator/cli/MountOptions.java b/src/main/java/org/cryptomator/cli/MountOptions.java new file mode 100644 index 0000000..1891a89 --- /dev/null +++ b/src/main/java/org/cryptomator/cli/MountOptions.java @@ -0,0 +1,81 @@ +package org.cryptomator.cli; + +import org.cryptomator.integrations.common.IntegrationsLoader; +import org.cryptomator.integrations.mount.*; +import picocli.CommandLine; + +import java.nio.file.FileSystem; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.stream.Collectors; + +public class MountOptions { + + @CommandLine.Spec + CommandLine.Model.CommandSpec spec; + + @CommandLine.Option(names = {"--mounter"}, paramLabel = "fully.qualified.ClassName", description = "Name of the mounter to use", required = true) + void setMountService(String value) { + var services = IntegrationsLoader.loadAll(MountService.class).toList(); + var service = services.stream().filter(s -> s.getClass().getName().equals(value)).findFirst(); + if (service.isEmpty()) { + var availableServices = services.stream().map(s -> getClass().getName()).collect(Collectors.joining(",")); + var errorMessage = String.format("Invalid value '%s' for option '--mounter': Available mounters are [%s].", value, availableServices); + throw new CommandLine.ParameterException(spec.commandLine(), errorMessage); + } + this.mountService = service.get(); + } + + private MountService mountService; + + @CommandLine.Option(names = {"--mountPoint"}, paramLabel = "/path/to/mount/point", description = "Path to the mount point. Requirements for mount point depend on the chosen mount service") + Optional mountPoint; + + @CommandLine.Option(names = {"--volumeName"}, description = "Name of the virtual volume.") + Optional volumeName; + + @CommandLine.Option(names = {"--volumeId"}, description = "Id of the virtual volume.") + String volumeId = UUID.randomUUID().toString(); + + @CommandLine.Option(names = {"--mountOption", "-mop"}, description = "Additional mount option. For a list of mountoptions, see the WinFsp, macFUSE, FUSE-T and libfuse documentation.") + List mountOptions = new ArrayList<>(); + + @CommandLine.Option(names = {"--loopbackHostName"}, description = "Name of the loopback address.") + Optional loopbackHostName; + @CommandLine.Option(names = {"--loopbackPort"}, description = "Port used at the loopback address.") + Optional loopbackPort; + + MountBuilder prepareMountBuilder(FileSystem fs) { + var builder = mountService.forFileSystem(fs.getPath("/")); + for (var capability : mountService.capabilities()) { + switch (capability) { + case FILE_SYSTEM_NAME -> builder.setFileSystemName("cryptoFs"); + case LOOPBACK_PORT -> loopbackPort.ifPresent(builder::setLoopbackPort); + case LOOPBACK_HOST_NAME -> loopbackHostName.ifPresent(builder::setLoopbackHostName); + //TODO: case READ_ONLY -> builder.setReadOnly(vaultSettings.usesReadOnlyMode.get()); + case MOUNT_FLAGS -> { + if (mountOptions.isEmpty()) { + builder.setMountFlags(mountService.getDefaultMountFlags()); + } else { + builder.setMountFlags(String.join(" ", mountOptions)); + } + } + case VOLUME_ID -> builder.setVolumeId(volumeId); + case VOLUME_NAME -> volumeName.ifPresent(builder::setVolumeName); + } + } + return builder; + } + + Mount mount(FileSystem fs) throws MountFailedException { + if (!mountService.hasCapability(MountCapability.MOUNT_TO_SYSTEM_CHOSEN_PATH) && mountPoint.isEmpty()) { + throw new CommandLine.ParameterException(spec.commandLine(), "The selected mounter %s requires a mount point. Use --mountPoint /path/to/mount/point to specify it.".formatted(mountService.displayName())); + } + var builder = prepareMountBuilder(fs); + mountPoint.ifPresent(builder::setMountpoint); + return builder.mount(); + } +} diff --git a/src/main/java/org/cryptomator/cli/PasswordSource.java b/src/main/java/org/cryptomator/cli/PasswordSource.java new file mode 100644 index 0000000..2bacb4c --- /dev/null +++ b/src/main/java/org/cryptomator/cli/PasswordSource.java @@ -0,0 +1,92 @@ +package org.cryptomator.cli; + +import picocli.CommandLine; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +public class PasswordSource { + + @CommandLine.Option(names = {"--password"}, paramLabel = "Passphrase", description = "Passphrase, read from STDIN") + boolean passphraseStdin; + + @CommandLine.Option(names = "--password:env", description = "Name of the environment variable containing the passphrase") + String passphraseEnvironmentVariable = null; + + @CommandLine.Option(names = "--password:file", description = "Path of the file containing the passphrase") + Path passphraseFile = null; + + @CommandLine.Option(names = "--password:ipc", hidden = true, description = "Used by Cryptomator GUI") + boolean passphraseIpc = false; + + + char[] readPassphrase() throws IOException { + if (passphraseStdin) { + return readPassphraseFromStdin(); + } else if (passphraseEnvironmentVariable != null) { + return readPassphraseFromEnvironment(); + } else if (passphraseFile != null) { + return readPassphraseFromFile(); + } else { + //TODO: use ipc + return new char[]{}; + } + } + + private char[] readPassphraseFromStdin() { + System.out.println("Enter a value for --password:"); + var console = System.console(); + if (console == null) { + throw new IllegalStateException("No console found to read password from."); + } + return console.readPassword(); + } + + private char[] readPassphraseFromEnvironment() { + var tmp = System.getenv(passphraseEnvironmentVariable); + if (tmp == null) { + throw new ReadingEnvironmentVariableFailedException("Environment variable " + passphraseEnvironmentVariable + " is not defined."); + } + char[] result = new char[tmp.length()]; + tmp.getChars(0, tmp.length(), result, 0); + return result; + } + + private char[] readPassphraseFromFile() throws ReadingFileFailedException { + try { + var bytes = Files.readAllBytes(passphraseFile); + var byteBuffer = ByteBuffer.wrap(bytes); + var charBuffer = StandardCharsets.UTF_8.decode(byteBuffer); + return charBuffer.array(); + } catch (IOException e) { + throw new ReadingFileFailedException(e); + } + } + + static class PasswordSourceException extends RuntimeException { + PasswordSourceException(String msg) { + super(msg); + } + + PasswordSourceException(Throwable cause) { + super(cause); + } + } + + static class ReadingFileFailedException extends PasswordSourceException { + ReadingFileFailedException(Throwable e) { + super(e); + + } + } + + static class ReadingEnvironmentVariableFailedException extends PasswordSourceException { + ReadingEnvironmentVariableFailedException(String msg) { + super(msg); + } + } + +}