diff --git a/main/filesystem-api/src/main/java/org/cryptomator/filesystem/WritableBytes.java b/main/filesystem-api/src/main/java/org/cryptomator/filesystem/WritableBytes.java index 39b4a50c4..698a7c62c 100644 --- a/main/filesystem-api/src/main/java/org/cryptomator/filesystem/WritableBytes.java +++ b/main/filesystem-api/src/main/java/org/cryptomator/filesystem/WritableBytes.java @@ -24,7 +24,7 @@ public interface WritableBytes { /** * Writes the data in the given byte buffer to this readable bytes at the - * given position. + * given position, overwriting existing content (not inserting). * * @param target * the byte buffer to use diff --git a/main/filesystem-api/src/main/java/org/cryptomator/io/ByteBuffers.java b/main/filesystem-api/src/main/java/org/cryptomator/io/ByteBuffers.java new file mode 100644 index 000000000..6452a3108 --- /dev/null +++ b/main/filesystem-api/src/main/java/org/cryptomator/io/ByteBuffers.java @@ -0,0 +1,30 @@ +package org.cryptomator.io; + +import java.nio.ByteBuffer; + +/** + * TODO this probably doesn't belong into this maven module, but it is used by various filesystem layers. + */ +public final class ByteBuffers { + + private ByteBuffers() { + } + + /** + * Copies as many bytes as possible from the given source to the destination buffer. + * The position of both buffers will be incremented by as many bytes as have been copied. + * + * @param source ByteBuffer from which bytes are read + * @param destination ByteBuffer into which bytes are written + * @return number of bytes copied, i.e. {@link ByteBuffer#remaining() source.remaining()} or {@link ByteBuffer#remaining() destination.remaining()}, whatever is less. + */ + public static int copy(ByteBuffer source, ByteBuffer destination) { + final int numBytes = Math.min(source.remaining(), destination.remaining()); + final ByteBuffer tmp = source.asReadOnlyBuffer(); + tmp.limit(tmp.position() + numBytes); + destination.put(tmp); + source.position(tmp.position()); + return numBytes; + } + +} diff --git a/main/filesystem-api/src/test/java/org/cryptomator/io/ByteBuffersTest.java b/main/filesystem-api/src/test/java/org/cryptomator/io/ByteBuffersTest.java new file mode 100644 index 000000000..fdaff9d62 --- /dev/null +++ b/main/filesystem-api/src/test/java/org/cryptomator/io/ByteBuffersTest.java @@ -0,0 +1,73 @@ +package org.cryptomator.io; + +import java.nio.ByteBuffer; + +import org.junit.Assert; +import org.junit.Test; + +public class ByteBuffersTest { + + @Test + public void testCopyOfEmptySource() { + final ByteBuffer src = ByteBuffer.allocate(0); + final ByteBuffer dst = ByteBuffer.allocate(5); + dst.put(new byte[3]); + Assert.assertEquals(0, src.position()); + Assert.assertEquals(0, src.remaining()); + Assert.assertEquals(3, dst.position()); + Assert.assertEquals(2, dst.remaining()); + ByteBuffers.copy(src, dst); + Assert.assertEquals(0, src.position()); + Assert.assertEquals(0, src.remaining()); + Assert.assertEquals(3, dst.position()); + Assert.assertEquals(2, dst.remaining()); + } + + @Test + public void testCopyToEmptyDestination() { + final ByteBuffer src = ByteBuffer.wrap(new byte[4]); + final ByteBuffer dst = ByteBuffer.allocate(0); + src.put(new byte[2]); + Assert.assertEquals(2, src.position()); + Assert.assertEquals(2, src.remaining()); + Assert.assertEquals(0, dst.position()); + Assert.assertEquals(0, dst.remaining()); + ByteBuffers.copy(src, dst); + Assert.assertEquals(2, src.position()); + Assert.assertEquals(2, src.remaining()); + Assert.assertEquals(0, dst.position()); + Assert.assertEquals(0, dst.remaining()); + } + + @Test + public void testCopyToBiggerDestination() { + final ByteBuffer src = ByteBuffer.wrap(new byte[2]); + final ByteBuffer dst = ByteBuffer.allocate(10); + dst.put(new byte[3]); + Assert.assertEquals(0, src.position()); + Assert.assertEquals(2, src.remaining()); + Assert.assertEquals(3, dst.position()); + Assert.assertEquals(7, dst.remaining()); + ByteBuffers.copy(src, dst); + Assert.assertEquals(2, src.position()); + Assert.assertEquals(0, src.remaining()); + Assert.assertEquals(5, dst.position()); + Assert.assertEquals(5, dst.remaining()); + } + + @Test + public void testCopyToSmallerDestination() { + final ByteBuffer src = ByteBuffer.wrap(new byte[5]); + final ByteBuffer dst = ByteBuffer.allocate(2); + Assert.assertEquals(0, src.position()); + Assert.assertEquals(5, src.remaining()); + Assert.assertEquals(0, dst.position()); + Assert.assertEquals(2, dst.remaining()); + ByteBuffers.copy(src, dst); + Assert.assertEquals(2, src.position()); + Assert.assertEquals(3, src.remaining()); + Assert.assertEquals(2, dst.position()); + Assert.assertEquals(0, dst.remaining()); + } + +} diff --git a/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/engine/ByteRange.java b/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/engine/ByteRange.java new file mode 100644 index 000000000..642e34e20 --- /dev/null +++ b/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/engine/ByteRange.java @@ -0,0 +1,43 @@ +package org.cryptomator.crypto.engine; + +public class ByteRange { + + private final long start; + private final long length; + + private ByteRange(long start, long length) { + if (start < 0) { + throw new IllegalArgumentException("start must not be a negative value"); + } + if (length < 0) { + throw new IllegalArgumentException("length must not be a negative value"); + } + this.start = start; + this.length = length; + } + + static ByteRange of(long start, long length) { + return new ByteRange(start, length); + } + + /** + * @return Begin of range (inclusive) + */ + public long start() { + return start; + } + + /** + * @return End of range (exclusive) + */ + public long end() { + return start + length; + } + + /** + * @return Number of bytes between start and end + */ + public long length() { + return length; + } +} diff --git a/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/engine/Cryptor.java b/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/engine/Cryptor.java index d55672f24..da06cd9c7 100644 --- a/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/engine/Cryptor.java +++ b/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/engine/Cryptor.java @@ -17,6 +17,8 @@ public interface Cryptor extends Destroyable { FilenameCryptor getFilenameCryptor(); + FileContentCryptor getFileContentCryptor(); + void randomizeMasterkey(); boolean readKeysFromMasterkeyFile(byte[] masterkeyFileContents, CharSequence passphrase); diff --git a/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/engine/FileContentCryptor.java b/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/engine/FileContentCryptor.java new file mode 100644 index 000000000..2ad156548 --- /dev/null +++ b/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/engine/FileContentCryptor.java @@ -0,0 +1,28 @@ +package org.cryptomator.crypto.engine; + +import java.nio.ByteBuffer; +import java.util.Optional; + +import javax.security.auth.Destroyable; + +public interface FileContentCryptor extends Destroyable { + + /** + * @return The fixed number of bytes of the file header. The header length is implementation-specific. + */ + int getHeaderSize(); + + /** + * @param header The full fixed-length header of an encrypted file. The caller is required to pass the exact amount of bytes returned by {@link #getHeaderSize()}. + * @return A possibly new FileContentDecryptor instance which is capable of decrypting ciphertexts associated with the given file header. + */ + FileContentDecryptor getFileContentDecryptor(ByteBuffer header); + + /** + * @param header The full fixed-length header of an encrypted file or {@link Optional#empty()}. The caller is required to pass the exact amount of bytes returned by {@link #getHeaderSize()}. + * If the header is empty, a new one will be created by the returned encryptor. + * @return A possibly new FileContentEncryptor instance which is capable of encrypting cleartext associated with the given file header. + */ + FileContentEncryptor getFileContentEncryptor(Optional header); + +} diff --git a/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/engine/FileContentDecryptor.java b/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/engine/FileContentDecryptor.java new file mode 100644 index 000000000..d88b5e495 --- /dev/null +++ b/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/engine/FileContentDecryptor.java @@ -0,0 +1,53 @@ +package org.cryptomator.crypto.engine; + +import java.nio.ByteBuffer; +import java.util.concurrent.BlockingQueue; + +/** + * Not necessarily thread-safe. + */ +public interface FileContentDecryptor { + + public static final ByteBuffer EOF = ByteBuffer.allocate(0); + + /** + * @return Number of bytes of the decrypted file. + */ + long contentLength(); + + /** + * Appends further ciphertext to this decryptor. This method might block until space becomes available. If so, it is interruptable. + * + * @param cleartext Cleartext data or {@link #EOF} to indicate the end of a ciphertext. + * @see #skipToPosition(long) + */ + void append(ByteBuffer ciphertext); + + /** + * Returns a queue containing cleartext in byte-by-byte FIFO order, meaning in the order ciphertext has been appended to this encryptor. + * However the number and size of the ciphertext byte buffers doesn't need to resemble the ciphertext buffers. + * + * The queue returns {@link #EOF}, when all ciphertext has been processed. + * + * @return A queue from which decrypted data can be {@link BlockingQueue#take() taken}. + */ + BlockingQueue cleartext(); + + /** + * Calculates the ciphertext bytes required to perform a partial decryption of a requested cleartext byte range. + * If this decryptor doesn't support partial decryption the result will be [0, {@link Long#MAX_VALUE}]. + * + * @param cleartextRange The cleartext range the caller is interested in. + * @return The ciphertext range required in order to decrypt the cleartext range. + */ + ByteRange ciphertextRequiredToDecryptRange(ByteRange cleartextRange); + + /** + * Informs the decryptor, what the first byte of the next ciphertext block will be. This method needs to be called only for partial decryption. + * + * @param nextCiphertextByte The first byte of the next ciphertext buffer given via {@link #append(ByteBuffer)}. + * @throws IllegalArgumentException If nextCiphertextByte is an invalid starting point. Only start bytes determined by {@link #ciphertextRequiredToDecryptRange(ByteRange)} are supported. + */ + void skipToPosition(long nextCiphertextByte) throws IllegalArgumentException; + +} diff --git a/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/engine/FileContentEncryptor.java b/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/engine/FileContentEncryptor.java new file mode 100644 index 000000000..4e180e2cd --- /dev/null +++ b/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/engine/FileContentEncryptor.java @@ -0,0 +1,55 @@ +package org.cryptomator.crypto.engine; + +import java.nio.ByteBuffer; +import java.util.concurrent.BlockingQueue; + +/** + * Not necessarily thread-safe. + */ +public interface FileContentEncryptor { + + public static final ByteBuffer EOF = ByteBuffer.allocate(0); + + /** + * Creates the encrypted file header. This header might depend on the already encrypted data, + * thus the caller should make sure all data is processed before requesting the header. + * + * @return Encrypted file header. + */ + ByteBuffer getHeader(); + + /** + * Appends further cleartext to this encryptor. This method might block until space becomes available. + * + * @param cleartext Cleartext data or {@link #EOF} to indicate the end of a cleartext. + */ + void append(ByteBuffer cleartext); + + /** + * Returns a queue containing ciphertext in byte-by-byte FIFO order, meaning in the order cleartext has been appended to this encryptor. + * However the number and size of the ciphertext byte buffers doesn't need to resemble the cleartext buffers. + * + * The queue returns {@link #EOF}, when all cleartext has been processed. + * + * @return A queue from which encrypted data can be {@link BlockingQueue#take() taken}. + */ + BlockingQueue ciphertext(); + + /** + * Calculates the cleartext bytes required to perform a partial encryption of a specific cleartext byte range. + * If this decryptor doesn't support partial encryption the result will be [0, {@link Long#MAX_VALUE}]. + * + * @param cleartextRange The cleartext range the caller wants to ecnrypt. + * @return The cleartext range required in order to encrypt the given cleartext range. + */ + ByteRange cleartextRequiredToEncryptRange(ByteRange cleartextRange); + + /** + * Informs the encryptor, what the first byte of the next cleartext block will be. This method needs to be called only for partial encryption. + * + * @param nextCleartextByte The first byte of the next cleartext buffer given via {@link #append(ByteBuffer)}. + * @throws IllegalArgumentException If nextCleartextByte is an invalid starting point. Only start bytes determined by {@link #cleartextRequiredToEncryptRange(ByteRange)} are supported. + */ + void skipToPosition(long nextCleartextByte) throws IllegalArgumentException; + +} diff --git a/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/engine/impl/CryptorImpl.java b/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/engine/impl/CryptorImpl.java index 330d5ce39..bb2714bb7 100644 --- a/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/engine/impl/CryptorImpl.java +++ b/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/engine/impl/CryptorImpl.java @@ -20,6 +20,7 @@ import javax.crypto.spec.SecretKeySpec; import javax.security.auth.DestroyFailedException; import org.cryptomator.crypto.engine.Cryptor; +import org.cryptomator.crypto.engine.FileContentCryptor; import org.cryptomator.crypto.engine.FilenameCryptor; import com.fasterxml.jackson.core.JsonProcessingException; @@ -37,6 +38,7 @@ public class CryptorImpl implements Cryptor { private SecretKey encryptionKey; private SecretKey macKey; private final AtomicReference filenameCryptor = new AtomicReference<>(); + private final AtomicReference fileContentCryptor = new AtomicReference<>(); private final SecureRandom randomSource; public CryptorImpl(SecureRandom randomSource) { @@ -61,6 +63,24 @@ public class CryptorImpl implements Cryptor { } } + @Override + public FileContentCryptor getFileContentCryptor() { + // lazy initialization pattern as proposed here http://stackoverflow.com/a/30247202/4014509 + final FileContentCryptor existingCryptor = fileContentCryptor.get(); + if (existingCryptor != null) { + return existingCryptor; + } else { + final FileContentCryptorImpl newCryptor = new FileContentCryptorImpl(encryptionKey, macKey); + if (fileContentCryptor.compareAndSet(null, newCryptor)) { + return newCryptor; + } else { + // CAS failed: other thread set an object + newCryptor.destroy(); + return fileContentCryptor.get(); + } + } + } + @Override public void randomizeMasterkey() { final byte[] randomBytes = new byte[KEYLENGTH_IN_BYTES]; @@ -147,11 +167,17 @@ public class CryptorImpl implements Cryptor { if (filenameCryptor.get() != null) { TheDestroyer.destroyQuietly(getFilenameCryptor()); } + if (fileContentCryptor.get() != null) { + TheDestroyer.destroyQuietly(getFileContentCryptor()); + } } @Override public boolean isDestroyed() { - return encryptionKey.isDestroyed() && macKey.isDestroyed() && (filenameCryptor.get() == null || filenameCryptor.get().isDestroyed()); + return encryptionKey.isDestroyed() // + && macKey.isDestroyed() // + && (filenameCryptor.get() == null || filenameCryptor.get().isDestroyed()) // + && (fileContentCryptor.get() == null || fileContentCryptor.get().isDestroyed()); } } diff --git a/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/engine/impl/FileContentCryptorImpl.java b/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/engine/impl/FileContentCryptorImpl.java new file mode 100644 index 000000000..3afa30654 --- /dev/null +++ b/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/engine/impl/FileContentCryptorImpl.java @@ -0,0 +1,53 @@ +package org.cryptomator.crypto.engine.impl; + +import java.nio.ByteBuffer; +import java.util.Optional; + +import javax.crypto.SecretKey; + +import org.cryptomator.crypto.engine.FileContentCryptor; +import org.cryptomator.crypto.engine.FileContentDecryptor; +import org.cryptomator.crypto.engine.FileContentEncryptor; + +class FileContentCryptorImpl implements FileContentCryptor { + + private final SecretKey encryptionKey; + private final SecretKey macKey; + + public FileContentCryptorImpl(SecretKey encryptionKey, SecretKey macKey) { + if (encryptionKey == null || macKey == null) { + throw new IllegalArgumentException("Key must not be null"); + } + this.encryptionKey = encryptionKey; + this.macKey = macKey; + } + + @Override + public int getHeaderSize() { + throw new UnsupportedOperationException("Method not implemented"); + } + + @Override + public FileContentDecryptor getFileContentDecryptor(ByteBuffer header) { + throw new UnsupportedOperationException("Method not implemented"); + } + + @Override + public FileContentEncryptor getFileContentEncryptor(Optional header) { + throw new UnsupportedOperationException("Method not implemented"); + } + + /* ======================= destruction ======================= */ + + @Override + public void destroy() { + TheDestroyer.destroyQuietly(encryptionKey); + TheDestroyer.destroyQuietly(macKey); + } + + @Override + public boolean isDestroyed() { + return encryptionKey.isDestroyed() && macKey.isDestroyed(); + } + +} diff --git a/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/fs/CryptoFile.java b/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/fs/CryptoFile.java index 72441695b..88f38070d 100644 --- a/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/fs/CryptoFile.java +++ b/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/fs/CryptoFile.java @@ -24,26 +24,24 @@ public class CryptoFile extends CryptoNode implements File { super(parent, name, cryptor); } - String encryptedName() { + @Override + protected String encryptedName() { return cryptor.getFilenameCryptor().encryptFilename(name()) + FILE_EXT; } @Override public Instant lastModified() throws UncheckedIOException { - // TODO Auto-generated method stub - return null; + return physicalFile().lastModified(); } @Override public ReadableFile openReadable() { - // TODO Auto-generated method stub - return null; + return new CryptoReadableFile(cryptor.getFileContentCryptor(), physicalFile().openReadable()); } @Override public WritableFile openWritable() { - // TODO Auto-generated method stub - return null; + return new CryptoWritableFile(cryptor.getFileContentCryptor(), physicalFile().openWritable()); } @Override diff --git a/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/fs/CryptoFileSystem.java b/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/fs/CryptoFileSystem.java index d5ce56c5e..4f56a5548 100644 --- a/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/fs/CryptoFileSystem.java +++ b/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/fs/CryptoFileSystem.java @@ -68,12 +68,12 @@ public class CryptoFileSystem extends CryptoFolder implements FileSystem { } @Override - File physicalFile() { + protected File physicalFile() { return physicalDataRoot().file(ROOT_DIR_FILE); } @Override - Folder physicalDataRoot() { + protected Folder physicalDataRoot() { return physicalRoot.folder(DATA_ROOT_DIR); } diff --git a/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/fs/CryptoFolder.java b/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/fs/CryptoFolder.java index 1806a8ff1..53b4c7590 100644 --- a/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/fs/CryptoFolder.java +++ b/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/fs/CryptoFolder.java @@ -35,10 +35,16 @@ class CryptoFolder extends CryptoNode implements Folder { super(parent, name, cryptor); } - String encryptedName() { + @Override + protected String encryptedName() { return cryptor.getFilenameCryptor().encryptFilename(name()) + FILE_EXT; } + Folder physicalFolder() { + final String encryptedThenHashedDirId = cryptor.getFilenameCryptor().hashDirectoryId(getDirectoryId()); + return physicalDataRoot().folder(encryptedThenHashedDirId.substring(0, 2)).folder(encryptedThenHashedDirId.substring(2)); + } + protected String getDirectoryId() { if (directoryId.get() == null) { File dirFile = physicalFile(); @@ -58,15 +64,6 @@ class CryptoFolder extends CryptoNode implements Folder { return directoryId.get(); } - File physicalFile() { - return parent.physicalFolder().file(encryptedName()); - } - - Folder physicalFolder() { - final String encryptedThenHashedDirId = cryptor.getFilenameCryptor().hashDirectoryId(getDirectoryId()); - return physicalDataRoot().folder(encryptedThenHashedDirId.substring(0, 2)).folder(encryptedThenHashedDirId.substring(2)); - } - @Override public Instant lastModified() { return physicalFile().lastModified(); diff --git a/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/fs/CryptoNode.java b/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/fs/CryptoNode.java index 10feba12a..ea0a6a348 100644 --- a/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/fs/CryptoNode.java +++ b/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/fs/CryptoNode.java @@ -11,6 +11,7 @@ package org.cryptomator.crypto.fs; import java.util.Optional; import org.cryptomator.crypto.engine.Cryptor; +import org.cryptomator.filesystem.File; import org.cryptomator.filesystem.Folder; import org.cryptomator.filesystem.Node; @@ -26,10 +27,16 @@ abstract class CryptoNode implements Node { this.cryptor = cryptor; } - Folder physicalDataRoot() { + protected Folder physicalDataRoot() { return parent.physicalDataRoot(); } + protected abstract String encryptedName(); + + protected File physicalFile() { + return parent.physicalFolder().file(encryptedName()); + } + @Override public Optional parent() { return Optional.of(parent); diff --git a/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/fs/CryptoReadableFile.java b/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/fs/CryptoReadableFile.java new file mode 100644 index 000000000..c8f61e1af --- /dev/null +++ b/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/fs/CryptoReadableFile.java @@ -0,0 +1,101 @@ +package org.cryptomator.crypto.fs; + +import java.nio.ByteBuffer; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +import org.cryptomator.crypto.engine.FileContentCryptor; +import org.cryptomator.crypto.engine.FileContentDecryptor; +import org.cryptomator.filesystem.ReadableFile; +import org.cryptomator.filesystem.WritableFile; +import org.cryptomator.io.ByteBuffers; + +class CryptoReadableFile implements ReadableFile { + + private static final int READ_BUFFER_SIZE = 32 * 1024 + 32; // aligned with encrypted chunk size + MAC size + + private final ExecutorService executorService = Executors.newSingleThreadScheduledExecutor(); + private final FileContentDecryptor decryptor; + private final ReadableFile file; + private Future readAheadTask; + private ByteBuffer bufferedCleartext; + + public CryptoReadableFile(FileContentCryptor cryptor, ReadableFile file) { + final ByteBuffer header = ByteBuffer.allocate(cryptor.getHeaderSize()); + file.read(header, 0); + header.flip(); + this.decryptor = cryptor.getFileContentDecryptor(header); + this.file = file; + this.prepareReadAtPosition(0); + } + + private void prepareReadAtPosition(long pos) { + if (readAheadTask != null) { + readAheadTask.cancel(true); + } + readAheadTask = executorService.submit(new Reader()); + } + + @Override + public void read(ByteBuffer target) { + try { + while (target.remaining() > 0 && bufferedCleartext != FileContentDecryptor.EOF) { + bufferCleartext(); + readFromBufferedCleartext(target); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + executorService.shutdownNow(); + } + } + + private void bufferCleartext() throws InterruptedException { + if (bufferedCleartext == null || !bufferedCleartext.hasRemaining()) { + bufferedCleartext = decryptor.cleartext().take(); + } + } + + private void readFromBufferedCleartext(ByteBuffer target) { + assert bufferedCleartext != null; + ByteBuffers.copy(bufferedCleartext, target); + } + + @Override + public void read(ByteBuffer target, int position) { + throw new UnsupportedOperationException("Partial read not implemented yet."); + } + + @Override + public void copyTo(WritableFile other) { + file.copyTo(other); + } + + @Override + public void close() { + file.close(); + } + + private class Reader implements Callable { + + @Override + public Void call() { + int bytesRead = -1; + do { + ByteBuffer ciphertext = ByteBuffer.allocate(READ_BUFFER_SIZE); + file.read(ciphertext); + ciphertext.flip(); + bytesRead = ciphertext.remaining(); + if (bytesRead > 0) { + decryptor.append(ciphertext); + } + } while (bytesRead > 0); + decryptor.append(FileContentDecryptor.EOF); + return null; + } + + } + +} diff --git a/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/fs/CryptoWritableFile.java b/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/fs/CryptoWritableFile.java new file mode 100644 index 000000000..480150667 --- /dev/null +++ b/main/filesystem-crypto/src/main/java/org/cryptomator/crypto/fs/CryptoWritableFile.java @@ -0,0 +1,109 @@ +package org.cryptomator.crypto.fs; + +import java.io.UncheckedIOException; +import java.nio.ByteBuffer; +import java.time.Instant; +import java.util.Optional; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +import org.cryptomator.crypto.engine.FileContentCryptor; +import org.cryptomator.crypto.engine.FileContentEncryptor; +import org.cryptomator.filesystem.WritableFile; +import org.cryptomator.io.ByteBuffers; + +class CryptoWritableFile implements WritableFile { + + private final ExecutorService executorService = Executors.newSingleThreadScheduledExecutor(); + private final FileContentEncryptor encryptor; + private final WritableFile file; + private final Future writeTask; + + public CryptoWritableFile(FileContentCryptor cryptor, WritableFile file) { + this.encryptor = cryptor.getFileContentEncryptor(Optional.empty()); + this.file = file; + writeHeader(); + this.writeTask = executorService.submit(new Writer()); + } + + private void writeHeader() { + ByteBuffer header = encryptor.getHeader(); + header.rewind(); + file.write(header, 0); + } + + @Override + public void write(ByteBuffer source) { + final ByteBuffer cleartextCopy = ByteBuffer.allocate(source.remaining()); + ByteBuffers.copy(source, cleartextCopy); + cleartextCopy.flip(); + encryptor.append(cleartextCopy); + file.write(source); + } + + @Override + public void write(ByteBuffer source, int position) { + throw new UnsupportedOperationException("Partial write not implemented yet."); + } + + @Override + public void moveTo(WritableFile other) { + file.moveTo(other); + } + + @Override + public void setLastModified(Instant instant) { + file.setLastModified(instant); + } + + @Override + public void delete() { + file.delete(); + } + + @Override + public void truncate() { + this.write(ByteBuffer.allocate(0), 0); + } + + @Override + public void close() { + try { + encryptor.append(FileContentEncryptor.EOF); + writeTask.get(); + executorService.shutdown(); + writeHeader(); + } catch (ExecutionException e) { + if (e.getCause() instanceof UncheckedIOException) { + throw (UncheckedIOException) e.getCause(); + } else { + throw new IllegalStateException("Unexpected exception while waiting for encrypted file to be written", e); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + file.close(); + } + } + + private class Writer implements Callable { + + @Override + public Void call() { + try { + ByteBuffer ciphertext; + while ((ciphertext = encryptor.ciphertext().take()) != FileContentEncryptor.EOF) { + file.write(ciphertext); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return null; + } + + } + +} diff --git a/main/filesystem-crypto/src/test/java/org/cryptomator/crypto/engine/NoCryptor.java b/main/filesystem-crypto/src/test/java/org/cryptomator/crypto/engine/NoCryptor.java index 1bc5da59e..346ca8ce6 100644 --- a/main/filesystem-crypto/src/test/java/org/cryptomator/crypto/engine/NoCryptor.java +++ b/main/filesystem-crypto/src/test/java/org/cryptomator/crypto/engine/NoCryptor.java @@ -11,12 +11,18 @@ package org.cryptomator.crypto.engine; public class NoCryptor implements Cryptor { private final FilenameCryptor filenameCryptor = new NoFilenameCryptor(); + private final FileContentCryptor fileContentCryptor = new NoFileContentCryptor(); @Override public FilenameCryptor getFilenameCryptor() { return filenameCryptor; } + @Override + public FileContentCryptor getFileContentCryptor() { + return fileContentCryptor; + } + @Override public void randomizeMasterkey() { // like this? https://xkcd.com/221/ diff --git a/main/filesystem-crypto/src/test/java/org/cryptomator/crypto/engine/NoFileContentCryptor.java b/main/filesystem-crypto/src/test/java/org/cryptomator/crypto/engine/NoFileContentCryptor.java new file mode 100644 index 000000000..5a12a7d54 --- /dev/null +++ b/main/filesystem-crypto/src/test/java/org/cryptomator/crypto/engine/NoFileContentCryptor.java @@ -0,0 +1,117 @@ +package org.cryptomator.crypto.engine; + +import java.nio.ByteBuffer; +import java.util.Optional; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; + +class NoFileContentCryptor implements FileContentCryptor { + + @Override + public int getHeaderSize() { + return Long.BYTES; + } + + @Override + public FileContentDecryptor getFileContentDecryptor(ByteBuffer header) { + if (header.remaining() != getHeaderSize()) { + throw new IllegalArgumentException("Invalid header size."); + } + return new Decryptor(header); + } + + @Override + public FileContentEncryptor getFileContentEncryptor(Optional header) { + return new Encryptor(); + } + + private class Decryptor implements FileContentDecryptor { + + private final BlockingQueue cleartextQueue = new LinkedBlockingQueue<>(); + private final long contentLength; + + private Decryptor(ByteBuffer header) { + assert header.remaining() == Long.BYTES; + this.contentLength = header.getLong(); + } + + @Override + public long contentLength() { + return contentLength; + } + + @Override + public void append(ByteBuffer ciphertext) { + try { + if (ciphertext == FileContentDecryptor.EOF) { + cleartextQueue.put(FileContentDecryptor.EOF); + } else { + cleartextQueue.put(ciphertext.asReadOnlyBuffer()); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + @Override + public BlockingQueue cleartext() { + return cleartextQueue; + } + + @Override + public ByteRange ciphertextRequiredToDecryptRange(ByteRange cleartextRange) { + return cleartextRange; + } + + @Override + public void skipToPosition(long nextCiphertextByte) throws IllegalArgumentException { + // no-op + } + + } + + private class Encryptor implements FileContentEncryptor { + + private final BlockingQueue ciphertextQueue = new LinkedBlockingQueue<>(); + private long numCleartextBytesEncrypted = 0; + + @Override + public ByteBuffer getHeader() { + ByteBuffer buf = ByteBuffer.allocate(Long.BYTES); + buf.putLong(numCleartextBytesEncrypted); + return buf; + } + + @Override + public void append(ByteBuffer cleartext) { + try { + if (cleartext == FileContentEncryptor.EOF) { + ciphertextQueue.put(FileContentEncryptor.EOF); + } else { + int cleartextLen = cleartext.remaining(); + ciphertextQueue.put(cleartext.asReadOnlyBuffer()); + numCleartextBytesEncrypted += cleartextLen; + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + @Override + public BlockingQueue ciphertext() { + return ciphertextQueue; + } + + @Override + public ByteRange cleartextRequiredToEncryptRange(ByteRange cleartextRange) { + return cleartextRange; + } + + @Override + public void skipToPosition(long nextCleartextByte) throws IllegalArgumentException { + // no-op + } + + } + +} diff --git a/main/filesystem-crypto/src/test/java/org/cryptomator/crypto/fs/CryptoFileSystemTest.java b/main/filesystem-crypto/src/test/java/org/cryptomator/crypto/fs/CryptoFileSystemTest.java index 276f5c171..7f2352ee0 100644 --- a/main/filesystem-crypto/src/test/java/org/cryptomator/crypto/fs/CryptoFileSystemTest.java +++ b/main/filesystem-crypto/src/test/java/org/cryptomator/crypto/fs/CryptoFileSystemTest.java @@ -10,7 +10,9 @@ package org.cryptomator.crypto.fs; import java.io.IOException; import java.io.UncheckedIOException; +import java.nio.ByteBuffer; import java.time.Instant; +import java.util.Arrays; import java.util.concurrent.atomic.AtomicInteger; import org.cryptomator.crypto.engine.Cryptor; @@ -19,6 +21,8 @@ import org.cryptomator.filesystem.File; import org.cryptomator.filesystem.FileSystem; import org.cryptomator.filesystem.Folder; import org.cryptomator.filesystem.FolderCreateMode; +import org.cryptomator.filesystem.ReadableFile; +import org.cryptomator.filesystem.WritableFile; import org.cryptomator.filesystem.inmem.InMemoryFileSystem; import org.junit.Assert; import org.junit.Test; @@ -142,6 +146,32 @@ public class CryptoFileSystemTest { fooBarFolder.moveTo(fooFolder); } + @Test + public void testWriteAndReadEncryptedFile() { + // mock stuff and prepare crypto FS: + final Cryptor cryptor = new NoCryptor(); + final FileSystem physicalFs = new InMemoryFileSystem(); + final FileSystem fs = new CryptoFileSystem(physicalFs, cryptor, "foo"); + fs.create(FolderCreateMode.INCLUDING_PARENTS); + + // write test content to physical file + try (WritableFile writable = fs.file("test1.txt").openWritable()) { + writable.write(ByteBuffer.wrap("Hello World".getBytes())); + } + + // read test content from encrypted file + try (ReadableFile readable = fs.file("test1.txt").openReadable()) { + ByteBuffer buf1 = ByteBuffer.allocate(5); + readable.read(buf1); + buf1.flip(); + Assert.assertEquals("Hello", new String(buf1.array(), 0, buf1.remaining())); + ByteBuffer buf2 = ByteBuffer.allocate(10); + readable.read(buf2); + buf2.flip(); + Assert.assertArrayEquals(" World".getBytes(), Arrays.copyOfRange(buf2.array(), 0, buf2.remaining())); + } + } + /** * @return number of folders on second level inside the given dataRoot folder. */ diff --git a/main/filesystem-inmemory/src/main/java/org/cryptomator/filesystem/inmem/InMemoryFile.java b/main/filesystem-inmemory/src/main/java/org/cryptomator/filesystem/inmem/InMemoryFile.java index 790a43c0c..621e17606 100644 --- a/main/filesystem-inmemory/src/main/java/org/cryptomator/filesystem/inmem/InMemoryFile.java +++ b/main/filesystem-inmemory/src/main/java/org/cryptomator/filesystem/inmem/InMemoryFile.java @@ -17,6 +17,7 @@ import java.util.concurrent.locks.ReentrantReadWriteLock; import org.cryptomator.filesystem.File; import org.cryptomator.filesystem.ReadableFile; import org.cryptomator.filesystem.WritableFile; +import org.cryptomator.io.ByteBuffers; class InMemoryFile extends InMemoryNode implements File, ReadableFile, WritableFile { @@ -33,6 +34,7 @@ class InMemoryFile extends InMemoryNode implements File, ReadableFile, WritableF throw new UncheckedIOException(new FileNotFoundException(this.name() + " does not exist")); } lock.readLock().lock(); + content.rewind(); return this; } @@ -51,14 +53,13 @@ class InMemoryFile extends InMemoryNode implements File, ReadableFile, WritableF @Override public void read(ByteBuffer target) { - this.read(target, 0); + ByteBuffers.copy(content, target); } @Override public void read(ByteBuffer target, int position) { - content.rewind(); content.position(position); - target.put(content); + ByteBuffers.copy(content, target); } @Override @@ -69,16 +70,23 @@ class InMemoryFile extends InMemoryNode implements File, ReadableFile, WritableF @Override public void write(ByteBuffer source, int position) { assert content != null; - if (position + source.remaining() > content.remaining()) { - // create bigger buffer - ByteBuffer tmp = ByteBuffer.allocate(Math.max(position, content.capacity()) + source.remaining()); - tmp.put(content); - content = tmp; - } + expandContentCapacityIfRequired(position + source.remaining()); content.position(position); + assert content.remaining() >= source.remaining(); content.put(source); } + private void expandContentCapacityIfRequired(int requiredCapacity) { + if (requiredCapacity > content.capacity()) { + final int currentPos = content.position(); + final ByteBuffer tmp = ByteBuffer.allocate(requiredCapacity); + content.rewind(); + ByteBuffers.copy(content, tmp); + content = tmp; + content.position(currentPos); + } + } + @Override public void setLastModified(Instant instant) { this.lastModified = instant; @@ -110,7 +118,7 @@ class InMemoryFile extends InMemoryNode implements File, ReadableFile, WritableF // returning null removes the entry. return null; }); - assert !this.exists(); + assert!this.exists(); } @Override diff --git a/main/filesystem-inmemory/src/test/java/org/cryptomator/filesystem/inmem/InMemoryFileSystemTest.java b/main/filesystem-inmemory/src/test/java/org/cryptomator/filesystem/inmem/InMemoryFileSystemTest.java index d5c9e1ac7..5e32c0289 100644 --- a/main/filesystem-inmemory/src/test/java/org/cryptomator/filesystem/inmem/InMemoryFileSystemTest.java +++ b/main/filesystem-inmemory/src/test/java/org/cryptomator/filesystem/inmem/InMemoryFileSystemTest.java @@ -125,6 +125,15 @@ public class InMemoryFileSystemTest { Assert.assertTrue(bazFile.exists()); // read "hello world" from baz + final ByteBuffer readBuf1 = ByteBuffer.allocate(6); + try (ReadableFile readable = bazFile.openReadable()) { + readable.read(readBuf1); + readBuf1.flip(); + Assert.assertEquals("hello ", new String(readBuf1.array(), 0, readBuf1.remaining())); + readable.read(readBuf1); + readBuf1.flip(); + Assert.assertEquals("world", new String(readBuf1.array(), 0, readBuf1.remaining())); + } final ByteBuffer readBuf = ByteBuffer.allocate(5); try (ReadableFile readable = bazFile.openReadable()) { readable.read(readBuf, 6); diff --git a/main/filesystem-nameshortening/src/test/java/org/cryptomator/shortening/ShorteningFileSystemTest.java b/main/filesystem-nameshortening/src/test/java/org/cryptomator/shortening/ShorteningFileSystemTest.java index e138f58d0..81aea2056 100644 --- a/main/filesystem-nameshortening/src/test/java/org/cryptomator/shortening/ShorteningFileSystemTest.java +++ b/main/filesystem-nameshortening/src/test/java/org/cryptomator/shortening/ShorteningFileSystemTest.java @@ -137,6 +137,7 @@ public class ShorteningFileSystemTest { try (ReadableFile file = fs.folder("foo").file("test1.txt").openReadable()) { ByteBuffer buf = ByteBuffer.allocate(11); file.read(buf); + buf.flip(); Assert.assertEquals("hello world", new String(buf.array())); } Assert.assertTrue(fs.folder("foo").file("test1.txt").lastModified().isAfter(testStart));