diff --git a/main/crypto-aes/src/main/java/org/cryptomator/crypto/aes256/Aes256Cryptor.java b/main/crypto-aes/src/main/java/org/cryptomator/crypto/aes256/Aes256Cryptor.java index 76f3c2c44..a7f6e94b3 100644 --- a/main/crypto-aes/src/main/java/org/cryptomator/crypto/aes256/Aes256Cryptor.java +++ b/main/crypto-aes/src/main/java/org/cryptomator/crypto/aes256/Aes256Cryptor.java @@ -90,7 +90,6 @@ public class Aes256Cryptor extends AbstractCryptor implements AesCryptographicCo private final byte[] masterKey = new byte[MASTER_KEY_LENGTH]; private static final int SIZE_OF_LONG = Long.SIZE / Byte.SIZE; - private static final int SIZE_OF_INT = Integer.SIZE / Byte.SIZE; static { try { @@ -403,14 +402,48 @@ public class Aes256Cryptor extends AbstractCryptor implements AesCryptographicCo return IOUtils.copyLarge(in, cipheredOut); } + @Override + public Long decryptRange(SeekableByteChannel encryptedFile, OutputStream plaintextFile, long pos, long length) throws IOException { + // skip content size: + encryptedFile.position(SIZE_OF_LONG); + + // read iv: + final ByteBuffer countingIv = ByteBuffer.allocate(AES_BLOCK_LENGTH); + final int read = encryptedFile.read(countingIv); + if (read != AES_BLOCK_LENGTH) { + throw new IOException("Failed to read encrypted file header."); + } + + // seek relevant position and update iv: + long firstRelevantBlock = pos / AES_BLOCK_LENGTH; // cut of fraction! + long numberOfRelevantBlocks = 1 + length / AES_BLOCK_LENGTH; + long numberOfRelevantBytes = numberOfRelevantBlocks * AES_BLOCK_LENGTH; + long beginOfFirstRelevantBlock = firstRelevantBlock * AES_BLOCK_LENGTH; + long offsetInsideFirstRelevantBlock = pos - beginOfFirstRelevantBlock; + countingIv.putLong(AES_BLOCK_LENGTH - SIZE_OF_LONG, firstRelevantBlock); + + // fast forward stream: + encryptedFile.position(SIZE_OF_LONG + AES_BLOCK_LENGTH + beginOfFirstRelevantBlock); + + // derive secret key and generate cipher: + final SecretKey key = this.pbkdf2(masterKey, EMPTY_SALT, PBKDF2_MASTERKEY_ITERATIONS, AES_KEY_LENGTH); + final Cipher cipher = this.cipher(FILE_CONTENT_CIPHER, key, countingIv.array(), Cipher.DECRYPT_MODE); + + // read content + final InputStream in = new SeekableByteChannelInputStream(encryptedFile); + final OutputStream rangedOut = new RangeFilterOutputStream(plaintextFile, offsetInsideFirstRelevantBlock, length); + final OutputStream cipheredOut = new CipherOutputStream(rangedOut, cipher); + return IOUtils.copyLarge(in, cipheredOut, 0, numberOfRelevantBytes); + } + @Override public Long encryptFile(InputStream plaintextFile, SeekableByteChannel encryptedFile) throws IOException { // truncate file encryptedFile.truncate(0); - // use an IV, whose last 4 bytes store an integer used in counter mode and write initial value to file. + // use an IV, whose last 8 bytes store a long used in counter mode and write initial value to file. final ByteBuffer countingIv = ByteBuffer.wrap(randomData(AES_BLOCK_LENGTH)); - countingIv.putInt(AES_BLOCK_LENGTH - SIZE_OF_INT, 0); + countingIv.putLong(AES_BLOCK_LENGTH - SIZE_OF_LONG, 0l); // derive secret key and generate cipher: final SecretKey key = this.pbkdf2(masterKey, EMPTY_SALT, PBKDF2_MASTERKEY_ITERATIONS, AES_KEY_LENGTH); diff --git a/main/crypto-aes/src/main/java/org/cryptomator/crypto/aes256/LimitFilterOutputStream.java b/main/crypto-aes/src/main/java/org/cryptomator/crypto/aes256/LimitFilterOutputStream.java new file mode 100644 index 000000000..6b36349f6 --- /dev/null +++ b/main/crypto-aes/src/main/java/org/cryptomator/crypto/aes256/LimitFilterOutputStream.java @@ -0,0 +1,46 @@ +package org.cryptomator.crypto.aes256; + +import java.io.IOException; +import java.io.OutputStream; + +class LimitFilterOutputStream extends java.io.FilterOutputStream { + + private final long limit; + private long bytesWritten; + + LimitFilterOutputStream(OutputStream out, long limit) { + super(out); + if (limit < 0) { + throw new IllegalArgumentException("Limit must be greater than or equal 0."); + } + this.limit = limit; + } + + @Override + public void write(int b) throws IOException { + this.write(new byte[] {(byte) b}); + } + + @Override + public void write(byte[] b) throws IOException { + this.write(b, 0, b.length); + } + + @Override + public synchronized void write(byte[] b, int off, int len) throws IOException { + final long adjustedLength = Math.min(bytesRemainingUntilReachingLimit(), len); + + // adjustedLength is <= len, so it must be INT and we can safely cast: + out.write(b, off, (int) adjustedLength); + bytesWritten += adjustedLength; + } + + private long bytesRemainingUntilReachingLimit() { + if (bytesWritten < limit) { + return limit - bytesWritten; + } else { + return 0l; + } + } + +} diff --git a/main/crypto-aes/src/main/java/org/cryptomator/crypto/aes256/OffsetFilterOutputStream.java b/main/crypto-aes/src/main/java/org/cryptomator/crypto/aes256/OffsetFilterOutputStream.java new file mode 100644 index 000000000..1d40e6b93 --- /dev/null +++ b/main/crypto-aes/src/main/java/org/cryptomator/crypto/aes256/OffsetFilterOutputStream.java @@ -0,0 +1,49 @@ +package org.cryptomator.crypto.aes256; + +import java.io.IOException; +import java.io.OutputStream; + +class OffsetFilterOutputStream extends java.io.FilterOutputStream { + + private final long offset; + private long bytesWritten; + + OffsetFilterOutputStream(OutputStream out, long offset) { + super(out); + if (offset < 0) { + throw new IllegalArgumentException("Offset must be greater than or equal 0."); + } + this.offset = offset; + } + + @Override + public void write(int b) throws IOException { + this.write(new byte[] {(byte) b}); + } + + @Override + public void write(byte[] b) throws IOException { + this.write(b, 0, b.length); + } + + @Override + public synchronized void write(byte[] b, int off, int len) throws IOException { + final long adjustedOffset = remainingOffset() + off; + final long adjustedLength = len - remainingOffset(); + + if (adjustedOffset < b.length && adjustedLength <= b.length) { + // b.length is INT, so by definition adjustedOffset and adjustedLength must be INT too and we can safely cast: + out.write(b, (int) adjustedOffset, (int) adjustedLength); + } + bytesWritten += len; + } + + private long remainingOffset() { + if (bytesWritten < offset) { + return offset - bytesWritten; + } else { + return 0l; + } + } + +} diff --git a/main/crypto-aes/src/main/java/org/cryptomator/crypto/aes256/RangeFilterOutputStream.java b/main/crypto-aes/src/main/java/org/cryptomator/crypto/aes256/RangeFilterOutputStream.java new file mode 100644 index 000000000..1e5fb5a2f --- /dev/null +++ b/main/crypto-aes/src/main/java/org/cryptomator/crypto/aes256/RangeFilterOutputStream.java @@ -0,0 +1,21 @@ +package org.cryptomator.crypto.aes256; + +import java.io.FilterOutputStream; +import java.io.IOException; +import java.io.OutputStream; + +/** + * Passthrough of all bytes except for certain bytes at the begin and end of the stream, which will get cut off. + */ +class RangeFilterOutputStream extends FilterOutputStream { + + RangeFilterOutputStream(OutputStream out, long offset, long limit) { + super(new OffsetFilterOutputStream(new LimitFilterOutputStream(out, limit), offset)); + } + + @Override + public void write(byte b[], int off, int len) throws IOException { + out.write(b, off, len); + } + +} diff --git a/main/crypto-aes/src/test/java/org/cryptomator/crypto/aes256/Aes256CryptorTest.java b/main/crypto-aes/src/test/java/org/cryptomator/crypto/aes256/Aes256CryptorTest.java index 750ab9a71..34d86a6db 100644 --- a/main/crypto-aes/src/test/java/org/cryptomator/crypto/aes256/Aes256CryptorTest.java +++ b/main/crypto-aes/src/test/java/org/cryptomator/crypto/aes256/Aes256CryptorTest.java @@ -8,9 +8,13 @@ ******************************************************************************/ package org.cryptomator.crypto.aes256; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.nio.channels.SeekableByteChannel; import java.nio.file.FileAlreadyExistsException; import java.nio.file.FileSystems; import java.nio.file.Files; @@ -33,10 +37,11 @@ import org.junit.Test; public class Aes256CryptorTest { - private static final Random TEST_PRNG = new Random(); + private static final Random TEST_PRNG = new NotReallyRandom(); private Path tmpDir; private Path masterKey; + private Path encryptedFile; @Before public void prepareTmpDir() throws IOException { @@ -44,6 +49,7 @@ public class Aes256CryptorTest { final Path path = FileSystems.getDefault().getPath(tmpDirName); tmpDir = Files.createTempDirectory(path, "oce-crypto-test"); masterKey = tmpDir.resolve("test" + Aes256Cryptor.MASTERKEY_FILE_EXT); + encryptedFile = tmpDir.resolve("test" + Aes256Cryptor.BASIC_FILE_EXT); } @After @@ -98,6 +104,39 @@ public class Aes256CryptorTest { decryptor.decryptMasterKey(in, pw); } + @Test + public void testPartialDecryption() throws IOException, DecryptFailedException, WrongPasswordException, UnsupportedKeyLengthException { + // our test plaintext data: + final byte[] plaintextData = new byte[500 * Integer.BYTES]; + final ByteBuffer bbIn = ByteBuffer.wrap(plaintextData); + for (int i = 0; i < 500; i++) { + bbIn.putInt(i); + } + final InputStream plaintextIn = new ByteArrayInputStream(plaintextData); + + // init cryptor: + final Aes256Cryptor cryptor = new Aes256Cryptor(TEST_PRNG); + + // encrypt: + final SeekableByteChannel fileOut = Files.newByteChannel(encryptedFile, StandardOpenOption.WRITE, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + cryptor.encryptFile(plaintextIn, fileOut); + fileOut.close(); + + // decrypt: + final SeekableByteChannel fileIn = Files.newByteChannel(encryptedFile, StandardOpenOption.READ); + final ByteArrayOutputStream plaintextOut = new ByteArrayOutputStream(); + final Long numDecryptedBytes = cryptor.decryptRange(fileIn, plaintextOut, 313 * Integer.BYTES, 50 * Integer.BYTES); + Assert.assertTrue(numDecryptedBytes > 0); + + final byte[] result = plaintextOut.toByteArray(); + final byte[] expected = new byte[50 * Integer.BYTES]; + final ByteBuffer bbOut = ByteBuffer.wrap(expected); + for (int i = 313; i < 363; i++) { + bbOut.putInt(i); + } + Assert.assertArrayEquals(expected, result); + } + @Test(expected = FileAlreadyExistsException.class) public void testReInitialization() throws IOException { final String pw = "asd"; @@ -146,4 +185,13 @@ public class Aes256CryptorTest { } + private static class NotReallyRandom extends Random { + private static final long serialVersionUID = 6080187127141721369L; + + @Override + protected int next(int bits) { + return 4; // http://xkcd.com/221/ + } + } + } diff --git a/main/crypto-aes/src/test/java/org/cryptomator/crypto/aes256/LimitFilterOutputStreamTest.java b/main/crypto-aes/src/test/java/org/cryptomator/crypto/aes256/LimitFilterOutputStreamTest.java new file mode 100644 index 000000000..03274e665 --- /dev/null +++ b/main/crypto-aes/src/test/java/org/cryptomator/crypto/aes256/LimitFilterOutputStreamTest.java @@ -0,0 +1,63 @@ +package org.cryptomator.crypto.aes256; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Arrays; + +import org.apache.commons.io.IOUtils; +import org.junit.Assert; +import org.junit.Test; + +public class LimitFilterOutputStreamTest { + + @Test + public void testNoLimit() throws IOException { + final byte[] testData = createTestData(256); + final InputStream in = new ByteArrayInputStream(testData); + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + + final OutputStream decorator = new LimitFilterOutputStream(out, Long.MAX_VALUE); + IOUtils.copy(in, decorator); + + final byte[] expected = Arrays.copyOfRange(testData, 0, 256); + Assert.assertArrayEquals(expected, out.toByteArray()); + } + + @Test + public void testLimit43() throws IOException { + final byte[] testData = createTestData(256); + final InputStream in = new ByteArrayInputStream(testData); + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + + final OutputStream decorator = new LimitFilterOutputStream(out, 43l); + IOUtils.copy(in, decorator); + + final byte[] expected = Arrays.copyOfRange(testData, 0, 43); + Assert.assertArrayEquals(expected, out.toByteArray()); + } + + @Test + public void testLimit307() throws IOException { + final byte[] testData = createTestData(512); + final InputStream in = new ByteArrayInputStream(testData); + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + + final OutputStream decorator = new LimitFilterOutputStream(out, 307l); + IOUtils.copy(in, decorator); + + final byte[] expected = Arrays.copyOfRange(testData, 0, 307); + Assert.assertArrayEquals(expected, out.toByteArray()); + } + + private byte[] createTestData(int length) { + final byte[] testData = new byte[length]; + for (int i = 0; i < length; i++) { + testData[i] = (byte) i; + } + return testData; + } + +} diff --git a/main/crypto-aes/src/test/java/org/cryptomator/crypto/aes256/OffsetFilterOutputStreamTest.java b/main/crypto-aes/src/test/java/org/cryptomator/crypto/aes256/OffsetFilterOutputStreamTest.java new file mode 100644 index 000000000..0b472b85e --- /dev/null +++ b/main/crypto-aes/src/test/java/org/cryptomator/crypto/aes256/OffsetFilterOutputStreamTest.java @@ -0,0 +1,63 @@ +package org.cryptomator.crypto.aes256; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Arrays; + +import org.apache.commons.io.IOUtils; +import org.junit.Assert; +import org.junit.Test; + +public class OffsetFilterOutputStreamTest { + + @Test + public void testNoOffset() throws IOException { + final byte[] testData = createTestData(256); + final InputStream in = new ByteArrayInputStream(testData); + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + + final OutputStream decorator = new OffsetFilterOutputStream(out, 0l); + IOUtils.copy(in, decorator); + + final byte[] expected = Arrays.copyOfRange(testData, 0, 256); + Assert.assertArrayEquals(expected, out.toByteArray()); + } + + @Test + public void testOffset43() throws IOException { + final byte[] testData = createTestData(256); + final InputStream in = new ByteArrayInputStream(testData); + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + + final OutputStream decorator = new OffsetFilterOutputStream(out, 43l); + IOUtils.copy(in, decorator); + + final byte[] expected = Arrays.copyOfRange(testData, 43, 256); + Assert.assertArrayEquals(expected, out.toByteArray()); + } + + @Test + public void testOffset307() throws IOException { + final byte[] testData = createTestData(512); + final InputStream in = new ByteArrayInputStream(testData); + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + + final OutputStream decorator = new OffsetFilterOutputStream(out, 307l); + IOUtils.copy(in, decorator); + + final byte[] expected = Arrays.copyOfRange(testData, 307, 512); + Assert.assertArrayEquals(expected, out.toByteArray()); + } + + private byte[] createTestData(int length) { + final byte[] testData = new byte[length]; + for (int i = 0; i < length; i++) { + testData[i] = (byte) i; + } + return testData; + } + +} diff --git a/main/crypto-aes/src/test/java/org/cryptomator/crypto/aes256/RangeFilterOutputStreamTest.java b/main/crypto-aes/src/test/java/org/cryptomator/crypto/aes256/RangeFilterOutputStreamTest.java new file mode 100644 index 000000000..2722a1b5c --- /dev/null +++ b/main/crypto-aes/src/test/java/org/cryptomator/crypto/aes256/RangeFilterOutputStreamTest.java @@ -0,0 +1,76 @@ +package org.cryptomator.crypto.aes256; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Arrays; + +import org.apache.commons.io.IOUtils; +import org.junit.Assert; +import org.junit.Test; + +public class RangeFilterOutputStreamTest { + + @Test + public void testNoOffsetUnlimited() throws IOException { + final byte[] testData = createTestData(256); + final InputStream in = new ByteArrayInputStream(testData); + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + + final OutputStream decorator = new RangeFilterOutputStream(out, 0l, Long.MAX_VALUE); + IOUtils.copy(in, decorator); + + final byte[] expected = Arrays.copyOfRange(testData, 0, 256); + Assert.assertArrayEquals(expected, out.toByteArray()); + } + + @Test + public void testNoOffsetButLimit() throws IOException { + final byte[] testData = createTestData(256); + final InputStream in = new ByteArrayInputStream(testData); + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + + final OutputStream decorator = new RangeFilterOutputStream(out, 0l, 97l); + IOUtils.copy(in, decorator); + + final byte[] expected = Arrays.copyOfRange(testData, 0, 97); + Assert.assertArrayEquals(expected, out.toByteArray()); + } + + @Test + public void testNoLimitButOffset() throws IOException { + final byte[] testData = createTestData(256); + final InputStream in = new ByteArrayInputStream(testData); + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + + final OutputStream decorator = new RangeFilterOutputStream(out, 43l, Long.MAX_VALUE); + IOUtils.copy(in, decorator); + + final byte[] expected = Arrays.copyOfRange(testData, 43, 256); + Assert.assertArrayEquals(expected, out.toByteArray()); + } + + @Test + public void testOffsettedAndLimited() throws IOException { + final byte[] testData = createTestData(256); + final InputStream in = new ByteArrayInputStream(testData); + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + + final OutputStream decorator = new RangeFilterOutputStream(out, 43l, 57l); + IOUtils.copy(in, decorator); + + final byte[] expected = Arrays.copyOfRange(testData, 43, 100); + Assert.assertArrayEquals(expected, out.toByteArray()); + } + + private byte[] createTestData(int length) { + final byte[] testData = new byte[length]; + for (int i = 0; i < length; i++) { + testData[i] = (byte) i; + } + return testData; + } + +} diff --git a/main/crypto-api/src/main/java/org/cryptomator/crypto/Cryptor.java b/main/crypto-api/src/main/java/org/cryptomator/crypto/Cryptor.java index 8e416b867..99a3608dd 100644 --- a/main/crypto-api/src/main/java/org/cryptomator/crypto/Cryptor.java +++ b/main/crypto-api/src/main/java/org/cryptomator/crypto/Cryptor.java @@ -79,6 +79,13 @@ public interface Cryptor extends SensitiveDataSwipeListener { */ Long decryptedFile(SeekableByteChannel encryptedFile, OutputStream plaintextFile) throws IOException; + /** + * @param pos First byte (inclusive) + * @param length Number of requested bytes beginning at pos. + * @return Number of decrypted bytes. This might not be equal to the number of bytes requested due to potential overheads. + */ + Long decryptRange(SeekableByteChannel encryptedFile, OutputStream plaintextFile, long pos, long length) throws IOException; + /** * @return Number of encrypted bytes. This might not be equal to the encrypted file size due to optional metadata written to it. */ diff --git a/main/crypto-api/src/main/java/org/cryptomator/crypto/SamplingDecorator.java b/main/crypto-api/src/main/java/org/cryptomator/crypto/SamplingDecorator.java index d81dd9903..656e7e840 100644 --- a/main/crypto-api/src/main/java/org/cryptomator/crypto/SamplingDecorator.java +++ b/main/crypto-api/src/main/java/org/cryptomator/crypto/SamplingDecorator.java @@ -87,6 +87,12 @@ public class SamplingDecorator implements Cryptor, CryptorIOSampling { return cryptor.decryptedFile(encryptedFile, countingInputStream); } + @Override + public Long decryptRange(SeekableByteChannel encryptedFile, OutputStream plaintextFile, long pos, long length) throws IOException { + final OutputStream countingInputStream = new CountingOutputStream(decryptedBytes, plaintextFile); + return cryptor.decryptRange(encryptedFile, countingInputStream, pos, length); + } + @Override public Long encryptFile(InputStream plaintextFile, SeekableByteChannel encryptedFile) throws IOException { final InputStream countingInputStream = new CountingInputStream(encryptedBytes, plaintextFile);