diff --git a/.github/workflows/publish-central.yml b/.github/workflows/publish-central.yml index 14a5af2..4959651 100644 --- a/.github/workflows/publish-central.yml +++ b/.github/workflows/publish-central.yml @@ -1,9 +1,11 @@ name: Publish to Maven Central on: + push: release: types: [published] jobs: publish: + if: github.event_name == 'release' && startsWith(github.ref, 'refs/tags/') || contains(github.event.head_commit.message, '[release snapshot]') runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -16,9 +18,16 @@ jobs: server-username: MAVEN_CENTRAL_USERNAME server-password: MAVEN_CENTRAL_PASSWORD - name: Enforce project version ${{ github.event.release.tag_name }} - run: mvn versions:set -B -DnewVersion="$GIT_TAG" - env: - GIT_TAG: ${{ github.event.release.tag_name }} + if: github.event_name == 'release' + run: mvn versions:set -B -DnewVersion=${{ github.event.release.tag_name }} + - name: Verify this is a SNAPSHOT + if: github.event_name == 'push' && contains(github.event.head_commit.message, '[release snapshot]') + run: | + VERSION=$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout) + if [[ "$VERSION" != *-SNAPSHOT ]]; then + echo "::error file=pom.xml,title=Not a SNAPSHOT::Project version ($VERSION) does not end with -SNAPSHOT" + exit 1 + fi - name: Deploy run: mvn deploy -B -DskipTests -Psign,deploy-central --no-transfer-progress env: diff --git a/.github/workflows/publish-github.yml b/.github/workflows/publish-github.yml index 2249659..cabd178 100644 --- a/.github/workflows/publish-github.yml +++ b/.github/workflows/publish-github.yml @@ -1,11 +1,12 @@ name: Publish to GitHub Packages on: + push: release: types: [published] jobs: publish: + if: github.event_name == 'release' && startsWith(github.ref, 'refs/tags/') || contains(github.event.head_commit.message, '[release snapshot]') runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/') # only allow publishing tagged versions steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v4 @@ -14,7 +15,16 @@ jobs: distribution: 'temurin' cache: 'maven' - name: Enforce project version ${{ github.event.release.tag_name }} + if: github.event_name == 'release' run: mvn versions:set -B -DnewVersion=${{ github.event.release.tag_name }} + - name: Verify this is a SNAPSHOT + if: github.event_name == 'push' && contains(github.event.head_commit.message, '[release snapshot]') + run: | + VERSION=$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout) + if [[ "$VERSION" != *-SNAPSHOT ]]; then + echo "::error file=pom.xml,title=Not a SNAPSHOT::Project version ($VERSION) does not end with -SNAPSHOT" + exit 1 + fi - name: Deploy run: mvn deploy -B -DskipTests -Psign,deploy-github --no-transfer-progress env: diff --git a/pom.xml b/pom.xml index f701b23..737e926 100644 --- a/pom.xml +++ b/pom.xml @@ -2,7 +2,7 @@ 4.0.0 org.cryptomator cryptolib - 2.3.0-SNAPSHOT + 2.3.0-UVF-SNAPSHOT Cryptomator Crypto Library This library contains all cryptographic functions that are used by Cryptomator. https://github.com/cryptomator/cryptolib diff --git a/src/main/java/org/cryptomator/cryptolib/api/Cryptor.java b/src/main/java/org/cryptomator/cryptolib/api/Cryptor.java index 5e79c2c..771200f 100644 --- a/src/main/java/org/cryptomator/cryptolib/api/Cryptor.java +++ b/src/main/java/org/cryptomator/cryptolib/api/Cryptor.java @@ -1,23 +1,50 @@ -/******************************************************************************* - * 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.api; import javax.security.auth.Destroyable; public interface Cryptor extends Destroyable, AutoCloseable { + /** + * Encryption and decryption of file content. + * @return utility for encrypting and decrypting file content + */ FileContentCryptor fileContentCryptor(); + /** + * Encryption and decryption of file headers. + * @return utility for encrypting and decrypting file headers + */ FileHeaderCryptor fileHeaderCryptor(); + /** + * Encryption and decryption of file headers. + * @param revision The revision of the seed to {@link RevolvingMasterkey#subKey(int, int, byte[], String) derive subkeys}. + * @return utility for encrypting and decrypting file headers + * @apiNote Only relevant for Universal Vault Format, for Cryptomator Vault Format see {@link #fileHeaderCryptor()} + */ + FileHeaderCryptor fileHeaderCryptor(int revision); + + /** + * Encryption and decryption of file names in Cryptomator Vault Format. + * @return utility for encrypting and decrypting file names + * @apiNote Only relevant for Cryptomator Vault Format, for Universal Vault Format see {@link #fileNameCryptor(int)} + */ FileNameCryptor fileNameCryptor(); + /** + * Encryption and decryption of file names in Universal Vault Format. + * @param revision The revision of the seed to {@link RevolvingMasterkey#subKey(int, int, byte[], String) derive subkeys}. + * @return utility for encrypting and decrypting file names + * @apiNote Only relevant for Universal Vault Format, for Cryptomator Vault Format see {@link #fileNameCryptor()} + */ + FileNameCryptor fileNameCryptor(int revision); + + /** + * High-Level API for file name encryption and decryption + * @return utility for encryption and decryption of file names in the context of a directory + */ + DirectoryContentCryptor directoryContentCryptor(); + @Override void destroy(); diff --git a/src/main/java/org/cryptomator/cryptolib/api/CryptorProvider.java b/src/main/java/org/cryptomator/cryptolib/api/CryptorProvider.java index cfa442e..ee53915 100644 --- a/src/main/java/org/cryptomator/cryptolib/api/CryptorProvider.java +++ b/src/main/java/org/cryptomator/cryptolib/api/CryptorProvider.java @@ -27,7 +27,15 @@ enum Scheme { * AES-SIV for file name encryption * AES-GCM for content encryption */ - SIV_GCM + SIV_GCM, + + /** + * Experimental implementation of UVF draft + * @deprecated may be removed any time + * @see UVF + */ + @Deprecated + UVF_DRAFT, } /** diff --git a/src/main/java/org/cryptomator/cryptolib/api/DirectoryContentCryptor.java b/src/main/java/org/cryptomator/cryptolib/api/DirectoryContentCryptor.java new file mode 100644 index 0000000..a8ab740 --- /dev/null +++ b/src/main/java/org/cryptomator/cryptolib/api/DirectoryContentCryptor.java @@ -0,0 +1,62 @@ +package org.cryptomator.cryptolib.api; + +public interface DirectoryContentCryptor { + + DirectoryMetadata rootDirectoryMetadata(); + + DirectoryMetadata newDirectoryMetadata(); + + /** + * Decrypts the given directory metadata. + * + * @param ciphertext The encrypted directory metadata to decrypt. + * @return The decrypted directory metadata. + * @throws AuthenticationFailedException If the ciphertext is unauthentic. + */ + DirectoryMetadata decryptDirectoryMetadata(byte[] ciphertext) throws AuthenticationFailedException; + + /** + * Encrypts the given directory metadata. + * + * @param directoryMetadata The directory metadata to encrypt. + * @return The encrypted directory metadata. + */ + byte[] encryptDirectoryMetadata(DirectoryMetadata directoryMetadata); + + /** + * Computes the directory path for the given directory metadata. + * @param directoryMetadata The directory metadata. + * @return A path relative to the vault's root (i.e. starting with `d/`). + * @apiNote The path contains "/" as separator and does neither start nor end with a "/". + */ + String dirPath(DirectoryMetadata directoryMetadata); + + Decrypting fileNameDecryptor(DirectoryMetadata directoryMetadata); + + Encrypting fileNameEncryptor(DirectoryMetadata directoryMetadata); + + @FunctionalInterface + interface Decrypting { + /** + * Decrypts a single filename + * + * @param ciphertext the full filename to decrypt, including the file extension + * @return Plaintext + * @throws AuthenticationFailedException If the ciphertext is unauthentic. + * @throws IllegalArgumentException If the filename does not meet the expected format. + */ + String decrypt(String ciphertext) throws AuthenticationFailedException, IllegalArgumentException; + } + + @FunctionalInterface + interface Encrypting { + /** + * Encrypts a single filename + * + * @param plaintext the full filename to encrypt, including the file extension + * @return Ciphertext + */ + String encrypt(String plaintext); + } + +} diff --git a/src/main/java/org/cryptomator/cryptolib/api/DirectoryMetadata.java b/src/main/java/org/cryptomator/cryptolib/api/DirectoryMetadata.java new file mode 100644 index 0000000..f51f371 --- /dev/null +++ b/src/main/java/org/cryptomator/cryptolib/api/DirectoryMetadata.java @@ -0,0 +1,4 @@ +package org.cryptomator.cryptolib.api; + +public interface DirectoryMetadata { +} diff --git a/src/main/java/org/cryptomator/cryptolib/api/FileNameCryptor.java b/src/main/java/org/cryptomator/cryptolib/api/FileNameCryptor.java index e20cd87..5187c69 100644 --- a/src/main/java/org/cryptomator/cryptolib/api/FileNameCryptor.java +++ b/src/main/java/org/cryptomator/cryptolib/api/FileNameCryptor.java @@ -10,6 +10,8 @@ import com.google.common.io.BaseEncoding; +import java.nio.charset.StandardCharsets; + /** * Provides deterministic encryption capabilities as filenames must not change on subsequent encryption attempts, * otherwise each change results in major directory structure changes which would be a terrible idea for cloud storage encryption. @@ -18,11 +20,23 @@ */ public interface FileNameCryptor { + /** + * @param cleartextDirectoryIdStr a UTF-8-encoded arbitrary directory id to be passed to one-way hash function + * @return constant length string, that is unlikely to collide with any other name. + * @apiNote Only relevant for Cryptomator Vault Format, not for Universal Vault Format + * @deprecated Use {@link #hashDirectoryId(byte[])} instead + */ + @Deprecated + default String hashDirectoryId(String cleartextDirectoryIdStr) { + return hashDirectoryId(cleartextDirectoryIdStr.getBytes(StandardCharsets.UTF_8)); + } + /** * @param cleartextDirectoryId an arbitrary directory id to be passed to one-way hash function * @return constant length string, that is unlikely to collide with any other name. + * @apiNote Only relevant for Cryptomator Vault Format, not for Universal Vault Format */ - String hashDirectoryId(String cleartextDirectoryId); + String hashDirectoryId(byte[] cleartextDirectoryId); /** * @param encoding Encoding to use to encode the returned ciphertext diff --git a/src/main/java/org/cryptomator/cryptolib/api/Masterkey.java b/src/main/java/org/cryptomator/cryptolib/api/Masterkey.java index c0ec2e5..407eacc 100644 --- a/src/main/java/org/cryptomator/cryptolib/api/Masterkey.java +++ b/src/main/java/org/cryptomator/cryptolib/api/Masterkey.java @@ -3,69 +3,50 @@ import com.google.common.base.Preconditions; import org.cryptomator.cryptolib.common.DestroyableSecretKey; +import javax.security.auth.Destroyable; import java.security.SecureRandom; import java.util.Arrays; -public class Masterkey extends DestroyableSecretKey { +public interface Masterkey extends Destroyable, AutoCloseable { - private static final String KEY_ALGORITHM = "MASTERKEY"; - public static final String ENC_ALG = "AES"; - public static final String MAC_ALG = "HmacSHA256"; - public static final int SUBKEY_LEN_BYTES = 32; - - public Masterkey(byte[] key) { - super(checkKeyLength(key), KEY_ALGORITHM); - } - - private static byte[] checkKeyLength(byte[] key) { - Preconditions.checkArgument(key.length == SUBKEY_LEN_BYTES + SUBKEY_LEN_BYTES, "Invalid raw key length %s", key.length); - return key; - } - - public static Masterkey generate(SecureRandom csprng) { - byte[] key = new byte[SUBKEY_LEN_BYTES + SUBKEY_LEN_BYTES]; + static PerpetualMasterkey generate(SecureRandom csprng) { + byte[] key = new byte[PerpetualMasterkey.SUBKEY_LEN_BYTES + PerpetualMasterkey.SUBKEY_LEN_BYTES]; try { csprng.nextBytes(key); - return new Masterkey(key); + return new PerpetualMasterkey(key); } finally { Arrays.fill(key, (byte) 0x00); } } - public static Masterkey from(DestroyableSecretKey encKey, DestroyableSecretKey macKey) { - Preconditions.checkArgument(encKey.getEncoded().length == SUBKEY_LEN_BYTES, "Invalid key length of encKey"); - Preconditions.checkArgument(macKey.getEncoded().length == SUBKEY_LEN_BYTES, "Invalid key length of macKey"); - byte[] key = new byte[SUBKEY_LEN_BYTES + SUBKEY_LEN_BYTES]; + static PerpetualMasterkey from(DestroyableSecretKey encKey, DestroyableSecretKey macKey) { + Preconditions.checkArgument(encKey.getEncoded().length == PerpetualMasterkey.SUBKEY_LEN_BYTES, "Invalid key length of encKey"); + Preconditions.checkArgument(macKey.getEncoded().length == PerpetualMasterkey.SUBKEY_LEN_BYTES, "Invalid key length of macKey"); + byte[] key = new byte[PerpetualMasterkey.SUBKEY_LEN_BYTES + PerpetualMasterkey.SUBKEY_LEN_BYTES]; try { - System.arraycopy(encKey.getEncoded(), 0, key, 0, SUBKEY_LEN_BYTES); - System.arraycopy(macKey.getEncoded(), 0, key, SUBKEY_LEN_BYTES, SUBKEY_LEN_BYTES); - return new Masterkey(key); + System.arraycopy(encKey.getEncoded(), 0, key, 0, PerpetualMasterkey.SUBKEY_LEN_BYTES); + System.arraycopy(macKey.getEncoded(), 0, key, PerpetualMasterkey.SUBKEY_LEN_BYTES, PerpetualMasterkey.SUBKEY_LEN_BYTES); + return new PerpetualMasterkey(key); } finally { Arrays.fill(key, (byte) 0x00); } } - @Override - public Masterkey copy() { - return new Masterkey(getEncoded()); - } - /** - * Get the encryption subkey. - * - * @return A new copy of the subkey used for encryption + * Returns the immutable directory ID of the root directory. This ID is unique for each vault and deterministically depends on the masterkey. + * @return a unique but deterministic byte sequence */ - public DestroyableSecretKey getEncKey() { - return new DestroyableSecretKey(getEncoded(), 0, SUBKEY_LEN_BYTES, ENC_ALG); - } + byte[] rootDirId(); + + @Override + void destroy(); /** - * Get the MAC subkey. - * - * @return A new copy of the subkey used for message authentication + * Same as {@link #destroy()} */ - public DestroyableSecretKey getMacKey() { - return new DestroyableSecretKey(getEncoded(), SUBKEY_LEN_BYTES, SUBKEY_LEN_BYTES, MAC_ALG); + @Override + default void close() { + destroy(); } } diff --git a/src/main/java/org/cryptomator/cryptolib/api/PerpetualMasterkey.java b/src/main/java/org/cryptomator/cryptolib/api/PerpetualMasterkey.java new file mode 100644 index 0000000..46f9d62 --- /dev/null +++ b/src/main/java/org/cryptomator/cryptolib/api/PerpetualMasterkey.java @@ -0,0 +1,106 @@ +package org.cryptomator.cryptolib.api; + +import com.google.common.base.Preconditions; +import org.cryptomator.cryptolib.common.DestroyableSecretKey; + +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Arrays; + +public class PerpetualMasterkey implements Masterkey { + + public static final String ENC_ALG = "AES"; + public static final String MAC_ALG = "HmacSHA256"; + public static final int SUBKEY_LEN_BYTES = 32; + + private final transient byte[] key; + private boolean destroyed; + + public PerpetualMasterkey(byte[] key) { + Preconditions.checkArgument(key.length == SUBKEY_LEN_BYTES + SUBKEY_LEN_BYTES, "Invalid raw key length %s", key.length); + this.key = new byte[key.length]; + this.destroyed = false; + System.arraycopy(key, 0, this.key, 0, key.length); + } + + public static PerpetualMasterkey generate(SecureRandom csprng) { + byte[] key = new byte[SUBKEY_LEN_BYTES + SUBKEY_LEN_BYTES]; + try { + csprng.nextBytes(key); + return new PerpetualMasterkey(key); + } finally { + Arrays.fill(key, (byte) 0x00); + } + } + + public static PerpetualMasterkey from(DestroyableSecretKey encKey, DestroyableSecretKey macKey) { + Preconditions.checkArgument(encKey.getEncoded().length == SUBKEY_LEN_BYTES, "Invalid key length of encKey"); + Preconditions.checkArgument(macKey.getEncoded().length == SUBKEY_LEN_BYTES, "Invalid key length of macKey"); + byte[] key = new byte[SUBKEY_LEN_BYTES + SUBKEY_LEN_BYTES]; + try { + System.arraycopy(encKey.getEncoded(), 0, key, 0, SUBKEY_LEN_BYTES); + System.arraycopy(macKey.getEncoded(), 0, key, SUBKEY_LEN_BYTES, SUBKEY_LEN_BYTES); + return new PerpetualMasterkey(key); + } finally { + Arrays.fill(key, (byte) 0x00); + } + } + + public Masterkey copy() { + return new PerpetualMasterkey(key); + } + + /** + * Get the encryption subkey. + * + * @return A new copy of the subkey used for encryption + */ + public DestroyableSecretKey getEncKey() { + return new DestroyableSecretKey(key, 0, SUBKEY_LEN_BYTES, ENC_ALG); + } + + /** + * Get the MAC subkey. + * + * @return A new copy of the subkey used for message authentication + */ + public DestroyableSecretKey getMacKey() { + return new DestroyableSecretKey(key, SUBKEY_LEN_BYTES, SUBKEY_LEN_BYTES, MAC_ALG); + } + + @Override + public byte[] rootDirId() { + // root directory ID is specified to be the "empty string": + // https://docs.cryptomator.org/security/vault/#directory-ids + return new byte[0]; + } + + @Override + public boolean isDestroyed() { + return destroyed; + } + + @Override + public void destroy() { + Arrays.fill(key, (byte) 0x00); + destroyed = true; + } + + public byte[] getEncoded() { + return key; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + PerpetualMasterkey that = (PerpetualMasterkey) o; + return MessageDigest.isEqual(this.key, that.key); + } + + @Override + public int hashCode() { + return Arrays.hashCode(key); + } + +} diff --git a/src/main/java/org/cryptomator/cryptolib/api/RevolvingMasterkey.java b/src/main/java/org/cryptomator/cryptolib/api/RevolvingMasterkey.java new file mode 100644 index 0000000..c73f595 --- /dev/null +++ b/src/main/java/org/cryptomator/cryptolib/api/RevolvingMasterkey.java @@ -0,0 +1,20 @@ +package org.cryptomator.cryptolib.api; + +import org.cryptomator.cryptolib.common.DestroyableSecretKey; + +public interface RevolvingMasterkey extends Masterkey { + + /** + * Returns a subkey for the given revision and usage context. + * @param revision Key revision + * @param length Desired key length in bytes + * @param context Usage context to distinguish subkeys + * @param algorithm The name of the {@link javax.crypto.SecretKey#getAlgorithm() algorithm} associated with the generated subkey + * @return A subkey specificially for the given revision and context + */ + DestroyableSecretKey subKey(int revision, int length, byte[] context, String algorithm); + + int firstRevision(); + + int currentRevision(); +} diff --git a/src/main/java/org/cryptomator/cryptolib/api/UVFMasterkey.java b/src/main/java/org/cryptomator/cryptolib/api/UVFMasterkey.java new file mode 100644 index 0000000..fa0d751 --- /dev/null +++ b/src/main/java/org/cryptomator/cryptolib/api/UVFMasterkey.java @@ -0,0 +1,103 @@ +package org.cryptomator.cryptolib.api; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Preconditions; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.cryptomator.cryptolib.common.DestroyableSecretKey; +import org.cryptomator.cryptolib.common.HKDFHelper; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Base64; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; + +/** + * @see UVF Vault Metadata Contents + */ +public class UVFMasterkey implements RevolvingMasterkey { + + private static final byte[] ROOT_DIRID_KDF_CONTEXT = "rootDirId".getBytes(StandardCharsets.US_ASCII); + + @VisibleForTesting final Map seeds; + @VisibleForTesting final byte[] kdfSalt; + @VisibleForTesting final int initialSeed; + @VisibleForTesting final int latestSeed; + + public UVFMasterkey(Map seeds, byte[] kdfSalt, int initialSeed, int latestSeed) { + this.seeds = new HashMap<>(seeds); + this.kdfSalt = kdfSalt; + this.initialSeed = initialSeed; + this.latestSeed = latestSeed; + } + + public static UVFMasterkey fromDecryptedPayload(String json) { + JsonObject root = JsonParser.parseString(json).getAsJsonObject(); + Preconditions.checkArgument("AES-256-GCM-32k".equals(root.get("fileFormat").getAsString())); + Preconditions.checkArgument("AES-SIV-512-B64URL".equals(root.get("nameFormat").getAsString())); + Preconditions.checkArgument("HKDF-SHA512".equals(root.get("kdf").getAsString())); + Preconditions.checkArgument(root.get("seeds").isJsonObject()); + + Base64.Decoder base64Url = Base64.getUrlDecoder(); + byte[] initialSeed = base64Url.decode(root.get("initialSeed").getAsString()); + byte[] latestSeed = base64Url.decode(root.get("latestSeed").getAsString()); + byte[] kdfSalt = base64Url.decode(root.get("kdfSalt").getAsString()); + + Map seeds = new HashMap<>(); + ByteBuffer intBuf = ByteBuffer.allocate(Integer.BYTES); + for (Map.Entry entry : root.getAsJsonObject("seeds").asMap().entrySet()) { + intBuf.clear(); + intBuf.put(base64Url.decode(entry.getKey())); + int seedNum = intBuf.getInt(0); + byte[] seedVal = base64Url.decode(entry.getValue().getAsString()); + seeds.put(seedNum, seedVal); + } + return new UVFMasterkey(seeds, kdfSalt, ByteBuffer.wrap(initialSeed).getInt(), ByteBuffer.wrap(latestSeed).getInt()); + } + + @Override + public int firstRevision() { + return initialSeed; + } + + @Override + public int currentRevision() { + return latestSeed; + } + + @Override + public byte[] rootDirId() { + return HKDFHelper.hkdfSha512(kdfSalt, seeds.get(initialSeed), ROOT_DIRID_KDF_CONTEXT, 32); + } + + @Override + public DestroyableSecretKey subKey(int revision, int length, byte[] context, String algorithm) { + if (isDestroyed()) { + throw new IllegalStateException("Masterkey is destroyed"); + } + if (!seeds.containsKey(revision)) { + throw new IllegalArgumentException("No seed for revision " + revision); + } + byte[] subkey = HKDFHelper.hkdfSha512(kdfSalt, seeds.get(revision), context, length); + try { + return new DestroyableSecretKey(subkey, algorithm); + } finally { + Arrays.fill(subkey, (byte) 0x00); + } + } + + @Override + public void destroy() { + Iterator> iter = seeds.entrySet().iterator(); + while (iter.hasNext()) { + Map.Entry entry = iter.next(); + Arrays.fill(entry.getValue(), (byte) 0x00); + iter.remove(); + } + Arrays.fill(kdfSalt, (byte) 0x00); + } +} diff --git a/src/main/java/org/cryptomator/cryptolib/api/package-info.java b/src/main/java/org/cryptomator/cryptolib/api/package-info.java index ba63c22..8d5711e 100644 --- a/src/main/java/org/cryptomator/cryptolib/api/package-info.java +++ b/src/main/java/org/cryptomator/cryptolib/api/package-info.java @@ -10,7 +10,7 @@ * // Create new masterkey and safe it to a file: * SecureRandom csprng = SecureRandom.getInstanceStrong(); * Masterkey masterkey = {@link org.cryptomator.cryptolib.api.Masterkey#generate(java.security.SecureRandom) Masterkey.generate(csprng)}; - * {@link org.cryptomator.cryptolib.common.MasterkeyFileAccess#persist(org.cryptomator.cryptolib.api.Masterkey, java.nio.file.Path, java.lang.CharSequence) masterkeyFileAccess.persist(masterkey, path, passphrase)}; + * {@link org.cryptomator.cryptolib.common.MasterkeyFileAccess#persist(org.cryptomator.cryptolib.api.PerpetualMasterkey, java.nio.file.Path, java.lang.CharSequence) masterkeyFileAccess.persist(masterkey, path, passphrase)}; * * // Load a masterkey from a file: * Masterkey masterkey = {@link org.cryptomator.cryptolib.common.MasterkeyFileAccess#load(java.nio.file.Path, java.lang.CharSequence) masterkeyFileAccess.load(path, passphrase)}; diff --git a/src/main/java/org/cryptomator/cryptolib/common/DestroyableSecretKey.java b/src/main/java/org/cryptomator/cryptolib/common/DestroyableSecretKey.java index f28baeb..4ecc16d 100644 --- a/src/main/java/org/cryptomator/cryptolib/common/DestroyableSecretKey.java +++ b/src/main/java/org/cryptomator/cryptolib/common/DestroyableSecretKey.java @@ -15,7 +15,7 @@ * actually implements {@link Destroyable}. *

* 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 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/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();
+	}
+
+}