* Furthermore, this implementation will not create copies when accessing {@link #getEncoded()}. - * Instead it implements {@link #copy} and {@link AutoCloseable} in an exception-free manner. To prevent mutation of the exposed key, + * Instead, it implements {@link #copy} and {@link AutoCloseable} in an exception-free manner. To prevent mutation of the exposed key, * you would want to make sure to always work on scoped copies, such as in this example: * *
diff --git a/src/main/java/org/cryptomator/cryptolib/common/HKDFHelper.java b/src/main/java/org/cryptomator/cryptolib/common/HKDFHelper.java new file mode 100644 index 0000000..bc50883 --- /dev/null +++ b/src/main/java/org/cryptomator/cryptolib/common/HKDFHelper.java @@ -0,0 +1,32 @@ +package org.cryptomator.cryptolib.common; + +import com.google.common.annotations.VisibleForTesting; +import org.bouncycastle.crypto.DerivationFunction; +import org.bouncycastle.crypto.Digest; +import org.bouncycastle.crypto.digests.SHA512Digest; +import org.bouncycastle.crypto.generators.HKDFBytesGenerator; +import org.bouncycastle.crypto.params.HKDFParameters; + +public class HKDFHelper { + + /** + * Derives a key from the given input keying material (IKM) using the HMAC-based Key Derivation Function (HKDF) with the SHA-512 hash function. + * @param salt The optional salt (can be an empty byte array) + * @param ikm The input keying material + * @param info The optional context (can be an empty byte array) + * @param length Desired output key length + * @return The derived key + * @implNote This method uses the Bouncy Castle library for HKDF computation. + */ + public static byte[] hkdfSha512(byte[] salt, byte[] ikm, byte[] info, int length) { + return hkdf(new SHA512Digest(), salt, ikm, info, length); + } + + @VisibleForTesting static byte[] hkdf(Digest digest, byte[] salt, byte[] ikm, byte[] info, int length) { + byte[] result = new byte[length]; + DerivationFunction hkdf = new HKDFBytesGenerator(digest); + hkdf.init(new HKDFParameters(ikm, salt, info)); + hkdf.generateBytes(result, 0, length); + return result; + } +} diff --git a/src/main/java/org/cryptomator/cryptolib/common/MasterkeyFileAccess.java b/src/main/java/org/cryptomator/cryptolib/common/MasterkeyFileAccess.java index dc43d97..984cd83 100644 --- a/src/main/java/org/cryptomator/cryptolib/common/MasterkeyFileAccess.java +++ b/src/main/java/org/cryptomator/cryptolib/common/MasterkeyFileAccess.java @@ -4,6 +4,7 @@ import org.cryptomator.cryptolib.api.InvalidPassphraseException; import org.cryptomator.cryptolib.api.Masterkey; import org.cryptomator.cryptolib.api.MasterkeyLoadingFailedException; +import org.cryptomator.cryptolib.api.PerpetualMasterkey; import javax.crypto.Mac; import java.io.ByteArrayInputStream; @@ -99,7 +100,7 @@ public void changePassphrase(InputStream oldIn, OutputStream newOut, CharSequenc // visible for testing MasterkeyFile changePassphrase(MasterkeyFile masterkey, CharSequence oldPassphrase, CharSequence newPassphrase) throws InvalidPassphraseException { - try (Masterkey key = unlock(masterkey, oldPassphrase)) { + try (PerpetualMasterkey key = unlock(masterkey, oldPassphrase)) { return lock(key, newPassphrase, masterkey.version, masterkey.scryptCostParam); } } @@ -114,7 +115,7 @@ MasterkeyFile changePassphrase(MasterkeyFile masterkey, CharSequence oldPassphra * @throws InvalidPassphraseException If the provided passphrase can not be used to unwrap the stored keys. * @throws MasterkeyLoadingFailedException If reading the masterkey file fails */ - public Masterkey load(Path filePath, CharSequence passphrase) throws MasterkeyLoadingFailedException { + public PerpetualMasterkey load(Path filePath, CharSequence passphrase) throws MasterkeyLoadingFailedException { try (InputStream in = Files.newInputStream(filePath, StandardOpenOption.READ)) { return load(in, passphrase); } catch (IOException e) { @@ -122,7 +123,7 @@ public Masterkey load(Path filePath, CharSequence passphrase) throws MasterkeyLo } } - public Masterkey load(InputStream in, CharSequence passphrase) throws IOException { + public PerpetualMasterkey load(InputStream in, CharSequence passphrase) throws IOException { try (Reader reader = new InputStreamReader(in, UTF_8)) { MasterkeyFile parsedFile = MasterkeyFile.read(reader); if (!parsedFile.isValid()) { @@ -134,14 +135,14 @@ public Masterkey load(InputStream in, CharSequence passphrase) throws IOExceptio } // visible for testing - Masterkey unlock(MasterkeyFile parsedFile, CharSequence passphrase) throws InvalidPassphraseException { + PerpetualMasterkey unlock(MasterkeyFile parsedFile, CharSequence passphrase) throws InvalidPassphraseException { Preconditions.checkNotNull(parsedFile); Preconditions.checkArgument(parsedFile.isValid(), "Invalid masterkey file"); Preconditions.checkNotNull(passphrase); try (DestroyableSecretKey kek = scrypt(passphrase, parsedFile.scryptSalt, pepper, parsedFile.scryptCostParam, parsedFile.scryptBlockSize); - DestroyableSecretKey encKey = AesKeyWrap.unwrap(kek, parsedFile.encMasterKey, Masterkey.ENC_ALG); - DestroyableSecretKey macKey = AesKeyWrap.unwrap(kek, parsedFile.macMasterKey, Masterkey.MAC_ALG)) { + DestroyableSecretKey encKey = AesKeyWrap.unwrap(kek, parsedFile.encMasterKey, PerpetualMasterkey.ENC_ALG); + DestroyableSecretKey macKey = AesKeyWrap.unwrap(kek, parsedFile.macMasterKey, PerpetualMasterkey.MAC_ALG)) { return Masterkey.from(encKey, macKey); } catch (InvalidKeyException e) { throw new InvalidPassphraseException(); @@ -158,11 +159,11 @@ Masterkey unlock(MasterkeyFile parsedFile, CharSequence passphrase) throws Inval * @param passphrase The passphrase used during key derivation * @throws IOException When unable to write to the given file */ - public void persist(Masterkey masterkey, Path filePath, CharSequence passphrase) throws IOException { + public void persist(PerpetualMasterkey masterkey, Path filePath, CharSequence passphrase) throws IOException { persist(masterkey, filePath, passphrase, DEFAULT_MASTERKEY_FILE_VERSION); } - public void persist(Masterkey masterkey, Path filePath, CharSequence passphrase, @Deprecated int vaultVersion) throws IOException { + public void persist(PerpetualMasterkey masterkey, Path filePath, CharSequence passphrase, @Deprecated int vaultVersion) throws IOException { Path tmpFilePath = filePath.resolveSibling(filePath.getFileName().toString() + ".tmp"); try (OutputStream out = Files.newOutputStream(tmpFilePath, StandardOpenOption.WRITE, StandardOpenOption.CREATE_NEW)) { persist(masterkey, out, passphrase, vaultVersion); @@ -170,12 +171,12 @@ public void persist(Masterkey masterkey, Path filePath, CharSequence passphrase, Files.move(tmpFilePath, filePath, StandardCopyOption.REPLACE_EXISTING); } - public void persist(Masterkey masterkey, OutputStream out, CharSequence passphrase, @Deprecated int vaultVersion) throws IOException { + public void persist(PerpetualMasterkey masterkey, OutputStream out, CharSequence passphrase, @Deprecated int vaultVersion) throws IOException { persist(masterkey, out, passphrase, vaultVersion, DEFAULT_SCRYPT_COST_PARAM); } // visible for testing - void persist(Masterkey masterkey, OutputStream out, CharSequence passphrase, @Deprecated int vaultVersion, int scryptCostParam) throws IOException { + void persist(PerpetualMasterkey masterkey, OutputStream out, CharSequence passphrase, @Deprecated int vaultVersion, int scryptCostParam) throws IOException { Preconditions.checkArgument(!masterkey.isDestroyed(), "masterkey has been destroyed"); MasterkeyFile fileContent = lock(masterkey, passphrase, vaultVersion, scryptCostParam); @@ -185,7 +186,7 @@ void persist(Masterkey masterkey, OutputStream out, CharSequence passphrase, @De } // visible for testing - MasterkeyFile lock(Masterkey masterkey, CharSequence passphrase, int vaultVersion, int scryptCostParam) { + MasterkeyFile lock(PerpetualMasterkey masterkey, CharSequence passphrase, int vaultVersion, int scryptCostParam) { Preconditions.checkNotNull(masterkey); Preconditions.checkNotNull(passphrase); Preconditions.checkArgument(!masterkey.isDestroyed(), "masterkey has been destroyed"); @@ -212,9 +213,9 @@ private static DestroyableSecretKey scrypt(CharSequence passphrase, byte[] salt, byte[] saltAndPepper = new byte[salt.length + pepper.length]; System.arraycopy(salt, 0, saltAndPepper, 0, salt.length); System.arraycopy(pepper, 0, saltAndPepper, salt.length, pepper.length); - byte[] kekBytes = Scrypt.scrypt(passphrase, saltAndPepper, costParam, blockSize, Masterkey.SUBKEY_LEN_BYTES); + byte[] kekBytes = Scrypt.scrypt(passphrase, saltAndPepper, costParam, blockSize, PerpetualMasterkey.SUBKEY_LEN_BYTES); try { - return new DestroyableSecretKey(kekBytes, Masterkey.ENC_ALG); + return new DestroyableSecretKey(kekBytes, PerpetualMasterkey.ENC_ALG); } finally { Arrays.fill(kekBytes, (byte) 0x00); } diff --git a/src/main/java/org/cryptomator/cryptolib/v1/Constants.java b/src/main/java/org/cryptomator/cryptolib/v1/Constants.java index 4662fae..26a4e5e 100644 --- a/src/main/java/org/cryptomator/cryptolib/v1/Constants.java +++ b/src/main/java/org/cryptomator/cryptolib/v1/Constants.java @@ -1,11 +1,3 @@ -/******************************************************************************* - * Copyright (c) 2016 Sebastian Stenzel and others. - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the accompanying LICENSE.txt. - * - * Contributors: - * Sebastian Stenzel - initial API and implementation - *******************************************************************************/ package org.cryptomator.cryptolib.v1; final class Constants { @@ -13,6 +5,8 @@ final class Constants { private Constants() { } + static final String C9R_FILE_EXT = ".c9r"; + static final String CONTENT_ENC_ALG = "AES"; static final int NONCE_SIZE = 16; diff --git a/src/main/java/org/cryptomator/cryptolib/v1/CryptorImpl.java b/src/main/java/org/cryptomator/cryptolib/v1/CryptorImpl.java index e34137f..fe7eabf 100644 --- a/src/main/java/org/cryptomator/cryptolib/v1/CryptorImpl.java +++ b/src/main/java/org/cryptomator/cryptolib/v1/CryptorImpl.java @@ -1,21 +1,17 @@ -/******************************************************************************* - * Copyright (c) 2016 Sebastian Stenzel and others. - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the accompanying LICENSE.txt. - * - * Contributors: - * Sebastian Stenzel - initial API and implementation - *******************************************************************************/ package org.cryptomator.cryptolib.v1; import org.cryptomator.cryptolib.api.Cryptor; +import org.cryptomator.cryptolib.api.DirectoryContentCryptor; +import org.cryptomator.cryptolib.api.FileHeaderCryptor; +import org.cryptomator.cryptolib.api.FileNameCryptor; import org.cryptomator.cryptolib.api.Masterkey; +import org.cryptomator.cryptolib.api.PerpetualMasterkey; import java.security.SecureRandom; class CryptorImpl implements Cryptor { - private final Masterkey masterkey; + private final PerpetualMasterkey masterkey; private final FileContentCryptorImpl fileContentCryptor; private final FileHeaderCryptorImpl fileHeaderCryptor; private final FileNameCryptorImpl fileNameCryptor; @@ -24,7 +20,7 @@ class CryptorImpl implements Cryptor { * Package-private constructor. * Use {@link CryptorProviderImpl#provide(Masterkey, SecureRandom)} to obtain a Cryptor instance. */ - CryptorImpl(Masterkey masterkey, SecureRandom random) { + CryptorImpl(PerpetualMasterkey masterkey, SecureRandom random) { this.masterkey = masterkey; this.fileHeaderCryptor = new FileHeaderCryptorImpl(masterkey, random); this.fileContentCryptor = new FileContentCryptorImpl(masterkey, random); @@ -43,12 +39,27 @@ public FileHeaderCryptorImpl fileHeaderCryptor() { return fileHeaderCryptor; } + @Override + public FileHeaderCryptor fileHeaderCryptor(int revision) { + throw new UnsupportedOperationException(); + } + @Override public FileNameCryptorImpl fileNameCryptor() { assertNotDestroyed(); return fileNameCryptor; } + @Override + public FileNameCryptor fileNameCryptor(int revision) { + throw new UnsupportedOperationException(); + } + + @Override + public DirectoryContentCryptor directoryContentCryptor() { + return new DirectoryContentCryptorImpl(this); + } + @Override public boolean isDestroyed() { return masterkey.isDestroyed(); diff --git a/src/main/java/org/cryptomator/cryptolib/v1/CryptorProviderImpl.java b/src/main/java/org/cryptomator/cryptolib/v1/CryptorProviderImpl.java index fad2b5a..7e02917 100644 --- a/src/main/java/org/cryptomator/cryptolib/v1/CryptorProviderImpl.java +++ b/src/main/java/org/cryptomator/cryptolib/v1/CryptorProviderImpl.java @@ -8,8 +8,10 @@ *******************************************************************************/ package org.cryptomator.cryptolib.v1; +import org.cryptomator.cryptolib.api.Cryptor; import org.cryptomator.cryptolib.api.CryptorProvider; import org.cryptomator.cryptolib.api.Masterkey; +import org.cryptomator.cryptolib.api.PerpetualMasterkey; import org.cryptomator.cryptolib.common.ReseedingSecureRandom; import java.security.SecureRandom; @@ -22,8 +24,13 @@ public Scheme scheme() { } @Override - public CryptorImpl provide(Masterkey masterkey, SecureRandom random) { - return new CryptorImpl(masterkey, ReseedingSecureRandom.create(random)); + public Cryptor provide(Masterkey masterkey, SecureRandom random) { + if (masterkey instanceof PerpetualMasterkey) { + PerpetualMasterkey perpetualMasterkey = (PerpetualMasterkey) masterkey; + return new CryptorImpl(perpetualMasterkey, ReseedingSecureRandom.create(random)); + } else { + throw new IllegalArgumentException("V1 Cryptor requires a PerpetualMasterkey."); + } } } diff --git a/src/main/java/org/cryptomator/cryptolib/v1/DirectoryContentCryptorImpl.java b/src/main/java/org/cryptomator/cryptolib/v1/DirectoryContentCryptorImpl.java new file mode 100644 index 0000000..85d4c57 --- /dev/null +++ b/src/main/java/org/cryptomator/cryptolib/v1/DirectoryContentCryptorImpl.java @@ -0,0 +1,87 @@ +package org.cryptomator.cryptolib.v1; + +import com.google.common.io.BaseEncoding; +import org.cryptomator.cryptolib.api.DirectoryContentCryptor; +import org.cryptomator.cryptolib.api.DirectoryMetadata; + +import java.nio.charset.StandardCharsets; +import java.util.UUID; + +import static org.cryptomator.cryptolib.v1.Constants.C9R_FILE_EXT; + +class DirectoryContentCryptorImpl implements DirectoryContentCryptor { + + private final CryptorImpl cryptor; + + public DirectoryContentCryptorImpl(CryptorImpl cryptor) { + this.cryptor = cryptor; + } + + // DIRECTORY METADATA + + @Override + public DirectoryMetadataImpl rootDirectoryMetadata() { + return new DirectoryMetadataImpl(new byte[0]); + } + + @Override + public DirectoryMetadataImpl newDirectoryMetadata() { + byte[] dirId = UUID.randomUUID().toString().getBytes(StandardCharsets.US_ASCII); + return new DirectoryMetadataImpl(dirId); + } + + @Override + public DirectoryMetadataImpl decryptDirectoryMetadata(byte[] ciphertext) { + // dirId is stored in plaintext + return new DirectoryMetadataImpl(ciphertext); + } + + @Override + public byte[] encryptDirectoryMetadata(DirectoryMetadata directoryMetadata) { + // dirId is stored in plaintext + DirectoryMetadataImpl metadataImpl = DirectoryMetadataImpl.cast(directoryMetadata); + return metadataImpl.dirId(); + } + + // DIR PATH + + @Override + public String dirPath(DirectoryMetadata directoryMetadata) { + DirectoryMetadataImpl metadataImpl = DirectoryMetadataImpl.cast(directoryMetadata); + String dirIdStr = cryptor.fileNameCryptor().hashDirectoryId(metadataImpl.dirId()); + assert dirIdStr.length() == 32; + return "d/" + dirIdStr.substring(0, 2) + "/" + dirIdStr.substring(2); + } + + // FILE NAMES + + @Override + public Decrypting fileNameDecryptor(DirectoryMetadata directoryMetadata) { + DirectoryMetadataImpl metadataImpl = DirectoryMetadataImpl.cast(directoryMetadata); + byte[] dirId = metadataImpl.dirId(); + FileNameCryptorImpl fileNameCryptor = cryptor.fileNameCryptor(); + return ciphertextAndExt -> { + String ciphertext = removeExtension(ciphertextAndExt); + return fileNameCryptor.decryptFilename(BaseEncoding.base64Url(), ciphertext, dirId); + }; + } + + @Override + public Encrypting fileNameEncryptor(DirectoryMetadata directoryMetadata) { + DirectoryMetadataImpl metadataImpl = DirectoryMetadataImpl.cast(directoryMetadata); + byte[] dirId = metadataImpl.dirId(); + FileNameCryptorImpl fileNameCryptor = cryptor.fileNameCryptor(); + return plaintext -> { + String ciphertext = fileNameCryptor.encryptFilename(BaseEncoding.base64Url(), plaintext, dirId); + return ciphertext + C9R_FILE_EXT; + }; + } + + private static String removeExtension(String filename) { + if (filename.endsWith(C9R_FILE_EXT)) { + return filename.substring(0, filename.length() - C9R_FILE_EXT.length()); + } else { + throw new IllegalArgumentException("Not a " + C9R_FILE_EXT + " file: " + filename); + } + } +} diff --git a/src/main/java/org/cryptomator/cryptolib/v1/DirectoryMetadataImpl.java b/src/main/java/org/cryptomator/cryptolib/v1/DirectoryMetadataImpl.java new file mode 100644 index 0000000..f21762d --- /dev/null +++ b/src/main/java/org/cryptomator/cryptolib/v1/DirectoryMetadataImpl.java @@ -0,0 +1,25 @@ +package org.cryptomator.cryptolib.v1; + +import org.cryptomator.cryptolib.api.DirectoryMetadata; + +class DirectoryMetadataImpl implements DirectoryMetadata { + + private final byte[] dirId; + + public DirectoryMetadataImpl(byte[] dirId) { + this.dirId = dirId; + } + + static DirectoryMetadataImpl cast(DirectoryMetadata metadata) { + if (metadata instanceof DirectoryMetadataImpl) { + return (DirectoryMetadataImpl) metadata; + } else { + throw new IllegalArgumentException("Unsupported metadata type " + metadata.getClass()); + } + } + + public byte[] dirId() { + return dirId; + } + +} diff --git a/src/main/java/org/cryptomator/cryptolib/v1/FileContentCryptorImpl.java b/src/main/java/org/cryptomator/cryptolib/v1/FileContentCryptorImpl.java index f7cf465..d974f40 100644 --- a/src/main/java/org/cryptomator/cryptolib/v1/FileContentCryptorImpl.java +++ b/src/main/java/org/cryptomator/cryptolib/v1/FileContentCryptorImpl.java @@ -1,9 +1,6 @@ package org.cryptomator.cryptolib.v1; -import org.cryptomator.cryptolib.api.AuthenticationFailedException; -import org.cryptomator.cryptolib.api.FileContentCryptor; -import org.cryptomator.cryptolib.api.FileHeader; -import org.cryptomator.cryptolib.api.Masterkey; +import org.cryptomator.cryptolib.api.*; import org.cryptomator.cryptolib.common.CipherSupplier; import org.cryptomator.cryptolib.common.DestroyableSecretKey; import org.cryptomator.cryptolib.common.MacSupplier; @@ -27,10 +24,10 @@ class FileContentCryptorImpl implements FileContentCryptor { - private final Masterkey masterkey; + private final PerpetualMasterkey masterkey; private final SecureRandom random; - FileContentCryptorImpl(Masterkey masterkey, SecureRandom random) { + FileContentCryptorImpl(PerpetualMasterkey masterkey, SecureRandom random) { this.masterkey = masterkey; this.random = random; } diff --git a/src/main/java/org/cryptomator/cryptolib/v1/FileHeaderCryptorImpl.java b/src/main/java/org/cryptomator/cryptolib/v1/FileHeaderCryptorImpl.java index f1b0c59..ed7663a 100644 --- a/src/main/java/org/cryptomator/cryptolib/v1/FileHeaderCryptorImpl.java +++ b/src/main/java/org/cryptomator/cryptolib/v1/FileHeaderCryptorImpl.java @@ -8,10 +8,7 @@ *******************************************************************************/ package org.cryptomator.cryptolib.v1; -import org.cryptomator.cryptolib.api.AuthenticationFailedException; -import org.cryptomator.cryptolib.api.FileHeader; -import org.cryptomator.cryptolib.api.FileHeaderCryptor; -import org.cryptomator.cryptolib.api.Masterkey; +import org.cryptomator.cryptolib.api.*; import org.cryptomator.cryptolib.common.CipherSupplier; import org.cryptomator.cryptolib.common.DestroyableSecretKey; import org.cryptomator.cryptolib.common.MacSupplier; @@ -30,10 +27,10 @@ class FileHeaderCryptorImpl implements FileHeaderCryptor { - private final Masterkey masterkey; + private final PerpetualMasterkey masterkey; private final SecureRandom random; - FileHeaderCryptorImpl(Masterkey masterkey, SecureRandom random) { + FileHeaderCryptorImpl(PerpetualMasterkey masterkey, SecureRandom random) { this.masterkey = masterkey; this.random = random; } diff --git a/src/main/java/org/cryptomator/cryptolib/v1/FileHeaderImpl.java b/src/main/java/org/cryptomator/cryptolib/v1/FileHeaderImpl.java index fbed9fa..31eab10 100644 --- a/src/main/java/org/cryptomator/cryptolib/v1/FileHeaderImpl.java +++ b/src/main/java/org/cryptomator/cryptolib/v1/FileHeaderImpl.java @@ -81,9 +81,9 @@ public static class Payload implements Destroyable { private long reserved; private final DestroyableSecretKey contentKey; - Payload(long reversed, byte[] contentKeyBytes) { + Payload(long reserved, byte[] contentKeyBytes) { Preconditions.checkArgument(contentKeyBytes.length == CONTENT_KEY_LEN, "Invalid key length. (was: " + contentKeyBytes.length + ", required: " + CONTENT_KEY_LEN + ")"); - this.reserved = reversed; + this.reserved = reserved; this.contentKey = new DestroyableSecretKey(contentKeyBytes, Constants.CONTENT_ENC_ALG); } diff --git a/src/main/java/org/cryptomator/cryptolib/v1/FileNameCryptorImpl.java b/src/main/java/org/cryptomator/cryptolib/v1/FileNameCryptorImpl.java index 5104aa5..ddf6ba0 100644 --- a/src/main/java/org/cryptomator/cryptolib/v1/FileNameCryptorImpl.java +++ b/src/main/java/org/cryptomator/cryptolib/v1/FileNameCryptorImpl.java @@ -12,6 +12,7 @@ import org.cryptomator.cryptolib.api.AuthenticationFailedException; import org.cryptomator.cryptolib.api.FileNameCryptor; import org.cryptomator.cryptolib.api.Masterkey; +import org.cryptomator.cryptolib.api.PerpetualMasterkey; import org.cryptomator.cryptolib.common.DestroyableSecretKey; import org.cryptomator.cryptolib.common.MessageDigestSupplier; import org.cryptomator.cryptolib.common.ObjectPool; @@ -28,19 +29,18 @@ class FileNameCryptorImpl implements FileNameCryptor { private static final BaseEncoding BASE32 = BaseEncoding.base32(); private static final ObjectPoolAES_SIV = new ObjectPool<>(SivMode::new); - private final Masterkey masterkey; + private final PerpetualMasterkey masterkey; - FileNameCryptorImpl(Masterkey masterkey) { + FileNameCryptorImpl(PerpetualMasterkey masterkey) { this.masterkey = masterkey; } @Override - public String hashDirectoryId(String cleartextDirectoryId) { + public String hashDirectoryId(byte[] cleartextDirectoryId) { try (DestroyableSecretKey ek = masterkey.getEncKey(); DestroyableSecretKey mk = masterkey.getMacKey(); ObjectPool.Lease sha1 = MessageDigestSupplier.SHA1.instance(); ObjectPool.Lease siv = AES_SIV.get()) { - byte[] cleartextBytes = cleartextDirectoryId.getBytes(UTF_8); - byte[] encryptedBytes = siv.get().encrypt(ek, mk, cleartextBytes); + byte[] encryptedBytes = siv.get().encrypt(ek, mk, cleartextDirectoryId); byte[] hashedBytes = sha1.get().digest(encryptedBytes); return BASE32.encode(hashedBytes); } diff --git a/src/main/java/org/cryptomator/cryptolib/v2/Constants.java b/src/main/java/org/cryptomator/cryptolib/v2/Constants.java index f6c0f4d..5e4be82 100644 --- a/src/main/java/org/cryptomator/cryptolib/v2/Constants.java +++ b/src/main/java/org/cryptomator/cryptolib/v2/Constants.java @@ -1,11 +1,3 @@ -/******************************************************************************* - * Copyright (c) 2016 Sebastian Stenzel and others. - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the accompanying LICENSE.txt. - * - * Contributors: - * Sebastian Stenzel - initial API and implementation - *******************************************************************************/ package org.cryptomator.cryptolib.v2; final class Constants { @@ -13,6 +5,8 @@ final class Constants { private Constants() { } + static final String C9R_FILE_EXT = ".c9r"; + static final String CONTENT_ENC_ALG = "AES"; static final int GCM_NONCE_SIZE = 12; // 96 bit IVs strongly recommended for GCM diff --git a/src/main/java/org/cryptomator/cryptolib/v2/CryptorImpl.java b/src/main/java/org/cryptomator/cryptolib/v2/CryptorImpl.java index 7389ccd..ac46a8b 100644 --- a/src/main/java/org/cryptomator/cryptolib/v2/CryptorImpl.java +++ b/src/main/java/org/cryptomator/cryptolib/v2/CryptorImpl.java @@ -1,22 +1,18 @@ -/******************************************************************************* - * Copyright (c) 2016 Sebastian Stenzel and others. - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the accompanying LICENSE.txt. - * - * Contributors: - * Sebastian Stenzel - initial API and implementation - *******************************************************************************/ package org.cryptomator.cryptolib.v2; import org.cryptomator.cryptolib.api.Cryptor; +import org.cryptomator.cryptolib.api.DirectoryContentCryptor; +import org.cryptomator.cryptolib.api.FileHeaderCryptor; +import org.cryptomator.cryptolib.api.FileNameCryptor; import org.cryptomator.cryptolib.api.Masterkey; +import org.cryptomator.cryptolib.api.PerpetualMasterkey; import org.cryptomator.cryptolib.v1.CryptorProviderImpl; import java.security.SecureRandom; class CryptorImpl implements Cryptor { - private final Masterkey masterkey; + private final PerpetualMasterkey masterkey; private final FileContentCryptorImpl fileContentCryptor; private final FileHeaderCryptorImpl fileHeaderCryptor; private final FileNameCryptorImpl fileNameCryptor; @@ -25,7 +21,7 @@ class CryptorImpl implements Cryptor { * Package-private constructor. * Use {@link CryptorProviderImpl#provide(Masterkey, SecureRandom)} to obtain a Cryptor instance. */ - CryptorImpl(Masterkey masterkey, SecureRandom random) { + CryptorImpl(PerpetualMasterkey masterkey, SecureRandom random) { this.masterkey = masterkey; this.fileHeaderCryptor = new FileHeaderCryptorImpl(masterkey, random); this.fileContentCryptor = new FileContentCryptorImpl(random); @@ -44,12 +40,27 @@ public FileHeaderCryptorImpl fileHeaderCryptor() { return fileHeaderCryptor; } + @Override + public FileHeaderCryptor fileHeaderCryptor(int revision) { + throw new UnsupportedOperationException(); + } + @Override public FileNameCryptorImpl fileNameCryptor() { assertNotDestroyed(); return fileNameCryptor; } + @Override + public FileNameCryptor fileNameCryptor(int revision) { + throw new UnsupportedOperationException(); + } + + @Override + public DirectoryContentCryptor directoryContentCryptor() { + return new DirectoryContentCryptorImpl(this); + } + @Override public boolean isDestroyed() { return masterkey.isDestroyed(); diff --git a/src/main/java/org/cryptomator/cryptolib/v2/CryptorProviderImpl.java b/src/main/java/org/cryptomator/cryptolib/v2/CryptorProviderImpl.java index 1a6a018..5fb4113 100644 --- a/src/main/java/org/cryptomator/cryptolib/v2/CryptorProviderImpl.java +++ b/src/main/java/org/cryptomator/cryptolib/v2/CryptorProviderImpl.java @@ -8,8 +8,10 @@ *******************************************************************************/ package org.cryptomator.cryptolib.v2; +import org.cryptomator.cryptolib.api.Cryptor; import org.cryptomator.cryptolib.api.CryptorProvider; import org.cryptomator.cryptolib.api.Masterkey; +import org.cryptomator.cryptolib.api.PerpetualMasterkey; import org.cryptomator.cryptolib.common.ReseedingSecureRandom; import java.security.SecureRandom; @@ -22,8 +24,13 @@ public Scheme scheme() { } @Override - public CryptorImpl provide(Masterkey masterkey, SecureRandom random) { - return new CryptorImpl(masterkey, ReseedingSecureRandom.create(random)); + public Cryptor provide(Masterkey masterkey, SecureRandom random) { + if (masterkey instanceof PerpetualMasterkey) { + PerpetualMasterkey perpetualMasterkey = (PerpetualMasterkey) masterkey; + return new CryptorImpl(perpetualMasterkey, ReseedingSecureRandom.create(random)); + } else { + throw new IllegalArgumentException("V2 Cryptor requires a PerpetualMasterkey."); + } } } diff --git a/src/main/java/org/cryptomator/cryptolib/v2/DirectoryContentCryptorImpl.java b/src/main/java/org/cryptomator/cryptolib/v2/DirectoryContentCryptorImpl.java new file mode 100644 index 0000000..09938ce --- /dev/null +++ b/src/main/java/org/cryptomator/cryptolib/v2/DirectoryContentCryptorImpl.java @@ -0,0 +1,87 @@ +package org.cryptomator.cryptolib.v2; + +import com.google.common.io.BaseEncoding; +import org.cryptomator.cryptolib.api.DirectoryContentCryptor; +import org.cryptomator.cryptolib.api.DirectoryMetadata; + +import java.nio.charset.StandardCharsets; +import java.util.UUID; + +import static org.cryptomator.cryptolib.v2.Constants.C9R_FILE_EXT; + +class DirectoryContentCryptorImpl implements DirectoryContentCryptor { + + private final CryptorImpl cryptor; + + public DirectoryContentCryptorImpl(CryptorImpl cryptor) { + this.cryptor = cryptor; + } + + // DIRECTORY METADATA + + @Override + public DirectoryMetadataImpl rootDirectoryMetadata() { + return new DirectoryMetadataImpl(new byte[0]); + } + + @Override + public DirectoryMetadataImpl newDirectoryMetadata() { + byte[] dirId = UUID.randomUUID().toString().getBytes(StandardCharsets.US_ASCII); + return new DirectoryMetadataImpl(dirId); + } + + @Override + public DirectoryMetadataImpl decryptDirectoryMetadata(byte[] ciphertext) { + // dirId is stored in plaintext + return new DirectoryMetadataImpl(ciphertext); + } + + @Override + public byte[] encryptDirectoryMetadata(DirectoryMetadata directoryMetadata) { + // dirId is stored in plaintext + DirectoryMetadataImpl metadataImpl = DirectoryMetadataImpl.cast(directoryMetadata); + return metadataImpl.dirId(); + } + + // DIR PATH + + @Override + public String dirPath(DirectoryMetadata directoryMetadata) { + DirectoryMetadataImpl metadataImpl = DirectoryMetadataImpl.cast(directoryMetadata); + String dirIdStr = cryptor.fileNameCryptor().hashDirectoryId(metadataImpl.dirId()); + assert dirIdStr.length() == 32; + return "d/" + dirIdStr.substring(0, 2) + "/" + dirIdStr.substring(2); + } + + // FILE NAMES + + @Override + public Decrypting fileNameDecryptor(DirectoryMetadata directoryMetadata) { + DirectoryMetadataImpl metadataImpl = DirectoryMetadataImpl.cast(directoryMetadata); + byte[] dirId = metadataImpl.dirId(); + FileNameCryptorImpl fileNameCryptor = cryptor.fileNameCryptor(); + return ciphertextAndExt -> { + String ciphertext = removeExtension(ciphertextAndExt); + return fileNameCryptor.decryptFilename(BaseEncoding.base64Url(), ciphertext, dirId); + }; + } + + @Override + public Encrypting fileNameEncryptor(DirectoryMetadata directoryMetadata) { + DirectoryMetadataImpl metadataImpl = DirectoryMetadataImpl.cast(directoryMetadata); + byte[] dirId = metadataImpl.dirId(); + FileNameCryptorImpl fileNameCryptor = cryptor.fileNameCryptor(); + return plaintext -> { + String ciphertext = fileNameCryptor.encryptFilename(BaseEncoding.base64Url(), plaintext, dirId); + return ciphertext + C9R_FILE_EXT; + }; + } + + private static String removeExtension(String filename) { + if (filename.endsWith(C9R_FILE_EXT)) { + return filename.substring(0, filename.length() - C9R_FILE_EXT.length()); + } else { + throw new IllegalArgumentException("Not a " + C9R_FILE_EXT + " file: " + filename); + } + } +} diff --git a/src/main/java/org/cryptomator/cryptolib/v2/DirectoryMetadataImpl.java b/src/main/java/org/cryptomator/cryptolib/v2/DirectoryMetadataImpl.java new file mode 100644 index 0000000..d57756b --- /dev/null +++ b/src/main/java/org/cryptomator/cryptolib/v2/DirectoryMetadataImpl.java @@ -0,0 +1,25 @@ +package org.cryptomator.cryptolib.v2; + +import org.cryptomator.cryptolib.api.DirectoryMetadata; + +class DirectoryMetadataImpl implements DirectoryMetadata { + + private final byte[] dirId; + + public DirectoryMetadataImpl(byte[] dirId) { + this.dirId = dirId; + } + + static DirectoryMetadataImpl cast(DirectoryMetadata metadata) { + if (metadata instanceof DirectoryMetadataImpl) { + return (DirectoryMetadataImpl) metadata; + } else { + throw new IllegalArgumentException("Unsupported metadata type " + metadata.getClass()); + } + } + + public byte[] dirId() { + return dirId; + } + +} diff --git a/src/main/java/org/cryptomator/cryptolib/v2/FileHeaderCryptorImpl.java b/src/main/java/org/cryptomator/cryptolib/v2/FileHeaderCryptorImpl.java index 35bebc1..4cba09c 100644 --- a/src/main/java/org/cryptomator/cryptolib/v2/FileHeaderCryptorImpl.java +++ b/src/main/java/org/cryptomator/cryptolib/v2/FileHeaderCryptorImpl.java @@ -8,10 +8,7 @@ *******************************************************************************/ package org.cryptomator.cryptolib.v2; -import org.cryptomator.cryptolib.api.AuthenticationFailedException; -import org.cryptomator.cryptolib.api.FileHeader; -import org.cryptomator.cryptolib.api.FileHeaderCryptor; -import org.cryptomator.cryptolib.api.Masterkey; +import org.cryptomator.cryptolib.api.*; import org.cryptomator.cryptolib.common.CipherSupplier; import org.cryptomator.cryptolib.common.DestroyableSecretKey; import org.cryptomator.cryptolib.common.ObjectPool; @@ -30,10 +27,10 @@ class FileHeaderCryptorImpl implements FileHeaderCryptor { - private final Masterkey masterkey; + private final PerpetualMasterkey masterkey; private final SecureRandom random; - FileHeaderCryptorImpl(Masterkey masterkey, SecureRandom random) { + FileHeaderCryptorImpl(PerpetualMasterkey masterkey, SecureRandom random) { this.masterkey = masterkey; this.random = random; } diff --git a/src/main/java/org/cryptomator/cryptolib/v2/FileHeaderImpl.java b/src/main/java/org/cryptomator/cryptolib/v2/FileHeaderImpl.java index 39bcbbc..94a266c 100644 --- a/src/main/java/org/cryptomator/cryptolib/v2/FileHeaderImpl.java +++ b/src/main/java/org/cryptomator/cryptolib/v2/FileHeaderImpl.java @@ -74,16 +74,16 @@ public void destroy() { public static class Payload implements Destroyable { - static final int REVERSED_LEN = Long.BYTES; + static final int RESERVED_LEN = Long.BYTES; static final int CONTENT_KEY_LEN = 32; - static final int SIZE = REVERSED_LEN + CONTENT_KEY_LEN; + static final int SIZE = RESERVED_LEN + CONTENT_KEY_LEN; private long reserved; private final DestroyableSecretKey contentKey; - Payload(long reversed, byte[] contentKeyBytes) { + Payload(long reserved, byte[] contentKeyBytes) { Preconditions.checkArgument(contentKeyBytes.length == CONTENT_KEY_LEN, "Invalid key length. (was: " + contentKeyBytes.length + ", required: " + CONTENT_KEY_LEN + ")"); - this.reserved = reversed; + this.reserved = reserved; this.contentKey = new DestroyableSecretKey(contentKeyBytes, Constants.CONTENT_ENC_ALG); } diff --git a/src/main/java/org/cryptomator/cryptolib/v2/FileNameCryptorImpl.java b/src/main/java/org/cryptomator/cryptolib/v2/FileNameCryptorImpl.java index 0498afe..c56d622 100644 --- a/src/main/java/org/cryptomator/cryptolib/v2/FileNameCryptorImpl.java +++ b/src/main/java/org/cryptomator/cryptolib/v2/FileNameCryptorImpl.java @@ -12,6 +12,7 @@ import org.cryptomator.cryptolib.api.AuthenticationFailedException; import org.cryptomator.cryptolib.api.FileNameCryptor; import org.cryptomator.cryptolib.api.Masterkey; +import org.cryptomator.cryptolib.api.PerpetualMasterkey; import org.cryptomator.cryptolib.common.DestroyableSecretKey; import org.cryptomator.cryptolib.common.MessageDigestSupplier; import org.cryptomator.cryptolib.common.ObjectPool; @@ -28,19 +29,18 @@ class FileNameCryptorImpl implements FileNameCryptor { private static final BaseEncoding BASE32 = BaseEncoding.base32(); private static final ObjectPool AES_SIV = new ObjectPool<>(SivMode::new); - private final Masterkey masterkey; + private final PerpetualMasterkey masterkey; - FileNameCryptorImpl(Masterkey masterkey) { + FileNameCryptorImpl(PerpetualMasterkey masterkey) { this.masterkey = masterkey; } @Override - public String hashDirectoryId(String cleartextDirectoryId) { + public String hashDirectoryId(byte[] cleartextDirectoryId) { try (DestroyableSecretKey ek = masterkey.getEncKey(); DestroyableSecretKey mk = masterkey.getMacKey(); ObjectPool.Lease sha1 = MessageDigestSupplier.SHA1.instance(); ObjectPool.Lease siv = AES_SIV.get()) { - byte[] cleartextBytes = cleartextDirectoryId.getBytes(UTF_8); - byte[] encryptedBytes = siv.get().encrypt(ek, mk, cleartextBytes); + byte[] encryptedBytes = siv.get().encrypt(ek, mk, cleartextDirectoryId); byte[] hashedBytes = sha1.get().digest(encryptedBytes); return BASE32.encode(hashedBytes); } diff --git a/src/main/java/org/cryptomator/cryptolib/v3/Constants.java b/src/main/java/org/cryptomator/cryptolib/v3/Constants.java new file mode 100644 index 0000000..aec90da --- /dev/null +++ b/src/main/java/org/cryptomator/cryptolib/v3/Constants.java @@ -0,0 +1,19 @@ +package org.cryptomator.cryptolib.v3; + +final class Constants { + + private Constants() { + } + + static final String UVF_FILE_EXT = ".uvf"; + + static final String CONTENT_ENC_ALG = "AES"; + + static final byte[] UVF_MAGIC_BYTES = new byte[]{'u', 'v', 'f', 0x00}; // TODO increase version number when adopting final spec + + static final int GCM_NONCE_SIZE = 12; // 96 bit IVs strongly recommended for GCM + static final int PAYLOAD_SIZE = 32 * 1024; + static final int GCM_TAG_SIZE = 16; + static final int CHUNK_SIZE = GCM_NONCE_SIZE + PAYLOAD_SIZE + GCM_TAG_SIZE; + +} diff --git a/src/main/java/org/cryptomator/cryptolib/v3/CryptorImpl.java b/src/main/java/org/cryptomator/cryptolib/v3/CryptorImpl.java new file mode 100644 index 0000000..c0947d7 --- /dev/null +++ b/src/main/java/org/cryptomator/cryptolib/v3/CryptorImpl.java @@ -0,0 +1,83 @@ +package org.cryptomator.cryptolib.v3; + +import org.cryptomator.cryptolib.api.Cryptor; +import org.cryptomator.cryptolib.api.DirectoryContentCryptor; +import org.cryptomator.cryptolib.api.FileHeaderCryptor; +import org.cryptomator.cryptolib.api.FileNameCryptor; +import org.cryptomator.cryptolib.api.Masterkey; +import org.cryptomator.cryptolib.api.RevolvingMasterkey; +import org.cryptomator.cryptolib.v1.CryptorProviderImpl; + +import java.security.SecureRandom; + +class CryptorImpl implements Cryptor { + + private final RevolvingMasterkey masterkey; + private final FileContentCryptorImpl fileContentCryptor; + private final SecureRandom random; + + /** + * Package-private constructor. + * Use {@link CryptorProviderImpl#provide(Masterkey, SecureRandom)} to obtain a Cryptor instance. + */ + CryptorImpl(RevolvingMasterkey masterkey, SecureRandom random) { + this.masterkey = masterkey; + this.fileContentCryptor = new FileContentCryptorImpl(random); + this.random = random; + } + + @Override + public FileContentCryptorImpl fileContentCryptor() { + assertNotDestroyed(); + return fileContentCryptor; + } + + @Override + public FileHeaderCryptorImpl fileHeaderCryptor() { + return fileHeaderCryptor(masterkey.currentRevision()); + } + + @Override + public FileHeaderCryptorImpl fileHeaderCryptor(int revision) { + assertNotDestroyed(); + return new FileHeaderCryptorImpl(masterkey, random, revision); + } + + @Override + public FileNameCryptorImpl fileNameCryptor() { + throw new UnsupportedOperationException(); + } + + @Override + public FileNameCryptorImpl fileNameCryptor(int revision) { + assertNotDestroyed(); + return new FileNameCryptorImpl(masterkey, revision); + } + + @Override + public DirectoryContentCryptorImpl directoryContentCryptor() { + return new DirectoryContentCryptorImpl(masterkey, random, this); + } + + @Override + public boolean isDestroyed() { + return masterkey.isDestroyed(); + } + + @Override + public void close() { + destroy(); + } + + @Override + public void destroy() { + masterkey.destroy(); + } + + private void assertNotDestroyed() { + if (isDestroyed()) { + throw new IllegalStateException("Cryptor destroyed."); + } + } + +} diff --git a/src/main/java/org/cryptomator/cryptolib/v3/CryptorProviderImpl.java b/src/main/java/org/cryptomator/cryptolib/v3/CryptorProviderImpl.java new file mode 100644 index 0000000..c9082aa --- /dev/null +++ b/src/main/java/org/cryptomator/cryptolib/v3/CryptorProviderImpl.java @@ -0,0 +1,25 @@ +package org.cryptomator.cryptolib.v3; + +import org.cryptomator.cryptolib.api.*; +import org.cryptomator.cryptolib.common.ReseedingSecureRandom; + +import java.security.SecureRandom; + +public class CryptorProviderImpl implements CryptorProvider { + + @Override + public Scheme scheme() { + return Scheme.UVF_DRAFT; + } + + @Override + public Cryptor provide(Masterkey masterkey, SecureRandom random) { + if (masterkey instanceof RevolvingMasterkey) { + RevolvingMasterkey revolvingMasterkey = (RevolvingMasterkey) masterkey; + return new CryptorImpl(revolvingMasterkey, ReseedingSecureRandom.create(random)); + } else { + throw new IllegalArgumentException("V3 Cryptor requires a RevolvingMasterkey."); + } + } + +} diff --git a/src/main/java/org/cryptomator/cryptolib/v3/DirectoryContentCryptorImpl.java b/src/main/java/org/cryptomator/cryptolib/v3/DirectoryContentCryptorImpl.java new file mode 100644 index 0000000..8d7227c --- /dev/null +++ b/src/main/java/org/cryptomator/cryptolib/v3/DirectoryContentCryptorImpl.java @@ -0,0 +1,117 @@ +package org.cryptomator.cryptolib.v3; + +import com.google.common.io.BaseEncoding; +import org.cryptomator.cryptolib.api.AuthenticationFailedException; +import org.cryptomator.cryptolib.api.DirectoryContentCryptor; +import org.cryptomator.cryptolib.api.DirectoryMetadata; +import org.cryptomator.cryptolib.api.FileHeader; +import org.cryptomator.cryptolib.api.RevolvingMasterkey; + +import java.nio.ByteBuffer; +import java.security.SecureRandom; + +import static org.cryptomator.cryptolib.v3.Constants.UVF_FILE_EXT; + +class DirectoryContentCryptorImpl implements DirectoryContentCryptor { + + private final RevolvingMasterkey masterkey; + private final SecureRandom random; + private final CryptorImpl cryptor; + + public DirectoryContentCryptorImpl(RevolvingMasterkey masterkey, SecureRandom random, CryptorImpl cryptor) { + this.masterkey = masterkey; + this.random = random; + this.cryptor = cryptor; + } + + // DIRECTORY METADATA + + @Override + public DirectoryMetadataImpl rootDirectoryMetadata() { + byte[] dirId = masterkey.rootDirId(); + return new DirectoryMetadataImpl(masterkey.firstRevision(), dirId); + } + + @Override + public DirectoryMetadataImpl newDirectoryMetadata() { + byte[] dirId = new byte[32]; + random.nextBytes(dirId); + return new DirectoryMetadataImpl(masterkey.currentRevision(), dirId); + } + + @Override + public DirectoryMetadataImpl decryptDirectoryMetadata(byte[] ciphertext) throws AuthenticationFailedException { + if (ciphertext.length != 128) { + throw new IllegalArgumentException("Invalid dir.uvf length: " + ciphertext.length); + } + int headerSize = cryptor.fileHeaderCryptor().headerSize(); + ByteBuffer buffer = ByteBuffer.wrap(ciphertext); + ByteBuffer headerBuf = buffer.duplicate(); + headerBuf.position(0).limit(headerSize); + ByteBuffer contentBuf = buffer.duplicate(); + contentBuf.position(headerSize); + FileHeaderImpl header = cryptor.fileHeaderCryptor().decryptHeader(headerBuf); + ByteBuffer plaintext = cryptor.fileContentCryptor().decryptChunk(contentBuf, 0, header, true); + assert plaintext.remaining() == 32; + byte[] dirId = new byte[32]; + plaintext.get(dirId); + return new DirectoryMetadataImpl(header.getSeedId(), dirId); + } + + @Override + public byte[] encryptDirectoryMetadata(DirectoryMetadata directoryMetadata) { + DirectoryMetadataImpl metadataImpl = DirectoryMetadataImpl.cast(directoryMetadata); + ByteBuffer cleartextBuf = ByteBuffer.wrap(metadataImpl.dirId()); + FileHeader header = cryptor.fileHeaderCryptor(metadataImpl.seedId()).create(); + ByteBuffer headerBuf = cryptor.fileHeaderCryptor().encryptHeader(header); + ByteBuffer contentBuf = cryptor.fileContentCryptor().encryptChunk(cleartextBuf, 0, header); + byte[] result = new byte[headerBuf.remaining() + contentBuf.remaining()]; + headerBuf.get(result, 0, headerBuf.remaining()); + contentBuf.get(result, headerBuf.limit(), contentBuf.remaining()); + return result; + } + + // DIR PATH + + @Override + public String dirPath(DirectoryMetadata directoryMetadata) { + DirectoryMetadataImpl metadataImpl = DirectoryMetadataImpl.cast(directoryMetadata); + FileNameCryptorImpl fileNameCryptor = cryptor.fileNameCryptor(metadataImpl.seedId()); + String dirIdStr = fileNameCryptor.hashDirectoryId(metadataImpl.dirId()); + assert dirIdStr.length() == 32; + return "d/" + dirIdStr.substring(0, 2) + "/" + dirIdStr.substring(2); + } + + // FILE NAMES + + @Override + public Decrypting fileNameDecryptor(DirectoryMetadata directoryMetadata) { + DirectoryMetadataImpl metadataImpl = DirectoryMetadataImpl.cast(directoryMetadata); + byte[] dirId = metadataImpl.dirId(); + FileNameCryptorImpl fileNameCryptor = cryptor.fileNameCryptor(metadataImpl.seedId()); + return ciphertextAndExt -> { + String ciphertext = removeExtension(ciphertextAndExt); + return fileNameCryptor.decryptFilename(BaseEncoding.base64Url(), ciphertext, dirId); + }; + } + + @Override + public Encrypting fileNameEncryptor(DirectoryMetadata directoryMetadata) { + DirectoryMetadataImpl metadataImpl = DirectoryMetadataImpl.cast(directoryMetadata); + byte[] dirId = metadataImpl.dirId(); + FileNameCryptorImpl fileNameCryptor = cryptor.fileNameCryptor(metadataImpl.seedId()); + return plaintext -> { + String ciphertext = fileNameCryptor.encryptFilename(BaseEncoding.base64Url(), plaintext, dirId); + return ciphertext + UVF_FILE_EXT; + }; + } + + private static String removeExtension(String filename) { + if (filename.endsWith(UVF_FILE_EXT)) { + return filename.substring(0, filename.length() - UVF_FILE_EXT.length()); + } else { + throw new IllegalArgumentException("Not a " + UVF_FILE_EXT + " file: " + filename); + } + } + +} diff --git a/src/main/java/org/cryptomator/cryptolib/v3/DirectoryMetadataImpl.java b/src/main/java/org/cryptomator/cryptolib/v3/DirectoryMetadataImpl.java new file mode 100644 index 0000000..ee0b908 --- /dev/null +++ b/src/main/java/org/cryptomator/cryptolib/v3/DirectoryMetadataImpl.java @@ -0,0 +1,31 @@ +package org.cryptomator.cryptolib.v3; + +import org.cryptomator.cryptolib.api.DirectoryMetadata; + +class DirectoryMetadataImpl implements DirectoryMetadata { + + private final int seedId; + private final byte[] dirId; + + public DirectoryMetadataImpl(int seedId, byte[] dirId) { + this.seedId = seedId; + this.dirId = dirId; + } + + static DirectoryMetadataImpl cast(DirectoryMetadata metadata) { + if (metadata instanceof DirectoryMetadataImpl) { + return (DirectoryMetadataImpl) metadata; + } else { + throw new IllegalArgumentException("Unsupported metadata type " + metadata.getClass()); + } + } + + public byte[] dirId() { + return dirId; + } + + public int seedId() { + return seedId; + } + +} diff --git a/src/main/java/org/cryptomator/cryptolib/v3/FileContentCryptorImpl.java b/src/main/java/org/cryptomator/cryptolib/v3/FileContentCryptorImpl.java new file mode 100644 index 0000000..77980f5 --- /dev/null +++ b/src/main/java/org/cryptomator/cryptolib/v3/FileContentCryptorImpl.java @@ -0,0 +1,149 @@ +package org.cryptomator.cryptolib.v3; + +import org.cryptomator.cryptolib.api.AuthenticationFailedException; +import org.cryptomator.cryptolib.api.FileContentCryptor; +import org.cryptomator.cryptolib.api.FileHeader; +import org.cryptomator.cryptolib.common.CipherSupplier; +import org.cryptomator.cryptolib.common.DestroyableSecretKey; +import org.cryptomator.cryptolib.common.ObjectPool; + +import javax.crypto.AEADBadTagException; +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.ShortBufferException; +import javax.crypto.spec.GCMParameterSpec; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.security.SecureRandom; + +import static org.cryptomator.cryptolib.v3.Constants.CHUNK_SIZE; +import static org.cryptomator.cryptolib.v3.Constants.GCM_NONCE_SIZE; +import static org.cryptomator.cryptolib.v3.Constants.GCM_TAG_SIZE; +import static org.cryptomator.cryptolib.v3.Constants.PAYLOAD_SIZE; + +class FileContentCryptorImpl implements FileContentCryptor { + + private final SecureRandom random; + + FileContentCryptorImpl(SecureRandom random) { + this.random = random; + } + + @Override + public boolean canSkipAuthentication() { + return false; + } + + @Override + public int cleartextChunkSize() { + return PAYLOAD_SIZE; + } + + @Override + public int ciphertextChunkSize() { + return CHUNK_SIZE; + } + + @Override + public ByteBuffer encryptChunk(ByteBuffer cleartextChunk, long chunkNumber, FileHeader header) { + ByteBuffer ciphertextChunk = ByteBuffer.allocate(CHUNK_SIZE); + encryptChunk(cleartextChunk, ciphertextChunk, chunkNumber, header); + ciphertextChunk.flip(); + return ciphertextChunk; + } + + @Override + public void encryptChunk(ByteBuffer cleartextChunk, ByteBuffer ciphertextChunk, long chunkNumber, FileHeader header) { + if (cleartextChunk.remaining() < 0 || cleartextChunk.remaining() > PAYLOAD_SIZE) { + throw new IllegalArgumentException("Invalid cleartext chunk size: " + cleartextChunk.remaining() + ", expected range [1, " + PAYLOAD_SIZE + "]"); + } + if (ciphertextChunk.remaining() < CHUNK_SIZE) { + throw new IllegalArgumentException("Invalid ciphertext chunk size: " + ciphertextChunk.remaining() + ", must fit up to " + CHUNK_SIZE + " bytes."); + } + FileHeaderImpl headerImpl = FileHeaderImpl.cast(header); + encryptChunk(cleartextChunk, ciphertextChunk, chunkNumber, headerImpl.getNonce(), headerImpl.getContentKey()); + } + + @Override + public ByteBuffer decryptChunk(ByteBuffer ciphertextChunk, long chunkNumber, FileHeader header, boolean authenticate) throws AuthenticationFailedException { + // FileHeaderImpl.Payload.SIZE + GCM_TAG_SIZE is required to fix a bug in Android API level pre 29, see https://issuetracker.google.com/issues/197534888 and #35 + ByteBuffer cleartextChunk = ByteBuffer.allocate(PAYLOAD_SIZE + GCM_TAG_SIZE); + decryptChunk(ciphertextChunk, cleartextChunk, chunkNumber, header, authenticate); + cleartextChunk.flip(); + return cleartextChunk; + } + + @Override + public void decryptChunk(ByteBuffer ciphertextChunk, ByteBuffer cleartextChunk, long chunkNumber, FileHeader header, boolean authenticate) throws AuthenticationFailedException { + if (ciphertextChunk.remaining() < GCM_NONCE_SIZE + GCM_TAG_SIZE || ciphertextChunk.remaining() > CHUNK_SIZE) { + throw new IllegalArgumentException("Invalid ciphertext chunk size: " + ciphertextChunk.remaining() + ", expected range [" + (GCM_NONCE_SIZE + GCM_TAG_SIZE) + ", " + CHUNK_SIZE + "]"); + } + if (cleartextChunk.remaining() < PAYLOAD_SIZE) { + throw new IllegalArgumentException("Invalid cleartext chunk size: " + cleartextChunk.remaining() + ", must fit up to " + PAYLOAD_SIZE + " bytes."); + } + if (!authenticate) { + throw new UnsupportedOperationException("authenticate can not be false"); + } + FileHeaderImpl headerImpl = FileHeaderImpl.cast(header); + decryptChunk(ciphertextChunk, cleartextChunk, chunkNumber, headerImpl.getNonce(), headerImpl.getContentKey()); + } + + // visible for testing + void encryptChunk(ByteBuffer cleartextChunk, ByteBuffer ciphertextChunk, long chunkNumber, byte[] headerNonce, DestroyableSecretKey fileKey) { + try (DestroyableSecretKey fk = fileKey.copy()) { + // nonce: + byte[] nonce = new byte[GCM_NONCE_SIZE]; + random.nextBytes(nonce); + + // payload: + try (ObjectPool.Lease cipher = CipherSupplier.AES_GCM.encryptionCipher(fk, new GCMParameterSpec(GCM_TAG_SIZE * Byte.SIZE, nonce))) { + final byte[] chunkNumberBigEndian = longToBigEndianByteArray(chunkNumber); + cipher.get().updateAAD(chunkNumberBigEndian); + cipher.get().updateAAD(headerNonce); + ciphertextChunk.put(nonce); + assert ciphertextChunk.remaining() >= cipher.get().getOutputSize(cleartextChunk.remaining()); + cipher.get().doFinal(cleartextChunk, ciphertextChunk); + } + } catch (ShortBufferException e) { + throw new IllegalStateException("Buffer allocated for reported output size apparently not big enough.", e); + } catch (IllegalBlockSizeException | BadPaddingException e) { + throw new IllegalStateException("Unexpected exception during GCM encryption.", e); + } + } + + // visible for testing + void decryptChunk(ByteBuffer ciphertextChunk, ByteBuffer cleartextChunk, long chunkNumber, byte[] headerNonce, DestroyableSecretKey fileKey) throws AuthenticationFailedException { + assert ciphertextChunk.remaining() >= GCM_NONCE_SIZE + GCM_TAG_SIZE; + + try (DestroyableSecretKey fk = fileKey.copy()) { + // nonce: + final byte[] nonce = new byte[GCM_NONCE_SIZE]; + ciphertextChunk.get(nonce, 0, GCM_NONCE_SIZE); + + // payload: + final ByteBuffer payloadBuf = ciphertextChunk.duplicate(); + assert payloadBuf.remaining() >= GCM_TAG_SIZE; + + // payload: + try (ObjectPool.Lease cipher = CipherSupplier.AES_GCM.decryptionCipher(fk, new GCMParameterSpec(GCM_TAG_SIZE * Byte.SIZE, nonce))) { + final byte[] chunkNumberBigEndian = longToBigEndianByteArray(chunkNumber); + cipher.get().updateAAD(chunkNumberBigEndian); + cipher.get().updateAAD(headerNonce); + assert cleartextChunk.remaining() >= cipher.get().getOutputSize(payloadBuf.remaining()); + cipher.get().doFinal(payloadBuf, cleartextChunk); + } + } catch (AEADBadTagException e) { + throw new AuthenticationFailedException("Content tag mismatch.", e); + } catch (ShortBufferException e) { + throw new IllegalStateException("Buffer allocated for reported output size apparently not big enough.", e); + } catch (IllegalBlockSizeException | BadPaddingException e) { + throw new IllegalStateException("Unexpected exception during GCM decryption.", e); + } + } + + private byte[] longToBigEndianByteArray(long n) { + return ByteBuffer.allocate(Long.BYTES).order(ByteOrder.BIG_ENDIAN).putLong(n).array(); + } + +} diff --git a/src/main/java/org/cryptomator/cryptolib/v3/FileHeaderCryptorImpl.java b/src/main/java/org/cryptomator/cryptolib/v3/FileHeaderCryptorImpl.java new file mode 100644 index 0000000..1bc10bc --- /dev/null +++ b/src/main/java/org/cryptomator/cryptolib/v3/FileHeaderCryptorImpl.java @@ -0,0 +1,130 @@ +package org.cryptomator.cryptolib.v3; + +import org.cryptomator.cryptolib.api.*; +import org.cryptomator.cryptolib.common.CipherSupplier; +import org.cryptomator.cryptolib.common.DestroyableSecretKey; +import org.cryptomator.cryptolib.common.ObjectPool; + +import javax.crypto.AEADBadTagException; +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.ShortBufferException; +import javax.crypto.spec.GCMParameterSpec; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Arrays; + +import static org.cryptomator.cryptolib.v3.Constants.GCM_TAG_SIZE; + +class FileHeaderCryptorImpl implements FileHeaderCryptor { + + private static final byte[] KDF_CONTEXT = "fileHeader".getBytes(StandardCharsets.US_ASCII); + + private final RevolvingMasterkey masterkey; + private final SecureRandom random; + private final int revision; + + FileHeaderCryptorImpl(RevolvingMasterkey masterkey, SecureRandom random, int revision) { + this.masterkey = masterkey; + this.random = random; + this.revision = revision; + } + + @Override + public FileHeaderImpl create() { + byte[] nonce = new byte[FileHeaderImpl.NONCE_LEN]; + random.nextBytes(nonce); + byte[] contentKeyBytes = new byte[FileHeaderImpl.CONTENT_KEY_LEN]; + random.nextBytes(contentKeyBytes); + DestroyableSecretKey contentKey = new DestroyableSecretKey(contentKeyBytes, Constants.CONTENT_ENC_ALG); + return new FileHeaderImpl(revision, nonce, contentKey); + } + + @Override + public int headerSize() { + return FileHeaderImpl.SIZE; + } + + @Override + public ByteBuffer encryptHeader(FileHeader header) { + FileHeaderImpl headerImpl = FileHeaderImpl.cast(header); + try (DestroyableSecretKey headerKey = masterkey.subKey(headerImpl.getSeedId(), 32, KDF_CONTEXT, "AES")) { + ByteBuffer result = ByteBuffer.allocate(FileHeaderImpl.SIZE); + + // general header: + result.put(Constants.UVF_MAGIC_BYTES); + result.order(ByteOrder.BIG_ENDIAN).putInt(headerImpl.getSeedId()); + ByteBuffer generalHeaderBuf = result.duplicate(); + generalHeaderBuf.position(0).limit(FileHeaderImpl.UVF_GENERAL_HEADERS_LEN); + + // format-specific header: + result.put(headerImpl.getNonce()); + try (ObjectPool.Lease cipher = CipherSupplier.AES_GCM.encryptionCipher(headerKey, new GCMParameterSpec(GCM_TAG_SIZE * Byte.SIZE, headerImpl.getNonce()))) { + cipher.get().updateAAD(generalHeaderBuf); + ByteBuffer payloadCleartextBuf = ByteBuffer.wrap(headerImpl.getContentKey().getEncoded()); + int encrypted = cipher.get().doFinal(payloadCleartextBuf, result); + assert encrypted == FileHeaderImpl.CONTENT_KEY_LEN + FileHeaderImpl.TAG_LEN; + } + result.flip(); + return result; + } catch (ShortBufferException e) { + throw new IllegalStateException("Result buffer too small for encrypted header payload.", e); + } catch (IllegalBlockSizeException | BadPaddingException e) { + throw new IllegalStateException("Unexpected exception during GCM encryption.", e); + } + } + + @Override + public FileHeaderImpl decryptHeader(ByteBuffer ciphertextHeaderBuf) throws AuthenticationFailedException { + if (ciphertextHeaderBuf.remaining() < FileHeaderImpl.SIZE) { + throw new IllegalArgumentException("Malformed ciphertext header"); + } + ByteBuffer buf = ciphertextHeaderBuf.duplicate(); + + // general header: + byte[] magicBytes = new byte[Constants.UVF_MAGIC_BYTES.length]; + buf.get(magicBytes); + if (!Arrays.equals(Constants.UVF_MAGIC_BYTES, magicBytes)) { + throw new IllegalArgumentException("Not an UVF0 file"); + } + int seedId = buf.order(ByteOrder.BIG_ENDIAN).getInt(); + ByteBuffer generalHeaderBuf = buf.duplicate(); + generalHeaderBuf.position(0).limit(FileHeaderImpl.UVF_GENERAL_HEADERS_LEN); + + // format-specific header: + byte[] nonce = new byte[FileHeaderImpl.NONCE_LEN]; + buf.position(FileHeaderImpl.NONCE_POS); + buf.get(nonce); + byte[] ciphertextAndTag = new byte[FileHeaderImpl.CONTENT_KEY_LEN + FileHeaderImpl.TAG_LEN]; + buf.position(FileHeaderImpl.CONTENT_KEY_POS); + buf.get(ciphertextAndTag); + + // FileHeaderImpl.Payload.SIZE + GCM_TAG_SIZE is required to fix a bug in Android API level pre 29, see https://issuetracker.google.com/issues/197534888 and #24 + ByteBuffer payloadCleartextBuf = ByteBuffer.allocate(FileHeaderImpl.CONTENT_KEY_LEN + GCM_TAG_SIZE); + try (DestroyableSecretKey headerKey = masterkey.subKey(seedId, 32, KDF_CONTEXT, "AES")) { + // decrypt payload: + try (ObjectPool.Lease cipher = CipherSupplier.AES_GCM.decryptionCipher(headerKey, new GCMParameterSpec(GCM_TAG_SIZE * Byte.SIZE, nonce))) { + cipher.get().updateAAD(generalHeaderBuf); + int decrypted = cipher.get().doFinal(ByteBuffer.wrap(ciphertextAndTag), payloadCleartextBuf); + assert decrypted == FileHeaderImpl.CONTENT_KEY_LEN; + } + payloadCleartextBuf.flip(); + byte[] contentKeyBytes = new byte[FileHeaderImpl.CONTENT_KEY_LEN]; + payloadCleartextBuf.get(contentKeyBytes); + DestroyableSecretKey contentKey = new DestroyableSecretKey(contentKeyBytes, Constants.CONTENT_ENC_ALG); + return new FileHeaderImpl(seedId, nonce, contentKey); + } catch (AEADBadTagException e) { + throw new AuthenticationFailedException("Header tag mismatch.", e); + } catch (ShortBufferException e) { + throw new IllegalStateException("Result buffer too small for decrypted header payload.", e); + } catch (IllegalBlockSizeException | BadPaddingException e) { + throw new IllegalStateException("Unexpected exception during GCM decryption.", e); + } finally { + Arrays.fill(payloadCleartextBuf.array(), (byte) 0x00); + } + } + +} diff --git a/src/main/java/org/cryptomator/cryptolib/v3/FileHeaderImpl.java b/src/main/java/org/cryptomator/cryptolib/v3/FileHeaderImpl.java new file mode 100644 index 0000000..40dccdb --- /dev/null +++ b/src/main/java/org/cryptomator/cryptolib/v3/FileHeaderImpl.java @@ -0,0 +1,72 @@ +package org.cryptomator.cryptolib.v3; + +import org.cryptomator.cryptolib.api.FileHeader; +import org.cryptomator.cryptolib.common.DestroyableSecretKey; + +import javax.security.auth.Destroyable; + +class FileHeaderImpl implements FileHeader, Destroyable { + + static final int UVF_GENERAL_HEADERS_LEN = Constants.UVF_MAGIC_BYTES.length + Integer.BYTES; + static final int NONCE_POS = 8; + static final int NONCE_LEN = Constants.GCM_NONCE_SIZE; + static final int CONTENT_KEY_POS = NONCE_POS + NONCE_LEN; // 20 + static final int CONTENT_KEY_LEN = 32; + static final int TAG_POS = CONTENT_KEY_POS + CONTENT_KEY_LEN; // 52 + static final int TAG_LEN = Constants.GCM_TAG_SIZE; + static final int SIZE = UVF_GENERAL_HEADERS_LEN + NONCE_LEN + CONTENT_KEY_LEN + TAG_LEN; + + private final int seedId; + private final byte[] nonce; + private final DestroyableSecretKey contentKey; + + FileHeaderImpl(int seedId, byte[] nonce, DestroyableSecretKey contentKey) { + if (nonce.length != NONCE_LEN) { + throw new IllegalArgumentException("Invalid nonce length. (was: " + nonce.length + ", required: " + NONCE_LEN + ")"); + } + this.seedId = seedId; + this.nonce = nonce; + this.contentKey = contentKey; + } + + static FileHeaderImpl cast(FileHeader header) { + if (header instanceof FileHeaderImpl) { + return (FileHeaderImpl) header; + } else { + throw new IllegalArgumentException("Unsupported header type " + header.getClass()); + } + } + + public int getSeedId() { + return seedId; + } + + public byte[] getNonce() { + return nonce; + } + + public DestroyableSecretKey getContentKey() { + return contentKey; + } + + @Override + public long getReserved() { + return 0; + } + + @Override + public void setReserved(long reserved) { + /* noop */ + } + + @Override + public boolean isDestroyed() { + return contentKey.isDestroyed(); + } + + @Override + public void destroy() { + contentKey.destroy(); + } + +} diff --git a/src/main/java/org/cryptomator/cryptolib/v3/FileNameCryptorImpl.java b/src/main/java/org/cryptomator/cryptolib/v3/FileNameCryptorImpl.java new file mode 100644 index 0000000..bc80a34 --- /dev/null +++ b/src/main/java/org/cryptomator/cryptolib/v3/FileNameCryptorImpl.java @@ -0,0 +1,71 @@ +package org.cryptomator.cryptolib.v3; + +import com.google.common.io.BaseEncoding; +import org.cryptomator.cryptolib.api.AuthenticationFailedException; +import org.cryptomator.cryptolib.api.FileNameCryptor; +import org.cryptomator.cryptolib.api.Masterkey; +import org.cryptomator.cryptolib.api.RevolvingMasterkey; +import org.cryptomator.cryptolib.common.DestroyableSecretKey; +import org.cryptomator.cryptolib.common.MacSupplier; +import org.cryptomator.cryptolib.common.MessageDigestSupplier; +import org.cryptomator.cryptolib.common.ObjectPool; +import org.cryptomator.siv.SivMode; +import org.cryptomator.siv.UnauthenticCiphertextException; + +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.Mac; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Arrays; + +import static java.nio.charset.StandardCharsets.UTF_8; + +class FileNameCryptorImpl implements FileNameCryptor { + + private static final BaseEncoding BASE32 = BaseEncoding.base32(); + private static final ObjectPool AES_SIV = new ObjectPool<>(SivMode::new); + + private final DestroyableSecretKey sivKey; + private final DestroyableSecretKey hmacKey; + + /** + * Create a file name encryption/decryption tool for a certain masterkey revision. + * @param masterkey The masterkey from which to derive subkeys + * @param revision Which masterkey revision to use + * @throws IllegalArgumentException If no subkey could be derived for the given revision + */ + FileNameCryptorImpl(RevolvingMasterkey masterkey, int revision) throws IllegalArgumentException { + this.sivKey = masterkey.subKey(revision, 64, "siv".getBytes(StandardCharsets.US_ASCII), "AES"); + this.hmacKey = masterkey.subKey(revision, 64, "hmac".getBytes(StandardCharsets.US_ASCII), "HMAC"); + } + + @Override + public String hashDirectoryId(byte[] cleartextDirectoryId) { + try (DestroyableSecretKey key = this.hmacKey.copy(); + ObjectPool.Lease hmacSha256 = MacSupplier.HMAC_SHA256.keyed(key)) { + byte[] hash = hmacSha256.get().doFinal(cleartextDirectoryId); + return BASE32.encode(hash, 0, 20); // only use first 160 bits + } + } + + @Override + public String encryptFilename(BaseEncoding encoding, String cleartextName, byte[]... associatedData) { + try (DestroyableSecretKey key = this.sivKey.copy(); ObjectPool.Lease siv = AES_SIV.get()) { + byte[] cleartextBytes = cleartextName.getBytes(UTF_8); + byte[] encryptedBytes = siv.get().encrypt(key, cleartextBytes, associatedData); + return encoding.encode(encryptedBytes); + } + } + + @Override + public String decryptFilename(BaseEncoding encoding, String ciphertextName, byte[]... associatedData) throws AuthenticationFailedException { + try (DestroyableSecretKey key = this.sivKey.copy(); ObjectPool.Lease siv = AES_SIV.get()) { + byte[] encryptedBytes = encoding.decode(ciphertextName); + byte[] cleartextBytes = siv.get().decrypt(key, encryptedBytes, associatedData); + return new String(cleartextBytes, UTF_8); + } catch (IllegalArgumentException | UnauthenticCiphertextException | IllegalBlockSizeException e) { + throw new AuthenticationFailedException("Invalid Ciphertext.", e); + } + } + +} diff --git a/src/main/java9/module-info.java b/src/main/java9/module-info.java index 512fb83..ad82dde 100644 --- a/src/main/java9/module-info.java +++ b/src/main/java9/module-info.java @@ -24,5 +24,5 @@ uses CryptorProvider; provides CryptorProvider - with org.cryptomator.cryptolib.v1.CryptorProviderImpl, org.cryptomator.cryptolib.v2.CryptorProviderImpl; + with org.cryptomator.cryptolib.v1.CryptorProviderImpl, org.cryptomator.cryptolib.v2.CryptorProviderImpl, org.cryptomator.cryptolib.v3.CryptorProviderImpl; } \ No newline at end of file diff --git a/src/main/resources/META-INF/services/org.cryptomator.cryptolib.api.CryptorProvider b/src/main/resources/META-INF/services/org.cryptomator.cryptolib.api.CryptorProvider index 4e7fe58..cbbe5e5 100644 --- a/src/main/resources/META-INF/services/org.cryptomator.cryptolib.api.CryptorProvider +++ b/src/main/resources/META-INF/services/org.cryptomator.cryptolib.api.CryptorProvider @@ -1,2 +1,3 @@ org.cryptomator.cryptolib.v1.CryptorProviderImpl -org.cryptomator.cryptolib.v2.CryptorProviderImpl \ No newline at end of file +org.cryptomator.cryptolib.v2.CryptorProviderImpl +org.cryptomator.cryptolib.v3.CryptorProviderImpl \ No newline at end of file diff --git a/src/test/java/org/cryptomator/cryptolib/api/CryptoLibIntegrationTest.java b/src/test/java/org/cryptomator/cryptolib/api/CryptoLibIntegrationTest.java index f846629..7f18ebc 100644 --- a/src/test/java/org/cryptomator/cryptolib/api/CryptoLibIntegrationTest.java +++ b/src/test/java/org/cryptomator/cryptolib/api/CryptoLibIntegrationTest.java @@ -30,11 +30,27 @@ public class CryptoLibIntegrationTest { private static final SecureRandom RANDOM_MOCK = SecureRandomMock.PRNG_RANDOM; + private static final String UVF_PAYLOAD = "{\n" + + " \"fileFormat\": \"AES-256-GCM-32k\",\n" + + " \"nameFormat\": \"AES-SIV-512-B64URL\",\n" + + " \"seeds\": {\n" + + " \"HDm38g\": \"ypeBEsobvcr6wjGzmiPcTaeG7_gUfE5yuYB3ha_uSLs\",\n" + + " \"gBryKw\": \"PiPoFgA5WUoziU9lZOGxNIu9egCI1CxKy3PurtWcAJ0\",\n" + + " \"QBsJFg\": \"Ln0sA6lQeuJl7PW1NWiFpTOTogKdJBOUmXJloaJa78Y\"\n" + + " },\n" + + " \"initialSeed\": \"HDm38i\",\n" + + " \"latestSeed\": \"QBsJFo\",\n" + + " \"kdf\": \"HKDF-SHA512\",\n" + + " \"kdfSalt\": \"NIlr89R7FhochyP4yuXZmDqCnQ0dBB3UZ2D-6oiIjr8\",\n" + + " \"org.example.customfield\": 42\n" + + "}"; private static Stream getCryptors() { return Stream.of( CryptorProvider.forScheme(CryptorProvider.Scheme.SIV_CTRMAC).provide(Masterkey.generate(RANDOM_MOCK), RANDOM_MOCK), - CryptorProvider.forScheme(CryptorProvider.Scheme.SIV_GCM).provide(Masterkey.generate(RANDOM_MOCK), RANDOM_MOCK) + CryptorProvider.forScheme(CryptorProvider.Scheme.SIV_GCM).provide(Masterkey.generate(RANDOM_MOCK), RANDOM_MOCK), + CryptorProvider.forScheme(CryptorProvider.Scheme.UVF_DRAFT).provide(UVFMasterkey.fromDecryptedPayload(UVF_PAYLOAD), RANDOM_MOCK) + ); } diff --git a/src/test/java/org/cryptomator/cryptolib/api/UVFMasterkeyTest.java b/src/test/java/org/cryptomator/cryptolib/api/UVFMasterkeyTest.java new file mode 100644 index 0000000..e995df8 --- /dev/null +++ b/src/test/java/org/cryptomator/cryptolib/api/UVFMasterkeyTest.java @@ -0,0 +1,60 @@ +package org.cryptomator.cryptolib.api; + +import org.cryptomator.cryptolib.common.DestroyableSecretKey; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +class UVFMasterkeyTest { + + @Test + public void testFromDecryptedPayload() { + String json = "{\n" + + " \"fileFormat\": \"AES-256-GCM-32k\",\n" + + " \"nameFormat\": \"AES-SIV-512-B64URL\",\n" + + " \"seeds\": {\n" + + " \"HDm38i\": \"ypeBEsobvcr6wjGzmiPcTaeG7_gUfE5yuYB3ha_uSLs\",\n" + + " \"gBryKw\": \"PiPoFgA5WUoziU9lZOGxNIu9egCI1CxKy3PurtWcAJ0\",\n" + + " \"QBsJFo\": \"Ln0sA6lQeuJl7PW1NWiFpTOTogKdJBOUmXJloaJa78Y\"\n" + + " },\n" + + " \"initialSeed\": \"HDm38i\",\n" + + " \"latestSeed\": \"QBsJFo\",\n" + + " \"kdf\": \"HKDF-SHA512\",\n" + + " \"kdfSalt\": \"NIlr89R7FhochyP4yuXZmDqCnQ0dBB3UZ2D-6oiIjr8\",\n" + + " \"org.example.customfield\": 42\n" + + "}"; + UVFMasterkey masterkey = UVFMasterkey.fromDecryptedPayload(json); + + Assertions.assertEquals(473544690, masterkey.initialSeed); + Assertions.assertEquals(1075513622, masterkey.latestSeed); + Assertions.assertArrayEquals(Base64.getUrlDecoder().decode("NIlr89R7FhochyP4yuXZmDqCnQ0dBB3UZ2D-6oiIjr8"), masterkey.kdfSalt); + Assertions.assertArrayEquals(Base64.getUrlDecoder().decode("ypeBEsobvcr6wjGzmiPcTaeG7_gUfE5yuYB3ha_uSLs"), masterkey.seeds.get(473544690)); + Assertions.assertArrayEquals(Base64.getUrlDecoder().decode("Ln0sA6lQeuJl7PW1NWiFpTOTogKdJBOUmXJloaJa78Y"), masterkey.seeds.get(1075513622)); + } + + @Test + public void testSubkey() { + Map seeds = Collections.singletonMap(-1540072521, Base64.getUrlDecoder().decode("fP4V4oAjsUw5DqackAvLzA0oP1kAQZ0f5YFZQviXSuU")); + byte[] kdfSalt = Base64.getUrlDecoder().decode("HE4OP-2vyfLLURicF1XmdIIsWv0Zs6MobLKROUIEhQY"); + try (UVFMasterkey masterkey = new UVFMasterkey(seeds, kdfSalt, -1540072521, -1540072521)) { + try (DestroyableSecretKey subkey = masterkey.subKey(-1540072521, 32, "fileHeader".getBytes(StandardCharsets.US_ASCII), "AES")) { + Assertions.assertEquals("PwnW2t/pK9dmzc+GTLdBSaB8ilcwsTq4sYOeiyo3cpU=", Base64.getEncoder().encodeToString(subkey.getEncoded())); + } + } + } + + @Test + public void testRootDirId() { + Map seeds = Collections.singletonMap(-1540072521, Base64.getUrlDecoder().decode("fP4V4oAjsUw5DqackAvLzA0oP1kAQZ0f5YFZQviXSuU")); + byte[] kdfSalt = Base64.getUrlDecoder().decode("HE4OP-2vyfLLURicF1XmdIIsWv0Zs6MobLKROUIEhQY"); + try (UVFMasterkey masterkey = new UVFMasterkey(seeds, kdfSalt, -1540072521, -1540072521)) { + Assertions.assertEquals("24UBEDeGu5taq7U4GqyA0MXUXb9HTYS6p3t9vvHGJAc=", Base64.getEncoder().encodeToString(masterkey.rootDirId())); + } + } + +} \ No newline at end of file diff --git a/src/test/java/org/cryptomator/cryptolib/common/DestroyableSecretKeyTest.java b/src/test/java/org/cryptomator/cryptolib/common/DestroyableSecretKeyTest.java index acbcf82..34397a4 100644 --- a/src/test/java/org/cryptomator/cryptolib/common/DestroyableSecretKeyTest.java +++ b/src/test/java/org/cryptomator/cryptolib/common/DestroyableSecretKeyTest.java @@ -14,6 +14,7 @@ import java.util.Arrays; import java.util.Random; +@SuppressWarnings("resource") public class DestroyableSecretKeyTest { @DisplayName("generate(...)") diff --git a/src/test/java/org/cryptomator/cryptolib/common/HKDFHelperTest.java b/src/test/java/org/cryptomator/cryptolib/common/HKDFHelperTest.java new file mode 100644 index 0000000..36f2b2d --- /dev/null +++ b/src/test/java/org/cryptomator/cryptolib/common/HKDFHelperTest.java @@ -0,0 +1,69 @@ +package org.cryptomator.cryptolib.common; + +import com.google.common.io.BaseEncoding; +import org.bouncycastle.crypto.digests.SHA256Digest; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +public class HKDFHelperTest { + + private static final BaseEncoding HEX = BaseEncoding.base16().ignoreCase(); + + @Test + @DisplayName("RFC 5869 Test Case 1") + public void testCase1() { + // https://datatracker.ietf.org/doc/html/rfc5869#appendix-A.1 + byte[] ikm = HEX.decode("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b"); + byte[] salt = HEX.decode("000102030405060708090a0b0c"); + byte[] info = HEX.decode("f0f1f2f3f4f5f6f7f8f9"); + + byte[] result = HKDFHelper.hkdf(new SHA256Digest(), salt, ikm, info, 42); + + byte[] expectedOkm = HEX.ignoreCase().decode("3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865"); + Assertions.assertArrayEquals(expectedOkm, result); + } + + @Test + @DisplayName("RFC 5869 Test Case 2") + public void testCase2() { + // https://datatracker.ietf.org/doc/html/rfc5869#appendix-A.2 + byte[] ikm = HEX.decode("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f"); + byte[] salt = HEX.decode("606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeaf"); + byte[] info = HEX.decode("b0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff"); + + byte[] result = HKDFHelper.hkdf(new SHA256Digest(), salt, ikm, info, 82); + + byte[] expectedOkm = HEX.ignoreCase().decode("b11e398dc80327a1c8e7f78c596a49344f012eda2d4efad8a050cc4c19afa97c59045a99cac7827271cb41c65e590e09da3275600c2f09b8367793a9aca3db71cc30c58179ec3e87c14c01d5c1f3434f1d87"); + Assertions.assertArrayEquals(expectedOkm, result); + } + + @Test + @DisplayName("RFC 5869 Test Case 3") + public void testCase3() { + // https://datatracker.ietf.org/doc/html/rfc5869#appendix-A.3 + byte[] ikm = HEX.decode("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b"); + byte[] salt = new byte[0]; + byte[] info = new byte[0]; + + byte[] result = HKDFHelper.hkdf(new SHA256Digest(), salt, ikm, info, 42); + + byte[] expectedOkm = HEX.ignoreCase().decode("8da4e775a563c18f715f802a063c5a31b8a11f5c5ee1879ec3454e5f3c738d2d9d201395faa4b61a96c8"); + Assertions.assertArrayEquals(expectedOkm, result); + } + + @Test + @DisplayName("Inofficial SHA-512 Test") + public void sha512Test() { + // https://github.com/patrickfav/hkdf/blob/60152fff852506a1b46f730b14d1b8f8ff69d071/src/test/java/at/favre/lib/hkdf/RFC5869TestCases.java#L116-L124 + byte[] ikm = HEX.decode("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b"); + byte[] salt = HEX.decode("000102030405060708090a0b0c"); + byte[] info = HEX.decode("f0f1f2f3f4f5f6f7f8f9"); + + byte[] result = HKDFHelper.hkdfSha512(salt, ikm, info, 42); + + byte[] expectedOkm = HEX.ignoreCase().decode("832390086CDA71FB47625BB5CEB168E4C8E26A1A16ED34D9FC7FE92C1481579338DA362CB8D9F925D7CB"); + Assertions.assertArrayEquals(expectedOkm, result); + } + +} \ No newline at end of file diff --git a/src/test/java/org/cryptomator/cryptolib/common/MasterkeyFileAccessTest.java b/src/test/java/org/cryptomator/cryptolib/common/MasterkeyFileAccessTest.java index e35d510..b587e75 100644 --- a/src/test/java/org/cryptomator/cryptolib/common/MasterkeyFileAccessTest.java +++ b/src/test/java/org/cryptomator/cryptolib/common/MasterkeyFileAccessTest.java @@ -1,10 +1,7 @@ package org.cryptomator.cryptolib.common; import com.google.common.io.BaseEncoding; -import org.cryptomator.cryptolib.api.CryptoException; -import org.cryptomator.cryptolib.api.InvalidPassphraseException; -import org.cryptomator.cryptolib.api.Masterkey; -import org.cryptomator.cryptolib.api.MasterkeyLoadingFailedException; +import org.cryptomator.cryptolib.api.*; import org.hamcrest.MatcherAssert; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; @@ -30,7 +27,7 @@ public class MasterkeyFileAccessTest { private static final SecureRandom RANDOM_MOCK = SecureRandomMock.NULL_RANDOM; private static final byte[] DEFAULT_PEPPER = new byte[0]; - private Masterkey key = new Masterkey(new byte[64]); + private PerpetualMasterkey key = new PerpetualMasterkey(new byte[64]); private MasterkeyFile keyFile = new MasterkeyFile(); private MasterkeyFileAccess masterkeyFileAccess = Mockito.spy(new MasterkeyFileAccess(DEFAULT_PEPPER, RANDOM_MOCK)); @@ -93,7 +90,7 @@ public void testChangePassphraseWithRawBytes() throws CryptoException, IOExcepti public void testLoad() throws IOException { InputStream in = new ByteArrayInputStream(serializedKeyFile); - Masterkey loaded = masterkeyFileAccess.load(in, "asd"); + PerpetualMasterkey loaded = masterkeyFileAccess.load(in, "asd"); Assertions.assertArrayEquals(key.getEncoded(), loaded.getEncoded()); } @@ -203,7 +200,7 @@ public void testPersistAndLoad(@TempDir Path tmpDir) throws IOException, Masterk Path masterkeyFile = tmpDir.resolve("masterkey.cryptomator"); masterkeyFileAccess.persist(key, masterkeyFile, "asd"); - Masterkey loaded = masterkeyFileAccess.load(masterkeyFile, "asd"); + PerpetualMasterkey loaded = masterkeyFileAccess.load(masterkeyFile, "asd"); Assertions.assertArrayEquals(key.getEncoded(), loaded.getEncoded()); } diff --git a/src/test/java/org/cryptomator/cryptolib/common/MasterkeyTest.java b/src/test/java/org/cryptomator/cryptolib/common/MasterkeyTest.java index 4b0a2a0..d25121a 100644 --- a/src/test/java/org/cryptomator/cryptolib/common/MasterkeyTest.java +++ b/src/test/java/org/cryptomator/cryptolib/common/MasterkeyTest.java @@ -1,6 +1,7 @@ package org.cryptomator.cryptolib.common; import org.cryptomator.cryptolib.api.Masterkey; +import org.cryptomator.cryptolib.api.PerpetualMasterkey; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -13,7 +14,7 @@ public class MasterkeyTest { private byte[] raw; - private Masterkey masterkey; + private PerpetualMasterkey masterkey; @BeforeEach public void setup() { @@ -21,7 +22,7 @@ public void setup() { for (byte b=0; b cryptor.fileNameCryptor(revision)); + } + } + @Test public void testExplicitDestruction() { - Masterkey masterkey = Mockito.mock(Masterkey.class); + PerpetualMasterkey masterkey = Mockito.mock(PerpetualMasterkey.class); try (CryptorImpl cryptor = new CryptorImpl(masterkey, RANDOM_MOCK)) { cryptor.destroy(); Mockito.verify(masterkey).destroy(); @@ -64,7 +74,7 @@ public void testExplicitDestruction() { @Test public void testImplicitDestruction() { - Masterkey masterkey = Mockito.mock(Masterkey.class); + PerpetualMasterkey masterkey = Mockito.mock(PerpetualMasterkey.class); try (CryptorImpl cryptor = new CryptorImpl(masterkey, RANDOM_MOCK)) { Assertions.assertFalse(cryptor.isDestroyed()); } diff --git a/src/test/java/org/cryptomator/cryptolib/v1/CryptorProviderImplTest.java b/src/test/java/org/cryptomator/cryptolib/v1/CryptorProviderImplTest.java index 77096c8..84ac1cb 100644 --- a/src/test/java/org/cryptomator/cryptolib/v1/CryptorProviderImplTest.java +++ b/src/test/java/org/cryptomator/cryptolib/v1/CryptorProviderImplTest.java @@ -8,7 +8,9 @@ *******************************************************************************/ package org.cryptomator.cryptolib.v1; +import org.cryptomator.cryptolib.api.CryptorProvider; import org.cryptomator.cryptolib.api.Masterkey; +import org.cryptomator.cryptolib.api.PerpetualMasterkey; import org.cryptomator.cryptolib.common.SecureRandomMock; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -22,9 +24,9 @@ public class CryptorProviderImplTest { @Test public void testProvide() { - Masterkey masterkey = Mockito.mock(Masterkey.class); - CryptorImpl cryptor = new CryptorProviderImpl().provide(masterkey, RANDOM_MOCK); - Assertions.assertNotNull(cryptor); + PerpetualMasterkey masterkey = Mockito.mock(PerpetualMasterkey.class); + CryptorProvider provider = new CryptorProviderImpl(); + Assertions.assertInstanceOf(CryptorImpl.class, provider.provide(masterkey, RANDOM_MOCK)); } } diff --git a/src/test/java/org/cryptomator/cryptolib/v1/DirectoryContentCryptorImplTest.java b/src/test/java/org/cryptomator/cryptolib/v1/DirectoryContentCryptorImplTest.java new file mode 100644 index 0000000..161139d --- /dev/null +++ b/src/test/java/org/cryptomator/cryptolib/v1/DirectoryContentCryptorImplTest.java @@ -0,0 +1,140 @@ +package org.cryptomator.cryptolib.v1; + +import org.cryptomator.cryptolib.api.AuthenticationFailedException; +import org.cryptomator.cryptolib.api.CryptorProvider; +import org.cryptomator.cryptolib.api.DirectoryContentCryptor; +import org.cryptomator.cryptolib.api.DirectoryMetadata; +import org.cryptomator.cryptolib.api.PerpetualMasterkey; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Arrays; + +class DirectoryContentCryptorImplTest { + + private static final SecureRandom CSPRNG = new SecureRandom(); + private static DirectoryContentCryptorImpl dirCryptor; + + @BeforeAll + public static void setUp() { + byte[] key = new byte[64]; + Arrays.fill(key, 0, 32, (byte) 0x55); // enc key + Arrays.fill(key, 32, 64, (byte) 0x77); // mac key + PerpetualMasterkey masterkey = new PerpetualMasterkey(key); + dirCryptor = (DirectoryContentCryptorImpl) CryptorProvider.forScheme(CryptorProvider.Scheme.SIV_CTRMAC).provide(masterkey, CSPRNG).directoryContentCryptor(); + } + + @Test + @DisplayName("encrypt and decrypt dir.c9r files") + public void encryptAndDecryptDirectoryMetadata() { + DirectoryMetadataImpl origMetadata = dirCryptor.newDirectoryMetadata(); + + byte[] encryptedMetadata = dirCryptor.encryptDirectoryMetadata(origMetadata); + DirectoryMetadataImpl decryptedMetadata = dirCryptor.decryptDirectoryMetadata(encryptedMetadata); + + Assertions.assertArrayEquals(origMetadata.dirId(), decryptedMetadata.dirId()); + } + + @Test + @DisplayName("encrypt WELCOME.rtf in root dir") + public void testEncryptReadme() { + DirectoryMetadata rootDirMetadata = dirCryptor.rootDirectoryMetadata(); + DirectoryContentCryptor.Encrypting enc = dirCryptor.fileNameEncryptor(rootDirMetadata); + String ciphertext = enc.encrypt("WELCOME.rtf"); + Assertions.assertEquals("4BwXESMPHMIGXeiyQifg2xBDzblPVdRuU1dy.c9r", ciphertext); + } + + @Test + @DisplayName("decrypt WELCOME.rtf in root dir") + public void testDecryptReadme() { + DirectoryMetadata rootDirMetadata = dirCryptor.rootDirectoryMetadata(); + DirectoryContentCryptor.Decrypting dec = dirCryptor.fileNameDecryptor(rootDirMetadata); + String plaintext = dec.decrypt("4BwXESMPHMIGXeiyQifg2xBDzblPVdRuU1dy.c9r"); + Assertions.assertEquals("WELCOME.rtf", plaintext); + } + + @Test + @DisplayName("get root dir path") + public void testRootDirPath() { + DirectoryMetadata rootDirMetadata = dirCryptor.rootDirectoryMetadata(); + String path = dirCryptor.dirPath(rootDirMetadata); + Assertions.assertEquals("d/VL/WEHT553J5DR7OZLRJAYDIWFCXZABOD", path); + } + + @Nested + @DisplayName("Given a specific dir.c9f file") + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + class WithDirectoryMetadata { + + DirectoryMetadataImpl dirC9r; + DirectoryContentCryptor.Encrypting enc; + DirectoryContentCryptor.Decrypting dec; + + @BeforeAll + public void setup() { + dirC9r = new DirectoryMetadataImpl("deadbeef-cafe-4bob-beef-decafbadface".getBytes(StandardCharsets.US_ASCII)); + enc = dirCryptor.fileNameEncryptor(dirC9r); + dec = dirCryptor.fileNameDecryptor(dirC9r); + } + + @DisplayName("encrypt multiple file names") + @ParameterizedTest(name = "fileNameEncryptor.encrypt('{0}') == '{1}'") + @CsvSource({ + "file1.txt, sL-e8HOmmqdyIJspqfB0P6zXxoBGHZw9XQ==.c9r", + "file2.txt, hNJmLgIVcneOTeK5E-K_v3Vd9hgb2jJcQA==.c9r", + "file3.txt, qjfr-LCwvfTDMjWmR1CwEAcM7cj-IFDVIw==.c9r", + "file4.txt, dqL5KkgfQveDBRXl3o6FmSZ87apNzNeiDg==.c9r" + }) + public void testBulkEncryption(String plaintext, String ciphertext) { + Assertions.assertEquals(ciphertext, enc.encrypt(plaintext)); + } + + @DisplayName("decrypt multiple file names") + @ParameterizedTest(name = "fileNameDecryptor.decrypt('{1}') == '{0}'") + @CsvSource({ + "file1.txt, sL-e8HOmmqdyIJspqfB0P6zXxoBGHZw9XQ==.c9r", + "file2.txt, hNJmLgIVcneOTeK5E-K_v3Vd9hgb2jJcQA==.c9r", + "file3.txt, qjfr-LCwvfTDMjWmR1CwEAcM7cj-IFDVIw==.c9r", + "file4.txt, dqL5KkgfQveDBRXl3o6FmSZ87apNzNeiDg==.c9r" + }) + public void testBulkDecryption(String plaintext, String ciphertext) { + Assertions.assertEquals(plaintext, dec.decrypt(ciphertext)); + } + + @Test + @DisplayName("decrypt file with invalid extension") + public void testDecryptMalformed1() { + Assertions.assertThrows(IllegalArgumentException.class, () -> { + dec.decrypt("sL-e8HOmmqdyIJspqfB0P6zXxoBGHZw9XQ==.INVALID"); + }); + } + + @Test + @DisplayName("decrypt file with unauthentic ciphertext") + public void testDecryptMalformed2() { + Assertions.assertThrows(AuthenticationFailedException.class, () -> { + dec.decrypt("INVALID-e8HOmmqdyIJspqfB0P6zXxoBGHZw9XQ==.c9r.c9r"); + }); + } + + @Test + @DisplayName("decrypt file with incorrect dirId") + public void testDecryptMalformed3() { + DirectoryMetadataImpl differentDirId = new DirectoryMetadataImpl("deadbeef-cafe-4bob-beef-badbadbadbad".getBytes(StandardCharsets.US_ASCII)); + DirectoryContentCryptor.Decrypting differentDirIdDec = dirCryptor.fileNameDecryptor(differentDirId); + Assertions.assertThrows(AuthenticationFailedException.class, () -> { + differentDirIdDec.decrypt("sL-e8HOmmqdyIJspqfB0P6zXxoBGHZw9XQ==.c9r"); + }); + } + + } + +} \ No newline at end of file diff --git a/src/test/java/org/cryptomator/cryptolib/v1/FileContentCryptorImplBenchmark.java b/src/test/java/org/cryptomator/cryptolib/v1/FileContentCryptorImplBenchmark.java index d8902e3..d54a49c 100644 --- a/src/test/java/org/cryptomator/cryptolib/v1/FileContentCryptorImplBenchmark.java +++ b/src/test/java/org/cryptomator/cryptolib/v1/FileContentCryptorImplBenchmark.java @@ -16,6 +16,7 @@ import javax.crypto.spec.SecretKeySpec; import org.cryptomator.cryptolib.api.Masterkey; +import org.cryptomator.cryptolib.api.PerpetualMasterkey; import org.cryptomator.cryptolib.common.DestroyableSecretKey; import org.cryptomator.cryptolib.common.SecureRandomMock; import org.openjdk.jmh.annotations.Benchmark; @@ -40,7 +41,7 @@ public class FileContentCryptorImplBenchmark { private static final SecureRandom RANDOM_MOCK = SecureRandomMock.PRNG_RANDOM; - private static final Masterkey MASTERKEY = new Masterkey(new byte[64]);; + private static final PerpetualMasterkey MASTERKEY = new PerpetualMasterkey(new byte[64]);; private final byte[] headerNonce = new byte[Constants.NONCE_SIZE]; private final ByteBuffer cleartextChunk = ByteBuffer.allocate(Constants.PAYLOAD_SIZE); private final ByteBuffer ciphertextChunk = ByteBuffer.allocate(Constants.CHUNK_SIZE); diff --git a/src/test/java/org/cryptomator/cryptolib/v1/FileContentCryptorImplTest.java b/src/test/java/org/cryptomator/cryptolib/v1/FileContentCryptorImplTest.java index f4b11bd..8bbfab2 100644 --- a/src/test/java/org/cryptomator/cryptolib/v1/FileContentCryptorImplTest.java +++ b/src/test/java/org/cryptomator/cryptolib/v1/FileContentCryptorImplTest.java @@ -12,11 +12,8 @@ import org.cryptomator.cryptolib.api.AuthenticationFailedException; import org.cryptomator.cryptolib.api.Cryptor; import org.cryptomator.cryptolib.api.Masterkey; -import org.cryptomator.cryptolib.common.DecryptingReadableByteChannel; -import org.cryptomator.cryptolib.common.DestroyableSecretKey; -import org.cryptomator.cryptolib.common.EncryptingWritableByteChannel; -import org.cryptomator.cryptolib.common.SecureRandomMock; -import org.cryptomator.cryptolib.common.SeekableByteChannelMock; +import org.cryptomator.cryptolib.api.PerpetualMasterkey; +import org.cryptomator.cryptolib.common.*; import org.hamcrest.CoreMatchers; import org.hamcrest.MatcherAssert; import org.junit.jupiter.api.Assertions; @@ -55,7 +52,7 @@ public class FileContentCryptorImplTest { @BeforeEach public void setup() { - Masterkey masterkey = new Masterkey(new byte[64]); + PerpetualMasterkey masterkey = new PerpetualMasterkey(new byte[64]); header = new FileHeaderImpl(new byte[FileHeaderImpl.NONCE_LEN], new FileHeaderImpl.Payload(-1, new byte[FileHeaderImpl.Payload.CONTENT_KEY_LEN])); headerCryptor = new FileHeaderCryptorImpl(masterkey, RANDOM_MOCK); fileContentCryptor = new FileContentCryptorImpl(masterkey, RANDOM_MOCK); diff --git a/src/test/java/org/cryptomator/cryptolib/v1/FileContentEncryptorBenchmark.java b/src/test/java/org/cryptomator/cryptolib/v1/FileContentEncryptorBenchmark.java index 229cbea..112ab43 100644 --- a/src/test/java/org/cryptomator/cryptolib/v1/FileContentEncryptorBenchmark.java +++ b/src/test/java/org/cryptomator/cryptolib/v1/FileContentEncryptorBenchmark.java @@ -15,6 +15,7 @@ import java.security.SecureRandom; import java.util.concurrent.TimeUnit; +import org.cryptomator.cryptolib.api.PerpetualMasterkey; import org.cryptomator.cryptolib.common.EncryptingWritableByteChannel; import org.cryptomator.cryptolib.api.Masterkey; import org.cryptomator.cryptolib.common.SecureRandomMock; @@ -40,7 +41,7 @@ public class FileContentEncryptorBenchmark { private static final SecureRandom RANDOM_MOCK = SecureRandomMock.PRNG_RANDOM; - private static final Masterkey MASTERKEY = new Masterkey(new byte[64]); + private static final PerpetualMasterkey MASTERKEY = new PerpetualMasterkey(new byte[64]); private CryptorImpl cryptor; diff --git a/src/test/java/org/cryptomator/cryptolib/v1/FileHeaderCryptorBenchmark.java b/src/test/java/org/cryptomator/cryptolib/v1/FileHeaderCryptorBenchmark.java index 5a89bae..44c74f1 100644 --- a/src/test/java/org/cryptomator/cryptolib/v1/FileHeaderCryptorBenchmark.java +++ b/src/test/java/org/cryptomator/cryptolib/v1/FileHeaderCryptorBenchmark.java @@ -11,6 +11,7 @@ import org.cryptomator.cryptolib.api.AuthenticationFailedException; import org.cryptomator.cryptolib.api.FileHeader; import org.cryptomator.cryptolib.api.Masterkey; +import org.cryptomator.cryptolib.api.PerpetualMasterkey; import org.cryptomator.cryptolib.common.DestroyableSecretKey; import org.cryptomator.cryptolib.common.SecureRandomMock; import org.openjdk.jmh.annotations.Benchmark; @@ -39,7 +40,7 @@ public class FileHeaderCryptorBenchmark { private static final SecureRandom RANDOM_MOCK = SecureRandomMock.PRNG_RANDOM; - private static final Masterkey MASTERKEY = new Masterkey(new byte[64]); + private static final PerpetualMasterkey MASTERKEY = new PerpetualMasterkey(new byte[64]); private static final FileHeaderCryptorImpl HEADER_CRYPTOR = new FileHeaderCryptorImpl(MASTERKEY, RANDOM_MOCK); private FileHeader header; private ByteBuffer validHeaderCiphertextBuf; diff --git a/src/test/java/org/cryptomator/cryptolib/v1/FileHeaderCryptorImplTest.java b/src/test/java/org/cryptomator/cryptolib/v1/FileHeaderCryptorImplTest.java index 5605700..eda018d 100644 --- a/src/test/java/org/cryptomator/cryptolib/v1/FileHeaderCryptorImplTest.java +++ b/src/test/java/org/cryptomator/cryptolib/v1/FileHeaderCryptorImplTest.java @@ -12,6 +12,7 @@ import org.cryptomator.cryptolib.api.AuthenticationFailedException; import org.cryptomator.cryptolib.api.FileHeader; import org.cryptomator.cryptolib.api.Masterkey; +import org.cryptomator.cryptolib.api.PerpetualMasterkey; import org.cryptomator.cryptolib.common.SecureRandomMock; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; @@ -27,7 +28,7 @@ public class FileHeaderCryptorImplTest { @BeforeEach public void setup() { - Masterkey masterkey = new Masterkey(new byte[64]); + PerpetualMasterkey masterkey = new PerpetualMasterkey(new byte[64]); headerCryptor = new FileHeaderCryptorImpl(masterkey, RANDOM_MOCK); } diff --git a/src/test/java/org/cryptomator/cryptolib/v1/FileNameCryptorImplTest.java b/src/test/java/org/cryptomator/cryptolib/v1/FileNameCryptorImplTest.java index 99ef409..088c211 100644 --- a/src/test/java/org/cryptomator/cryptolib/v1/FileNameCryptorImplTest.java +++ b/src/test/java/org/cryptomator/cryptolib/v1/FileNameCryptorImplTest.java @@ -11,6 +11,7 @@ import com.google.common.io.BaseEncoding; import org.cryptomator.cryptolib.api.AuthenticationFailedException; import org.cryptomator.cryptolib.api.Masterkey; +import org.cryptomator.cryptolib.api.PerpetualMasterkey; import org.cryptomator.siv.UnauthenticCiphertextException; import org.hamcrest.CoreMatchers; import org.hamcrest.MatcherAssert; @@ -29,7 +30,7 @@ public class FileNameCryptorImplTest { private static final BaseEncoding BASE32 = BaseEncoding.base32(); - private final Masterkey masterkey = new Masterkey(new byte[64]); + private final PerpetualMasterkey masterkey = new PerpetualMasterkey(new byte[64]); private final FileNameCryptorImpl filenameCryptor = new FileNameCryptorImpl(masterkey); private static Stream filenameGenerator() { diff --git a/src/test/java/org/cryptomator/cryptolib/v2/BenchmarkTest.java b/src/test/java/org/cryptomator/cryptolib/v2/BenchmarkTest.java index c10d849..834c3ed 100644 --- a/src/test/java/org/cryptomator/cryptolib/v2/BenchmarkTest.java +++ b/src/test/java/org/cryptomator/cryptolib/v2/BenchmarkTest.java @@ -9,12 +9,14 @@ package org.cryptomator.cryptolib.v2; import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.openjdk.jmh.runner.Runner; import org.openjdk.jmh.runner.RunnerException; import org.openjdk.jmh.runner.options.Options; import org.openjdk.jmh.runner.options.OptionsBuilder; +@DisplayName("Benchmark V2 (GCM)") public class BenchmarkTest { @Disabled("only on demand") diff --git a/src/test/java/org/cryptomator/cryptolib/v2/CryptorImplTest.java b/src/test/java/org/cryptomator/cryptolib/v2/CryptorImplTest.java index ea33b48..34b8a0b 100644 --- a/src/test/java/org/cryptomator/cryptolib/v2/CryptorImplTest.java +++ b/src/test/java/org/cryptomator/cryptolib/v2/CryptorImplTest.java @@ -1,20 +1,14 @@ -/******************************************************************************* - * Copyright (c) 2016 Sebastian Stenzel and others. - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the accompanying LICENSE.txt. - * - * Contributors: - * Sebastian Stenzel - initial API and implementation - *******************************************************************************/ package org.cryptomator.cryptolib.v2; -import org.cryptomator.cryptolib.api.Masterkey; +import org.cryptomator.cryptolib.api.PerpetualMasterkey; import org.cryptomator.cryptolib.common.SecureRandomMock; import org.hamcrest.CoreMatchers; import org.hamcrest.MatcherAssert; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.Mockito; import java.security.SecureRandom; @@ -23,11 +17,11 @@ public class CryptorImplTest { private static final SecureRandom RANDOM_MOCK = SecureRandomMock.NULL_RANDOM; - private Masterkey masterkey; + private PerpetualMasterkey masterkey; @BeforeEach public void setup() { - this.masterkey = new Masterkey(new byte[64]); + this.masterkey = new PerpetualMasterkey(new byte[64]); } @Test @@ -51,9 +45,17 @@ public void testGetFileNameCryptor() { } } + @ParameterizedTest + @ValueSource(ints = {-1, 0, 1, 42, 1337}) + public void testGetFileNameCryptorWithRevisions(int revision) { + try (CryptorImpl cryptor = new CryptorImpl(masterkey, RANDOM_MOCK)) { + Assertions.assertThrows(UnsupportedOperationException.class, () -> cryptor.fileNameCryptor(revision)); + } + } + @Test public void testExplicitDestruction() { - Masterkey masterkey = Mockito.mock(Masterkey.class); + PerpetualMasterkey masterkey = Mockito.mock(PerpetualMasterkey.class); try (CryptorImpl cryptor = new CryptorImpl(masterkey, RANDOM_MOCK)) { cryptor.destroy(); Mockito.verify(masterkey).destroy(); @@ -64,7 +66,7 @@ public void testExplicitDestruction() { @Test public void testImplicitDestruction() { - Masterkey masterkey = Mockito.mock(Masterkey.class); + PerpetualMasterkey masterkey = Mockito.mock(PerpetualMasterkey.class); try (CryptorImpl cryptor = new CryptorImpl(masterkey, RANDOM_MOCK)) { Assertions.assertFalse(cryptor.isDestroyed()); } diff --git a/src/test/java/org/cryptomator/cryptolib/v2/CryptorProviderImplTest.java b/src/test/java/org/cryptomator/cryptolib/v2/CryptorProviderImplTest.java index 95a2618..cecf1e4 100644 --- a/src/test/java/org/cryptomator/cryptolib/v2/CryptorProviderImplTest.java +++ b/src/test/java/org/cryptomator/cryptolib/v2/CryptorProviderImplTest.java @@ -8,7 +8,9 @@ *******************************************************************************/ package org.cryptomator.cryptolib.v2; +import org.cryptomator.cryptolib.api.CryptorProvider; import org.cryptomator.cryptolib.api.Masterkey; +import org.cryptomator.cryptolib.api.PerpetualMasterkey; import org.cryptomator.cryptolib.common.SecureRandomMock; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; @@ -23,9 +25,9 @@ public class CryptorProviderImplTest { @Test public void testProvide() { - Masterkey masterkey = Mockito.mock(Masterkey.class); - CryptorImpl cryptor = new CryptorProviderImpl().provide(masterkey, RANDOM_MOCK); - Assertions.assertNotNull(cryptor); + PerpetualMasterkey masterkey = Mockito.mock(PerpetualMasterkey.class); + CryptorProvider provider = new CryptorProviderImpl(); + Assertions.assertInstanceOf(CryptorImpl.class, provider.provide(masterkey, RANDOM_MOCK)); } } diff --git a/src/test/java/org/cryptomator/cryptolib/v2/DirectoryContentCryptorImplTest.java b/src/test/java/org/cryptomator/cryptolib/v2/DirectoryContentCryptorImplTest.java new file mode 100644 index 0000000..f8ab676 --- /dev/null +++ b/src/test/java/org/cryptomator/cryptolib/v2/DirectoryContentCryptorImplTest.java @@ -0,0 +1,140 @@ +package org.cryptomator.cryptolib.v2; + +import org.cryptomator.cryptolib.api.AuthenticationFailedException; +import org.cryptomator.cryptolib.api.CryptorProvider; +import org.cryptomator.cryptolib.api.DirectoryContentCryptor; +import org.cryptomator.cryptolib.api.DirectoryMetadata; +import org.cryptomator.cryptolib.api.PerpetualMasterkey; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Arrays; + +class DirectoryContentCryptorImplTest { + + private static final SecureRandom CSPRNG = new SecureRandom(); + private static DirectoryContentCryptorImpl dirCryptor; + + @BeforeAll + public static void setUp() { + byte[] key = new byte[64]; + Arrays.fill(key, 0, 32, (byte) 0x55); // enc key + Arrays.fill(key, 32, 64, (byte) 0x77); // mac key + PerpetualMasterkey masterkey = new PerpetualMasterkey(key); + dirCryptor = (DirectoryContentCryptorImpl) CryptorProvider.forScheme(CryptorProvider.Scheme.SIV_GCM).provide(masterkey, CSPRNG).directoryContentCryptor(); + } + + @Test + @DisplayName("encrypt and decrypt dir.c9r files") + public void encryptAndDecryptDirectoryMetadata() { + DirectoryMetadataImpl origMetadata = dirCryptor.newDirectoryMetadata(); + + byte[] encryptedMetadata = dirCryptor.encryptDirectoryMetadata(origMetadata); + DirectoryMetadataImpl decryptedMetadata = dirCryptor.decryptDirectoryMetadata(encryptedMetadata); + + Assertions.assertArrayEquals(origMetadata.dirId(), decryptedMetadata.dirId()); + } + + @Test + @DisplayName("encrypt WELCOME.rtf in root dir") + public void testEncryptReadme() { + DirectoryMetadata rootDirMetadata = dirCryptor.rootDirectoryMetadata(); + DirectoryContentCryptor.Encrypting enc = dirCryptor.fileNameEncryptor(rootDirMetadata); + String ciphertext = enc.encrypt("WELCOME.rtf"); + Assertions.assertEquals("4BwXESMPHMIGXeiyQifg2xBDzblPVdRuU1dy.c9r", ciphertext); + } + + @Test + @DisplayName("decrypt WELCOME.rtf in root dir") + public void testDecryptReadme() { + DirectoryMetadata rootDirMetadata = dirCryptor.rootDirectoryMetadata(); + DirectoryContentCryptor.Decrypting dec = dirCryptor.fileNameDecryptor(rootDirMetadata); + String plaintext = dec.decrypt("4BwXESMPHMIGXeiyQifg2xBDzblPVdRuU1dy.c9r"); + Assertions.assertEquals("WELCOME.rtf", plaintext); + } + + @Test + @DisplayName("get root dir path") + public void testRootDirPath() { + DirectoryMetadata rootDirMetadata = dirCryptor.rootDirectoryMetadata(); + String path = dirCryptor.dirPath(rootDirMetadata); + Assertions.assertEquals("d/VL/WEHT553J5DR7OZLRJAYDIWFCXZABOD", path); + } + + @Nested + @DisplayName("Given a specific dir.c9f file") + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + class WithDirectoryMetadata { + + DirectoryMetadataImpl dirC9r; + DirectoryContentCryptor.Encrypting enc; + DirectoryContentCryptor.Decrypting dec; + + @BeforeAll + public void setup() { + dirC9r = new DirectoryMetadataImpl("deadbeef-cafe-4bob-beef-decafbadface".getBytes(StandardCharsets.US_ASCII)); + enc = dirCryptor.fileNameEncryptor(dirC9r); + dec = dirCryptor.fileNameDecryptor(dirC9r); + } + + @DisplayName("encrypt multiple file names") + @ParameterizedTest(name = "fileNameEncryptor.encrypt('{0}') == '{1}'") + @CsvSource({ + "file1.txt, sL-e8HOmmqdyIJspqfB0P6zXxoBGHZw9XQ==.c9r", + "file2.txt, hNJmLgIVcneOTeK5E-K_v3Vd9hgb2jJcQA==.c9r", + "file3.txt, qjfr-LCwvfTDMjWmR1CwEAcM7cj-IFDVIw==.c9r", + "file4.txt, dqL5KkgfQveDBRXl3o6FmSZ87apNzNeiDg==.c9r" + }) + public void testBulkEncryption(String plaintext, String ciphertext) { + Assertions.assertEquals(ciphertext, enc.encrypt(plaintext)); + } + + @DisplayName("decrypt multiple file names") + @ParameterizedTest(name = "fileNameDecryptor.decrypt('{1}') == '{0}'") + @CsvSource({ + "file1.txt, sL-e8HOmmqdyIJspqfB0P6zXxoBGHZw9XQ==.c9r", + "file2.txt, hNJmLgIVcneOTeK5E-K_v3Vd9hgb2jJcQA==.c9r", + "file3.txt, qjfr-LCwvfTDMjWmR1CwEAcM7cj-IFDVIw==.c9r", + "file4.txt, dqL5KkgfQveDBRXl3o6FmSZ87apNzNeiDg==.c9r" + }) + public void testBulkDecryption(String plaintext, String ciphertext) { + Assertions.assertEquals(plaintext, dec.decrypt(ciphertext)); + } + + @Test + @DisplayName("decrypt file with invalid extension") + public void testDecryptMalformed1() { + Assertions.assertThrows(IllegalArgumentException.class, () -> { + dec.decrypt("sL-e8HOmmqdyIJspqfB0P6zXxoBGHZw9XQ==.INVALID"); + }); + } + + @Test + @DisplayName("decrypt file with unauthentic ciphertext") + public void testDecryptMalformed2() { + Assertions.assertThrows(AuthenticationFailedException.class, () -> { + dec.decrypt("INVALID-e8HOmmqdyIJspqfB0P6zXxoBGHZw9XQ==.c9r.c9r"); + }); + } + + @Test + @DisplayName("decrypt file with incorrect dirId") + public void testDecryptMalformed3() { + DirectoryMetadataImpl differentDirId = new DirectoryMetadataImpl("deadbeef-cafe-4bob-beef-badbadbadbad".getBytes(StandardCharsets.US_ASCII)); + DirectoryContentCryptor.Decrypting differentDirIdDec = dirCryptor.fileNameDecryptor(differentDirId); + Assertions.assertThrows(AuthenticationFailedException.class, () -> { + differentDirIdDec.decrypt("sL-e8HOmmqdyIJspqfB0P6zXxoBGHZw9XQ==.c9r"); + }); + } + + } + +} \ No newline at end of file diff --git a/src/test/java/org/cryptomator/cryptolib/v2/FileContentCryptorImplTest.java b/src/test/java/org/cryptomator/cryptolib/v2/FileContentCryptorImplTest.java index 10b1e69..dcbabc0 100644 --- a/src/test/java/org/cryptomator/cryptolib/v2/FileContentCryptorImplTest.java +++ b/src/test/java/org/cryptomator/cryptolib/v2/FileContentCryptorImplTest.java @@ -9,14 +9,14 @@ package org.cryptomator.cryptolib.v2; import com.google.common.io.BaseEncoding; -import org.cryptomator.cryptolib.common.CipherSupplier; -import org.cryptomator.cryptolib.common.DecryptingReadableByteChannel; -import org.cryptomator.cryptolib.common.EncryptingWritableByteChannel; import org.cryptomator.cryptolib.api.AuthenticationFailedException; import org.cryptomator.cryptolib.api.Cryptor; import org.cryptomator.cryptolib.api.FileHeader; -import org.cryptomator.cryptolib.api.Masterkey; +import org.cryptomator.cryptolib.api.PerpetualMasterkey; +import org.cryptomator.cryptolib.common.CipherSupplier; +import org.cryptomator.cryptolib.common.DecryptingReadableByteChannel; import org.cryptomator.cryptolib.common.DestroyableSecretKey; +import org.cryptomator.cryptolib.common.EncryptingWritableByteChannel; import org.cryptomator.cryptolib.common.GcmTestHelper; import org.cryptomator.cryptolib.common.ObjectPool; import org.cryptomator.cryptolib.common.SecureRandomMock; @@ -64,7 +64,7 @@ public class FileContentCryptorImplTest { @BeforeEach public void setup() { - Masterkey masterkey = new Masterkey(new byte[64]); + PerpetualMasterkey masterkey = new PerpetualMasterkey(new byte[64]); header = new FileHeaderImpl(new byte[FileHeaderImpl.NONCE_LEN], new FileHeaderImpl.Payload(-1, new byte[FileHeaderImpl.Payload.CONTENT_KEY_LEN])); headerCryptor = new FileHeaderCryptorImpl(masterkey, CSPRNG); fileContentCryptor = new FileContentCryptorImpl(CSPRNG); diff --git a/src/test/java/org/cryptomator/cryptolib/v2/FileContentEncryptorBenchmark.java b/src/test/java/org/cryptomator/cryptolib/v2/FileContentEncryptorBenchmark.java index c64ee04..65a95d1 100644 --- a/src/test/java/org/cryptomator/cryptolib/v2/FileContentEncryptorBenchmark.java +++ b/src/test/java/org/cryptomator/cryptolib/v2/FileContentEncryptorBenchmark.java @@ -15,6 +15,7 @@ import java.security.SecureRandom; import java.util.concurrent.TimeUnit; +import org.cryptomator.cryptolib.api.PerpetualMasterkey; import org.cryptomator.cryptolib.common.EncryptingWritableByteChannel; import org.cryptomator.cryptolib.api.Masterkey; import org.cryptomator.cryptolib.common.SecureRandomMock; @@ -40,7 +41,7 @@ public class FileContentEncryptorBenchmark { private static final SecureRandom RANDOM_MOCK = SecureRandomMock.PRNG_RANDOM; - private static final Masterkey MASTERKEY = new Masterkey(new byte[64]); + private static final PerpetualMasterkey MASTERKEY = new PerpetualMasterkey(new byte[64]); private CryptorImpl cryptor; diff --git a/src/test/java/org/cryptomator/cryptolib/v2/FileHeaderCryptorBenchmark.java b/src/test/java/org/cryptomator/cryptolib/v2/FileHeaderCryptorBenchmark.java index 1d2cd4b..541e41f 100644 --- a/src/test/java/org/cryptomator/cryptolib/v2/FileHeaderCryptorBenchmark.java +++ b/src/test/java/org/cryptomator/cryptolib/v2/FileHeaderCryptorBenchmark.java @@ -11,6 +11,7 @@ import org.cryptomator.cryptolib.api.AuthenticationFailedException; import org.cryptomator.cryptolib.api.FileHeader; import org.cryptomator.cryptolib.api.Masterkey; +import org.cryptomator.cryptolib.api.PerpetualMasterkey; import org.cryptomator.cryptolib.common.DestroyableSecretKey; import org.cryptomator.cryptolib.common.SecureRandomMock; import org.openjdk.jmh.annotations.Benchmark; @@ -39,7 +40,7 @@ public class FileHeaderCryptorBenchmark { private static final SecureRandom RANDOM_MOCK = SecureRandomMock.PRNG_RANDOM; - private static final Masterkey MASTERKEY = new Masterkey(new byte[64]); + private static final PerpetualMasterkey MASTERKEY = new PerpetualMasterkey(new byte[64]); private static final FileHeaderCryptorImpl HEADER_CRYPTOR = new FileHeaderCryptorImpl(MASTERKEY, RANDOM_MOCK); private ByteBuffer validHeaderCiphertextBuf; diff --git a/src/test/java/org/cryptomator/cryptolib/v2/FileHeaderCryptorImplTest.java b/src/test/java/org/cryptomator/cryptolib/v2/FileHeaderCryptorImplTest.java index baea1a5..5b729e2 100644 --- a/src/test/java/org/cryptomator/cryptolib/v2/FileHeaderCryptorImplTest.java +++ b/src/test/java/org/cryptomator/cryptolib/v2/FileHeaderCryptorImplTest.java @@ -12,6 +12,7 @@ import org.cryptomator.cryptolib.api.AuthenticationFailedException; import org.cryptomator.cryptolib.api.FileHeader; import org.cryptomator.cryptolib.api.Masterkey; +import org.cryptomator.cryptolib.api.PerpetualMasterkey; import org.cryptomator.cryptolib.common.CipherSupplier; import org.cryptomator.cryptolib.common.GcmTestHelper; import org.cryptomator.cryptolib.common.ObjectPool; @@ -32,7 +33,7 @@ public class FileHeaderCryptorImplTest { @BeforeEach public void setup() { - Masterkey masterkey = new Masterkey(new byte[64]); + PerpetualMasterkey masterkey = new PerpetualMasterkey(new byte[64]); headerCryptor = new FileHeaderCryptorImpl(masterkey, RANDOM_MOCK); // reset cipher state to avoid InvalidAlgorithmParameterExceptions due to IV-reuse diff --git a/src/test/java/org/cryptomator/cryptolib/v2/FileNameCryptorImplTest.java b/src/test/java/org/cryptomator/cryptolib/v2/FileNameCryptorImplTest.java index d808f89..4069392 100644 --- a/src/test/java/org/cryptomator/cryptolib/v2/FileNameCryptorImplTest.java +++ b/src/test/java/org/cryptomator/cryptolib/v2/FileNameCryptorImplTest.java @@ -11,6 +11,7 @@ import com.google.common.io.BaseEncoding; import org.cryptomator.cryptolib.api.AuthenticationFailedException; import org.cryptomator.cryptolib.api.Masterkey; +import org.cryptomator.cryptolib.api.PerpetualMasterkey; import org.cryptomator.siv.UnauthenticCiphertextException; import org.hamcrest.CoreMatchers; import org.hamcrest.MatcherAssert; @@ -30,7 +31,7 @@ public class FileNameCryptorImplTest { private static final BaseEncoding BASE32 = BaseEncoding.base32(); - private final Masterkey masterkey = new Masterkey(new byte[64]); + private final PerpetualMasterkey masterkey = new PerpetualMasterkey(new byte[64]); private final FileNameCryptorImpl filenameCryptor = new FileNameCryptorImpl(masterkey); private static Stream filenameGenerator() { diff --git a/src/test/java/org/cryptomator/cryptolib/v3/BenchmarkTest.java b/src/test/java/org/cryptomator/cryptolib/v3/BenchmarkTest.java new file mode 100644 index 0000000..714488d --- /dev/null +++ b/src/test/java/org/cryptomator/cryptolib/v3/BenchmarkTest.java @@ -0,0 +1,31 @@ +package org.cryptomator.cryptolib.v3; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.openjdk.jmh.runner.Runner; +import org.openjdk.jmh.runner.RunnerException; +import org.openjdk.jmh.runner.options.Options; +import org.openjdk.jmh.runner.options.OptionsBuilder; + +@DisplayName("Benchmark V3 (UVF)") +public class BenchmarkTest { + + @Disabled("only on demand") + @Test + public void runBenchmarks() throws RunnerException { + // Taken from http://stackoverflow.com/a/30486197/4014509: + Options opt = new OptionsBuilder() + // Specify which benchmarks to run + .include(getClass().getPackage().getName() + ".*Benchmark.*") + // Set the following options as needed + .threads(2).forks(1) // + .shouldFailOnError(true).shouldDoGC(true) + // .jvmArgs("-XX:+UnlockDiagnosticVMOptions", "-XX:+PrintInlining") + // .addProfiler(WinPerfAsmProfiler.class) + .build(); + + new Runner(opt).run(); + } + +} diff --git a/src/test/java/org/cryptomator/cryptolib/v3/CryptorImplTest.java b/src/test/java/org/cryptomator/cryptolib/v3/CryptorImplTest.java new file mode 100644 index 0000000..194ce41 --- /dev/null +++ b/src/test/java/org/cryptomator/cryptolib/v3/CryptorImplTest.java @@ -0,0 +1,83 @@ +package org.cryptomator.cryptolib.v3; + +import org.cryptomator.cryptolib.api.UVFMasterkey; +import org.cryptomator.cryptolib.common.SecureRandomMock; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.security.SecureRandom; +import java.util.Base64; +import java.util.Collections; +import java.util.Map; + +public class CryptorImplTest { + + private static final SecureRandom RANDOM_MOCK = SecureRandomMock.NULL_RANDOM; + private static final Map SEEDS = Collections.singletonMap(-1540072521, Base64.getUrlDecoder().decode("fP4V4oAjsUw5DqackAvLzA0oP1kAQZ0f5YFZQviXSuU")); + private static final byte[] KDF_SALT = Base64.getUrlDecoder().decode("HE4OP-2vyfLLURicF1XmdIIsWv0Zs6MobLKROUIEhQY"); + + private UVFMasterkey masterkey; + + @BeforeEach + public void setup() { + masterkey = new UVFMasterkey(SEEDS, KDF_SALT, -1540072521, -1540072521); + } + + @Test + public void testGetFileContentCryptor() { + try (CryptorImpl cryptor = new CryptorImpl(masterkey, RANDOM_MOCK)) { + Assertions.assertInstanceOf(FileContentCryptorImpl.class, cryptor.fileContentCryptor()); + } + } + + @Test + public void testGetFileHeaderCryptor() { + try (CryptorImpl cryptor = new CryptorImpl(masterkey, RANDOM_MOCK)) { + Assertions.assertInstanceOf(FileHeaderCryptorImpl.class, cryptor.fileHeaderCryptor()); + } + } + + @Test + public void testGetFileNameCryptor() { + try (CryptorImpl cryptor = new CryptorImpl(masterkey, RANDOM_MOCK)) { + Assertions.assertThrows(UnsupportedOperationException.class, cryptor::fileNameCryptor); + } + } + + @Test + public void testGetFileNameCryptorWithInvalidRevisions() { + try (CryptorImpl cryptor = new CryptorImpl(masterkey, RANDOM_MOCK)) { + Assertions.assertThrows(IllegalArgumentException.class, () -> cryptor.fileNameCryptor(0xBAD5EED)); + } + } + + @Test + public void testGetFileNameCryptorWithCorrectRevisions() { + try (CryptorImpl cryptor = new CryptorImpl(masterkey, RANDOM_MOCK)) { + Assertions.assertInstanceOf(FileNameCryptorImpl.class, cryptor.fileNameCryptor(-1540072521)); + } + } + + @Test + public void testExplicitDestruction() { + UVFMasterkey masterkey = Mockito.mock(UVFMasterkey.class); + try (CryptorImpl cryptor = new CryptorImpl(masterkey, RANDOM_MOCK)) { + cryptor.destroy(); + Mockito.verify(masterkey).destroy(); + Mockito.when(masterkey.isDestroyed()).thenReturn(true); + Assertions.assertTrue(cryptor.isDestroyed()); + } + } + + @Test + public void testImplicitDestruction() { + UVFMasterkey masterkey = Mockito.mock(UVFMasterkey.class); + try (CryptorImpl cryptor = new CryptorImpl(masterkey, RANDOM_MOCK)) { + Assertions.assertFalse(cryptor.isDestroyed()); + } + Mockito.verify(masterkey).destroy(); + } + +} diff --git a/src/test/java/org/cryptomator/cryptolib/v3/CryptorProviderImplTest.java b/src/test/java/org/cryptomator/cryptolib/v3/CryptorProviderImplTest.java new file mode 100644 index 0000000..be4bc9d --- /dev/null +++ b/src/test/java/org/cryptomator/cryptolib/v3/CryptorProviderImplTest.java @@ -0,0 +1,23 @@ +package org.cryptomator.cryptolib.v3; + +import org.cryptomator.cryptolib.api.CryptorProvider; +import org.cryptomator.cryptolib.api.RevolvingMasterkey; +import org.cryptomator.cryptolib.common.SecureRandomMock; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.security.SecureRandom; + +public class CryptorProviderImplTest { + + private static final SecureRandom RANDOM_MOCK = SecureRandomMock.NULL_RANDOM; + + @Test + public void testProvide() { + RevolvingMasterkey masterkey = Mockito.mock(RevolvingMasterkey.class); + CryptorProvider provider = new CryptorProviderImpl(); + Assertions.assertInstanceOf(CryptorImpl.class, provider.provide(masterkey, RANDOM_MOCK)); + } + +} diff --git a/src/test/java/org/cryptomator/cryptolib/v3/DirectoryContentCryptorImplTest.java b/src/test/java/org/cryptomator/cryptolib/v3/DirectoryContentCryptorImplTest.java new file mode 100644 index 0000000..fd8f6ed --- /dev/null +++ b/src/test/java/org/cryptomator/cryptolib/v3/DirectoryContentCryptorImplTest.java @@ -0,0 +1,162 @@ +package org.cryptomator.cryptolib.v3; + +import org.cryptomator.cryptolib.api.AuthenticationFailedException; +import org.cryptomator.cryptolib.api.CryptorProvider; +import org.cryptomator.cryptolib.api.DirectoryContentCryptor; +import org.cryptomator.cryptolib.api.DirectoryMetadata; +import org.cryptomator.cryptolib.api.UVFMasterkey; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import java.security.SecureRandom; + +class DirectoryContentCryptorImplTest { + + private static final SecureRandom CSPRNG = new SecureRandom(); + private static UVFMasterkey masterkey; + private static DirectoryContentCryptorImpl dirCryptor; + + @BeforeAll + public static void setUp() { + // copied from UVFMasterkeyTest: + String json = "{\n" + + " \"fileFormat\": \"AES-256-GCM-32k\",\n" + + " \"nameFormat\": \"AES-SIV-512-B64URL\",\n" + + " \"seeds\": {\n" + + " \"HDm38g\": \"ypeBEsobvcr6wjGzmiPcTaeG7_gUfE5yuYB3ha_uSLs\",\n" + + " \"gBryKw\": \"PiPoFgA5WUoziU9lZOGxNIu9egCI1CxKy3PurtWcAJ0\",\n" + + " \"QBsJFg\": \"Ln0sA6lQeuJl7PW1NWiFpTOTogKdJBOUmXJloaJa78Y\"\n" + + " },\n" + + " \"initialSeed\": \"HDm38i\",\n" + + " \"latestSeed\": \"QBsJFo\",\n" + + " \"kdf\": \"HKDF-SHA512\",\n" + + " \"kdfSalt\": \"NIlr89R7FhochyP4yuXZmDqCnQ0dBB3UZ2D-6oiIjr8\",\n" + + " \"org.example.customfield\": 42\n" + + "}"; + masterkey = UVFMasterkey.fromDecryptedPayload(json); + dirCryptor = (DirectoryContentCryptorImpl) CryptorProvider.forScheme(CryptorProvider.Scheme.UVF_DRAFT).provide(masterkey, CSPRNG).directoryContentCryptor(); + } + + @Test + @DisplayName("encrypt and decrypt dir.uvf files") + public void encryptAndDecryptDirectoryMetadata() { + DirectoryMetadataImpl origMetadata = dirCryptor.newDirectoryMetadata(); + + byte[] encryptedMetadata = dirCryptor.encryptDirectoryMetadata(origMetadata); + DirectoryMetadataImpl decryptedMetadata = dirCryptor.decryptDirectoryMetadata(encryptedMetadata); + + Assertions.assertEquals(origMetadata.seedId(), decryptedMetadata.seedId()); + Assertions.assertArrayEquals(origMetadata.dirId(), decryptedMetadata.dirId()); + } + + @Test + @DisplayName("encrypt WELCOME.rtf in root dir") + public void testEncryptReadme() { + DirectoryMetadata rootDirMetadata = dirCryptor.rootDirectoryMetadata(); + DirectoryContentCryptor.Encrypting enc = dirCryptor.fileNameEncryptor(rootDirMetadata); + String ciphertext = enc.encrypt("WELCOME.rtf"); + Assertions.assertEquals("Dx1binBPsg_KNby6KFD_2k3vZHPgo39rg4ks.uvf", ciphertext); + } + + @Test + @DisplayName("decrypt WELCOME.rtf in root dir") + public void testDecryptReadme() { + DirectoryMetadata rootDirMetadata = dirCryptor.rootDirectoryMetadata(); + DirectoryContentCryptor.Decrypting dec = dirCryptor.fileNameDecryptor(rootDirMetadata); + String plaintext = dec.decrypt("Dx1binBPsg_KNby6KFD_2k3vZHPgo39rg4ks.uvf"); + Assertions.assertEquals("WELCOME.rtf", plaintext); + } + + @Test + @DisplayName("get root dir path") + public void testRootDirPath() { + DirectoryMetadata rootDirMetadata = dirCryptor.rootDirectoryMetadata(); + String path = dirCryptor.dirPath(rootDirMetadata); + Assertions.assertEquals("d/RZ/K7ZH7KBXULNEKBMGX3CU42PGUIAIX4", path); + } + + @Nested + @DisplayName("Given a specific dir.uvf file") + @TestInstance(TestInstance.Lifecycle.PER_CLASS) + class WithDirectoryMetadata { + + DirectoryMetadataImpl dirUvf; + DirectoryContentCryptor.Encrypting enc; + DirectoryContentCryptor.Decrypting dec; + + @BeforeAll + public void setup() { + dirUvf = new DirectoryMetadataImpl(masterkey.currentRevision(), new byte[32]); + enc = dirCryptor.fileNameEncryptor(dirUvf); + dec = dirCryptor.fileNameDecryptor(dirUvf); + } + + @DisplayName("encrypt multiple file names") + @ParameterizedTest(name = "fileNameEncryptor.encrypt('{0}') == '{1}'") + @CsvSource({ + "file1.txt, NIWamUJBS3u619f3yKOWlT2q_raURsHXhg==.uvf", + "file2.txt, _EWTVc9qooJQyk-P9pwQkvSu9mFb0UWNeg==.uvf", + "file3.txt, dunZsv8VRuh81R-u6pioPx2DWeQAU0nLfw==.uvf", + "file4.txt, 2-clI661p9TBSzC2IJjvBF3ehaKas5Vqxg==.uvf" + }) + public void testBulkEncryption(String plaintext, String ciphertext) { + Assertions.assertEquals(ciphertext, enc.encrypt(plaintext)); + } + + @DisplayName("decrypt multiple file names") + @ParameterizedTest(name = "fileNameDecryptor.decrypt('{1}') == '{0}'") + @CsvSource({ + "file1.txt, NIWamUJBS3u619f3yKOWlT2q_raURsHXhg==.uvf", + "file2.txt, _EWTVc9qooJQyk-P9pwQkvSu9mFb0UWNeg==.uvf", + "file3.txt, dunZsv8VRuh81R-u6pioPx2DWeQAU0nLfw==.uvf", + "file4.txt, 2-clI661p9TBSzC2IJjvBF3ehaKas5Vqxg==.uvf" + }) + public void testBulkDecryption(String plaintext, String ciphertext) { + Assertions.assertEquals(plaintext, dec.decrypt(ciphertext)); + } + + @Test + @DisplayName("decrypt file with invalid extension") + public void testDecryptMalformed1() { + Assertions.assertThrows(IllegalArgumentException.class, () -> { + dec.decrypt("NIWamUJBS3u619f3yKOWlT2q_raURsHXhg==.INVALID"); + }); + } + + @Test + @DisplayName("decrypt file with unauthentic ciphertext") + public void testDecryptMalformed2() { + Assertions.assertThrows(AuthenticationFailedException.class, () -> { + dec.decrypt("INVALIDamUJBS3u619f3yKOWlT2q_raURsHXhg==.uvf"); + }); + } + + @Test + @DisplayName("decrypt file with incorrect seed") + public void testDecryptMalformed3() { + DirectoryMetadataImpl differentRevision = new DirectoryMetadataImpl(masterkey.firstRevision(), new byte[32]); + DirectoryContentCryptor.Decrypting differentRevisionDec = dirCryptor.fileNameDecryptor(differentRevision); + Assertions.assertThrows(AuthenticationFailedException.class, () -> { + differentRevisionDec.decrypt("NIWamUJBS3u619f3yKOWlT2q_raURsHXhg==.uvf"); + }); + } + + @Test + @DisplayName("decrypt file with incorrect dirId") + public void testDecryptMalformed4() { + DirectoryMetadataImpl differentDirId = new DirectoryMetadataImpl(masterkey.firstRevision(), new byte[]{(byte) 0xDE, (byte) 0x0AD}); + DirectoryContentCryptor.Decrypting differentDirIdDec = dirCryptor.fileNameDecryptor(differentDirId); + Assertions.assertThrows(AuthenticationFailedException.class, () -> { + differentDirIdDec.decrypt("NIWamUJBS3u619f3yKOWlT2q_raURsHXhg==.uvf"); + }); + } + + } + +} \ No newline at end of file diff --git a/src/test/java/org/cryptomator/cryptolib/v3/FileContentCryptorImplBenchmark.java b/src/test/java/org/cryptomator/cryptolib/v3/FileContentCryptorImplBenchmark.java new file mode 100644 index 0000000..39a4f33 --- /dev/null +++ b/src/test/java/org/cryptomator/cryptolib/v3/FileContentCryptorImplBenchmark.java @@ -0,0 +1,65 @@ +package org.cryptomator.cryptolib.v3; + +import org.cryptomator.cryptolib.api.AuthenticationFailedException; +import org.cryptomator.cryptolib.common.DestroyableSecretKey; +import org.cryptomator.cryptolib.common.SecureRandomMock; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; + +import java.nio.ByteBuffer; +import java.security.SecureRandom; +import java.util.concurrent.TimeUnit; + +/** + * Needs to be compiled via maven as the JMH annotation processor needs to do stuff... + */ +@State(Scope.Thread) +@Warmup(iterations = 3, time = 1, timeUnit = TimeUnit.SECONDS) +@Measurement(iterations = 2, time = 1, timeUnit = TimeUnit.SECONDS) +@BenchmarkMode(value = {Mode.AverageTime}) +@OutputTimeUnit(TimeUnit.MICROSECONDS) +public class FileContentCryptorImplBenchmark { + + private static final SecureRandom RANDOM_MOCK = SecureRandomMock.PRNG_RANDOM; + private static final DestroyableSecretKey ENC_KEY = new DestroyableSecretKey(new byte[16], "AES"); + private final byte[] headerNonce = new byte[FileHeaderImpl.NONCE_LEN]; + private final ByteBuffer cleartextChunk = ByteBuffer.allocate(Constants.PAYLOAD_SIZE); + private final ByteBuffer ciphertextChunk = ByteBuffer.allocate(Constants.CHUNK_SIZE); + private final FileContentCryptorImpl fileContentCryptor = new FileContentCryptorImpl(RANDOM_MOCK); + private long chunkNumber; + + @Setup(Level.Trial) + public void prepareData() { + cleartextChunk.rewind(); + fileContentCryptor.encryptChunk(cleartextChunk, ciphertextChunk, 0l, new byte[12], ENC_KEY); + ciphertextChunk.flip(); + } + + @Setup(Level.Invocation) + public void shuffleData() { + chunkNumber = RANDOM_MOCK.nextLong(); + cleartextChunk.rewind(); + ciphertextChunk.rewind(); + RANDOM_MOCK.nextBytes(headerNonce); + RANDOM_MOCK.nextBytes(cleartextChunk.array()); + } + + @Benchmark + public void benchmarkEncryption() { + fileContentCryptor.encryptChunk(cleartextChunk, ciphertextChunk, chunkNumber, headerNonce, ENC_KEY); + } + + @Benchmark + public void benchmarkDecryption() throws AuthenticationFailedException { + fileContentCryptor.decryptChunk(ciphertextChunk, cleartextChunk, 0l, new byte[12], ENC_KEY); + } + +} diff --git a/src/test/java/org/cryptomator/cryptolib/v3/FileContentCryptorImplTest.java b/src/test/java/org/cryptomator/cryptolib/v3/FileContentCryptorImplTest.java new file mode 100644 index 0000000..838b0ee --- /dev/null +++ b/src/test/java/org/cryptomator/cryptolib/v3/FileContentCryptorImplTest.java @@ -0,0 +1,307 @@ +package org.cryptomator.cryptolib.v3; + +import com.google.common.io.BaseEncoding; +import org.cryptomator.cryptolib.api.AuthenticationFailedException; +import org.cryptomator.cryptolib.api.Cryptor; +import org.cryptomator.cryptolib.api.FileHeader; +import org.cryptomator.cryptolib.api.UVFMasterkey; +import org.cryptomator.cryptolib.common.CipherSupplier; +import org.cryptomator.cryptolib.common.DecryptingReadableByteChannel; +import org.cryptomator.cryptolib.common.DestroyableSecretKey; +import org.cryptomator.cryptolib.common.EncryptingWritableByteChannel; +import org.cryptomator.cryptolib.common.GcmTestHelper; +import org.cryptomator.cryptolib.common.ObjectPool; +import org.cryptomator.cryptolib.common.SecureRandomMock; +import org.cryptomator.cryptolib.common.SeekableByteChannelMock; +import org.hamcrest.CoreMatchers; +import org.hamcrest.MatcherAssert; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mockito; + +import javax.crypto.Cipher; +import java.io.ByteArrayInputStream; +import java.io.EOFException; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.channels.Channels; +import java.nio.channels.ReadableByteChannel; +import java.nio.channels.SeekableByteChannel; +import java.nio.channels.WritableByteChannel; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.Base64; +import java.util.Collections; +import java.util.Map; + +import static java.nio.charset.StandardCharsets.US_ASCII; +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.cryptomator.cryptolib.v3.Constants.GCM_NONCE_SIZE; +import static org.cryptomator.cryptolib.v3.Constants.GCM_TAG_SIZE; + +public class FileContentCryptorImplTest { + + // AES-GCM implementation requires non-repeating nonces, still we need deterministic nonces for testing + private static final SecureRandom CSPRNG = Mockito.spy(SecureRandomMock.cycle((byte) 0xF0, (byte) 0x0F)); + private static final Map SEEDS = Collections.singletonMap(-1540072521, Base64.getUrlDecoder().decode("fP4V4oAjsUw5DqackAvLzA0oP1kAQZ0f5YFZQviXSuU")); + private static final byte[] KDF_SALT = Base64.getUrlDecoder().decode("HE4OP-2vyfLLURicF1XmdIIsWv0Zs6MobLKROUIEhQY"); + private static final UVFMasterkey MASTERKEY = new UVFMasterkey(SEEDS, KDF_SALT, -1540072521, -1540072521); + + private FileHeaderImpl header; + private FileHeaderCryptorImpl headerCryptor; + private FileContentCryptorImpl fileContentCryptor; + private Cryptor cryptor; + + @BeforeEach + public void setup() { + header = new FileHeaderImpl(-1540072521, new byte[FileHeaderImpl.NONCE_LEN], new DestroyableSecretKey(new byte[FileHeaderImpl.CONTENT_KEY_LEN], "AES")); + headerCryptor = new FileHeaderCryptorImpl(MASTERKEY, CSPRNG, -1540072521); + fileContentCryptor = new FileContentCryptorImpl(CSPRNG); + cryptor = Mockito.mock(Cryptor.class); + Mockito.when(cryptor.fileContentCryptor()).thenReturn(fileContentCryptor); + Mockito.when(cryptor.fileHeaderCryptor()).thenReturn(headerCryptor); + + // reset cipher state to avoid InvalidAlgorithmParameterExceptions due to IV-reuse + GcmTestHelper.reset((mode, key, params) -> { + try (ObjectPool.Lease cipher = CipherSupplier.AES_GCM.encryptionCipher(key, params)) { + cipher.get(); + } + }); + } + + @Test + public void testDecryptedEncryptedEqualsPlaintext() throws AuthenticationFailedException { + DestroyableSecretKey fileKey = new DestroyableSecretKey(new byte[16], "AES"); + ByteBuffer ciphertext = ByteBuffer.allocate(fileContentCryptor.ciphertextChunkSize()); + ByteBuffer cleartext = ByteBuffer.allocate(fileContentCryptor.cleartextChunkSize()); + fileContentCryptor.encryptChunk(UTF_8.encode("asd"), ciphertext, 42l, new byte[12], fileKey); + ciphertext.flip(); + fileContentCryptor.decryptChunk(ciphertext, cleartext, 42l, new byte[12], fileKey); + cleartext.flip(); + Assertions.assertEquals(UTF_8.encode("asd"), cleartext); + } + + @Nested + public class Encryption { + + @BeforeEach + public void resetGcmNonce() { + // reset cipher state to avoid InvalidAlgorithmParameterExceptions due to IV-reuse + GcmTestHelper.reset((mode, key, params) -> { + try (ObjectPool.Lease cipher = CipherSupplier.AES_GCM.encryptionCipher(key, params)) { + cipher.get(); + } + }); + } + + @DisplayName("encrypt chunk with invalid size") + @ParameterizedTest(name = "cleartext size: {0}") + @ValueSource(ints = {Constants.PAYLOAD_SIZE + 1}) + public void testEncryptChunkOfInvalidSize(int size) { + ByteBuffer cleartext = ByteBuffer.allocate(size); + + Assertions.assertThrows(IllegalArgumentException.class, () -> { + fileContentCryptor.encryptChunk(cleartext, 0, header); + }); + } + + @Test + @DisplayName("encrypt chunk") + public void testChunkEncryption() { + Mockito.doAnswer(invocation -> { + byte[] nonce = invocation.getArgument(0); + Arrays.fill(nonce, (byte) 0x33); + return null; + }).when(CSPRNG).nextBytes(Mockito.any()); + ByteBuffer cleartext = StandardCharsets.US_ASCII.encode(CharBuffer.wrap("hello world")); + ByteBuffer ciphertext = fileContentCryptor.encryptChunk(cleartext, 0, header); + // echo -n "hello world" | openssl enc -aes-256-gcm -K 0 -iv 333333333333333333333333 -a + byte[] expected = BaseEncoding.base64().decode("MzMzMzMzMzMzMzMzbYvL7CusRmzk70Kn1QxFA5WQg/hgKeba4bln"); + Assertions.assertEquals(ByteBuffer.wrap(expected), ciphertext); + } + + @Test + @DisplayName("encrypt chunk with offset ByteBuffer") + public void testChunkEncryptionWithByteBufferView() { + Mockito.doAnswer(invocation -> { + byte[] nonce = invocation.getArgument(0); + Arrays.fill(nonce, (byte) 0x33); + return null; + }).when(CSPRNG).nextBytes(Mockito.any()); + ByteBuffer cleartext = US_ASCII.encode("12345hello world12345"); + cleartext.position(5).limit(16); + ByteBuffer ciphertext = fileContentCryptor.encryptChunk(cleartext, 0, header); + byte[] expected = BaseEncoding.base64().decode("MzMzMzMzMzMzMzMzbYvL7CusRmzk70Kn1QxFA5WQg/hgKeba4bln"); + Assertions.assertEquals(ByteBuffer.wrap(expected), ciphertext); + } + + @Test + @DisplayName("encrypt chunk with too small ciphertext buffer") + public void testChunkEncryptionWithBufferUnderflow() { + ByteBuffer cleartext = StandardCharsets.US_ASCII.encode(CharBuffer.wrap("hello world")); + ByteBuffer ciphertext = ByteBuffer.allocate(Constants.CHUNK_SIZE - 1); + Assertions.assertThrows(IllegalArgumentException.class, () -> { + fileContentCryptor.encryptChunk(cleartext, ciphertext, 0, header); + }); + } + + @Test + @DisplayName("encrypt file") + public void testFileEncryption() throws IOException { + Mockito.doAnswer(invocation -> { + byte[] nonce = invocation.getArgument(0); + Arrays.fill(nonce, (byte) 0x55); // header nonce + return null; + }).doAnswer(invocation -> { + byte[] nonce = invocation.getArgument(0); + Arrays.fill(nonce, (byte) 0x77); // header content key + return null; + }).doAnswer(invocation -> { + byte[] nonce = invocation.getArgument(0); + Arrays.fill(nonce, (byte) 0xAA); // chunk nonce + return null; + }).when(CSPRNG).nextBytes(Mockito.any()); + ByteBuffer dst = ByteBuffer.allocate(200); + SeekableByteChannel dstCh = new SeekableByteChannelMock(dst); + try (WritableByteChannel ch = new EncryptingWritableByteChannel(dstCh, cryptor)) { + ch.write(StandardCharsets.US_ASCII.encode("hello world")); + } + dst.flip(); + byte[] ciphertext = new byte[dst.remaining()]; + dst.get(ciphertext); + byte[] expected = BaseEncoding.base64().decode("dXZmAKQ0W7dVVVVVVVVVVVVVVVUcowd1FbpM8eMdABtmD/I3RO0n0rV2V3iDYXb++QHGHkO9MqYKLnd7ZjeoyNpG1Nmqqqqqqqqqqqqqqqq3J+X9CFc6SL5hl+6GdwnBgUYN6cPnoxF4C2Q="); + Assertions.assertArrayEquals(expected, ciphertext); + } + + } + + @Nested + public class Decryption { + + @DisplayName("decrypt chunk with invalid size") + @ParameterizedTest(name = "ciphertext size: {0}") + @ValueSource(ints = {0, Constants.GCM_NONCE_SIZE + Constants.GCM_TAG_SIZE - 1, Constants.CHUNK_SIZE + 1}) + public void testDecryptChunkOfInvalidSize(int size) { + ByteBuffer ciphertext = ByteBuffer.allocate(size); + + Assertions.assertThrows(IllegalArgumentException.class, () -> { + fileContentCryptor.decryptChunk(ciphertext, 0, header, true); + }); + } + + @Test + @DisplayName("decrypt chunk") + public void testChunkDecryption() throws AuthenticationFailedException { + ByteBuffer ciphertext = ByteBuffer.wrap(BaseEncoding.base64().decode("VVVVVVVVVVVVVVVVnHVdh+EbedvPeiCwCdaTYpzn1CXQjhSh7PHv")); + ByteBuffer cleartext = fileContentCryptor.decryptChunk(ciphertext, 0, header, true); + ByteBuffer expected = StandardCharsets.US_ASCII.encode("hello world"); + Assertions.assertEquals(expected, cleartext); + } + + @Test + @DisplayName("decrypt chunk with offset ByteBuffer") + public void testChunkDecryptionWithByteBufferView() throws AuthenticationFailedException { + byte[] actualCiphertext = BaseEncoding.base64().decode("VVVVVVVVVVVVVVVVnHVdh+EbedvPeiCwCdaTYpzn1CXQjhSh7PHv"); + ByteBuffer ciphertext = ByteBuffer.allocate(100); + ciphertext.position(10); + ciphertext.put(actualCiphertext); + ciphertext.position(10).limit(10 + actualCiphertext.length); + ByteBuffer cleartext = fileContentCryptor.decryptChunk(ciphertext, 0, header, true); + ByteBuffer expected = US_ASCII.encode("hello world"); + Assertions.assertEquals(expected, cleartext); + } + + @Test + @DisplayName("decrypt chunk with too small cleartext buffer") + public void testChunkDecryptionWithBufferUnderflow() { + ByteBuffer ciphertext = ByteBuffer.wrap(BaseEncoding.base64().decode("VVVVVVVVVVVVVVVVnHVdh+EbedvPeiCwCdaTYpzn1CXQjhSh7PHv")); + ByteBuffer cleartext = ByteBuffer.allocate(Constants.PAYLOAD_SIZE - 1); + Assertions.assertThrows(IllegalArgumentException.class, () -> { + fileContentCryptor.decryptChunk(ciphertext, cleartext, 0, header, true); + }); + } + + @Test + @DisplayName("decrypt file") + public void testFileDecryption() throws IOException { + byte[] ciphertext = BaseEncoding.base64().decode("dXZmAKQ0W7dVVVVVVVVVVVVVVVUcowd1FbpM8eMdABtmD/I3RO0n0rV2V3iDYXb++QHGHkO9MqYKLnd7ZjeoyNpG1Nmqqqqqqqqqqqqqqqq3J+X9CFc6SL5hl+6GdwnBgUYN6cPnoxF4C2Q="); + ReadableByteChannel ciphertextCh = Channels.newChannel(new ByteArrayInputStream(ciphertext)); + + ByteBuffer result = ByteBuffer.allocate(20); + try (ReadableByteChannel cleartextCh = new DecryptingReadableByteChannel(ciphertextCh, cryptor, true)) { + int read = cleartextCh.read(result); + Assertions.assertEquals(11, read); + byte[] expected = "hello world".getBytes(StandardCharsets.US_ASCII); + Assertions.assertArrayEquals(expected, Arrays.copyOfRange(result.array(), 0, read)); + } + } + + @Test + @DisplayName("decrypt file with unauthentic file header") + public void testDecryptionWithTooShortHeader() throws InterruptedException, IOException { + byte[] ciphertext = BaseEncoding.base64().decode("AAAAAAAA"); + ReadableByteChannel ciphertextCh = Channels.newChannel(new ByteArrayInputStream(ciphertext)); + + try (ReadableByteChannel cleartextCh = new DecryptingReadableByteChannel(ciphertextCh, cryptor, true)) { + Assertions.assertThrows(EOFException.class, () -> { + cleartextCh.read(ByteBuffer.allocate(3)); + }); + } + } + + @DisplayName("decrypt unauthentic chunk") + @ParameterizedTest(name = "unauthentic {1}") + @CsvSource(value = { + "vVVVVVVVVVVVVVVVnHVdh+EbedvPeiCwCdaTYpzn1CXQjhSh7PHv, NONCE", + "VVVVVVVVVVVVVVVVNHVdh+EbedvPeiCwCdaTYpzn1CXQjhSh7PHv, CONTENT", + "VVVVVVVVVVVVVVVVnHVdh+EbedvPeiCwCdaTYpzn1CXQjhSh7PHV, TAG", + }) + public void testUnauthenticChunkDecryption(String chunkData, String ignored) { + ByteBuffer ciphertext = ByteBuffer.wrap(BaseEncoding.base64().decode(chunkData)); + + Assertions.assertThrows(AuthenticationFailedException.class, () -> { + fileContentCryptor.decryptChunk(ciphertext, 0, header, true); + }); + } + + @DisplayName("decrypt unauthentic file") + @ParameterizedTest(name = "unauthentic {1} in first chunk") + @CsvSource(value = { + "dXZmAKQ0W7dVVVVVVVVVVVVVVVUcowd1FbpM8eMdABtmD/I3RO0n0rV2V3iDYXb++QHGHvqv753T4D5ZzPJtHYv0+ieqqqqxqqqqqqqqqqq3J+X9CFc6SL5hl+6GdwnBgUYN6cPnoxF4C2Q=, NONCE", + "dXZmAKQ0W7dVVVVVVVVVVVVVVVUcowd1FbpM8eMdABtmD/I3RO0n0rV2V3iDYXb++QHGHvqv753T4D5ZzPJtHYv0+ieqqqqqqqqqqqqqqqq3JxX9CFc6SL5hl+6GdwnBgUYN6cPnoxF4C2Q=, CONTENT", + "dXZmAKQ0W7dVVVVVVVVVVVVVVVUcowd1FbpM8eMdABtmD/I3RO0n0rV2V3iDYXb++QHGHvqv753T4D5ZzPJtHYv0+ieqqqqqqqqqqqqqqqq3J+X9CFc6SL5hl+6GdwnBgUYN6cPnoxF4C2x=, TAG", + }) + public void testDecryptionWithUnauthenticFirstChunk(String fileData, String ignored) throws IOException { + byte[] ciphertext = BaseEncoding.base64().decode(fileData); + + ReadableByteChannel ciphertextCh = Channels.newChannel(new ByteArrayInputStream(ciphertext)); + + try (ReadableByteChannel cleartextCh = new DecryptingReadableByteChannel(ciphertextCh, cryptor, true)) { + IOException thrown = Assertions.assertThrows(IOException.class, () -> { + cleartextCh.read(ByteBuffer.allocate(3)); + }); + MatcherAssert.assertThat(thrown.getCause(), CoreMatchers.instanceOf(AuthenticationFailedException.class)); + } + } + + @Test + @DisplayName("decrypt chunk with unauthentic tag but skipping authentication") + public void testChunkDecryptionWithUnauthenticTagSkipAuth() { + ByteBuffer dummyCiphertext = ByteBuffer.allocate(GCM_NONCE_SIZE + GCM_TAG_SIZE); + FileHeader header = Mockito.mock(FileHeader.class); + Assertions.assertThrows(UnsupportedOperationException.class, () -> { + fileContentCryptor.decryptChunk(dummyCiphertext, 0, header, false); + }); + } + + } + +} diff --git a/src/test/java/org/cryptomator/cryptolib/v3/FileContentEncryptorBenchmark.java b/src/test/java/org/cryptomator/cryptolib/v3/FileContentEncryptorBenchmark.java new file mode 100644 index 0000000..44df3e6 --- /dev/null +++ b/src/test/java/org/cryptomator/cryptolib/v3/FileContentEncryptorBenchmark.java @@ -0,0 +1,128 @@ +package org.cryptomator.cryptolib.v3; + +import org.cryptomator.cryptolib.api.UVFMasterkey; +import org.cryptomator.cryptolib.common.EncryptingWritableByteChannel; +import org.cryptomator.cryptolib.common.SecureRandomMock; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.SeekableByteChannel; +import java.nio.channels.WritableByteChannel; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.Collections; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * Needs to be compiled via maven as the JMH annotation processor needs to do stuff... + */ +@State(Scope.Thread) +@Warmup(iterations = 2) +@Measurement(iterations = 2) +@BenchmarkMode(value = {Mode.SingleShotTime}) +@OutputTimeUnit(TimeUnit.MILLISECONDS) +public class FileContentEncryptorBenchmark { + + private static final SecureRandom RANDOM_MOCK = SecureRandomMock.PRNG_RANDOM; + private static final Map SEEDS = Collections.singletonMap(-1540072521, Base64.getUrlDecoder().decode("fP4V4oAjsUw5DqackAvLzA0oP1kAQZ0f5YFZQviXSuU")); + private static final byte[] KDF_SALT = Base64.getUrlDecoder().decode("HE4OP-2vyfLLURicF1XmdIIsWv0Zs6MobLKROUIEhQY"); + private static final UVFMasterkey MASTERKEY = new UVFMasterkey(SEEDS, KDF_SALT, -1540072521, -1540072521); + + private CryptorImpl cryptor; + + @Setup(Level.Iteration) + public void shuffleData() { + cryptor = new CryptorImpl(MASTERKEY, RANDOM_MOCK); + } + + @Benchmark + public void benchmark100MegabytesEncryption() throws IOException { + ByteBuffer megabyte = ByteBuffer.allocate(1 * 1024 * 1024); + try (WritableByteChannel ch = new EncryptingWritableByteChannel(new NullSeekableByteChannel(), cryptor)) { + for (int i = 0; i < 100; i++) { + ch.write(megabyte); + megabyte.clear(); + } + } + } + + @Benchmark + public void benchmark10MegabytesEncryption() throws IOException { + ByteBuffer megabyte = ByteBuffer.allocate(1 * 1024 * 1024); + try (WritableByteChannel ch = new EncryptingWritableByteChannel(new NullSeekableByteChannel(), cryptor)) { + for (int i = 0; i < 10; i++) { + ch.write(megabyte); + megabyte.clear(); + } + } + } + + @Benchmark + public void benchmark1MegabytesEncryption() throws IOException { + ByteBuffer megabyte = ByteBuffer.allocate(1 * 1024 * 1024); + try (WritableByteChannel ch = new EncryptingWritableByteChannel(new NullSeekableByteChannel(), cryptor)) { + ch.write(megabyte); + megabyte.clear(); + } + } + + private static class NullSeekableByteChannel implements SeekableByteChannel { + + boolean open; + + @Override + public boolean isOpen() { + return open; + } + + @Override + public void close() { + open = false; + } + + @Override + public int read(ByteBuffer dst) { + throw new UnsupportedOperationException(); + } + + @Override + public int write(ByteBuffer src) { + int delta = src.remaining(); + src.position(src.position() + delta); + return delta; + } + + @Override + public long position() { + return 0; + } + + @Override + public SeekableByteChannel position(long newPosition) { + return this; + } + + @Override + public long size() { + return 0; + } + + @Override + public SeekableByteChannel truncate(long size) { + return this; + } + + } + +} diff --git a/src/test/java/org/cryptomator/cryptolib/v3/FileHeaderCryptorBenchmark.java b/src/test/java/org/cryptomator/cryptolib/v3/FileHeaderCryptorBenchmark.java new file mode 100644 index 0000000..b5e077b --- /dev/null +++ b/src/test/java/org/cryptomator/cryptolib/v3/FileHeaderCryptorBenchmark.java @@ -0,0 +1,64 @@ +package org.cryptomator.cryptolib.v3; + +import org.cryptomator.cryptolib.api.AuthenticationFailedException; +import org.cryptomator.cryptolib.api.FileHeader; +import org.cryptomator.cryptolib.api.UVFMasterkey; +import org.cryptomator.cryptolib.common.SecureRandomMock; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; + +import java.nio.ByteBuffer; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.Collections; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * Needs to be compiled via maven as the JMH annotation processor needs to do stuff... + */ +@State(Scope.Thread) +@Warmup(iterations = 3, time = 1, timeUnit = TimeUnit.SECONDS) +@Measurement(iterations = 2, time = 1, timeUnit = TimeUnit.SECONDS) +@BenchmarkMode(value = {Mode.AverageTime}) +@OutputTimeUnit(TimeUnit.MICROSECONDS) +public class FileHeaderCryptorBenchmark { + + private static final SecureRandom RANDOM_MOCK = SecureRandomMock.PRNG_RANDOM; + private static final Map SEEDS = Collections.singletonMap(-1540072521, Base64.getUrlDecoder().decode("fP4V4oAjsUw5DqackAvLzA0oP1kAQZ0f5YFZQviXSuU")); + private static final byte[] KDF_SALT = Base64.getUrlDecoder().decode("HE4OP-2vyfLLURicF1XmdIIsWv0Zs6MobLKROUIEhQY"); + private static final UVFMasterkey MASTERKEY = new UVFMasterkey(SEEDS, KDF_SALT, -1540072521, -1540072521); + private static final FileHeaderCryptorImpl HEADER_CRYPTOR = new FileHeaderCryptorImpl(MASTERKEY, RANDOM_MOCK, -1540072521); + + private ByteBuffer validHeaderCiphertextBuf; + private FileHeader header; + + @Setup(Level.Iteration) + public void prepareData() { + validHeaderCiphertextBuf = HEADER_CRYPTOR.encryptHeader(HEADER_CRYPTOR.create()); + } + + @Setup(Level.Invocation) + public void shuffleData() { + header = HEADER_CRYPTOR.create(); + } + + @Benchmark + public void benchmarkEncryption() { + HEADER_CRYPTOR.encryptHeader(header); + } + + @Benchmark + public void benchmarkDecryption() throws AuthenticationFailedException { + HEADER_CRYPTOR.decryptHeader(validHeaderCiphertextBuf); + } + +} diff --git a/src/test/java/org/cryptomator/cryptolib/v3/FileHeaderCryptorImplTest.java b/src/test/java/org/cryptomator/cryptolib/v3/FileHeaderCryptorImplTest.java new file mode 100644 index 0000000..58d97e7 --- /dev/null +++ b/src/test/java/org/cryptomator/cryptolib/v3/FileHeaderCryptorImplTest.java @@ -0,0 +1,101 @@ +package org.cryptomator.cryptolib.v3; + +import com.google.common.io.BaseEncoding; +import org.cryptomator.cryptolib.api.AuthenticationFailedException; +import org.cryptomator.cryptolib.api.FileHeader; +import org.cryptomator.cryptolib.api.UVFMasterkey; +import org.cryptomator.cryptolib.common.CipherSupplier; +import org.cryptomator.cryptolib.common.DestroyableSecretKey; +import org.cryptomator.cryptolib.common.GcmTestHelper; +import org.cryptomator.cryptolib.common.ObjectPool; +import org.cryptomator.cryptolib.common.SecureRandomMock; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import javax.crypto.Cipher; +import java.nio.ByteBuffer; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.Collections; +import java.util.Map; + +public class FileHeaderCryptorImplTest { + + private static final SecureRandom RANDOM_MOCK = SecureRandomMock.NULL_RANDOM; + private static final Map SEEDS = Collections.singletonMap(-1540072521, Base64.getUrlDecoder().decode("fP4V4oAjsUw5DqackAvLzA0oP1kAQZ0f5YFZQviXSuU")); + private static final byte[] KDF_SALT = Base64.getUrlDecoder().decode("HE4OP-2vyfLLURicF1XmdIIsWv0Zs6MobLKROUIEhQY"); + private static final UVFMasterkey MASTERKEY = new UVFMasterkey(SEEDS, KDF_SALT, -1540072521, -1540072521); + + private FileHeaderCryptorImpl headerCryptor; + + @BeforeEach + public void setup() { + headerCryptor = new FileHeaderCryptorImpl(MASTERKEY, RANDOM_MOCK, -1540072521); + + // reset cipher state to avoid InvalidAlgorithmParameterExceptions due to IV-reuse + GcmTestHelper.reset((mode, key, params) -> { + try (ObjectPool.Lease cipher = CipherSupplier.AES_GCM.encryptionCipher(key, params)) { + cipher.get(); + } + }); + } + + @Test + public void testHeaderSize() { + Assertions.assertEquals(FileHeaderImpl.SIZE, headerCryptor.headerSize()); + Assertions.assertEquals(FileHeaderImpl.SIZE, headerCryptor.encryptHeader(headerCryptor.create()).limit()); + } + + @Test + public void testSubkeyGeneration() { + DestroyableSecretKey subkey = MASTERKEY.subKey(-1540072521, 32, "fileHeader".getBytes(), "AES"); + Assertions.assertArrayEquals(Base64.getUrlDecoder().decode("PwnW2t_pK9dmzc-GTLdBSaB8ilcwsTq4sYOeiyo3cpU"), subkey.getEncoded()); + } + + @Test + public void testEncryption() { + DestroyableSecretKey contentKey = new DestroyableSecretKey(new byte[FileHeaderImpl.CONTENT_KEY_LEN], "AES"); + FileHeader header = new FileHeaderImpl(-1540072521, new byte[FileHeaderImpl.NONCE_LEN], contentKey); + + ByteBuffer ciphertext = headerCryptor.encryptHeader(header); + + Assertions.assertArrayEquals(Base64.getDecoder().decode("dXZmAKQ0W7cAAAAAAAAAAAAAAAA/UGgFA8QGho7E1QTsHWyZIVFqabbGJ/TCwvp3StG0JTkKGj3hwERhnFmZek61Xtc="), ciphertext.array()); + } + + @Test + public void testDecryption() throws AuthenticationFailedException { + byte[] ciphertext = BaseEncoding.base64().decode("dXZmAKQ0W7cAAAAAAAAAAAAAAAA/UGgFA8QGho7E1QTsHWyZIVFqabbGJ/TCwvp3StG0JTkKGj3hwERhnFmZek61Xtc="); + FileHeaderImpl header = headerCryptor.decryptHeader(ByteBuffer.wrap(ciphertext)); + Assertions.assertArrayEquals(new byte[FileHeaderImpl.NONCE_LEN], header.getNonce()); + Assertions.assertArrayEquals(new byte[FileHeaderImpl.CONTENT_KEY_LEN], header.getContentKey().getEncoded()); + } + + @Test + public void testDecryptionWithTooShortHeader() { + ByteBuffer ciphertext = ByteBuffer.allocate(7); + + Assertions.assertThrows(IllegalArgumentException.class, () -> { + headerCryptor.decryptHeader(ciphertext); + }); + } + + @Test + public void testDecryptionWithInvalidTag() { + ByteBuffer ciphertext = ByteBuffer.wrap(BaseEncoding.base64().decode("dXZmAKQ0W7cAAAAAAAAAAAAAAAA/UGgFA8QGho7E1QTsHWyZIVFqabbGJ/TCwvp3StG0JTkKGj3hwERhnFmZek61XtX=")); + + Assertions.assertThrows(AuthenticationFailedException.class, () -> { + headerCryptor.decryptHeader(ciphertext); + }); + } + + @Test + public void testDecryptionWithInvalidCiphertext() { + ByteBuffer ciphertext = ByteBuffer.wrap(BaseEncoding.base64().decode("dXZmAKQ0W7cAAAAAAAAAAAAAAAA/UGgFA8QGho7E1QTsHWyZIVFqabbGJ/XCwvp3StG0JTkKGj3hwERhnFmZek61Xtc=")); + + Assertions.assertThrows(AuthenticationFailedException.class, () -> { + headerCryptor.decryptHeader(ciphertext); + }); + } + +} diff --git a/src/test/java/org/cryptomator/cryptolib/v3/FileHeaderImplTest.java b/src/test/java/org/cryptomator/cryptolib/v3/FileHeaderImplTest.java new file mode 100644 index 0000000..85f46af --- /dev/null +++ b/src/test/java/org/cryptomator/cryptolib/v3/FileHeaderImplTest.java @@ -0,0 +1,31 @@ +package org.cryptomator.cryptolib.v3; + +import org.cryptomator.cryptolib.common.DestroyableSecretKey; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; + +public class FileHeaderImplTest { + + @Test + public void testConstructionFailsWithInvalidNonceSize() { + DestroyableSecretKey contentKey = new DestroyableSecretKey(new byte[FileHeaderImpl.CONTENT_KEY_LEN], "AES"); + Assertions.assertThrows(IllegalArgumentException.class, () -> { + new FileHeaderImpl(-1540072521, new byte[3], contentKey); + }); + } + + @Test + public void testDestruction() { + byte[] nonNullKey = new byte[FileHeaderImpl.CONTENT_KEY_LEN]; + Arrays.fill(nonNullKey, (byte) 0x42); + DestroyableSecretKey contentKey = new DestroyableSecretKey(nonNullKey, "AES"); + FileHeaderImpl header = new FileHeaderImpl(-1540072521, new byte[FileHeaderImpl.NONCE_LEN], contentKey); + Assertions.assertFalse(header.isDestroyed()); + header.destroy(); + Assertions.assertTrue(header.isDestroyed()); + Assertions.assertTrue(contentKey.isDestroyed()); + } + +} diff --git a/src/test/java/org/cryptomator/cryptolib/v3/FileNameCryptorImplTest.java b/src/test/java/org/cryptomator/cryptolib/v3/FileNameCryptorImplTest.java new file mode 100644 index 0000000..fae565c --- /dev/null +++ b/src/test/java/org/cryptomator/cryptolib/v3/FileNameCryptorImplTest.java @@ -0,0 +1,143 @@ +package org.cryptomator.cryptolib.v3; + +import com.google.common.io.BaseEncoding; +import org.cryptomator.cryptolib.api.AuthenticationFailedException; +import org.cryptomator.cryptolib.api.PerpetualMasterkey; +import org.cryptomator.cryptolib.api.UVFMasterkey; +import org.cryptomator.cryptolib.common.HKDFHelper; +import org.cryptomator.siv.UnauthenticCiphertextException; +import org.hamcrest.CoreMatchers; +import org.hamcrest.MatcherAssert; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.Base64; +import java.util.Collections; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Stream; + +import static java.nio.charset.StandardCharsets.UTF_8; + + +public class FileNameCryptorImplTest { + + private static final BaseEncoding BASE32 = BaseEncoding.base32(); + private static final Map SEEDS = Collections.singletonMap(-1540072521, Base64.getUrlDecoder().decode("fP4V4oAjsUw5DqackAvLzA0oP1kAQZ0f5YFZQviXSuU")); + private static final byte[] KDF_SALT = Base64.getUrlDecoder().decode("HE4OP-2vyfLLURicF1XmdIIsWv0Zs6MobLKROUIEhQY"); + private static final UVFMasterkey MASTERKEY = new UVFMasterkey(SEEDS, KDF_SALT, -1540072521, -1540072521); + + private final FileNameCryptorImpl filenameCryptor = new FileNameCryptorImpl(MASTERKEY, -1540072521); + + private static Stream filenameGenerator() { + return Stream.generate(UUID::randomUUID).map(UUID::toString).limit(100); + } + + @DisplayName("encrypt and decrypt file names") + @ParameterizedTest(name = "decrypt(encrypt({0}))") + @MethodSource("filenameGenerator") + public void testDeterministicEncryptionOfFilenames(String origName) throws AuthenticationFailedException { + String encrypted1 = filenameCryptor.encryptFilename(BASE32, origName); + String encrypted2 = filenameCryptor.encryptFilename(BASE32, origName); + String decrypted = filenameCryptor.decryptFilename(BASE32, encrypted1); + + Assertions.assertEquals(encrypted1, encrypted2); + Assertions.assertEquals(origName, decrypted); + } + + @DisplayName("encrypt and decrypt file names with AD and custom encoding") + @ParameterizedTest(name = "decrypt(encrypt({0}))") + @MethodSource("filenameGenerator") + public void testDeterministicEncryptionOfFilenamesWithCustomEncodingAndAssociatedData(String origName) throws AuthenticationFailedException { + byte[] associdatedData = new byte[10]; + String encrypted1 = filenameCryptor.encryptFilename(BaseEncoding.base64Url(), origName, associdatedData); + String encrypted2 = filenameCryptor.encryptFilename(BaseEncoding.base64Url(), origName, associdatedData); + String decrypted = filenameCryptor.decryptFilename(BaseEncoding.base64Url(), encrypted1, associdatedData); + + Assertions.assertEquals(encrypted1, encrypted2); + Assertions.assertEquals(origName, decrypted); + } + + @Test + @DisplayName("encrypt and decrypt 128 bit filename") + public void testDeterministicEncryptionOf128bitFilename() throws AuthenticationFailedException { + // block size length file names + String originalPath3 = "aaaabbbbccccdddd"; // 128 bit ascii + String encryptedPath3a = filenameCryptor.encryptFilename(BASE32, originalPath3); + String encryptedPath3b = filenameCryptor.encryptFilename(BASE32, originalPath3); + String decryptedPath3 = filenameCryptor.decryptFilename(BASE32, encryptedPath3a); + + Assertions.assertEquals(encryptedPath3a, encryptedPath3b); + Assertions.assertEquals(originalPath3, decryptedPath3); + } + + @DisplayName("hash root dir id") + @Test + public void testHashRootDirId() { + final byte[] rootDirId = Base64.getDecoder().decode("24UBEDeGu5taq7U4GqyA0MXUXb9HTYS6p3t9vvHGJAc="); + final String hashedRootDirId = filenameCryptor.hashDirectoryId(rootDirId); + Assertions.assertEquals("6DYU3E5BTPAZ4DWEQPQK3AIHX2DXSPHG", hashedRootDirId); + } + + @DisplayName("hash directory id for random directory ids") + @ParameterizedTest(name = "hashDirectoryId({0})") + @MethodSource("filenameGenerator") + public void testDeterministicHashingOfDirectoryIds(String originalDirectoryId) { + final String hashedDirectory1 = filenameCryptor.hashDirectoryId(originalDirectoryId.getBytes(UTF_8)); + final String hashedDirectory2 = filenameCryptor.hashDirectoryId(originalDirectoryId.getBytes(UTF_8)); + Assertions.assertEquals(hashedDirectory1, hashedDirectory2); + } + + @Test + @DisplayName("decrypt non-ciphertext") + public void testDecryptionOfMalformedFilename() { + AuthenticationFailedException e = Assertions.assertThrows(AuthenticationFailedException.class, () -> { + filenameCryptor.decryptFilename(BASE32, "lol"); + }); + MatcherAssert.assertThat(e.getCause(), CoreMatchers.instanceOf(IllegalArgumentException.class)); + } + + @Test + @DisplayName("decrypt tampered ciphertext") + public void testDecryptionOfManipulatedFilename() { + final byte[] encrypted = filenameCryptor.encryptFilename(BASE32, "test").getBytes(UTF_8); + encrypted[0] ^= (byte) 0x01; // change 1 bit in first byte + String ciphertextName = new String(encrypted, UTF_8); + + AuthenticationFailedException e = Assertions.assertThrows(AuthenticationFailedException.class, () -> { + filenameCryptor.decryptFilename(BASE32, ciphertextName); + }); + MatcherAssert.assertThat(e.getCause(), CoreMatchers.instanceOf(UnauthenticCiphertextException.class)); + } + + @Test + @DisplayName("encrypt with different AD") + public void testEncryptionOfSameFilenamesWithDifferentAssociatedData() { + final String encrypted1 = filenameCryptor.encryptFilename(BASE32, "test", "ad1".getBytes(UTF_8)); + final String encrypted2 = filenameCryptor.encryptFilename(BASE32, "test", "ad2".getBytes(UTF_8)); + Assertions.assertNotEquals(encrypted1, encrypted2); + } + + @Test + @DisplayName("decrypt ciphertext with correct AD") + public void testDeterministicEncryptionOfFilenamesWithAssociatedData() throws AuthenticationFailedException { + final String encrypted = filenameCryptor.encryptFilename(BASE32, "test", "ad".getBytes(UTF_8)); + final String decrypted = filenameCryptor.decryptFilename(BASE32, encrypted, "ad".getBytes(UTF_8)); + Assertions.assertEquals("test", decrypted); + } + + @Test + @DisplayName("decrypt ciphertext with incorrect AD") + public void testDeterministicEncryptionOfFilenamesWithWrongAssociatedData() { + final String encrypted = filenameCryptor.encryptFilename(BASE32, "test", "right".getBytes(UTF_8)); + final byte[] ad = "wrong".getBytes(UTF_8); + + Assertions.assertThrows(AuthenticationFailedException.class, () -> { + filenameCryptor.decryptFilename(BASE32, encrypted, ad); + }); + } + +} diff --git a/src/test/java/org/cryptomator/cryptolib/v3/UVFIntegrationTest.java b/src/test/java/org/cryptomator/cryptolib/v3/UVFIntegrationTest.java new file mode 100644 index 0000000..346920c --- /dev/null +++ b/src/test/java/org/cryptomator/cryptolib/v3/UVFIntegrationTest.java @@ -0,0 +1,171 @@ +package org.cryptomator.cryptolib.v3; + +import org.cryptomator.cryptolib.api.Cryptor; +import org.cryptomator.cryptolib.api.CryptorProvider; +import org.cryptomator.cryptolib.api.DirectoryContentCryptor; +import org.cryptomator.cryptolib.api.DirectoryMetadata; +import org.cryptomator.cryptolib.api.UVFMasterkey; +import org.cryptomator.cryptolib.common.DecryptingReadableByteChannel; +import org.cryptomator.cryptolib.common.EncryptingWritableByteChannel; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.Channels; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.Base64; + +public class UVFIntegrationTest { + + private static final SecureRandom CSPRNG = new SecureRandom(); + private static UVFMasterkey masterkey; + private static Cryptor cryptor; + + @BeforeAll + public static void setUp() { + // copied from UVFMasterkeyTest: + String json = "{\n" + + " \"fileFormat\": \"AES-256-GCM-32k\",\n" + + " \"nameFormat\": \"AES-SIV-512-B64URL\",\n" + + " \"seeds\": {\n" + + " \"HDm38g\": \"ypeBEsobvcr6wjGzmiPcTaeG7_gUfE5yuYB3ha_uSLs\",\n" + + " \"gBryKw\": \"PiPoFgA5WUoziU9lZOGxNIu9egCI1CxKy3PurtWcAJ0\",\n" + + " \"QBsJFg\": \"Ln0sA6lQeuJl7PW1NWiFpTOTogKdJBOUmXJloaJa78Y\"\n" + + " },\n" + + " \"initialSeed\": \"HDm38i\",\n" + + " \"latestSeed\": \"QBsJFo\",\n" + + " \"kdf\": \"HKDF-SHA512\",\n" + + " \"kdfSalt\": \"NIlr89R7FhochyP4yuXZmDqCnQ0dBB3UZ2D-6oiIjr8\",\n" + + " \"org.example.customfield\": 42\n" + + "}"; + masterkey = UVFMasterkey.fromDecryptedPayload(json); + cryptor = CryptorProvider.forScheme(CryptorProvider.Scheme.UVF_DRAFT).provide(masterkey, CSPRNG); + } + + @Test + @DisplayName("root dir id must be deterministic") + public void testRootDirId() { + byte[] rootDirId = masterkey.rootDirId(); + Assertions.assertEquals("5WEGzwKkAHPwVSjT2Brr3P3zLz7oMiNpMn/qBvht7eM=", Base64.getEncoder().encodeToString(rootDirId)); + } + + @Test + @DisplayName("root dir hash must be deterministic") + public void testRootDirHash() { + byte[] rootDirId = Base64.getDecoder().decode("5WEGzwKkAHPwVSjT2Brr3P3zLz7oMiNpMn/qBvht7eM="); + String dirHash = cryptor.fileNameCryptor(masterkey.firstRevision()).hashDirectoryId(rootDirId); + Assertions.assertEquals("RZK7ZH7KBXULNEKBMGX3CU42PGUIAIX4", dirHash); + } + + @Test + @DisplayName("encrypt dir.uvf for root directory") + public void testRootDirUvfEncryption() { + DirectoryMetadata rootDirMetadata = cryptor.directoryContentCryptor().rootDirectoryMetadata(); + byte[] result = cryptor.directoryContentCryptor().encryptDirectoryMetadata(rootDirMetadata); + Assertions.assertArrayEquals(new byte[]{0x75, 0x76, 0x66, 0x00}, Arrays.copyOf(result, 4), "expected to begin with UVF0 magic bytes"); + Assertions.assertArrayEquals(Base64.getUrlDecoder().decode("HDm38i"), Arrays.copyOfRange(result, 4, 8), "expected seed to be initial seed"); + } + + @Test + @DisplayName("decrypt dir.uvf for root directory") + public void testRootDirUvfDecryption() { + byte[] input = Base64.getDecoder().decode("dXZmABw5t/Ievp74RjIgGHn4+/Zt32dmqmYhmHiPNQ5Q2z+WYb4z8NbnynTgMWlGBCc65bTqSt4Pqhj9EGhrn8KVbQqzBVWcZkLVr4tntfvgZoVJYkeD5w9mJMwRlQJwqiC0uR+Lk2aBT2cfdPT92e/6+t7nlvoYtoahMtowCqY="); + DirectoryMetadata result = cryptor.directoryContentCryptor().decryptDirectoryMetadata(input); + DirectoryMetadataImpl metadata = Assertions.assertInstanceOf(DirectoryMetadataImpl.class, result); + Assertions.assertArrayEquals(masterkey.rootDirId(), metadata.dirId()); + Assertions.assertEquals(masterkey.firstRevision(), metadata.seedId()); + + } + + @Test + @DisplayName("encrypt file containing 'Hello, World!'") + public void testContentEncryption() throws IOException { + byte[] result = encryptFile(StandardCharsets.UTF_8.encode("Hello, World!"), cryptor); + Assertions.assertArrayEquals(new byte[]{0x75, 0x76, 0x66, 0x00}, Arrays.copyOf(result, 4), "expected to begin with UVF0 magic bytes"); + Assertions.assertArrayEquals(Base64.getUrlDecoder().decode("QBsJFo"), Arrays.copyOfRange(result, 4, 8), "expected seed to be latest seed"); + } + + @Test + @DisplayName("decrypt file containing 'Hello, World!'") + public void testContentDecryption() throws IOException { + byte[] input = Base64.getDecoder().decode("dXZmAEAbCRZxhI5sPsMiMlAQpwXzsOw13pBVX/yHydeHoOlHBS9d+wVpmRvzUKx5HQUmtGR4avjDownMNOS4sBX8G0SVc5dIADKnGUOwgF20kkc/EpGzrrgkS3C9lZoRPPOj3dm2ONfy3UkT1Q=="); + byte[] result = decryptFile(ByteBuffer.wrap(input), cryptor); + Assertions.assertEquals(13, result.length); + Assertions.assertEquals("Hello, World!", new String(result, StandardCharsets.UTF_8)); + } + + @Test + @DisplayName("create reference directory structure") + public void testCreateReferenceDirStructure(@TempDir Path vaultDir) throws IOException { + DirectoryContentCryptor dirContentCryptor = cryptor.directoryContentCryptor(); + + // ROOT + DirectoryMetadata rootDirMetadata = cryptor.directoryContentCryptor().rootDirectoryMetadata(); + String rootDirPath = dirContentCryptor.dirPath(rootDirMetadata); + String rootDirUvfFilePath = rootDirPath + "/dir.uvf"; + byte[] rootDirUvfFileContents = dirContentCryptor.encryptDirectoryMetadata(rootDirMetadata); + Files.createDirectories(vaultDir.resolve(rootDirPath)); + Files.write(vaultDir.resolve(rootDirUvfFilePath), rootDirUvfFileContents); + DirectoryContentCryptor.Encrypting filesWithinRootDir = dirContentCryptor.fileNameEncryptor(rootDirMetadata); + + // ROOT/foo.txt + String fooFileName = filesWithinRootDir.encrypt("foo.txt"); + String fooFilePath = rootDirPath + "/" + fooFileName; + byte[] fooFileContents = encryptFile(StandardCharsets.UTF_8.encode("Hello Foo"), cryptor); + Files.write(vaultDir.resolve(fooFilePath), fooFileContents); + + // ROOT/subdir + DirectoryMetadata subDirMetadata = dirContentCryptor.newDirectoryMetadata(); + String subDirName = filesWithinRootDir.encrypt("subdir"); + String subDirUvfFilePath1 = rootDirPath + "/" + subDirName + "/dir.uvf"; + byte[] subDirUvfFileContents1 = dirContentCryptor.encryptDirectoryMetadata(subDirMetadata); + Files.createDirectories(vaultDir.resolve(rootDirPath + "/" + subDirName)); + Files.write(vaultDir.resolve(subDirUvfFilePath1), subDirUvfFileContents1); + String subDirPath = dirContentCryptor.dirPath(subDirMetadata); + String subDirUvfFilePath2 = subDirPath + "/dir.uvf"; + byte[] subDirUvfFileContents2 = dirContentCryptor.encryptDirectoryMetadata(subDirMetadata); + Files.createDirectories(vaultDir.resolve(subDirPath)); + Files.write(vaultDir.resolve(subDirUvfFilePath2), subDirUvfFileContents2); + DirectoryContentCryptor.Encrypting filesWithinSubDir = dirContentCryptor.fileNameEncryptor(subDirMetadata); + + // ROOT/subdir/bar.txt + String barFileName = filesWithinSubDir.encrypt("bar.txt"); + String barFilePath = subDirPath + "/" + barFileName; + byte[] barFileContents = encryptFile(StandardCharsets.UTF_8.encode("Hello Bar"), cryptor); + Files.write(vaultDir.resolve(barFilePath), barFileContents); + + // set breakpoint here to inspect the created directory structure + System.out.println(vaultDir); + + } + + private static byte[] encryptFile(ByteBuffer cleartext, Cryptor cryptor) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (EncryptingWritableByteChannel ch = new EncryptingWritableByteChannel(Channels.newChannel(baos), cryptor)) { + ch.write(cleartext); + } + return baos.toByteArray(); + } + + private static byte[] decryptFile(ByteBuffer ciphertext, Cryptor cryptor) throws IOException { + assert ciphertext.hasArray(); + byte[] in = ciphertext.array(); + ByteBuffer result = ByteBuffer.allocate((int) cryptor.fileContentCryptor().cleartextSize(in.length) - cryptor.fileHeaderCryptor().headerSize()); + try (DecryptingReadableByteChannel ch = new DecryptingReadableByteChannel(Channels.newChannel(new ByteArrayInputStream(in)), cryptor, true)) { + int read = ch.read(result); + Assertions.assertEquals(13, read); + } + return result.array(); + } + +}