Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Public method for decrypting ciphertext name. #263

Open
wants to merge 5 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions src/main/java/org/cryptomator/cryptofs/CryptoFileSystem.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package org.cryptomator.cryptofs;

import org.cryptomator.cryptofs.common.Constants;

import java.io.IOException;
import java.nio.file.FileSystem;
import java.nio.file.Files;
Expand Down Expand Up @@ -41,6 +43,26 @@ public abstract class CryptoFileSystem extends FileSystem {
*/
public abstract Path getCiphertextPath(Path cleartextPath) throws IOException;

/**
* Computes from a valid,encrypted node (file or folder) its cleartext name.
* <p>
* Due to the structure of a vault, an encrypted node is valid if:
* <ul>
* <li>the path points into the vault (duh!)</li>
* <li>the "file" extension is {@value Constants#CRYPTOMATOR_FILE_SUFFIX} or {@value Constants#DEFLATED_FILE_SUFFIX}</li>
* <li>the node name is at least {@value Constants#MIN_CIPHER_NAME_LENGTH} characters long</li>
* <li>it is located at depth 4 from the vault storage root, i.e. d/AB/CDEFG...XYZ/validFile.c9r</li>
* </ul>
*
* @param ciphertextNode path to the ciphertext file or directory
* @return the cleartext name of the ciphertext file or directory
* @throws java.nio.file.NoSuchFileException if the ciphertextFile does not exist
* @throws IOException if an I/O error occurs reading the ciphertext files
* @throws IllegalArgumentException if {@param ciphertextNode} is not a valid ciphertext content node of the vault
* @throws UnsupportedOperationException if the directory containing the {@param ciphertextNode} does not have a {@value Constants#DIR_ID_BACKUP_FILE_NAME} file
*/
public abstract String getCleartextName(Path ciphertextNode) throws IOException, UnsupportedOperationException;

/**
* Provides file system performance statistics.
*
Expand Down
21 changes: 14 additions & 7 deletions src/main/java/org/cryptomator/cryptofs/CryptoFileSystemImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@
import org.cryptomator.cryptofs.common.DeletingFileVisitor;
import org.cryptomator.cryptofs.common.FinallyUtil;
import org.cryptomator.cryptofs.dir.CiphertextDirectoryDeleter;
import org.cryptomator.cryptofs.dir.DirectoryStreamFilters;
import org.cryptomator.cryptofs.dir.DirectoryStreamFactory;
import org.cryptomator.cryptofs.dir.DirectoryStreamFilters;
import org.cryptomator.cryptofs.fh.OpenCryptoFiles;
import org.cryptomator.cryptolib.api.Cryptor;

Expand Down Expand Up @@ -95,16 +95,17 @@ class CryptoFileSystemImpl extends CryptoFileSystem {

private final CryptoPath rootPath;
private final CryptoPath emptyPath;
private final FileNameDecryptor fileNameDecryptor;

private volatile boolean open = true;

@Inject
public CryptoFileSystemImpl(CryptoFileSystemProvider provider, CryptoFileSystems cryptoFileSystems, @PathToVault Path pathToVault, Cryptor cryptor,
CryptoFileStore fileStore, CryptoFileSystemStats stats, CryptoPathMapper cryptoPathMapper, CryptoPathFactory cryptoPathFactory,
PathMatcherFactory pathMatcherFactory, DirectoryStreamFactory directoryStreamFactory, DirectoryIdProvider dirIdProvider, DirectoryIdBackup dirIdBackup,
AttributeProvider fileAttributeProvider, AttributeByNameProvider fileAttributeByNameProvider, AttributeViewProvider fileAttributeViewProvider,
OpenCryptoFiles openCryptoFiles, Symlinks symlinks, FinallyUtil finallyUtil, CiphertextDirectoryDeleter ciphertextDirDeleter, ReadonlyFlag readonlyFlag,
CryptoFileSystemProperties fileSystemProperties) {
public CryptoFileSystemImpl(CryptoFileSystemProvider provider, CryptoFileSystems cryptoFileSystems, @PathToVault Path pathToVault, Cryptor cryptor, //
CryptoFileStore fileStore, CryptoFileSystemStats stats, CryptoPathMapper cryptoPathMapper, CryptoPathFactory cryptoPathFactory, //
PathMatcherFactory pathMatcherFactory, DirectoryStreamFactory directoryStreamFactory, DirectoryIdProvider dirIdProvider, DirectoryIdBackup dirIdBackup, //
AttributeProvider fileAttributeProvider, AttributeByNameProvider fileAttributeByNameProvider, AttributeViewProvider fileAttributeViewProvider, //
OpenCryptoFiles openCryptoFiles, Symlinks symlinks, FinallyUtil finallyUtil, CiphertextDirectoryDeleter ciphertextDirDeleter, ReadonlyFlag readonlyFlag, //
CryptoFileSystemProperties fileSystemProperties, FileNameDecryptor fileNameDecryptor) {
this.provider = provider;
this.cryptoFileSystems = cryptoFileSystems;
this.pathToVault = pathToVault;
Expand All @@ -129,6 +130,7 @@ public CryptoFileSystemImpl(CryptoFileSystemProvider provider, CryptoFileSystems

this.rootPath = cryptoPathFactory.rootFor(this);
this.emptyPath = cryptoPathFactory.emptyFor(this);
this.fileNameDecryptor = fileNameDecryptor;
}

@Override
Expand All @@ -151,6 +153,11 @@ public Path getCiphertextPath(Path cleartextPath) throws IOException {
}
}

@Override
public String getCleartextName(Path ciphertextNode) throws IOException, UnsupportedOperationException {
return fileNameDecryptor.decryptFilename(ciphertextNode);
}

@Override
public CryptoFileSystemStats getStats() {
return stats;
Expand Down
96 changes: 96 additions & 0 deletions src/main/java/org/cryptomator/cryptofs/FileNameDecryptor.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package org.cryptomator.cryptofs;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.io.BaseEncoding;
import org.cryptomator.cryptofs.common.Constants;
import org.cryptomator.cryptofs.common.StringUtils;
import org.cryptomator.cryptolib.api.CryptoException;
import org.cryptomator.cryptolib.api.Cryptor;
import org.cryptomator.cryptolib.api.FileNameCryptor;

import javax.inject.Inject;
import java.io.IOException;
import java.nio.file.FileSystemException;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.util.stream.Stream;

/**
* @see CryptoFileSystem#getCleartextName(Path)
*/
@CryptoFileSystemScoped
class FileNameDecryptor {

private final DirectoryIdBackup dirIdBackup;
private final LongFileNameProvider longFileNameProvider;
private final Path vaultPath;
private final FileNameCryptor fileNameCryptor;

@Inject
public FileNameDecryptor(@PathToVault Path vaultPath, Cryptor cryptor, DirectoryIdBackup dirIdBackup, LongFileNameProvider longFileNameProvider) {
this.vaultPath = vaultPath;
this.fileNameCryptor = cryptor.fileNameCryptor();
this.dirIdBackup = dirIdBackup;
this.longFileNameProvider = longFileNameProvider;
}

public String decryptFilename(Path ciphertextNode) throws IOException, UnsupportedOperationException {
validatePath(ciphertextNode.toAbsolutePath());
return decryptFilenameInternal(ciphertextNode);
}

@VisibleForTesting
String decryptFilenameInternal(Path ciphertextNode) throws IOException, UnsupportedOperationException {
byte[] dirId = null;
try {
dirId = dirIdBackup.read(ciphertextNode);
} catch (NoSuchFileException e) {
throw new UnsupportedOperationException("Directory does not have a " + Constants.DIR_ID_BACKUP_FILE_NAME + " file.");
} catch (CryptoException | IllegalStateException e) {
throw new FileSystemException(ciphertextNode.toString(), null, "Decryption of dirId backup file failed:" + e);
}
var fullCipherNodeName = ciphertextNode.getFileName().toString();
var cipherNodeExtension = fullCipherNodeName.substring(fullCipherNodeName.length() - 4);

infeo marked this conversation as resolved.
Show resolved Hide resolved
String actualEncryptedName = switch (cipherNodeExtension) {
case Constants.CRYPTOMATOR_FILE_SUFFIX -> StringUtils.removeEnd(fullCipherNodeName, Constants.CRYPTOMATOR_FILE_SUFFIX);
case Constants.DEFLATED_FILE_SUFFIX -> longFileNameProvider.inflate(ciphertextNode);
default -> throw new IllegalStateException("SHOULD NOT REACH HERE");
};
try {
return fileNameCryptor.decryptFilename(BaseEncoding.base64Url(), actualEncryptedName, dirId);
} catch (CryptoException e) {
throw new FileSystemException(ciphertextNode.toString(), null, "Filname decryption failed:" + e);
}
}

@VisibleForTesting
void validatePath(Path absolutePath) {
if (!belongsToVault(absolutePath)) {
throw new IllegalArgumentException("Node %s is not a part of vault %s".formatted(absolutePath, vaultPath));
}
if (!isAtCipherNodeLevel(absolutePath)) {
throw new IllegalArgumentException("Node %s is not located at depth 4 from vault storage root".formatted(absolutePath));
}
if (!(hasCipherNodeExtension(absolutePath) && hasMinimumFileNameLength(absolutePath))) {
throw new IllegalArgumentException("Node %s does not end with %s or %s or filename is shorter than %d characters.".formatted(absolutePath, Constants.CRYPTOMATOR_FILE_SUFFIX, Constants.DEFLATED_FILE_SUFFIX, Constants.MIN_CIPHER_NAME_LENGTH));
}
}

boolean hasCipherNodeExtension(Path p) {
var name = p.getFileName();
return name != null && Stream.of(Constants.CRYPTOMATOR_FILE_SUFFIX, Constants.DEFLATED_FILE_SUFFIX).anyMatch(name.toString()::endsWith);
}

boolean isAtCipherNodeLevel(Path p) {
return vaultPath.relativize(p).getNameCount() == 4; //TODO: relativize is defined for two relative Paths. For two absolute paths, the result depends on the OS
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Ensure OS-independent behavior when using Path.relativize

The use of vaultPath.relativize(p) may not behave consistently across different operating systems when both paths are absolute, as noted in the TODO comment. This could lead to incorrect path depth calculations.

Consider adjusting the implementation to ensure OS-independent behavior. One approach is to convert both paths to relative paths before calling relativize, or manually calculate the depth using getNameCount() after verifying that p starts with vaultPath.

}

boolean hasMinimumFileNameLength(Path p) {
return p.getFileName().toString().length() >= Constants.MIN_CIPHER_NAME_LENGTH;
}

boolean belongsToVault(Path p) {
return p.startsWith(vaultPath);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ public class CryptoFileSystemImplTest {
private final CiphertextDirectoryDeleter ciphertextDirDeleter = mock(CiphertextDirectoryDeleter.class);
private final ReadonlyFlag readonlyFlag = mock(ReadonlyFlag.class);
private final CryptoFileSystemProperties fileSystemProperties = mock(CryptoFileSystemProperties.class);
private final FileNameDecryptor filenameDecryptor = mock(FileNameDecryptor.class);

private final CryptoPath root = mock(CryptoPath.class);
private final CryptoPath empty = mock(CryptoPath.class);
Expand All @@ -127,7 +128,7 @@ public void setup() {
pathMatcherFactory, directoryStreamFactory, dirIdProvider, dirIdBackup, //
fileAttributeProvider, fileAttributeByNameProvider, fileAttributeViewProvider, //
openCryptoFiles, symlinks, finallyUtil, ciphertextDirDeleter, readonlyFlag, //
fileSystemProperties);
fileSystemProperties, filenameDecryptor);
}

@Test
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package org.cryptomator.cryptofs;

import org.cryptomator.cryptofs.common.Constants;
import org.cryptomator.cryptofs.health.dirid.OrphanContentDirTest;
import org.cryptomator.cryptofs.util.TestCryptoException;
import org.cryptomator.cryptolib.api.CryptoException;
import org.cryptomator.cryptolib.api.Cryptor;
import org.cryptomator.cryptolib.common.DecryptingReadableByteChannel;
Expand Down Expand Up @@ -103,17 +105,13 @@ public void contentLongerThan36Chars() throws IOException {
@DisplayName("If the backup file cannot be decrypted, a CryptoException is thrown")
public void invalidEncryptionThrowsCryptoException() throws IOException {
var dirIdBackupSpy = spy(dirIdBackup);
var expectedException = new MyCryptoException();
var expectedException = new TestCryptoException();
Mockito.when(dirIdBackupSpy.wrapDecryptionAround(Mockito.any(), Mockito.eq(cryptor))).thenReturn(decChannel);
Mockito.when(decChannel.read(Mockito.any())).thenThrow(expectedException);
var actual = Assertions.assertThrows(CryptoException.class, () -> dirIdBackupSpy.read(contentPath));
Assertions.assertEquals(expectedException, actual);
}

static class MyCryptoException extends CryptoException {

}

@Test
@DisplayName("IOException accessing the file is rethrown")
public void ioException() throws IOException {
Expand Down
Loading
Loading