Parcourir la source

- preparation for http range requests: cryptor supports partial decryption now

Sebastian Stenzel il y a 10 ans
Parent
commit
6d98442f7e

+ 36 - 3
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);

+ 46 - 0
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;
+		}
+	}
+
+}

+ 49 - 0
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;
+		}
+	}
+
+}

+ 21 - 0
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);
+	}
+
+}

+ 49 - 1
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/
+		}
+	}
+
 }

+ 63 - 0
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;
+	}
+
+}

+ 63 - 0
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;
+	}
+
+}

+ 76 - 0
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;
+	}
+
+}

+ 7 - 0
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.
 	 */

+ 6 - 0
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);