From b07e43b1a8d99d4194e09fc237cf07f9894333e4 Mon Sep 17 00:00:00 2001 From: Sebastian Stenzel Date: Fri, 9 Feb 2024 16:12:27 +0100 Subject: [PATCH 01/21] add v3 impl with UVF compatible file header and hardcoded key id --- .../cryptolib/api/CryptorProvider.java | 10 +- .../cryptomator/cryptolib/v3/Constants.java | 28 ++++ .../cryptomator/cryptolib/v3/CryptorImpl.java | 74 ++++++++ .../cryptolib/v3/CryptorProviderImpl.java | 29 ++++ .../cryptolib/v3/FileContentCryptorImpl.java | 158 ++++++++++++++++++ .../cryptolib/v3/FileHeaderCryptorImpl.java | 129 ++++++++++++++ .../cryptolib/v3/FileHeaderImpl.java | 74 ++++++++ .../cryptolib/v3/FileNameCryptorImpl.java | 71 ++++++++ src/main/java9/module-info.java | 2 +- ....cryptomator.cryptolib.api.CryptorProvider | 3 +- 10 files changed, 575 insertions(+), 3 deletions(-) create mode 100644 src/main/java/org/cryptomator/cryptolib/v3/Constants.java create mode 100644 src/main/java/org/cryptomator/cryptolib/v3/CryptorImpl.java create mode 100644 src/main/java/org/cryptomator/cryptolib/v3/CryptorProviderImpl.java create mode 100644 src/main/java/org/cryptomator/cryptolib/v3/FileContentCryptorImpl.java create mode 100644 src/main/java/org/cryptomator/cryptolib/v3/FileHeaderCryptorImpl.java create mode 100644 src/main/java/org/cryptomator/cryptolib/v3/FileHeaderImpl.java create mode 100644 src/main/java/org/cryptomator/cryptolib/v3/FileNameCryptorImpl.java 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/v3/Constants.java b/src/main/java/org/cryptomator/cryptolib/v3/Constants.java new file mode 100644 index 0000000..6ab48ca --- /dev/null +++ b/src/main/java/org/cryptomator/cryptolib/v3/Constants.java @@ -0,0 +1,28 @@ +/******************************************************************************* + * 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.v3; + +import java.nio.charset.StandardCharsets; + +final class Constants { + + private Constants() { + } + + static final String CONTENT_ENC_ALG = "AES"; + + static final byte[] UVF_MAGIC_BYTES = "UVF0".getBytes(StandardCharsets.US_ASCII); + static final byte[] KEY_ID = "KEY0".getBytes(StandardCharsets.US_ASCII); + + 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..808fc08 --- /dev/null +++ b/src/main/java/org/cryptomator/cryptolib/v3/CryptorImpl.java @@ -0,0 +1,74 @@ +/******************************************************************************* + * 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.v3; + +import org.cryptomator.cryptolib.api.Cryptor; +import org.cryptomator.cryptolib.api.Masterkey; +import org.cryptomator.cryptolib.v1.CryptorProviderImpl; + +import java.security.SecureRandom; + +class CryptorImpl implements Cryptor { + + private final Masterkey masterkey; + private final FileContentCryptorImpl fileContentCryptor; + private final FileHeaderCryptorImpl fileHeaderCryptor; + private final FileNameCryptorImpl fileNameCryptor; + + /** + * Package-private constructor. + * Use {@link CryptorProviderImpl#provide(Masterkey, SecureRandom)} to obtain a Cryptor instance. + */ + CryptorImpl(Masterkey masterkey, SecureRandom random) { + this.masterkey = masterkey; + this.fileHeaderCryptor = new FileHeaderCryptorImpl(masterkey, random); + this.fileContentCryptor = new FileContentCryptorImpl(random); + this.fileNameCryptor = new FileNameCryptorImpl(masterkey); + } + + @Override + public FileContentCryptorImpl fileContentCryptor() { + assertNotDestroyed(); + return fileContentCryptor; + } + + @Override + public FileHeaderCryptorImpl fileHeaderCryptor() { + assertNotDestroyed(); + return fileHeaderCryptor; + } + + @Override + public FileNameCryptorImpl fileNameCryptor() { + assertNotDestroyed(); + return fileNameCryptor; + } + + @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..a4f2fb6 --- /dev/null +++ b/src/main/java/org/cryptomator/cryptolib/v3/CryptorProviderImpl.java @@ -0,0 +1,29 @@ +/******************************************************************************* + * 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.v3; + +import org.cryptomator.cryptolib.api.CryptorProvider; +import org.cryptomator.cryptolib.api.Masterkey; +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 CryptorImpl provide(Masterkey masterkey, SecureRandom random) { + return new CryptorImpl(masterkey, ReseedingSecureRandom.create(random)); + } + +} 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..fe44b60 --- /dev/null +++ b/src/main/java/org/cryptomator/cryptolib/v3/FileContentCryptorImpl.java @@ -0,0 +1,158 @@ +/******************************************************************************* + * 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.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 cipehrtext 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(); + payloadBuf.position(GCM_NONCE_SIZE); + 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.SIZE / Byte.SIZE).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..9560f2c --- /dev/null +++ b/src/main/java/org/cryptomator/cryptolib/v3/FileHeaderCryptorImpl.java @@ -0,0 +1,129 @@ +/******************************************************************************* + * 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.v3; + +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.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.security.SecureRandom; +import java.util.Arrays; + +import static org.cryptomator.cryptolib.v3.Constants.GCM_TAG_SIZE; + +class FileHeaderCryptorImpl implements FileHeaderCryptor { + + private final Masterkey masterkey; + private final SecureRandom random; + + FileHeaderCryptorImpl(Masterkey masterkey, SecureRandom random) { + this.masterkey = masterkey; + this.random = random; + } + + @Override + public FileHeader 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(nonce, contentKey); + } + + @Override + public int headerSize() { + return FileHeaderImpl.SIZE; + } + + @Override + public ByteBuffer encryptHeader(FileHeader header) { + FileHeaderImpl headerImpl = FileHeaderImpl.cast(header); + ByteBuffer payloadCleartextBuf = ByteBuffer.wrap(headerImpl.getContentKey().getEncoded()); + try (DestroyableSecretKey ek = masterkey.getEncKey()) { + ByteBuffer result = ByteBuffer.allocate(FileHeaderImpl.SIZE); + result.put(Constants.UVF_MAGIC_BYTES); + result.put(Constants.KEY_ID); + result.put(headerImpl.getNonce()); + + // encrypt payload: + try (ObjectPool.Lease cipher = CipherSupplier.AES_GCM.encryptionCipher(ek, new GCMParameterSpec(GCM_TAG_SIZE * Byte.SIZE, headerImpl.getNonce()))) { + 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); + } finally { + Arrays.fill(payloadCleartextBuf.array(), (byte) 0x00); + } + } + + @Override + public FileHeader decryptHeader(ByteBuffer ciphertextHeaderBuf) throws AuthenticationFailedException { + if (ciphertextHeaderBuf.remaining() < FileHeaderImpl.SIZE) { + throw new IllegalArgumentException("Malformed ciphertext header"); + } + ByteBuffer buf = ciphertextHeaderBuf.duplicate(); + 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"); + } + byte[] keyId = new byte[Constants.KEY_ID.length]; + buf.get(keyId); + if (Arrays.equals(Constants.KEY_ID, keyId)) { + throw new IllegalArgumentException("Unsupported key"); + } + 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 ek = masterkey.getEncKey()) { + // decrypt payload: + try (ObjectPool.Lease cipher = CipherSupplier.AES_GCM.decryptionCipher(ek, new GCMParameterSpec(GCM_TAG_SIZE * Byte.SIZE, nonce))) { + 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(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..3ec47f5 --- /dev/null +++ b/src/main/java/org/cryptomator/cryptolib/v3/FileHeaderImpl.java @@ -0,0 +1,74 @@ +/******************************************************************************* + * 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.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_HEADER_LEN = Constants.UVF_MAGIC_BYTES.length + Constants.KEY_ID.length; + 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_HEADER_LEN + NONCE_LEN + CONTENT_KEY_LEN + TAG_LEN; + + private final byte[] nonce; + private final DestroyableSecretKey contentKey; + + FileHeaderImpl(byte[] nonce, DestroyableSecretKey contentKey) { + if (nonce.length != NONCE_LEN) { + throw new IllegalArgumentException("Invalid nonce length. (was: " + nonce.length + ", required: " + NONCE_LEN + ")"); + } + 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 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..a205920 --- /dev/null +++ b/src/main/java/org/cryptomator/cryptolib/v3/FileNameCryptorImpl.java @@ -0,0 +1,71 @@ +/******************************************************************************* + * Copyright (c) 2015, 2016 Sebastian Stenzel and others. + * This file is licensed under the terms of the MIT license. + * See the LICENSE.txt file for more info. + * + * Contributors: + * Sebastian Stenzel - initial API and implementation + *******************************************************************************/ +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.common.DestroyableSecretKey; +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 java.security.MessageDigest; + +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 Masterkey masterkey; + + FileNameCryptorImpl(Masterkey masterkey) { + this.masterkey = masterkey; + } + + @Override + public String hashDirectoryId(String 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[] hashedBytes = sha1.get().digest(encryptedBytes); + return BASE32.encode(hashedBytes); + } + } + + @Override + public String encryptFilename(BaseEncoding encoding, String cleartextName, byte[]... associatedData) { + try (DestroyableSecretKey ek = masterkey.getEncKey(); DestroyableSecretKey mk = masterkey.getMacKey(); + ObjectPool.Lease siv = AES_SIV.get()) { + byte[] cleartextBytes = cleartextName.getBytes(UTF_8); + byte[] encryptedBytes = siv.get().encrypt(ek, mk, cleartextBytes, associatedData); + return encoding.encode(encryptedBytes); + } + } + + @Override + public String decryptFilename(BaseEncoding encoding, String ciphertextName, byte[]... associatedData) throws AuthenticationFailedException { + try (DestroyableSecretKey ek = masterkey.getEncKey(); DestroyableSecretKey mk = masterkey.getMacKey(); + ObjectPool.Lease siv = AES_SIV.get()) { + byte[] encryptedBytes = encoding.decode(ciphertextName); + byte[] cleartextBytes = siv.get().decrypt(ek, mk, 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 From f47b27bd0add5d08f566bed3671d1cd1bc2baf9a Mon Sep 17 00:00:00 2001 From: Sebastian Stenzel Date: Fri, 29 Nov 2024 13:56:22 +0100 Subject: [PATCH 02/21] split Masterkey API into Perpetual + Revolving for use with UVF vaults --- .../cryptomator/cryptolib/api/Masterkey.java | 59 ++-- .../cryptolib/api/PerpetualMasterkey.java | 99 +++++++ .../cryptolib/api/RevolvingMasterkey.java | 20 ++ .../cryptolib/api/UVFMasterkey.java | 96 +++++++ .../common/DestroyableSecretKey.java | 2 +- .../cryptolib/common/HKDFHelper.java | 32 +++ .../cryptolib/common/MasterkeyFileAccess.java | 27 +- .../cryptomator/cryptolib/v1/CryptorImpl.java | 5 +- .../cryptolib/v1/CryptorProviderImpl.java | 11 +- .../cryptolib/v1/FileContentCryptorImpl.java | 9 +- .../cryptolib/v1/FileHeaderCryptorImpl.java | 9 +- .../cryptolib/v1/FileHeaderImpl.java | 4 +- .../cryptolib/v1/FileNameCryptorImpl.java | 5 +- .../cryptomator/cryptolib/v2/CryptorImpl.java | 5 +- .../cryptolib/v2/CryptorProviderImpl.java | 11 +- .../cryptolib/v2/FileHeaderCryptorImpl.java | 9 +- .../cryptolib/v2/FileHeaderImpl.java | 8 +- .../cryptolib/v2/FileNameCryptorImpl.java | 5 +- .../cryptomator/cryptolib/v3/Constants.java | 1 - .../cryptomator/cryptolib/v3/CryptorImpl.java | 5 +- .../cryptolib/v3/CryptorProviderImpl.java | 12 +- .../cryptolib/v3/FileContentCryptorImpl.java | 10 +- .../cryptolib/v3/FileHeaderCryptorImpl.java | 61 ++-- .../cryptolib/v3/FileHeaderImpl.java | 4 +- .../cryptolib/v3/FileNameCryptorImpl.java | 16 +- .../cryptolib/api/UVFMasterkeyTest.java | 51 ++++ .../common/DestroyableSecretKeyTest.java | 1 + .../cryptolib/common/HKDFHelperTest.java | 69 +++++ .../common/MasterkeyFileAccessTest.java | 11 +- .../cryptolib/common/MasterkeyTest.java | 9 +- .../cryptolib/v1/CryptorImplTest.java | 9 +- .../cryptolib/v1/CryptorProviderImplTest.java | 8 +- .../v1/FileContentCryptorImplBenchmark.java | 3 +- .../v1/FileContentCryptorImplTest.java | 9 +- .../v1/FileContentEncryptorBenchmark.java | 3 +- .../v1/FileHeaderCryptorBenchmark.java | 3 +- .../v1/FileHeaderCryptorImplTest.java | 3 +- .../cryptolib/v1/FileNameCryptorImplTest.java | 3 +- .../cryptolib/v2/CryptorImplTest.java | 9 +- .../cryptolib/v2/CryptorProviderImplTest.java | 8 +- .../v2/FileContentCryptorImplTest.java | 13 +- .../v2/FileContentEncryptorBenchmark.java | 3 +- .../v2/FileHeaderCryptorBenchmark.java | 3 +- .../v2/FileHeaderCryptorImplTest.java | 3 +- .../cryptolib/v2/FileNameCryptorImplTest.java | 3 +- .../cryptolib/v3/BenchmarkTest.java | 29 ++ .../cryptolib/v3/CryptorImplTest.java | 64 +++++ .../cryptolib/v3/CryptorProviderImplTest.java | 23 ++ .../v3/FileContentCryptorImplBenchmark.java | 65 +++++ .../v3/FileContentCryptorImplTest.java | 268 ++++++++++++++++++ .../v3/FileContentEncryptorBenchmark.java | 128 +++++++++ .../v3/FileHeaderCryptorBenchmark.java | 64 +++++ .../v3/FileHeaderCryptorImplTest.java | 101 +++++++ .../cryptolib/v3/FileHeaderImplTest.java | 31 ++ 54 files changed, 1326 insertions(+), 196 deletions(-) create mode 100644 src/main/java/org/cryptomator/cryptolib/api/PerpetualMasterkey.java create mode 100644 src/main/java/org/cryptomator/cryptolib/api/RevolvingMasterkey.java create mode 100644 src/main/java/org/cryptomator/cryptolib/api/UVFMasterkey.java create mode 100644 src/main/java/org/cryptomator/cryptolib/common/HKDFHelper.java create mode 100644 src/test/java/org/cryptomator/cryptolib/api/UVFMasterkeyTest.java create mode 100644 src/test/java/org/cryptomator/cryptolib/common/HKDFHelperTest.java create mode 100644 src/test/java/org/cryptomator/cryptolib/v3/BenchmarkTest.java create mode 100644 src/test/java/org/cryptomator/cryptolib/v3/CryptorImplTest.java create mode 100644 src/test/java/org/cryptomator/cryptolib/v3/CryptorProviderImplTest.java create mode 100644 src/test/java/org/cryptomator/cryptolib/v3/FileContentCryptorImplBenchmark.java create mode 100644 src/test/java/org/cryptomator/cryptolib/v3/FileContentCryptorImplTest.java create mode 100644 src/test/java/org/cryptomator/cryptolib/v3/FileContentEncryptorBenchmark.java create mode 100644 src/test/java/org/cryptomator/cryptolib/v3/FileHeaderCryptorBenchmark.java create mode 100644 src/test/java/org/cryptomator/cryptolib/v3/FileHeaderCryptorImplTest.java create mode 100644 src/test/java/org/cryptomator/cryptolib/v3/FileHeaderImplTest.java diff --git a/src/main/java/org/cryptomator/cryptolib/api/Masterkey.java b/src/main/java/org/cryptomator/cryptolib/api/Masterkey.java index c0ec2e5..9baf583 100644 --- a/src/main/java/org/cryptomator/cryptolib/api/Masterkey.java +++ b/src/main/java/org/cryptomator/cryptolib/api/Masterkey.java @@ -3,69 +3,44 @@ 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()); - } + void destroy(); /** - * Get the encryption subkey. - * - * @return A new copy of the subkey used for encryption + * Same as {@link #destroy()} */ - public DestroyableSecretKey getEncKey() { - return new DestroyableSecretKey(getEncoded(), 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(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..5173266 --- /dev/null +++ b/src/main/java/org/cryptomator/cryptolib/api/PerpetualMasterkey.java @@ -0,0 +1,99 @@ +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 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..c58c7bb --- /dev/null +++ b/src/main/java/org/cryptomator/cryptolib/api/UVFMasterkey.java @@ -0,0 +1,96 @@ +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.util.Arrays; +import java.util.Base64; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; + +/** + * @see UVF Vault Metadata Contents + */ +public class UVFMasterkey implements RevolvingMasterkey { + + @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 base64 = Base64.getDecoder(); + byte[] initialSeed = base64.decode(root.get("initialSeed").getAsString()); + byte[] latestSeed = base64.decode(root.get("latestSeed").getAsString()); + byte[] kdfSalt = base64.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(base64.decode(entry.getKey())); + int seedNum = intBuf.getInt(0); + byte[] seedVal = base64.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 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/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/CryptorImpl.java b/src/main/java/org/cryptomator/cryptolib/v1/CryptorImpl.java
index e34137f..ca4480d 100644
--- a/src/main/java/org/cryptomator/cryptolib/v1/CryptorImpl.java
+++ b/src/main/java/org/cryptomator/cryptolib/v1/CryptorImpl.java
@@ -10,12 +10,13 @@
 
 import org.cryptomator.cryptolib.api.Cryptor;
 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 +25,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);
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/FileContentCryptorImpl.java b/src/main/java/org/cryptomator/cryptolib/v1/FileContentCryptorImpl.java
index 004ff10..9196d48 100644
--- a/src/main/java/org/cryptomator/cryptolib/v1/FileContentCryptorImpl.java
+++ b/src/main/java/org/cryptomator/cryptolib/v1/FileContentCryptorImpl.java
@@ -8,10 +8,7 @@
  *******************************************************************************/
 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;
@@ -35,10 +32,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..9f9cfa3 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,9 +29,9 @@ 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;
 	}
 
diff --git a/src/main/java/org/cryptomator/cryptolib/v2/CryptorImpl.java b/src/main/java/org/cryptomator/cryptolib/v2/CryptorImpl.java
index 7389ccd..402d595 100644
--- a/src/main/java/org/cryptomator/cryptolib/v2/CryptorImpl.java
+++ b/src/main/java/org/cryptomator/cryptolib/v2/CryptorImpl.java
@@ -10,13 +10,14 @@
 
 import org.cryptomator.cryptolib.api.Cryptor;
 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 +26,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);
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/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..286352a 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,9 +29,9 @@ 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;
 	}
 
diff --git a/src/main/java/org/cryptomator/cryptolib/v3/Constants.java b/src/main/java/org/cryptomator/cryptolib/v3/Constants.java
index 6ab48ca..f6608da 100644
--- a/src/main/java/org/cryptomator/cryptolib/v3/Constants.java
+++ b/src/main/java/org/cryptomator/cryptolib/v3/Constants.java
@@ -18,7 +18,6 @@ private Constants() {
 	static final String CONTENT_ENC_ALG = "AES";
 
 	static final byte[] UVF_MAGIC_BYTES = "UVF0".getBytes(StandardCharsets.US_ASCII);
-	static final byte[] KEY_ID = "KEY0".getBytes(StandardCharsets.US_ASCII);
 
 	static final int GCM_NONCE_SIZE = 12; // 96 bit IVs strongly recommended for GCM
 	static final int PAYLOAD_SIZE = 32 * 1024;
diff --git a/src/main/java/org/cryptomator/cryptolib/v3/CryptorImpl.java b/src/main/java/org/cryptomator/cryptolib/v3/CryptorImpl.java
index 808fc08..5c181c7 100644
--- a/src/main/java/org/cryptomator/cryptolib/v3/CryptorImpl.java
+++ b/src/main/java/org/cryptomator/cryptolib/v3/CryptorImpl.java
@@ -10,13 +10,14 @@
 
 import org.cryptomator.cryptolib.api.Cryptor;
 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 Masterkey masterkey;
+	private final RevolvingMasterkey masterkey;
 	private final FileContentCryptorImpl fileContentCryptor;
 	private final FileHeaderCryptorImpl fileHeaderCryptor;
 	private final FileNameCryptorImpl fileNameCryptor;
@@ -25,7 +26,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(RevolvingMasterkey masterkey, SecureRandom random) {
 		this.masterkey = masterkey;
 		this.fileHeaderCryptor = new FileHeaderCryptorImpl(masterkey, random);
 		this.fileContentCryptor = new FileContentCryptorImpl(random);
diff --git a/src/main/java/org/cryptomator/cryptolib/v3/CryptorProviderImpl.java b/src/main/java/org/cryptomator/cryptolib/v3/CryptorProviderImpl.java
index a4f2fb6..6a0df88 100644
--- a/src/main/java/org/cryptomator/cryptolib/v3/CryptorProviderImpl.java
+++ b/src/main/java/org/cryptomator/cryptolib/v3/CryptorProviderImpl.java
@@ -8,8 +8,7 @@
  *******************************************************************************/
 package org.cryptomator.cryptolib.v3;
 
-import org.cryptomator.cryptolib.api.CryptorProvider;
-import org.cryptomator.cryptolib.api.Masterkey;
+import org.cryptomator.cryptolib.api.*;
 import org.cryptomator.cryptolib.common.ReseedingSecureRandom;
 
 import java.security.SecureRandom;
@@ -22,8 +21,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 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/FileContentCryptorImpl.java b/src/main/java/org/cryptomator/cryptolib/v3/FileContentCryptorImpl.java
index fe44b60..f301d5e 100644
--- a/src/main/java/org/cryptomator/cryptolib/v3/FileContentCryptorImpl.java
+++ b/src/main/java/org/cryptomator/cryptolib/v3/FileContentCryptorImpl.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.v3;
 
 import org.cryptomator.cryptolib.api.AuthenticationFailedException;
@@ -152,7 +144,7 @@ void decryptChunk(ByteBuffer ciphertextChunk, ByteBuffer cleartextChunk, long ch
 	}
 
 	private byte[] longToBigEndianByteArray(long n) {
-		return ByteBuffer.allocate(Long.SIZE / Byte.SIZE).order(ByteOrder.BIG_ENDIAN).putLong(n).array();
+		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
index 9560f2c..ff33e76 100644
--- a/src/main/java/org/cryptomator/cryptolib/v3/FileHeaderCryptorImpl.java
+++ b/src/main/java/org/cryptomator/cryptolib/v3/FileHeaderCryptorImpl.java
@@ -1,17 +1,6 @@
-/*******************************************************************************
- * 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.v3;
 
-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;
@@ -23,17 +12,22 @@
 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 java.util.Base64;
 
 import static org.cryptomator.cryptolib.v3.Constants.GCM_TAG_SIZE;
 
 class FileHeaderCryptorImpl implements FileHeaderCryptor {
 
-	private final Masterkey masterkey;
+	private static final byte[] KDF_CONTEXT = "fileHeader".getBytes(StandardCharsets.US_ASCII);
+
+	private final RevolvingMasterkey masterkey;
 	private final SecureRandom random;
 
-	FileHeaderCryptorImpl(Masterkey masterkey, SecureRandom random) {
+	FileHeaderCryptorImpl(RevolvingMasterkey masterkey, SecureRandom random) {
 		this.masterkey = masterkey;
 		this.random = random;
 	}
@@ -56,15 +50,20 @@ public int headerSize() {
 	@Override
 	public ByteBuffer encryptHeader(FileHeader header) {
 		FileHeaderImpl headerImpl = FileHeaderImpl.cast(header);
-		ByteBuffer payloadCleartextBuf = ByteBuffer.wrap(headerImpl.getContentKey().getEncoded());
-		try (DestroyableSecretKey ek = masterkey.getEncKey()) {
+		int seedId = masterkey.currentRevision();
+		try (DestroyableSecretKey headerKey = masterkey.subKey(seedId, 32, KDF_CONTEXT, "AES")) {
 			ByteBuffer result = ByteBuffer.allocate(FileHeaderImpl.SIZE);
+
+			// general header:
 			result.put(Constants.UVF_MAGIC_BYTES);
-			result.put(Constants.KEY_ID);
-			result.put(headerImpl.getNonce());
+			result.order(ByteOrder.BIG_ENDIAN).putInt(seedId);
+			ByteBuffer generalHeaderBuf = result.duplicate().position(0).limit(FileHeaderImpl.UVF_GENERAL_HEADERS_LEN);
 
-			// encrypt payload:
-			try (ObjectPool.Lease cipher = CipherSupplier.AES_GCM.encryptionCipher(ek, new GCMParameterSpec(GCM_TAG_SIZE * Byte.SIZE, headerImpl.getNonce()))) {
+			// 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;
 			}
@@ -74,27 +73,26 @@ public ByteBuffer encryptHeader(FileHeader header) {
 			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);
-		} finally {
-			Arrays.fill(payloadCleartextBuf.array(), (byte) 0x00);
 		}
 	}
 
 	@Override
-	public FileHeader decryptHeader(ByteBuffer ciphertextHeaderBuf) throws AuthenticationFailedException {
+	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)) {
+		if (!Arrays.equals(Constants.UVF_MAGIC_BYTES, magicBytes)) {
 			throw new IllegalArgumentException("Not an UVF0 file");
 		}
-		byte[] keyId = new byte[Constants.KEY_ID.length];
-		buf.get(keyId);
-		if (Arrays.equals(Constants.KEY_ID, keyId)) {
-			throw new IllegalArgumentException("Unsupported key");
-		}
+		int seedId = buf.order(ByteOrder.BIG_ENDIAN).getInt();
+		ByteBuffer generalHeaderBuf = buf.duplicate().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);
@@ -104,9 +102,10 @@ public FileHeader decryptHeader(ByteBuffer ciphertextHeaderBuf) throws Authentic
 
 		// 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 ek = masterkey.getEncKey()) {
+		try (DestroyableSecretKey headerKey = masterkey.subKey(seedId, 32, KDF_CONTEXT, "AES")) {
 			// decrypt payload:
-			try (ObjectPool.Lease cipher = CipherSupplier.AES_GCM.decryptionCipher(ek, new GCMParameterSpec(GCM_TAG_SIZE * Byte.SIZE, nonce))) {
+			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;
 			}
diff --git a/src/main/java/org/cryptomator/cryptolib/v3/FileHeaderImpl.java b/src/main/java/org/cryptomator/cryptolib/v3/FileHeaderImpl.java
index 3ec47f5..d56cf43 100644
--- a/src/main/java/org/cryptomator/cryptolib/v3/FileHeaderImpl.java
+++ b/src/main/java/org/cryptomator/cryptolib/v3/FileHeaderImpl.java
@@ -15,14 +15,14 @@
 
 class FileHeaderImpl implements FileHeader, Destroyable {
 
-	static final int UVF_HEADER_LEN = Constants.UVF_MAGIC_BYTES.length + Constants.KEY_ID.length;
+	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_HEADER_LEN + NONCE_LEN + CONTENT_KEY_LEN + TAG_LEN;
+	static final int SIZE = UVF_GENERAL_HEADERS_LEN + NONCE_LEN + CONTENT_KEY_LEN + TAG_LEN;
 
 	private final byte[] nonce;
 	private final DestroyableSecretKey contentKey;
diff --git a/src/main/java/org/cryptomator/cryptolib/v3/FileNameCryptorImpl.java b/src/main/java/org/cryptomator/cryptolib/v3/FileNameCryptorImpl.java
index a205920..72147c3 100644
--- a/src/main/java/org/cryptomator/cryptolib/v3/FileNameCryptorImpl.java
+++ b/src/main/java/org/cryptomator/cryptolib/v3/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.RevolvingMasterkey;
 import org.cryptomator.cryptolib.common.DestroyableSecretKey;
 import org.cryptomator.cryptolib.common.MessageDigestSupplier;
 import org.cryptomator.cryptolib.common.ObjectPool;
@@ -19,6 +20,7 @@
 import org.cryptomator.siv.UnauthenticCiphertextException;
 
 import javax.crypto.IllegalBlockSizeException;
+import java.nio.charset.StandardCharsets;
 import java.security.MessageDigest;
 
 import static java.nio.charset.StandardCharsets.UTF_8;
@@ -28,15 +30,19 @@ 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 RevolvingMasterkey masterkey;
 
-	FileNameCryptorImpl(Masterkey masterkey) {
+	FileNameCryptorImpl(RevolvingMasterkey masterkey) {
 		this.masterkey = masterkey;
 	}
 
+	private DestroyableSecretKey todo() {
+		return masterkey.subKey(0, 64, "TODO".getBytes(StandardCharsets.US_ASCII), "AES");
+	}
+
 	@Override
 	public String hashDirectoryId(String cleartextDirectoryId) {
-		try (DestroyableSecretKey ek = masterkey.getEncKey(); DestroyableSecretKey mk = masterkey.getMacKey();
+		try (DestroyableSecretKey ek = todo(); DestroyableSecretKey mk = todo(); //FIXME
 			 ObjectPool.Lease sha1 = MessageDigestSupplier.SHA1.instance();
 			 ObjectPool.Lease siv = AES_SIV.get()) {
 			byte[] cleartextBytes = cleartextDirectoryId.getBytes(UTF_8);
@@ -48,7 +54,7 @@ public String hashDirectoryId(String cleartextDirectoryId) {
 
 	@Override
 	public String encryptFilename(BaseEncoding encoding, String cleartextName, byte[]... associatedData) {
-		try (DestroyableSecretKey ek = masterkey.getEncKey(); DestroyableSecretKey mk = masterkey.getMacKey();
+		try (DestroyableSecretKey ek = todo(); DestroyableSecretKey mk = todo(); //FIXME
 			 ObjectPool.Lease siv = AES_SIV.get()) {
 			byte[] cleartextBytes = cleartextName.getBytes(UTF_8);
 			byte[] encryptedBytes = siv.get().encrypt(ek, mk, cleartextBytes, associatedData);
@@ -58,7 +64,7 @@ public String encryptFilename(BaseEncoding encoding, String cleartextName, byte[
 
 	@Override
 	public String decryptFilename(BaseEncoding encoding, String ciphertextName, byte[]... associatedData) throws AuthenticationFailedException {
-		try (DestroyableSecretKey ek = masterkey.getEncKey(); DestroyableSecretKey mk = masterkey.getMacKey();
+		try (DestroyableSecretKey ek = todo(); DestroyableSecretKey mk = todo(); //FIXME
 			 ObjectPool.Lease siv = AES_SIV.get()) {
 			byte[] encryptedBytes = encoding.decode(ciphertextName);
 			byte[] cleartextBytes = siv.get().decrypt(ek, mk, encryptedBytes, associatedData);
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..ca0a4e9
--- /dev/null
+++ b/src/test/java/org/cryptomator/cryptolib/api/UVFMasterkeyTest.java
@@ -0,0 +1,51 @@
+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.getDecoder().decode("NIlr89R7FhochyP4yuXZmDqCnQ0dBB3UZ2D+6oiIjr8="), masterkey.kdfSalt);
+		Assertions.assertArrayEquals(Base64.getDecoder().decode("ypeBEsobvcr6wjGzmiPcTaeG7/gUfE5yuYB3ha/uSLs="), masterkey.seeds.get(473544690));
+		Assertions.assertArrayEquals(Base64.getDecoder().decode("Ln0sA6lQeuJl7PW1NWiFpTOTogKdJBOUmXJloaJa78Y="), masterkey.seeds.get(1075513622));
+	}
+
+	@Test
+	public void testSubkey() {
+		Map seeds = Collections.singletonMap(-1540072521, Base64.getDecoder().decode("fP4V4oAjsUw5DqackAvLzA0oP1kAQZ0f5YFZQviXSuU="));
+		byte[] kdfSalt =  Base64.getDecoder().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()));
+			}
+		}
+	}
+
+}
\ 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 filenameGenerator() {
diff --git a/src/test/java/org/cryptomator/cryptolib/v2/CryptorImplTest.java b/src/test/java/org/cryptomator/cryptolib/v2/CryptorImplTest.java
index ea33b48..56497dd 100644
--- a/src/test/java/org/cryptomator/cryptolib/v2/CryptorImplTest.java
+++ b/src/test/java/org/cryptomator/cryptolib/v2/CryptorImplTest.java
@@ -9,6 +9,7 @@
 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;
@@ -23,11 +24,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
@@ -53,7 +54,7 @@ public void testGetFileNameCryptor() {
 
 	@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 +65,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/FileContentCryptorImplTest.java b/src/test/java/org/cryptomator/cryptolib/v2/FileContentCryptorImplTest.java
index 15363ab..e350fbd 100644
--- a/src/test/java/org/cryptomator/cryptolib/v2/FileContentCryptorImplTest.java
+++ b/src/test/java/org/cryptomator/cryptolib/v2/FileContentCryptorImplTest.java
@@ -9,15 +9,8 @@
 package org.cryptomator.cryptolib.v2;
 
 import com.google.common.io.BaseEncoding;
-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.common.DestroyableSecretKey;
-import org.cryptomator.cryptolib.common.SecureRandomMock;
-import org.cryptomator.cryptolib.common.SeekableByteChannelMock;
+import org.cryptomator.cryptolib.api.*;
+import org.cryptomator.cryptolib.common.*;
 import org.hamcrest.CoreMatchers;
 import org.hamcrest.MatcherAssert;
 import org.junit.jupiter.api.Assertions;
@@ -59,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, 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..b7e5537
--- /dev/null
+++ b/src/test/java/org/cryptomator/cryptolib/v3/BenchmarkTest.java
@@ -0,0 +1,29 @@
+package org.cryptomator.cryptolib.v3;
+
+import org.junit.jupiter.api.Disabled;
+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;
+
+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..e738d9c
--- /dev/null
+++ b/src/test/java/org/cryptomator/cryptolib/v3/CryptorImplTest.java
@@ -0,0 +1,64 @@
+package org.cryptomator.cryptolib.v3;
+
+import org.cryptomator.cryptolib.api.UVFMasterkey;
+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.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.getDecoder().decode("fP4V4oAjsUw5DqackAvLzA0oP1kAQZ0f5YFZQviXSuU="));
+	private static final byte[] KDF_SALT =  Base64.getDecoder().decode("HE4OP+2vyfLLURicF1XmdIIsWv0Zs6MobLKROUIEhQY=");
+	private static final UVFMasterkey MASTERKEY = new UVFMasterkey(SEEDS, KDF_SALT, -1540072521, -1540072521);
+
+	@Test
+	public void testGetFileContentCryptor() {
+		try (CryptorImpl cryptor = new CryptorImpl(MASTERKEY, RANDOM_MOCK)) {
+			MatcherAssert.assertThat(cryptor.fileContentCryptor(), CoreMatchers.instanceOf(FileContentCryptorImpl.class));
+		}
+	}
+
+	@Test
+	public void testGetFileHeaderCryptor() {
+		try (CryptorImpl cryptor = new CryptorImpl(MASTERKEY, RANDOM_MOCK)) {
+			MatcherAssert.assertThat(cryptor.fileHeaderCryptor(), CoreMatchers.instanceOf(FileHeaderCryptorImpl.class));
+		}
+	}
+
+	@Test
+	public void testGetFileNameCryptor() {
+		try (CryptorImpl cryptor = new CryptorImpl(MASTERKEY, RANDOM_MOCK)) {
+			MatcherAssert.assertThat(cryptor.fileNameCryptor(), CoreMatchers.instanceOf(FileNameCryptorImpl.class));
+		}
+	}
+
+	@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/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..d60d58f
--- /dev/null
+++ b/src/test/java/org/cryptomator/cryptolib/v3/FileContentCryptorImplTest.java
@@ -0,0 +1,268 @@
+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.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.getDecoder().decode("fP4V4oAjsUw5DqackAvLzA0oP1kAQZ0f5YFZQviXSuU="));
+	private static final byte[] KDF_SALT =  Base64.getDecoder().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(new byte[FileHeaderImpl.NONCE_LEN], new DestroyableSecretKey(new byte[FileHeaderImpl.CONTENT_KEY_LEN], "AES"));
+		headerCryptor = new FileHeaderCryptorImpl(MASTERKEY, CSPRNG);
+		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 {
+
+		@DisplayName("encrypt chunk with invalid size")
+		@ParameterizedTest(name = "cleartext size: {0}")
+		@ValueSource(ints = {0, 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 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("VVZGMKQ0W7dVVVVVVVVVVVVVVVUcowd1FbpM8eMdABtmD/I3RO0n0rV2V3iDYXb++QHGHvqv753T4D5ZzPJtHYv0+ieqqqqqqqqqqqqqqqq3J+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 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("VVZGMKQ0W7dVVVVVVVVVVVVVVVUcowd1FbpM8eMdABtmD/I3RO0n0rV2V3iDYXb++QHGHvqv753T4D5ZzPJtHYv0+ieqqqqqqqqqqqqqqqq3J+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 = {
+				"VVZGMKQ0W7dVVVVVVVVVVVVVVVUcowd1FbpM8eMdABtmD/I3RO0n0rV2V3iDYXb++QHGHvqv753T4D5ZzPJtHYv0+ieqqqqxqqqqqqqqqqq3J+X9CFc6SL5hl+6GdwnBgUYN6cPnoxF4C2Q=, NONCE",
+				"VVZGMKQ0W7dVVVVVVVVVVVVVVVUcowd1FbpM8eMdABtmD/I3RO0n0rV2V3iDYXb++QHGHvqv753T4D5ZzPJtHYv0+ieqqqqqqqqqqqqqqqq3JxX9CFc6SL5hl+6GdwnBgUYN6cPnoxF4C2Q=, CONTENT",
+				"VVZGMKQ0W7dVVVVVVVVVVVVVVVUcowd1FbpM8eMdABtmD/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..d6828a6
--- /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.getDecoder().decode("fP4V4oAjsUw5DqackAvLzA0oP1kAQZ0f5YFZQviXSuU="));
+	private static final byte[] KDF_SALT =  Base64.getDecoder().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..87f6992
--- /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.getDecoder().decode("fP4V4oAjsUw5DqackAvLzA0oP1kAQZ0f5YFZQviXSuU="));
+	private static final byte[] KDF_SALT =  Base64.getDecoder().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);
+
+	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..e0e3b07
--- /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.getDecoder().decode("fP4V4oAjsUw5DqackAvLzA0oP1kAQZ0f5YFZQviXSuU="));
+	private static final byte[] KDF_SALT =  Base64.getDecoder().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);
+
+		// 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.getDecoder().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(new byte[FileHeaderImpl.NONCE_LEN], contentKey);
+
+		ByteBuffer ciphertext = headerCryptor.encryptHeader(header);
+
+		Assertions.assertArrayEquals(Base64.getDecoder().decode("VVZGMKQ0W7cAAAAAAAAAAAAAAAA/UGgFA8QGho7E1QTsHWyZIVFqabbGJ/TCwvp3StG0JYAYxwY4Dg1DNpxcrx8HcCk="), ciphertext.array());
+	}
+
+	@Test
+	public void testDecryption() throws AuthenticationFailedException {
+		byte[] ciphertext = BaseEncoding.base64().decode("VVZGMKQ0W7cAAAAAAAAAAAAAAAA/UGgFA8QGho7E1QTsHWyZIVFqabbGJ/TCwvp3StG0JYAYxwY4Dg1DNpxcrx8HcCk=");
+		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("VVZGMKQ0W7cAAAAAAAAAAAAAAAA/UGgFA8QGho7E1QTsHWyZIVFqabbGJ/TCwvp3StG0JYAYxwY4Dg1DNpxcrx8HcCX="));
+
+		Assertions.assertThrows(AuthenticationFailedException.class, () -> {
+			headerCryptor.decryptHeader(ciphertext);
+		});
+	}
+
+	@Test
+	public void testDecryptionWithInvalidCiphertext() {
+		ByteBuffer ciphertext = ByteBuffer.wrap(BaseEncoding.base64().decode("VVZGMKQ0W7cAAAAAAAAAAAAAAAA/UGgFA8QGho7E1QTsHWyZIVFqabbGJ/XCwvp3StG0JYAYxwY4Dg1DNpxcrx8HcCk="));
+
+		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..f70d8aa
--- /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(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(new byte[FileHeaderImpl.NONCE_LEN], contentKey);
+		Assertions.assertFalse(header.isDestroyed());
+		header.destroy();
+		Assertions.assertTrue(header.isDestroyed());
+		Assertions.assertTrue(contentKey.isDestroyed());
+	}
+
+}

From f07ef0e507a358c7c6af633cf1c70cdd29172a98 Mon Sep 17 00:00:00 2001
From: Sebastian Stenzel 
Date: Fri, 29 Nov 2024 13:56:57 +0100
Subject: [PATCH 03/21] allow empty chunks, so UVF's EOF-chunks can be added

---
 .../common/EncryptingWritableByteChannel.java     | 14 ++------------
 .../cryptolib/v1/FileContentCryptorImpl.java      |  2 +-
 .../cryptolib/v2/FileContentCryptorImpl.java      |  2 +-
 .../common/EncryptingWritableByteChannelTest.java | 15 ++++-----------
 4 files changed, 8 insertions(+), 25 deletions(-)

diff --git a/src/main/java/org/cryptomator/cryptolib/common/EncryptingWritableByteChannel.java b/src/main/java/org/cryptomator/cryptolib/common/EncryptingWritableByteChannel.java
index f336258..3b45836 100644
--- a/src/main/java/org/cryptomator/cryptolib/common/EncryptingWritableByteChannel.java
+++ b/src/main/java/org/cryptomator/cryptolib/common/EncryptingWritableByteChannel.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.common;
 
 import org.cryptomator.cryptolib.api.Cryptor;
@@ -66,10 +58,8 @@ private void writeHeaderOnFirstWrite() throws IOException {
 
 	private void encryptAndFlushBuffer() throws IOException {
 		cleartextBuffer.flip();
-		if (cleartextBuffer.hasRemaining()) {
-			ByteBuffer ciphertextBuffer = cryptor.fileContentCryptor().encryptChunk(cleartextBuffer, chunkNumber++, header);
-			delegate.write(ciphertextBuffer);
-		}
+		ByteBuffer ciphertextBuffer = cryptor.fileContentCryptor().encryptChunk(cleartextBuffer, chunkNumber++, header);
+		delegate.write(ciphertextBuffer);
 		cleartextBuffer.clear();
 	}
 
diff --git a/src/main/java/org/cryptomator/cryptolib/v1/FileContentCryptorImpl.java b/src/main/java/org/cryptomator/cryptolib/v1/FileContentCryptorImpl.java
index 9196d48..729490f 100644
--- a/src/main/java/org/cryptomator/cryptolib/v1/FileContentCryptorImpl.java
+++ b/src/main/java/org/cryptomator/cryptolib/v1/FileContentCryptorImpl.java
@@ -65,7 +65,7 @@ public ByteBuffer encryptChunk(ByteBuffer cleartextChunk, long chunkNumber, File
 
 	@Override
 	public void encryptChunk(ByteBuffer cleartextChunk, ByteBuffer ciphertextChunk, long chunkNumber, FileHeader header) {
-		if (cleartextChunk.remaining() <= 0 || cleartextChunk.remaining() > PAYLOAD_SIZE) {
+		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) {
diff --git a/src/main/java/org/cryptomator/cryptolib/v2/FileContentCryptorImpl.java b/src/main/java/org/cryptomator/cryptolib/v2/FileContentCryptorImpl.java
index 45e6f79..24e00e3 100644
--- a/src/main/java/org/cryptomator/cryptolib/v2/FileContentCryptorImpl.java
+++ b/src/main/java/org/cryptomator/cryptolib/v2/FileContentCryptorImpl.java
@@ -63,7 +63,7 @@ public ByteBuffer encryptChunk(ByteBuffer cleartextChunk, long chunkNumber, File
 
 	@Override
 	public void encryptChunk(ByteBuffer cleartextChunk, ByteBuffer ciphertextChunk, long chunkNumber, FileHeader header) {
-		if (cleartextChunk.remaining() <= 0 || cleartextChunk.remaining() > PAYLOAD_SIZE) {
+		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) {
diff --git a/src/test/java/org/cryptomator/cryptolib/common/EncryptingWritableByteChannelTest.java b/src/test/java/org/cryptomator/cryptolib/common/EncryptingWritableByteChannelTest.java
index a900757..396d75c 100644
--- a/src/test/java/org/cryptomator/cryptolib/common/EncryptingWritableByteChannelTest.java
+++ b/src/test/java/org/cryptomator/cryptolib/common/EncryptingWritableByteChannelTest.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.common;
 
 import org.cryptomator.cryptolib.api.Cryptor;
@@ -49,7 +41,8 @@ public void setup() {
 		Mockito.when(contentCryptor.encryptChunk(Mockito.any(ByteBuffer.class), Mockito.anyLong(), Mockito.any(FileHeader.class))).thenAnswer(invocation -> {
 			ByteBuffer input = invocation.getArgument(0);
 			String inStr = UTF_8.decode(input).toString();
-			return ByteBuffer.wrap(inStr.toUpperCase().getBytes(UTF_8));
+			String outStr = "<" + inStr.toUpperCase() + ">";
+			return UTF_8.encode(outStr);
 		});
 	}
 
@@ -60,7 +53,7 @@ public void testEncryption() throws IOException {
 			ch.write(UTF_8.encode("hello world 2"));
 		}
 		dstFile.flip();
-		Assertions.assertArrayEquals("hhhhhHELLO WORLD 1HELLO WORLD 2".getBytes(), Arrays.copyOfRange(dstFile.array(), 0, dstFile.remaining()));
+		Assertions.assertEquals("hhhhh", UTF_8.decode(dstFile).toString());
 	}
 
 	@Test
@@ -69,7 +62,7 @@ public void testEncryptionOfEmptyFile() throws IOException {
 			// empty, so write nothing
 		}
 		dstFile.flip();
-		Assertions.assertArrayEquals("hhhhh".getBytes(), Arrays.copyOfRange(dstFile.array(), 0, dstFile.remaining()));
+		Assertions.assertEquals("hhhhh<>", UTF_8.decode(dstFile).toString());
 	}
 
 }

From 56dc34e595ba078591aae0e498147df59c4cf1cc Mon Sep 17 00:00:00 2001
From: Sebastian Stenzel 
Date: Fri, 29 Nov 2024 15:24:25 +0100
Subject: [PATCH 04/21] java 8 api sucks...

---
 .../org/cryptomator/cryptolib/v3/FileHeaderCryptorImpl.java | 6 ++++--
 1 file changed, 4 insertions(+), 2 deletions(-)

diff --git a/src/main/java/org/cryptomator/cryptolib/v3/FileHeaderCryptorImpl.java b/src/main/java/org/cryptomator/cryptolib/v3/FileHeaderCryptorImpl.java
index ff33e76..d18b553 100644
--- a/src/main/java/org/cryptomator/cryptolib/v3/FileHeaderCryptorImpl.java
+++ b/src/main/java/org/cryptomator/cryptolib/v3/FileHeaderCryptorImpl.java
@@ -57,7 +57,8 @@ public ByteBuffer encryptHeader(FileHeader header) {
 			// general header:
 			result.put(Constants.UVF_MAGIC_BYTES);
 			result.order(ByteOrder.BIG_ENDIAN).putInt(seedId);
-			ByteBuffer generalHeaderBuf = result.duplicate().position(0).limit(FileHeaderImpl.UVF_GENERAL_HEADERS_LEN);
+			ByteBuffer generalHeaderBuf = result.duplicate();
+			generalHeaderBuf.position(0).limit(FileHeaderImpl.UVF_GENERAL_HEADERS_LEN);
 
 			// format-specific header:
 			result.put(headerImpl.getNonce());
@@ -90,7 +91,8 @@ public FileHeaderImpl decryptHeader(ByteBuffer ciphertextHeaderBuf) throws Authe
 			throw new IllegalArgumentException("Not an UVF0 file");
 		}
 		int seedId = buf.order(ByteOrder.BIG_ENDIAN).getInt();
-		ByteBuffer generalHeaderBuf = buf.duplicate().position(0).limit(FileHeaderImpl.UVF_GENERAL_HEADERS_LEN);
+		ByteBuffer generalHeaderBuf = buf.duplicate();
+		generalHeaderBuf.position(0).limit(FileHeaderImpl.UVF_GENERAL_HEADERS_LEN);
 
 		// format-specific header:
 		byte[] nonce = new byte[FileHeaderImpl.NONCE_LEN];

From dd3ac8492209f077cf6a87bdac5a9145891839a9 Mon Sep 17 00:00:00 2001
From: Sebastian Stenzel 
Date: Fri, 29 Nov 2024 15:27:03 +0100
Subject: [PATCH 05/21] fixed test after changing f07ef0e

---
 .../cryptomator/cryptolib/v1/FileContentCryptorImplTest.java    | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/test/java/org/cryptomator/cryptolib/v1/FileContentCryptorImplTest.java b/src/test/java/org/cryptomator/cryptolib/v1/FileContentCryptorImplTest.java
index 2d13e23..dd66cb2 100644
--- a/src/test/java/org/cryptomator/cryptolib/v1/FileContentCryptorImplTest.java
+++ b/src/test/java/org/cryptomator/cryptolib/v1/FileContentCryptorImplTest.java
@@ -88,7 +88,7 @@ public class Encryption {
 
 		@DisplayName("encrypt chunk with invalid size")
 		@ParameterizedTest(name = "cleartext size: {0}")
-		@ValueSource(ints = {0, Constants.PAYLOAD_SIZE + 1})
+		@ValueSource(ints = {Constants.PAYLOAD_SIZE + 1})
 		public void testEncryptChunkOfInvalidSize(int size) {
 			ByteBuffer cleartext = ByteBuffer.allocate(size);
 

From 485a7bbdc26c4d6a574d3863c88e2b2e6731e4e2 Mon Sep 17 00:00:00 2001
From: Sebastian Stenzel 
Date: Thu, 5 Dec 2024 10:31:39 +0100
Subject: [PATCH 06/21] fix javadoc

---
 src/main/java/org/cryptomator/cryptolib/api/package-info.java | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

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)};

From 084e78a213d7fd5f193d8f545cd7a366988ba996 Mon Sep 17 00:00:00 2001
From: Sebastian Stenzel 
Date: Fri, 6 Dec 2024 17:25:39 +0100
Subject: [PATCH 07/21] added primitives for file name encryption

---
 pom.xml                                       |   2 +-
 .../cryptomator/cryptolib/api/Cryptor.java    |  29 +++-
 .../cryptolib/api/FileNameCryptor.java        |  16 +-
 .../cryptolib/api/UVFMasterkey.java           |  10 +-
 .../cryptomator/cryptolib/v1/CryptorImpl.java |  14 +-
 .../cryptolib/v1/FileNameCryptorImpl.java     |   5 +-
 .../cryptomator/cryptolib/v2/CryptorImpl.java |  14 +-
 .../cryptolib/v2/FileNameCryptorImpl.java     |   5 +-
 .../cryptomator/cryptolib/v3/CryptorImpl.java |  10 +-
 .../cryptolib/v3/FileNameCryptorImpl.java     |  52 +++----
 .../cryptolib/api/UVFMasterkeyTest.java       |   9 ++
 .../cryptolib/v1/CryptorImplTest.java         |  11 +-
 .../cryptolib/v2/CryptorImplTest.java         |  19 +--
 .../cryptolib/v3/CryptorImplTest.java         |  37 +++--
 .../cryptolib/v3/FileNameCryptorImplTest.java | 143 ++++++++++++++++++
 15 files changed, 291 insertions(+), 85 deletions(-)
 create mode 100644 src/test/java/org/cryptomator/cryptolib/v3/FileNameCryptorImplTest.java

diff --git a/pom.xml b/pom.xml
index 41d9bde..ef49b41 100644
--- a/pom.xml
+++ b/pom.xml
@@ -20,7 +20,7 @@
 		
 		2.11.0
 		33.2.1-jre
-		1.5.2
+		1.6.0
 		1.78.1
 		2.0.13
 
diff --git a/src/main/java/org/cryptomator/cryptolib/api/Cryptor.java b/src/main/java/org/cryptomator/cryptolib/api/Cryptor.java
index 5e79c2c..b040ce4 100644
--- a/src/main/java/org/cryptomator/cryptolib/api/Cryptor.java
+++ b/src/main/java/org/cryptomator/cryptolib/api/Cryptor.java
@@ -1,23 +1,36 @@
-/*******************************************************************************
- * 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 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);
+
 	@Override
 	void destroy();
 
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/UVFMasterkey.java b/src/main/java/org/cryptomator/cryptolib/api/UVFMasterkey.java
index c58c7bb..140881c 100644
--- a/src/main/java/org/cryptomator/cryptolib/api/UVFMasterkey.java
+++ b/src/main/java/org/cryptomator/cryptolib/api/UVFMasterkey.java
@@ -9,9 +9,9 @@
 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.Collections;
 import java.util.HashMap;
 import java.util.Iterator;
 import java.util.Map;
@@ -21,6 +21,8 @@
  */
 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;
@@ -67,6 +69,10 @@ public int currentRevision() {
 		return latestSeed;
 	}
 
+	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()) {
@@ -79,7 +85,7 @@ public DestroyableSecretKey subKey(int revision, int length, byte[] context, Str
 		try {
 			return new DestroyableSecretKey(subkey, algorithm);
 		} finally {
-			//Arrays.fill(subkey, (byte) 0x00);
+			Arrays.fill(subkey, (byte) 0x00);
 		}
 	}
 
diff --git a/src/main/java/org/cryptomator/cryptolib/v1/CryptorImpl.java b/src/main/java/org/cryptomator/cryptolib/v1/CryptorImpl.java
index ca4480d..3406862 100644
--- a/src/main/java/org/cryptomator/cryptolib/v1/CryptorImpl.java
+++ b/src/main/java/org/cryptomator/cryptolib/v1/CryptorImpl.java
@@ -1,14 +1,7 @@
-/*******************************************************************************
- * 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.FileNameCryptor;
 import org.cryptomator.cryptolib.api.Masterkey;
 import org.cryptomator.cryptolib.api.PerpetualMasterkey;
 
@@ -50,6 +43,11 @@ public FileNameCryptorImpl fileNameCryptor() {
 		return fileNameCryptor;
 	}
 
+	@Override
+	public FileNameCryptor fileNameCryptor(int revision) {
+		throw new UnsupportedOperationException();
+	}
+
 	@Override
 	public boolean isDestroyed() {
 		return masterkey.isDestroyed();
diff --git a/src/main/java/org/cryptomator/cryptolib/v1/FileNameCryptorImpl.java b/src/main/java/org/cryptomator/cryptolib/v1/FileNameCryptorImpl.java
index 9f9cfa3..ddf6ba0 100644
--- a/src/main/java/org/cryptomator/cryptolib/v1/FileNameCryptorImpl.java
+++ b/src/main/java/org/cryptomator/cryptolib/v1/FileNameCryptorImpl.java
@@ -36,12 +36,11 @@ class FileNameCryptorImpl implements FileNameCryptor {
 	}
 
 	@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/CryptorImpl.java b/src/main/java/org/cryptomator/cryptolib/v2/CryptorImpl.java
index 402d595..02e063b 100644
--- a/src/main/java/org/cryptomator/cryptolib/v2/CryptorImpl.java
+++ b/src/main/java/org/cryptomator/cryptolib/v2/CryptorImpl.java
@@ -1,14 +1,7 @@
-/*******************************************************************************
- * 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.FileNameCryptor;
 import org.cryptomator.cryptolib.api.Masterkey;
 import org.cryptomator.cryptolib.api.PerpetualMasterkey;
 import org.cryptomator.cryptolib.v1.CryptorProviderImpl;
@@ -51,6 +44,11 @@ public FileNameCryptorImpl fileNameCryptor() {
 		return fileNameCryptor;
 	}
 
+	@Override
+	public FileNameCryptor fileNameCryptor(int revision) {
+		throw new UnsupportedOperationException();
+	}
+
 	@Override
 	public boolean isDestroyed() {
 		return masterkey.isDestroyed();
diff --git a/src/main/java/org/cryptomator/cryptolib/v2/FileNameCryptorImpl.java b/src/main/java/org/cryptomator/cryptolib/v2/FileNameCryptorImpl.java
index 286352a..c56d622 100644
--- a/src/main/java/org/cryptomator/cryptolib/v2/FileNameCryptorImpl.java
+++ b/src/main/java/org/cryptomator/cryptolib/v2/FileNameCryptorImpl.java
@@ -36,12 +36,11 @@ class FileNameCryptorImpl implements FileNameCryptor {
 	}
 
 	@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/CryptorImpl.java b/src/main/java/org/cryptomator/cryptolib/v3/CryptorImpl.java
index 5c181c7..95effaf 100644
--- a/src/main/java/org/cryptomator/cryptolib/v3/CryptorImpl.java
+++ b/src/main/java/org/cryptomator/cryptolib/v3/CryptorImpl.java
@@ -9,6 +9,7 @@
 package org.cryptomator.cryptolib.v3;
 
 import org.cryptomator.cryptolib.api.Cryptor;
+import org.cryptomator.cryptolib.api.FileNameCryptor;
 import org.cryptomator.cryptolib.api.Masterkey;
 import org.cryptomator.cryptolib.api.RevolvingMasterkey;
 import org.cryptomator.cryptolib.v1.CryptorProviderImpl;
@@ -20,7 +21,6 @@ class CryptorImpl implements Cryptor {
 	private final RevolvingMasterkey masterkey;
 	private final FileContentCryptorImpl fileContentCryptor;
 	private final FileHeaderCryptorImpl fileHeaderCryptor;
-	private final FileNameCryptorImpl fileNameCryptor;
 
 	/**
 	 * Package-private constructor.
@@ -30,7 +30,6 @@ class CryptorImpl implements Cryptor {
 		this.masterkey = masterkey;
 		this.fileHeaderCryptor = new FileHeaderCryptorImpl(masterkey, random);
 		this.fileContentCryptor = new FileContentCryptorImpl(random);
-		this.fileNameCryptor = new FileNameCryptorImpl(masterkey);
 	}
 
 	@Override
@@ -47,8 +46,13 @@ public FileHeaderCryptorImpl fileHeaderCryptor() {
 
 	@Override
 	public FileNameCryptorImpl fileNameCryptor() {
+		throw new UnsupportedOperationException();
+	}
+
+	@Override
+	public FileNameCryptor fileNameCryptor(int revision) {
 		assertNotDestroyed();
-		return fileNameCryptor;
+		return new FileNameCryptorImpl(masterkey, revision);
 	}
 
 	@Override
diff --git a/src/main/java/org/cryptomator/cryptolib/v3/FileNameCryptorImpl.java b/src/main/java/org/cryptomator/cryptolib/v3/FileNameCryptorImpl.java
index 72147c3..15df644 100644
--- a/src/main/java/org/cryptomator/cryptolib/v3/FileNameCryptorImpl.java
+++ b/src/main/java/org/cryptomator/cryptolib/v3/FileNameCryptorImpl.java
@@ -1,11 +1,3 @@
-/*******************************************************************************
- * Copyright (c) 2015, 2016 Sebastian Stenzel and others.
- * This file is licensed under the terms of the MIT license.
- * See the LICENSE.txt file for more info.
- *
- * Contributors:
- *     Sebastian Stenzel - initial API and implementation
- *******************************************************************************/
 package org.cryptomator.cryptolib.v3;
 
 import com.google.common.io.BaseEncoding;
@@ -14,14 +6,17 @@
 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;
 
@@ -30,44 +25,43 @@ class FileNameCryptorImpl implements FileNameCryptor {
 	private static final BaseEncoding BASE32 = BaseEncoding.base32();
 	private static final ObjectPool AES_SIV = new ObjectPool<>(SivMode::new);
 
-	private final RevolvingMasterkey masterkey;
+	private final DestroyableSecretKey sivKey;
+	private final DestroyableSecretKey hmacKey;
 
-	FileNameCryptorImpl(RevolvingMasterkey masterkey) {
-		this.masterkey = masterkey;
-	}
-
-	private DestroyableSecretKey todo() {
-		return masterkey.subKey(0, 64, "TODO".getBytes(StandardCharsets.US_ASCII), "AES");
+	/**
+	 * 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, 32, "hmac".getBytes(StandardCharsets.US_ASCII), "HMAC");
 	}
 
 	@Override
-	public String hashDirectoryId(String cleartextDirectoryId) {
-		try (DestroyableSecretKey ek = todo(); DestroyableSecretKey mk = todo(); //FIXME
-			 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[] hashedBytes = sha1.get().digest(encryptedBytes);
-			return BASE32.encode(hashedBytes);
+	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 ek = todo(); DestroyableSecretKey mk = todo(); //FIXME
-			 ObjectPool.Lease siv = AES_SIV.get()) {
+		try (DestroyableSecretKey key = this.sivKey.copy(); ObjectPool.Lease siv = AES_SIV.get()) {
 			byte[] cleartextBytes = cleartextName.getBytes(UTF_8);
-			byte[] encryptedBytes = siv.get().encrypt(ek, mk, cleartextBytes, associatedData);
+			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 ek = todo(); DestroyableSecretKey mk = todo(); //FIXME
-			 ObjectPool.Lease siv = AES_SIV.get()) {
+		try (DestroyableSecretKey key = this.sivKey.copy(); ObjectPool.Lease siv = AES_SIV.get()) {
 			byte[] encryptedBytes = encoding.decode(ciphertextName);
-			byte[] cleartextBytes = siv.get().decrypt(ek, mk, encryptedBytes, associatedData);
+			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/test/java/org/cryptomator/cryptolib/api/UVFMasterkeyTest.java b/src/test/java/org/cryptomator/cryptolib/api/UVFMasterkeyTest.java
index ca0a4e9..40270a9 100644
--- a/src/test/java/org/cryptomator/cryptolib/api/UVFMasterkeyTest.java
+++ b/src/test/java/org/cryptomator/cryptolib/api/UVFMasterkeyTest.java
@@ -48,4 +48,13 @@ public void testSubkey() {
 		}
 	}
 
+	@Test
+	public void testRootDirId() {
+		Map seeds = Collections.singletonMap(-1540072521, Base64.getDecoder().decode("fP4V4oAjsUw5DqackAvLzA0oP1kAQZ0f5YFZQviXSuU="));
+		byte[] kdfSalt =  Base64.getDecoder().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/v1/CryptorImplTest.java b/src/test/java/org/cryptomator/cryptolib/v1/CryptorImplTest.java
index a967398..9dd9058 100644
--- a/src/test/java/org/cryptomator/cryptolib/v1/CryptorImplTest.java
+++ b/src/test/java/org/cryptomator/cryptolib/v1/CryptorImplTest.java
@@ -8,7 +8,6 @@
  *******************************************************************************/
 package org.cryptomator.cryptolib.v1;
 
-import org.cryptomator.cryptolib.api.Masterkey;
 import org.cryptomator.cryptolib.api.PerpetualMasterkey;
 import org.cryptomator.cryptolib.common.SecureRandomMock;
 import org.hamcrest.CoreMatchers;
@@ -16,6 +15,8 @@
 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;
@@ -52,6 +53,14 @@ 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() {
 		PerpetualMasterkey masterkey = Mockito.mock(PerpetualMasterkey.class);
diff --git a/src/test/java/org/cryptomator/cryptolib/v2/CryptorImplTest.java b/src/test/java/org/cryptomator/cryptolib/v2/CryptorImplTest.java
index 56497dd..34b8a0b 100644
--- a/src/test/java/org/cryptomator/cryptolib/v2/CryptorImplTest.java
+++ b/src/test/java/org/cryptomator/cryptolib/v2/CryptorImplTest.java
@@ -1,14 +1,5 @@
-/*******************************************************************************
- * 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;
@@ -16,6 +7,8 @@
 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;
@@ -52,6 +45,14 @@ 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() {
 		PerpetualMasterkey masterkey = Mockito.mock(PerpetualMasterkey.class);
diff --git a/src/test/java/org/cryptomator/cryptolib/v3/CryptorImplTest.java b/src/test/java/org/cryptomator/cryptolib/v3/CryptorImplTest.java
index e738d9c..6e561f1 100644
--- a/src/test/java/org/cryptomator/cryptolib/v3/CryptorImplTest.java
+++ b/src/test/java/org/cryptomator/cryptolib/v3/CryptorImplTest.java
@@ -2,9 +2,8 @@
 
 import org.cryptomator.cryptolib.api.UVFMasterkey;
 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.mockito.Mockito;
 
@@ -18,26 +17,46 @@ public class CryptorImplTest {
 	private static final SecureRandom RANDOM_MOCK = SecureRandomMock.NULL_RANDOM;
 	private static final Map SEEDS = Collections.singletonMap(-1540072521, Base64.getDecoder().decode("fP4V4oAjsUw5DqackAvLzA0oP1kAQZ0f5YFZQviXSuU="));
 	private static final byte[] KDF_SALT =  Base64.getDecoder().decode("HE4OP+2vyfLLURicF1XmdIIsWv0Zs6MobLKROUIEhQY=");
-	private static final UVFMasterkey MASTERKEY = new UVFMasterkey(SEEDS, KDF_SALT, -1540072521, -1540072521);
+
+	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)) {
-			MatcherAssert.assertThat(cryptor.fileContentCryptor(), CoreMatchers.instanceOf(FileContentCryptorImpl.class));
+		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)) {
-			MatcherAssert.assertThat(cryptor.fileHeaderCryptor(), CoreMatchers.instanceOf(FileHeaderCryptorImpl.class));
+		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)) {
-			MatcherAssert.assertThat(cryptor.fileNameCryptor(), CoreMatchers.instanceOf(FileNameCryptorImpl.class));
+		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));
 		}
 	}
 
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..1283230
--- /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.getDecoder().decode("fP4V4oAjsUw5DqackAvLzA0oP1kAQZ0f5YFZQviXSuU="));
+	private static final byte[] KDF_SALT =  Base64.getDecoder().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("CRAX3I7DP4HQHA6TDQDMJQUTDKDJ7QG5", 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);
+		});
+	}
+
+}

From 2445d1cdb438f1a05a939f140579f22c139991bd Mon Sep 17 00:00:00 2001
From: Sebastian Stenzel 
Date: Fri, 10 Jan 2025 17:08:34 +0100
Subject: [PATCH 08/21] allow encrypting empty chunks

related to f07ef0e
---
 .../org/cryptomator/cryptolib/v3/FileContentCryptorImpl.java  | 2 +-
 .../cryptomator/cryptolib/v2/FileContentCryptorImplTest.java  | 4 ++--
 .../cryptomator/cryptolib/v3/FileContentCryptorImplTest.java  | 2 +-
 3 files changed, 4 insertions(+), 4 deletions(-)

diff --git a/src/main/java/org/cryptomator/cryptolib/v3/FileContentCryptorImpl.java b/src/main/java/org/cryptomator/cryptolib/v3/FileContentCryptorImpl.java
index f301d5e..6a8c823 100644
--- a/src/main/java/org/cryptomator/cryptolib/v3/FileContentCryptorImpl.java
+++ b/src/main/java/org/cryptomator/cryptolib/v3/FileContentCryptorImpl.java
@@ -55,7 +55,7 @@ public ByteBuffer encryptChunk(ByteBuffer cleartextChunk, long chunkNumber, File
 
 	@Override
 	public void encryptChunk(ByteBuffer cleartextChunk, ByteBuffer ciphertextChunk, long chunkNumber, FileHeader header) {
-		if (cleartextChunk.remaining() <= 0 || cleartextChunk.remaining() > PAYLOAD_SIZE) {
+		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) {
diff --git a/src/test/java/org/cryptomator/cryptolib/v2/FileContentCryptorImplTest.java b/src/test/java/org/cryptomator/cryptolib/v2/FileContentCryptorImplTest.java
index e350fbd..592481d 100644
--- a/src/test/java/org/cryptomator/cryptolib/v2/FileContentCryptorImplTest.java
+++ b/src/test/java/org/cryptomator/cryptolib/v2/FileContentCryptorImplTest.java
@@ -78,7 +78,7 @@ public class Encryption {
 
 		@DisplayName("encrypt chunk with invalid size")
 		@ParameterizedTest(name = "cleartext size: {0}")
-		@ValueSource(ints = {0, org.cryptomator.cryptolib.v2.Constants.PAYLOAD_SIZE + 1})
+		@ValueSource(ints = {0, Constants.PAYLOAD_SIZE + 1})
 		public void testEncryptChunkOfInvalidSize(int size) {
 			ByteBuffer cleartext = ByteBuffer.allocate(size);
 
@@ -147,7 +147,7 @@ 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, org.cryptomator.cryptolib.v2.Constants.CHUNK_SIZE + 1})
+		@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);
 
diff --git a/src/test/java/org/cryptomator/cryptolib/v3/FileContentCryptorImplTest.java b/src/test/java/org/cryptomator/cryptolib/v3/FileContentCryptorImplTest.java
index d60d58f..6968d2e 100644
--- a/src/test/java/org/cryptomator/cryptolib/v3/FileContentCryptorImplTest.java
+++ b/src/test/java/org/cryptomator/cryptolib/v3/FileContentCryptorImplTest.java
@@ -93,7 +93,7 @@ public class Encryption {
 
 		@DisplayName("encrypt chunk with invalid size")
 		@ParameterizedTest(name = "cleartext size: {0}")
-		@ValueSource(ints = {0, Constants.PAYLOAD_SIZE + 1})
+		@ValueSource(ints = {Constants.PAYLOAD_SIZE + 1})
 		public void testEncryptChunkOfInvalidSize(int size) {
 			ByteBuffer cleartext = ByteBuffer.allocate(size);
 

From 940857f7fcac835ad597593c60de6c72e888f6b6 Mon Sep 17 00:00:00 2001
From: Sebastian Stenzel 
Date: Sat, 11 Jan 2025 10:37:45 +0100
Subject: [PATCH 09/21] allow empty chunks (third attempt)

related to 2445d1c, missed yet another test case
---
 .../cryptomator/cryptolib/v2/FileContentCryptorImplTest.java    | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/test/java/org/cryptomator/cryptolib/v2/FileContentCryptorImplTest.java b/src/test/java/org/cryptomator/cryptolib/v2/FileContentCryptorImplTest.java
index 592481d..2d6ad09 100644
--- a/src/test/java/org/cryptomator/cryptolib/v2/FileContentCryptorImplTest.java
+++ b/src/test/java/org/cryptomator/cryptolib/v2/FileContentCryptorImplTest.java
@@ -78,7 +78,7 @@ public class Encryption {
 
 		@DisplayName("encrypt chunk with invalid size")
 		@ParameterizedTest(name = "cleartext size: {0}")
-		@ValueSource(ints = {0, Constants.PAYLOAD_SIZE + 1})
+		@ValueSource(ints = {Constants.PAYLOAD_SIZE + 1})
 		public void testEncryptChunkOfInvalidSize(int size) {
 			ByteBuffer cleartext = ByteBuffer.allocate(size);
 

From 4db62e931d51ecf42eb64d35d294139dd6083422 Mon Sep 17 00:00:00 2001
From: Sebastian Stenzel 
Date: Fri, 17 Jan 2025 21:38:44 +0100
Subject: [PATCH 10/21] fix UVF file header

---
 .../java/org/cryptomator/cryptolib/v3/Constants.java | 12 +-----------
 .../cryptolib/v3/FileContentCryptorImplTest.java     | 10 +++++-----
 .../cryptolib/v3/FileHeaderCryptorImplTest.java      |  8 ++++----
 3 files changed, 10 insertions(+), 20 deletions(-)

diff --git a/src/main/java/org/cryptomator/cryptolib/v3/Constants.java b/src/main/java/org/cryptomator/cryptolib/v3/Constants.java
index f6608da..9d685c4 100644
--- a/src/main/java/org/cryptomator/cryptolib/v3/Constants.java
+++ b/src/main/java/org/cryptomator/cryptolib/v3/Constants.java
@@ -1,15 +1,5 @@
-/*******************************************************************************
- * 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.v3;
 
-import java.nio.charset.StandardCharsets;
-
 final class Constants {
 
 	private Constants() {
@@ -17,7 +7,7 @@ private Constants() {
 
 	static final String CONTENT_ENC_ALG = "AES";
 
-	static final byte[] UVF_MAGIC_BYTES = "UVF0".getBytes(StandardCharsets.US_ASCII);
+	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;
diff --git a/src/test/java/org/cryptomator/cryptolib/v3/FileContentCryptorImplTest.java b/src/test/java/org/cryptomator/cryptolib/v3/FileContentCryptorImplTest.java
index 6968d2e..dc41812 100644
--- a/src/test/java/org/cryptomator/cryptolib/v3/FileContentCryptorImplTest.java
+++ b/src/test/java/org/cryptomator/cryptolib/v3/FileContentCryptorImplTest.java
@@ -151,7 +151,7 @@ public void testFileEncryption() throws IOException {
 			dst.flip();
 			byte[] ciphertext = new byte[dst.remaining()];
 			dst.get(ciphertext);
-			byte[] expected = BaseEncoding.base64().decode("VVZGMKQ0W7dVVVVVVVVVVVVVVVUcowd1FbpM8eMdABtmD/I3RO0n0rV2V3iDYXb++QHGHvqv753T4D5ZzPJtHYv0+ieqqqqqqqqqqqqqqqq3J+X9CFc6SL5hl+6GdwnBgUYN6cPnoxF4C2Q=");
+			byte[] expected = BaseEncoding.base64().decode("dXZmAKQ0W7dVVVVVVVVVVVVVVVUcowd1FbpM8eMdABtmD/I3RO0n0rV2V3iDYXb++QHGHkO9MqYKLnd7ZjeoyNpG1Nmqqqqqqqqqqqqqqqq3J+X9CFc6SL5hl+6GdwnBgUYN6cPnoxF4C2Q=");
 			Assertions.assertArrayEquals(expected, ciphertext);
 		}
 
@@ -193,7 +193,7 @@ public void testChunkDecryptionWithBufferUnderflow() {
 		@Test
 		@DisplayName("decrypt file")
 		public void testFileDecryption() throws IOException {
-			byte[] ciphertext = BaseEncoding.base64().decode("VVZGMKQ0W7dVVVVVVVVVVVVVVVUcowd1FbpM8eMdABtmD/I3RO0n0rV2V3iDYXb++QHGHvqv753T4D5ZzPJtHYv0+ieqqqqqqqqqqqqqqqq3J+X9CFc6SL5hl+6GdwnBgUYN6cPnoxF4C2Q=");
+			byte[] ciphertext = BaseEncoding.base64().decode("dXZmAKQ0W7dVVVVVVVVVVVVVVVUcowd1FbpM8eMdABtmD/I3RO0n0rV2V3iDYXb++QHGHkO9MqYKLnd7ZjeoyNpG1Nmqqqqqqqqqqqqqqqq3J+X9CFc6SL5hl+6GdwnBgUYN6cPnoxF4C2Q=");
 			ReadableByteChannel ciphertextCh = Channels.newChannel(new ByteArrayInputStream(ciphertext));
 
 			ByteBuffer result = ByteBuffer.allocate(20);
@@ -236,9 +236,9 @@ public void testUnauthenticChunkDecryption(String chunkData, String ignored) {
 		@DisplayName("decrypt unauthentic file")
 		@ParameterizedTest(name = "unauthentic {1} in first chunk")
 		@CsvSource(value = {
-				"VVZGMKQ0W7dVVVVVVVVVVVVVVVUcowd1FbpM8eMdABtmD/I3RO0n0rV2V3iDYXb++QHGHvqv753T4D5ZzPJtHYv0+ieqqqqxqqqqqqqqqqq3J+X9CFc6SL5hl+6GdwnBgUYN6cPnoxF4C2Q=, NONCE",
-				"VVZGMKQ0W7dVVVVVVVVVVVVVVVUcowd1FbpM8eMdABtmD/I3RO0n0rV2V3iDYXb++QHGHvqv753T4D5ZzPJtHYv0+ieqqqqqqqqqqqqqqqq3JxX9CFc6SL5hl+6GdwnBgUYN6cPnoxF4C2Q=, CONTENT",
-				"VVZGMKQ0W7dVVVVVVVVVVVVVVVUcowd1FbpM8eMdABtmD/I3RO0n0rV2V3iDYXb++QHGHvqv753T4D5ZzPJtHYv0+ieqqqqqqqqqqqqqqqq3J+X9CFc6SL5hl+6GdwnBgUYN6cPnoxF4C2x=, TAG",
+				"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);
diff --git a/src/test/java/org/cryptomator/cryptolib/v3/FileHeaderCryptorImplTest.java b/src/test/java/org/cryptomator/cryptolib/v3/FileHeaderCryptorImplTest.java
index e0e3b07..f9e8492 100644
--- a/src/test/java/org/cryptomator/cryptolib/v3/FileHeaderCryptorImplTest.java
+++ b/src/test/java/org/cryptomator/cryptolib/v3/FileHeaderCryptorImplTest.java
@@ -60,12 +60,12 @@ public void testEncryption() {
 
 		ByteBuffer ciphertext = headerCryptor.encryptHeader(header);
 
-		Assertions.assertArrayEquals(Base64.getDecoder().decode("VVZGMKQ0W7cAAAAAAAAAAAAAAAA/UGgFA8QGho7E1QTsHWyZIVFqabbGJ/TCwvp3StG0JYAYxwY4Dg1DNpxcrx8HcCk="), ciphertext.array());
+		Assertions.assertArrayEquals(Base64.getDecoder().decode("dXZmAKQ0W7cAAAAAAAAAAAAAAAA/UGgFA8QGho7E1QTsHWyZIVFqabbGJ/TCwvp3StG0JTkKGj3hwERhnFmZek61Xtc="), ciphertext.array());
 	}
 
 	@Test
 	public void testDecryption() throws AuthenticationFailedException {
-		byte[] ciphertext = BaseEncoding.base64().decode("VVZGMKQ0W7cAAAAAAAAAAAAAAAA/UGgFA8QGho7E1QTsHWyZIVFqabbGJ/TCwvp3StG0JYAYxwY4Dg1DNpxcrx8HcCk=");
+		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());
@@ -82,7 +82,7 @@ public void testDecryptionWithTooShortHeader() {
 
 	@Test
 	public void testDecryptionWithInvalidTag() {
-		ByteBuffer ciphertext = ByteBuffer.wrap(BaseEncoding.base64().decode("VVZGMKQ0W7cAAAAAAAAAAAAAAAA/UGgFA8QGho7E1QTsHWyZIVFqabbGJ/TCwvp3StG0JYAYxwY4Dg1DNpxcrx8HcCX="));
+		ByteBuffer ciphertext = ByteBuffer.wrap(BaseEncoding.base64().decode("dXZmAKQ0W7cAAAAAAAAAAAAAAAA/UGgFA8QGho7E1QTsHWyZIVFqabbGJ/TCwvp3StG0JTkKGj3hwERhnFmZek61XtX="));
 
 		Assertions.assertThrows(AuthenticationFailedException.class, () -> {
 			headerCryptor.decryptHeader(ciphertext);
@@ -91,7 +91,7 @@ public void testDecryptionWithInvalidTag() {
 
 	@Test
 	public void testDecryptionWithInvalidCiphertext() {
-		ByteBuffer ciphertext = ByteBuffer.wrap(BaseEncoding.base64().decode("VVZGMKQ0W7cAAAAAAAAAAAAAAAA/UGgFA8QGho7E1QTsHWyZIVFqabbGJ/XCwvp3StG0JYAYxwY4Dg1DNpxcrx8HcCk="));
+		ByteBuffer ciphertext = ByteBuffer.wrap(BaseEncoding.base64().decode("dXZmAKQ0W7cAAAAAAAAAAAAAAAA/UGgFA8QGho7E1QTsHWyZIVFqabbGJ/XCwvp3StG0JTkKGj3hwERhnFmZek61Xtc="));
 
 		Assertions.assertThrows(AuthenticationFailedException.class, () -> {
 			headerCryptor.decryptHeader(ciphertext);

From e8aeec4287df907f8a83c341589f20a8f8d8604d Mon Sep 17 00:00:00 2001
From: Sebastian Stenzel 
Date: Fri, 17 Jan 2025 21:39:08 +0100
Subject: [PATCH 11/21] use same test vectors as in typescript impl

---
 .../cryptolib/v3/UVFIntegrationTest.java      | 86 +++++++++++++++++++
 1 file changed, 86 insertions(+)
 create mode 100644 src/test/java/org/cryptomator/cryptolib/v3/UVFIntegrationTest.java

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..c1ea974
--- /dev/null
+++ b/src/test/java/org/cryptomator/cryptolib/v3/UVFIntegrationTest.java
@@ -0,0 +1,86 @@
+package org.cryptomator.cryptolib.v3;
+
+import org.cryptomator.cryptolib.api.Cryptor;
+import org.cryptomator.cryptolib.api.CryptorProvider;
+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.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.channels.ByteChannel;
+import java.nio.channels.Channels;
+import java.nio.charset.StandardCharsets;
+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
+	public void testRootDirId() {
+		var rootDirId = masterkey.rootDirId();
+		Assertions.assertEquals("5WEGzwKkAHPwVSjT2Brr3P3zLz7oMiNpMn/qBvht7eM=", Base64.getEncoder().encodeToString(rootDirId));
+	}
+
+	@Test
+	public void testRootDirHash() {
+		var rootDirId = Base64.getDecoder().decode("5WEGzwKkAHPwVSjT2Brr3P3zLz7oMiNpMn/qBvht7eM=");
+		var dirHash = cryptor.fileNameCryptor(masterkey.firstRevision()).hashDirectoryId(rootDirId);
+		Assertions.assertEquals("RKHZLENL3PQIW6GZHE3KRRRGLFBHWHRU", dirHash);
+	}
+
+	@Test
+	public void testContentEncryption() throws IOException {
+		var baos = new java.io.ByteArrayOutputStream();
+		try (EncryptingWritableByteChannel ch = new EncryptingWritableByteChannel(Channels.newChannel(baos), cryptor)) {
+			int written = ch.write(StandardCharsets.UTF_8.encode("Hello, World!"));
+			Assertions.assertEquals(13, written);
+		}
+		var result = baos.toByteArray();
+		Assertions.assertArrayEquals(new byte[]{0x75, 0x76, 0x66, 0x00}, Arrays.copyOf(result, 4));
+		Assertions.assertArrayEquals(Base64.getUrlDecoder().decode("QBsJFo"), Arrays.copyOfRange(result, 4, 8));
+	}
+
+	@Test
+	public void testContentDecryption() throws IOException {
+		var input = Base64.getDecoder().decode("dXZmAEAbCRZxhI5sPsMiMlAQpwXzsOw13pBVX/yHydeHoOlHBS9d+wVpmRvzUKx5HQUmtGR4avjDownMNOS4sBX8G0SVc5dIADKnGUOwgF20kkc/EpGzrrgkS3C9lZoRPPOj3dm2ONfy3UkT1Q==");
+		ByteBuffer result = ByteBuffer.allocate(100);
+		try (DecryptingReadableByteChannel ch = new DecryptingReadableByteChannel(Channels.newChannel(new ByteArrayInputStream(input)), cryptor, true)) {
+			int read = ch.read(result);
+			Assertions.assertEquals(13, read);
+		}
+		result.flip();
+		Assertions.assertEquals("Hello, World!", StandardCharsets.UTF_8.decode(result).toString());
+	}
+
+}

From 47a26a2bd013e0e11884fafd4ee9cb3d7f0d1f39 Mon Sep 17 00:00:00 2001
From: Sebastian Stenzel 
Date: Sat, 18 Jan 2025 12:21:25 +0100
Subject: [PATCH 12/21] fix build with Java 8

---
 .../cryptolib/v3/UVFIntegrationTest.java           | 14 +++++++-------
 1 file changed, 7 insertions(+), 7 deletions(-)

diff --git a/src/test/java/org/cryptomator/cryptolib/v3/UVFIntegrationTest.java b/src/test/java/org/cryptomator/cryptolib/v3/UVFIntegrationTest.java
index c1ea974..ee819c6 100644
--- a/src/test/java/org/cryptomator/cryptolib/v3/UVFIntegrationTest.java
+++ b/src/test/java/org/cryptomator/cryptolib/v3/UVFIntegrationTest.java
@@ -10,9 +10,9 @@
 import org.junit.jupiter.api.Test;
 
 import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.nio.ByteBuffer;
-import java.nio.channels.ByteChannel;
 import java.nio.channels.Channels;
 import java.nio.charset.StandardCharsets;
 import java.security.SecureRandom;
@@ -48,32 +48,32 @@ public static void setUp() {
 
 	@Test
 	public void testRootDirId() {
-		var rootDirId = masterkey.rootDirId();
+		byte[] rootDirId = masterkey.rootDirId();
 		Assertions.assertEquals("5WEGzwKkAHPwVSjT2Brr3P3zLz7oMiNpMn/qBvht7eM=", Base64.getEncoder().encodeToString(rootDirId));
 	}
 
 	@Test
 	public void testRootDirHash() {
-		var rootDirId = Base64.getDecoder().decode("5WEGzwKkAHPwVSjT2Brr3P3zLz7oMiNpMn/qBvht7eM=");
-		var dirHash = cryptor.fileNameCryptor(masterkey.firstRevision()).hashDirectoryId(rootDirId);
+		byte[] rootDirId = Base64.getDecoder().decode("5WEGzwKkAHPwVSjT2Brr3P3zLz7oMiNpMn/qBvht7eM=");
+		String dirHash = cryptor.fileNameCryptor(masterkey.firstRevision()).hashDirectoryId(rootDirId);
 		Assertions.assertEquals("RKHZLENL3PQIW6GZHE3KRRRGLFBHWHRU", dirHash);
 	}
 
 	@Test
 	public void testContentEncryption() throws IOException {
-		var baos = new java.io.ByteArrayOutputStream();
+		ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
 		try (EncryptingWritableByteChannel ch = new EncryptingWritableByteChannel(Channels.newChannel(baos), cryptor)) {
 			int written = ch.write(StandardCharsets.UTF_8.encode("Hello, World!"));
 			Assertions.assertEquals(13, written);
 		}
-		var result = baos.toByteArray();
+		byte[] result = baos.toByteArray();
 		Assertions.assertArrayEquals(new byte[]{0x75, 0x76, 0x66, 0x00}, Arrays.copyOf(result, 4));
 		Assertions.assertArrayEquals(Base64.getUrlDecoder().decode("QBsJFo"), Arrays.copyOfRange(result, 4, 8));
 	}
 
 	@Test
 	public void testContentDecryption() throws IOException {
-		var input = Base64.getDecoder().decode("dXZmAEAbCRZxhI5sPsMiMlAQpwXzsOw13pBVX/yHydeHoOlHBS9d+wVpmRvzUKx5HQUmtGR4avjDownMNOS4sBX8G0SVc5dIADKnGUOwgF20kkc/EpGzrrgkS3C9lZoRPPOj3dm2ONfy3UkT1Q==");
+		byte[] input = Base64.getDecoder().decode("dXZmAEAbCRZxhI5sPsMiMlAQpwXzsOw13pBVX/yHydeHoOlHBS9d+wVpmRvzUKx5HQUmtGR4avjDownMNOS4sBX8G0SVc5dIADKnGUOwgF20kkc/EpGzrrgkS3C9lZoRPPOj3dm2ONfy3UkT1Q==");
 		ByteBuffer result = ByteBuffer.allocate(100);
 		try (DecryptingReadableByteChannel ch = new DecryptingReadableByteChannel(Channels.newChannel(new ByteArrayInputStream(input)), cryptor, true)) {
 			int read = ch.read(result);

From dcea94ddbf03cdb796645111707ba35359bb0952 Mon Sep 17 00:00:00 2001
From: Sebastian Stenzel 
Date: Fri, 24 Jan 2025 19:35:48 +0100
Subject: [PATCH 13/21] Introduce new `DirectoryContentCryptor` API

for high level encryption/decryption of directory contents
---
 .../cryptomator/cryptolib/api/Cryptor.java    |   8 ++
 .../api/DirectoryContentCryptor.java          |  54 +++++++
 .../cryptolib/api/DirectoryMetadata.java      |   4 +
 .../cryptomator/cryptolib/v3/Constants.java   |   2 +
 .../cryptomator/cryptolib/v3/CryptorImpl.java |  16 +--
 .../cryptolib/v3/CryptorProviderImpl.java     |   8 --
 .../v3/DirectoryContentCryptorImpl.java       | 100 +++++++++++++
 .../cryptolib/v3/DirectoryMetadataImpl.java   |  23 +++
 .../cryptolib/v3/FileHeaderCryptorImpl.java   |  12 +-
 .../cryptolib/v3/FileHeaderImpl.java          |  16 +--
 .../v3/DirectoryContentCryptorImplTest.java   | 135 ++++++++++++++++++
 .../v3/FileContentCryptorImplTest.java        |   2 +-
 .../v3/FileHeaderCryptorImplTest.java         |   2 +-
 .../cryptolib/v3/FileHeaderImplTest.java      |   4 +-
 14 files changed, 350 insertions(+), 36 deletions(-)
 create mode 100644 src/main/java/org/cryptomator/cryptolib/api/DirectoryContentCryptor.java
 create mode 100644 src/main/java/org/cryptomator/cryptolib/api/DirectoryMetadata.java
 create mode 100644 src/main/java/org/cryptomator/cryptolib/v3/DirectoryContentCryptorImpl.java
 create mode 100644 src/main/java/org/cryptomator/cryptolib/v3/DirectoryMetadataImpl.java
 create mode 100644 src/test/java/org/cryptomator/cryptolib/v3/DirectoryContentCryptorImplTest.java

diff --git a/src/main/java/org/cryptomator/cryptolib/api/Cryptor.java b/src/main/java/org/cryptomator/cryptolib/api/Cryptor.java
index b040ce4..0b09b64 100644
--- a/src/main/java/org/cryptomator/cryptolib/api/Cryptor.java
+++ b/src/main/java/org/cryptomator/cryptolib/api/Cryptor.java
@@ -31,6 +31,14 @@ public interface Cryptor extends Destroyable, AutoCloseable {
 	 */
 	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
+	 */
+	default DirectoryContentCryptor directoryContentCryptor() {
+		throw new UnsupportedOperationException("not implemented");
+	}
+
 	@Override
 	void destroy();
 
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..2c50aff
--- /dev/null
+++ b/src/main/java/org/cryptomator/cryptolib/api/DirectoryContentCryptor.java
@@ -0,0 +1,54 @@
+package org.cryptomator.cryptolib.api;
+
+public interface DirectoryContentCryptor {
+
+	T rootDirectoryMetadata(); // TODO required?
+
+	T 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.
+	 */
+	T decryptDirectoryMetadata(byte[] ciphertext) throws AuthenticationFailedException;
+
+	/**
+	 * Encrypts the given directory metadata.
+	 *
+	 * @param directoryMetadata The directory metadata to encrypt.
+	 * @return The encrypted directory metadata.
+	 */
+	byte[] encryptDirectoryMetadata(T directoryMetadata);
+
+	Decrypting fileNameDecryptor(T directoryMetadata);
+
+	Encrypting fileNameEncryptor(T 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/v3/Constants.java b/src/main/java/org/cryptomator/cryptolib/v3/Constants.java
index 9d685c4..2ba44f9 100644
--- a/src/main/java/org/cryptomator/cryptolib/v3/Constants.java
+++ b/src/main/java/org/cryptomator/cryptolib/v3/Constants.java
@@ -2,6 +2,8 @@
 
 final class Constants {
 
+	public static final String UVF_FILE_EXT = ".uvf";
+
 	private Constants() {
 	}
 
diff --git a/src/main/java/org/cryptomator/cryptolib/v3/CryptorImpl.java b/src/main/java/org/cryptomator/cryptolib/v3/CryptorImpl.java
index 95effaf..c5fa2a4 100644
--- a/src/main/java/org/cryptomator/cryptolib/v3/CryptorImpl.java
+++ b/src/main/java/org/cryptomator/cryptolib/v3/CryptorImpl.java
@@ -1,14 +1,7 @@
-/*******************************************************************************
- * 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.v3;
 
 import org.cryptomator.cryptolib.api.Cryptor;
+import org.cryptomator.cryptolib.api.DirectoryContentCryptor;
 import org.cryptomator.cryptolib.api.FileNameCryptor;
 import org.cryptomator.cryptolib.api.Masterkey;
 import org.cryptomator.cryptolib.api.RevolvingMasterkey;
@@ -21,6 +14,7 @@ class CryptorImpl implements Cryptor {
 	private final RevolvingMasterkey masterkey;
 	private final FileContentCryptorImpl fileContentCryptor;
 	private final FileHeaderCryptorImpl fileHeaderCryptor;
+	private final SecureRandom random;
 
 	/**
 	 * Package-private constructor.
@@ -30,6 +24,7 @@ class CryptorImpl implements Cryptor {
 		this.masterkey = masterkey;
 		this.fileHeaderCryptor = new FileHeaderCryptorImpl(masterkey, random);
 		this.fileContentCryptor = new FileContentCryptorImpl(random);
+		this.random = random;
 	}
 
 	@Override
@@ -55,6 +50,11 @@ public FileNameCryptor fileNameCryptor(int revision) {
 		return new FileNameCryptorImpl(masterkey, revision);
 	}
 
+	@Override
+	public DirectoryContentCryptorImpl directoryContentCryptor() {
+		return new DirectoryContentCryptorImpl(masterkey, random, this);
+	}
+
 	@Override
 	public boolean isDestroyed() {
 		return masterkey.isDestroyed();
diff --git a/src/main/java/org/cryptomator/cryptolib/v3/CryptorProviderImpl.java b/src/main/java/org/cryptomator/cryptolib/v3/CryptorProviderImpl.java
index 6a0df88..c9082aa 100644
--- a/src/main/java/org/cryptomator/cryptolib/v3/CryptorProviderImpl.java
+++ b/src/main/java/org/cryptomator/cryptolib/v3/CryptorProviderImpl.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.v3;
 
 import org.cryptomator.cryptolib.api.*;
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..142bb12
--- /dev/null
+++ b/src/main/java/org/cryptomator/cryptolib/v3/DirectoryContentCryptorImpl.java
@@ -0,0 +1,100 @@
+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.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() {
+		// TODO
+		return null;
+	}
+
+	@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().position(0).limit(headerSize);
+		ByteBuffer contentBuf = buffer.duplicate().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(DirectoryMetadataImpl directoryMetadata) {
+		ByteBuffer cleartextBuf = ByteBuffer.wrap(directoryMetadata.dirId());
+		FileHeader header = cryptor.fileHeaderCryptor().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;
+	}
+
+	// FILE NAMES
+
+	@Override
+	public Decrypting fileNameDecryptor(DirectoryMetadataImpl directoryMetadata) {
+		byte[] dirId = directoryMetadata.dirId();
+		FileNameCryptorImpl fileNameCryptor = new FileNameCryptorImpl(masterkey, directoryMetadata.seedId());
+		return ciphertextAndExt -> {
+			String ciphertext = removeExtension(ciphertextAndExt);
+			return fileNameCryptor.decryptFilename(BaseEncoding.base64Url(), ciphertext, dirId);
+		};
+	}
+
+	@Override
+	public Encrypting fileNameEncryptor(DirectoryMetadataImpl directoryMetadata) {
+		byte[] dirId = directoryMetadata.dirId();
+		FileNameCryptorImpl fileNameCryptor = new FileNameCryptorImpl(masterkey, directoryMetadata.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..ef58fc6
--- /dev/null
+++ b/src/main/java/org/cryptomator/cryptolib/v3/DirectoryMetadataImpl.java
@@ -0,0 +1,23 @@
+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;
+	}
+
+	public byte[] dirId() {
+		return dirId;
+	}
+
+	public int seedId() {
+		return seedId;
+	}
+
+}
diff --git a/src/main/java/org/cryptomator/cryptolib/v3/FileHeaderCryptorImpl.java b/src/main/java/org/cryptomator/cryptolib/v3/FileHeaderCryptorImpl.java
index d18b553..a190cc9 100644
--- a/src/main/java/org/cryptomator/cryptolib/v3/FileHeaderCryptorImpl.java
+++ b/src/main/java/org/cryptomator/cryptolib/v3/FileHeaderCryptorImpl.java
@@ -16,7 +16,6 @@
 import java.nio.charset.StandardCharsets;
 import java.security.SecureRandom;
 import java.util.Arrays;
-import java.util.Base64;
 
 import static org.cryptomator.cryptolib.v3.Constants.GCM_TAG_SIZE;
 
@@ -33,13 +32,13 @@ class FileHeaderCryptorImpl implements FileHeaderCryptor {
 	}
 
 	@Override
-	public FileHeader create() {
+	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(nonce, contentKey);
+		return new FileHeaderImpl(masterkey.currentRevision(), nonce, contentKey);
 	}
 
 	@Override
@@ -50,13 +49,12 @@ public int headerSize() {
 	@Override
 	public ByteBuffer encryptHeader(FileHeader header) {
 		FileHeaderImpl headerImpl = FileHeaderImpl.cast(header);
-		int seedId = masterkey.currentRevision();
-		try (DestroyableSecretKey headerKey = masterkey.subKey(seedId, 32, KDF_CONTEXT, "AES")) {
+		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(seedId);
+			result.order(ByteOrder.BIG_ENDIAN).putInt(headerImpl.getSeedId());
 			ByteBuffer generalHeaderBuf = result.duplicate();
 			generalHeaderBuf.position(0).limit(FileHeaderImpl.UVF_GENERAL_HEADERS_LEN);
 
@@ -115,7 +113,7 @@ public FileHeaderImpl decryptHeader(ByteBuffer ciphertextHeaderBuf) throws Authe
 			byte[] contentKeyBytes = new byte[FileHeaderImpl.CONTENT_KEY_LEN];
 			payloadCleartextBuf.get(contentKeyBytes);
 			DestroyableSecretKey contentKey = new DestroyableSecretKey(contentKeyBytes, Constants.CONTENT_ENC_ALG);
-			return new FileHeaderImpl(nonce, contentKey);
+			return new FileHeaderImpl(seedId, nonce, contentKey);
 		} catch (AEADBadTagException e) {
 			throw new AuthenticationFailedException("Header tag mismatch.", e);
 		} catch (ShortBufferException e) {
diff --git a/src/main/java/org/cryptomator/cryptolib/v3/FileHeaderImpl.java b/src/main/java/org/cryptomator/cryptolib/v3/FileHeaderImpl.java
index d56cf43..40dccdb 100644
--- a/src/main/java/org/cryptomator/cryptolib/v3/FileHeaderImpl.java
+++ b/src/main/java/org/cryptomator/cryptolib/v3/FileHeaderImpl.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.v3;
 
 import org.cryptomator.cryptolib.api.FileHeader;
@@ -24,13 +16,15 @@ class FileHeaderImpl implements FileHeader, Destroyable {
 	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(byte[] nonce, 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;
 	}
@@ -42,6 +36,10 @@ static FileHeaderImpl cast(FileHeader header) {
 			throw new IllegalArgumentException("Unsupported header type " + header.getClass());
 		}
 	}
+	
+	public int getSeedId() {
+		return seedId;
+	}
 
 	public byte[] getNonce() {
 		return nonce;
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..6e79758
--- /dev/null
+++ b/src/test/java/org/cryptomator/cryptolib/v3/DirectoryContentCryptorImplTest.java
@@ -0,0 +1,135 @@
+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.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());
+	}
+
+	@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/FileContentCryptorImplTest.java b/src/test/java/org/cryptomator/cryptolib/v3/FileContentCryptorImplTest.java
index e36a4cb..a0c32a4 100644
--- a/src/test/java/org/cryptomator/cryptolib/v3/FileContentCryptorImplTest.java
+++ b/src/test/java/org/cryptomator/cryptolib/v3/FileContentCryptorImplTest.java
@@ -62,7 +62,7 @@ public class FileContentCryptorImplTest {
 
 	@BeforeEach
 	public void setup() {
-		header = new FileHeaderImpl(new byte[FileHeaderImpl.NONCE_LEN], new DestroyableSecretKey(new byte[FileHeaderImpl.CONTENT_KEY_LEN], "AES"));
+		header = new FileHeaderImpl(-1540072521, new byte[FileHeaderImpl.NONCE_LEN], new DestroyableSecretKey(new byte[FileHeaderImpl.CONTENT_KEY_LEN], "AES"));
 		headerCryptor = new FileHeaderCryptorImpl(MASTERKEY, CSPRNG);
 		fileContentCryptor = new FileContentCryptorImpl(CSPRNG);
 		cryptor = Mockito.mock(Cryptor.class);
diff --git a/src/test/java/org/cryptomator/cryptolib/v3/FileHeaderCryptorImplTest.java b/src/test/java/org/cryptomator/cryptolib/v3/FileHeaderCryptorImplTest.java
index f9e8492..e659b0e 100644
--- a/src/test/java/org/cryptomator/cryptolib/v3/FileHeaderCryptorImplTest.java
+++ b/src/test/java/org/cryptomator/cryptolib/v3/FileHeaderCryptorImplTest.java
@@ -56,7 +56,7 @@ public void testSubkeyGeneration() {
 	@Test
 	public void testEncryption() {
 		DestroyableSecretKey contentKey = new DestroyableSecretKey(new byte[FileHeaderImpl.CONTENT_KEY_LEN], "AES");
-		FileHeader header = new FileHeaderImpl(new byte[FileHeaderImpl.NONCE_LEN], contentKey);
+		FileHeader header = new FileHeaderImpl(-1540072521, new byte[FileHeaderImpl.NONCE_LEN], contentKey);
 
 		ByteBuffer ciphertext = headerCryptor.encryptHeader(header);
 
diff --git a/src/test/java/org/cryptomator/cryptolib/v3/FileHeaderImplTest.java b/src/test/java/org/cryptomator/cryptolib/v3/FileHeaderImplTest.java
index f70d8aa..85f46af 100644
--- a/src/test/java/org/cryptomator/cryptolib/v3/FileHeaderImplTest.java
+++ b/src/test/java/org/cryptomator/cryptolib/v3/FileHeaderImplTest.java
@@ -12,7 +12,7 @@ public class FileHeaderImplTest {
 	public void testConstructionFailsWithInvalidNonceSize() {
 		DestroyableSecretKey contentKey = new DestroyableSecretKey(new byte[FileHeaderImpl.CONTENT_KEY_LEN], "AES");
 		Assertions.assertThrows(IllegalArgumentException.class, () -> {
-			new FileHeaderImpl(new byte[3], contentKey);
+			new FileHeaderImpl(-1540072521, new byte[3], contentKey);
 		});
 	}
 
@@ -21,7 +21,7 @@ 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(new byte[FileHeaderImpl.NONCE_LEN], contentKey);
+		FileHeaderImpl header = new FileHeaderImpl(-1540072521, new byte[FileHeaderImpl.NONCE_LEN], contentKey);
 		Assertions.assertFalse(header.isDestroyed());
 		header.destroy();
 		Assertions.assertTrue(header.isDestroyed());

From 361b3b07f103c154d8f62ec2beffedbd60c573e8 Mon Sep 17 00:00:00 2001
From: Sebastian Stenzel 
Date: Wed, 5 Mar 2025 11:55:37 +0100
Subject: [PATCH 14/21] typo

Co-authored-by: Christian Eichenberger 
---
 .../org/cryptomator/cryptolib/v3/FileContentCryptorImpl.java    | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/main/java/org/cryptomator/cryptolib/v3/FileContentCryptorImpl.java b/src/main/java/org/cryptomator/cryptolib/v3/FileContentCryptorImpl.java
index fd28389..77980f5 100644
--- a/src/main/java/org/cryptomator/cryptolib/v3/FileContentCryptorImpl.java
+++ b/src/main/java/org/cryptomator/cryptolib/v3/FileContentCryptorImpl.java
@@ -59,7 +59,7 @@ public void encryptChunk(ByteBuffer cleartextChunk, ByteBuffer ciphertextChunk,
 			throw new IllegalArgumentException("Invalid cleartext chunk size: " + cleartextChunk.remaining() + ", expected range [1, " + PAYLOAD_SIZE + "]");
 		}
 		if (ciphertextChunk.remaining() < CHUNK_SIZE) {
-			throw new IllegalArgumentException("Invalid cipehrtext chunk size: " + ciphertextChunk.remaining() + ", must fit up to " + CHUNK_SIZE + " bytes.");
+			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());

From 1e9bd327d6a04f9eb617fe83564eae3ec65a48f8 Mon Sep 17 00:00:00 2001
From: Sebastian Stenzel 
Date: Thu, 6 Mar 2025 18:02:20 +0100
Subject: [PATCH 15/21] UVF: use 64 bit keys for HMAC-SHA256

see https://github.com/encryption-alliance/unified-vault-format/pull/31
---
 .../java/org/cryptomator/cryptolib/v3/FileNameCryptorImpl.java  | 2 +-
 .../org/cryptomator/cryptolib/v3/FileNameCryptorImplTest.java   | 2 +-
 .../java/org/cryptomator/cryptolib/v3/UVFIntegrationTest.java   | 2 +-
 3 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/src/main/java/org/cryptomator/cryptolib/v3/FileNameCryptorImpl.java b/src/main/java/org/cryptomator/cryptolib/v3/FileNameCryptorImpl.java
index 15df644..bc80a34 100644
--- a/src/main/java/org/cryptomator/cryptolib/v3/FileNameCryptorImpl.java
+++ b/src/main/java/org/cryptomator/cryptolib/v3/FileNameCryptorImpl.java
@@ -36,7 +36,7 @@ class FileNameCryptorImpl implements FileNameCryptor {
 	 */
 	FileNameCryptorImpl(RevolvingMasterkey masterkey, int revision) throws IllegalArgumentException {
 		this.sivKey = masterkey.subKey(revision, 64, "siv".getBytes(StandardCharsets.US_ASCII), "AES");
-		this.hmacKey = masterkey.subKey(revision, 32, "hmac".getBytes(StandardCharsets.US_ASCII), "HMAC");
+		this.hmacKey = masterkey.subKey(revision, 64, "hmac".getBytes(StandardCharsets.US_ASCII), "HMAC");
 	}
 
 	@Override
diff --git a/src/test/java/org/cryptomator/cryptolib/v3/FileNameCryptorImplTest.java b/src/test/java/org/cryptomator/cryptolib/v3/FileNameCryptorImplTest.java
index 1283230..3a37e61 100644
--- a/src/test/java/org/cryptomator/cryptolib/v3/FileNameCryptorImplTest.java
+++ b/src/test/java/org/cryptomator/cryptolib/v3/FileNameCryptorImplTest.java
@@ -79,7 +79,7 @@ public void testDeterministicEncryptionOf128bitFilename() throws AuthenticationF
 	public void testHashRootDirId() {
 		final byte[] rootDirId = Base64.getDecoder().decode("24UBEDeGu5taq7U4GqyA0MXUXb9HTYS6p3t9vvHGJAc=");
 		final String hashedRootDirId = filenameCryptor.hashDirectoryId(rootDirId);
-		Assertions.assertEquals("CRAX3I7DP4HQHA6TDQDMJQUTDKDJ7QG5", hashedRootDirId);
+		Assertions.assertEquals("6DYU3E5BTPAZ4DWEQPQK3AIHX2DXSPHG", hashedRootDirId);
 	}
 
 	@DisplayName("hash directory id for random directory ids")
diff --git a/src/test/java/org/cryptomator/cryptolib/v3/UVFIntegrationTest.java b/src/test/java/org/cryptomator/cryptolib/v3/UVFIntegrationTest.java
index ee819c6..cb0f700 100644
--- a/src/test/java/org/cryptomator/cryptolib/v3/UVFIntegrationTest.java
+++ b/src/test/java/org/cryptomator/cryptolib/v3/UVFIntegrationTest.java
@@ -56,7 +56,7 @@ public void testRootDirId() {
 	public void testRootDirHash() {
 		byte[] rootDirId = Base64.getDecoder().decode("5WEGzwKkAHPwVSjT2Brr3P3zLz7oMiNpMn/qBvht7eM=");
 		String dirHash = cryptor.fileNameCryptor(masterkey.firstRevision()).hashDirectoryId(rootDirId);
-		Assertions.assertEquals("RKHZLENL3PQIW6GZHE3KRRRGLFBHWHRU", dirHash);
+		Assertions.assertEquals("RZK7ZH7KBXULNEKBMGX3CU42PGUIAIX4", dirHash);
 	}
 
 	@Test

From 4fa5861d5396ca888919a8e37f55ee2e52f34291 Mon Sep 17 00:00:00 2001
From: Sebastian Stenzel 
Date: Fri, 7 Mar 2025 14:56:22 +0100
Subject: [PATCH 16/21] remove generic types

---
 .../cryptomator/cryptolib/api/Cryptor.java    |  2 +-
 .../api/DirectoryContentCryptor.java          | 14 ++++++------
 .../cryptomator/cryptolib/v3/CryptorImpl.java |  2 +-
 .../v3/DirectoryContentCryptorImpl.java       | 22 +++++++++++--------
 .../cryptolib/v3/DirectoryMetadataImpl.java   |  8 +++++++
 5 files changed, 30 insertions(+), 18 deletions(-)

diff --git a/src/main/java/org/cryptomator/cryptolib/api/Cryptor.java b/src/main/java/org/cryptomator/cryptolib/api/Cryptor.java
index 0b09b64..8e348be 100644
--- a/src/main/java/org/cryptomator/cryptolib/api/Cryptor.java
+++ b/src/main/java/org/cryptomator/cryptolib/api/Cryptor.java
@@ -35,7 +35,7 @@ public interface Cryptor extends Destroyable, AutoCloseable {
 	 * High-Level API for file name encryption and decryption
 	 * @return utility for encryption and decryption of file names in the context of a directory
 	 */
-	default DirectoryContentCryptor directoryContentCryptor() {
+	default DirectoryContentCryptor directoryContentCryptor() {
 		throw new UnsupportedOperationException("not implemented");
 	}
 
diff --git a/src/main/java/org/cryptomator/cryptolib/api/DirectoryContentCryptor.java b/src/main/java/org/cryptomator/cryptolib/api/DirectoryContentCryptor.java
index 2c50aff..0d391d1 100644
--- a/src/main/java/org/cryptomator/cryptolib/api/DirectoryContentCryptor.java
+++ b/src/main/java/org/cryptomator/cryptolib/api/DirectoryContentCryptor.java
@@ -1,10 +1,10 @@
 package org.cryptomator.cryptolib.api;
 
-public interface DirectoryContentCryptor {
+public interface DirectoryContentCryptor {
 
-	T rootDirectoryMetadata(); // TODO required?
+	DirectoryMetadata rootDirectoryMetadata();
 
-	T newDirectoryMetadata();
+	DirectoryMetadata newDirectoryMetadata();
 
 	/**
 	 * Decrypts the given directory metadata.
@@ -13,7 +13,7 @@ public interface DirectoryContentCryptor {
 	 * @return The decrypted directory metadata.
 	 * @throws AuthenticationFailedException If the ciphertext is unauthentic.
 	 */
-	T decryptDirectoryMetadata(byte[] ciphertext) throws AuthenticationFailedException;
+	DirectoryMetadata decryptDirectoryMetadata(byte[] ciphertext) throws AuthenticationFailedException;
 
 	/**
 	 * Encrypts the given directory metadata.
@@ -21,11 +21,11 @@ public interface DirectoryContentCryptor {
 	 * @param directoryMetadata The directory metadata to encrypt.
 	 * @return The encrypted directory metadata.
 	 */
-	byte[] encryptDirectoryMetadata(T directoryMetadata);
+	byte[] encryptDirectoryMetadata(DirectoryMetadata directoryMetadata);
 
-	Decrypting fileNameDecryptor(T directoryMetadata);
+	Decrypting fileNameDecryptor(DirectoryMetadata directoryMetadata);
 
-	Encrypting fileNameEncryptor(T directoryMetadata);
+	Encrypting fileNameEncryptor(DirectoryMetadata directoryMetadata);
 
 	@FunctionalInterface
 	interface Decrypting {
diff --git a/src/main/java/org/cryptomator/cryptolib/v3/CryptorImpl.java b/src/main/java/org/cryptomator/cryptolib/v3/CryptorImpl.java
index c5fa2a4..68f615a 100644
--- a/src/main/java/org/cryptomator/cryptolib/v3/CryptorImpl.java
+++ b/src/main/java/org/cryptomator/cryptolib/v3/CryptorImpl.java
@@ -45,7 +45,7 @@ public FileNameCryptorImpl fileNameCryptor() {
 	}
 
 	@Override
-	public FileNameCryptor fileNameCryptor(int revision) {
+	public FileNameCryptorImpl fileNameCryptor(int revision) {
 		assertNotDestroyed();
 		return new FileNameCryptorImpl(masterkey, revision);
 	}
diff --git a/src/main/java/org/cryptomator/cryptolib/v3/DirectoryContentCryptorImpl.java b/src/main/java/org/cryptomator/cryptolib/v3/DirectoryContentCryptorImpl.java
index 8caa81e..5fd9afc 100644
--- a/src/main/java/org/cryptomator/cryptolib/v3/DirectoryContentCryptorImpl.java
+++ b/src/main/java/org/cryptomator/cryptolib/v3/DirectoryContentCryptorImpl.java
@@ -3,6 +3,7 @@
 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;
 
@@ -11,7 +12,7 @@
 
 import static org.cryptomator.cryptolib.v3.Constants.UVF_FILE_EXT;
 
-class DirectoryContentCryptorImpl implements DirectoryContentCryptor {
+class DirectoryContentCryptorImpl implements DirectoryContentCryptor {
 
 	private final RevolvingMasterkey masterkey;
 	private final SecureRandom random;
@@ -58,8 +59,9 @@ public DirectoryMetadataImpl decryptDirectoryMetadata(byte[] ciphertext) throws
 	}
 
 	@Override
-	public byte[] encryptDirectoryMetadata(DirectoryMetadataImpl directoryMetadata) {
-		ByteBuffer cleartextBuf = ByteBuffer.wrap(directoryMetadata.dirId());
+	public byte[] encryptDirectoryMetadata(DirectoryMetadata directoryMetadata) {
+		DirectoryMetadataImpl metadataImpl = DirectoryMetadataImpl.cast(directoryMetadata);
+		ByteBuffer cleartextBuf = ByteBuffer.wrap(metadataImpl.dirId());
 		FileHeader header = cryptor.fileHeaderCryptor().create();
 		ByteBuffer headerBuf = cryptor.fileHeaderCryptor().encryptHeader(header);
 		ByteBuffer contentBuf = cryptor.fileContentCryptor().encryptChunk(cleartextBuf, 0, header);
@@ -72,9 +74,10 @@ public byte[] encryptDirectoryMetadata(DirectoryMetadataImpl directoryMetadata)
 	// FILE NAMES
 
 	@Override
-	public Decrypting fileNameDecryptor(DirectoryMetadataImpl directoryMetadata) {
-		byte[] dirId = directoryMetadata.dirId();
-		FileNameCryptorImpl fileNameCryptor = new FileNameCryptorImpl(masterkey, directoryMetadata.seedId());
+	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);
@@ -82,9 +85,10 @@ public Decrypting fileNameDecryptor(DirectoryMetadataImpl directoryMetadata) {
 	}
 
 	@Override
-	public Encrypting fileNameEncryptor(DirectoryMetadataImpl directoryMetadata) {
-		byte[] dirId = directoryMetadata.dirId();
-		FileNameCryptorImpl fileNameCryptor = new FileNameCryptorImpl(masterkey, directoryMetadata.seedId());
+	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;
diff --git a/src/main/java/org/cryptomator/cryptolib/v3/DirectoryMetadataImpl.java b/src/main/java/org/cryptomator/cryptolib/v3/DirectoryMetadataImpl.java
index ef58fc6..ee0b908 100644
--- a/src/main/java/org/cryptomator/cryptolib/v3/DirectoryMetadataImpl.java
+++ b/src/main/java/org/cryptomator/cryptolib/v3/DirectoryMetadataImpl.java
@@ -12,6 +12,14 @@ public DirectoryMetadataImpl(int seedId, 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;
 	}

From a431cf47a18e5b7928d1461db781007a4b7a55d9 Mon Sep 17 00:00:00 2001
From: Sebastian Stenzel 
Date: Fri, 7 Mar 2025 19:06:07 +0100
Subject: [PATCH 17/21] cleanup

---
 .../api/CryptoLibIntegrationTest.java          | 18 +++++++++++++++++-
 .../cryptolib/v1/BenchmarkTest.java            |  2 ++
 .../cryptolib/v2/BenchmarkTest.java            |  2 ++
 .../cryptolib/v3/BenchmarkTest.java            |  2 ++
 4 files changed, 23 insertions(+), 1 deletion(-)

diff --git a/src/test/java/org/cryptomator/cryptolib/api/CryptoLibIntegrationTest.java b/src/test/java/org/cryptomator/cryptolib/api/CryptoLibIntegrationTest.java
index f846629..b1d9135 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/v1/BenchmarkTest.java b/src/test/java/org/cryptomator/cryptolib/v1/BenchmarkTest.java
index 0b24af0..e4a4098 100644
--- a/src/test/java/org/cryptomator/cryptolib/v1/BenchmarkTest.java
+++ b/src/test/java/org/cryptomator/cryptolib/v1/BenchmarkTest.java
@@ -9,12 +9,14 @@
 package org.cryptomator.cryptolib.v1;
 
 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 V1 (CTR + HMAC)")
 public class BenchmarkTest {
 
 	@Disabled("only on demand")
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/v3/BenchmarkTest.java b/src/test/java/org/cryptomator/cryptolib/v3/BenchmarkTest.java
index b7e5537..714488d 100644
--- a/src/test/java/org/cryptomator/cryptolib/v3/BenchmarkTest.java
+++ b/src/test/java/org/cryptomator/cryptolib/v3/BenchmarkTest.java
@@ -1,12 +1,14 @@
 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")

From 688845da15ae19208e1310f92700c7b1e5932285 Mon Sep 17 00:00:00 2001
From: Sebastian Stenzel 
Date: Fri, 7 Mar 2025 19:10:16 +0100
Subject: [PATCH 18/21] API: allow file encryption w/ specific revision

---
 .../java/org/cryptomator/cryptolib/api/Cryptor.java    |  8 ++++++++
 .../java/org/cryptomator/cryptolib/v1/CryptorImpl.java |  6 ++++++
 .../java/org/cryptomator/cryptolib/v2/CryptorImpl.java |  6 ++++++
 .../java/org/cryptomator/cryptolib/v3/CryptorImpl.java | 10 +++++++---
 .../cryptolib/v3/DirectoryContentCryptorImpl.java      |  2 +-
 .../cryptolib/v3/FileHeaderCryptorImpl.java            |  6 ++++--
 .../cryptolib/v3/FileContentCryptorImplTest.java       |  2 +-
 .../cryptolib/v3/FileHeaderCryptorBenchmark.java       |  2 +-
 .../cryptolib/v3/FileHeaderCryptorImplTest.java        |  2 +-
 9 files changed, 35 insertions(+), 9 deletions(-)

diff --git a/src/main/java/org/cryptomator/cryptolib/api/Cryptor.java b/src/main/java/org/cryptomator/cryptolib/api/Cryptor.java
index 8e348be..8886aa5 100644
--- a/src/main/java/org/cryptomator/cryptolib/api/Cryptor.java
+++ b/src/main/java/org/cryptomator/cryptolib/api/Cryptor.java
@@ -16,6 +16,14 @@ public interface Cryptor extends Destroyable, AutoCloseable {
 	 */
 	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
diff --git a/src/main/java/org/cryptomator/cryptolib/v1/CryptorImpl.java b/src/main/java/org/cryptomator/cryptolib/v1/CryptorImpl.java
index 3406862..fe1d553 100644
--- a/src/main/java/org/cryptomator/cryptolib/v1/CryptorImpl.java
+++ b/src/main/java/org/cryptomator/cryptolib/v1/CryptorImpl.java
@@ -1,6 +1,7 @@
 package org.cryptomator.cryptolib.v1;
 
 import org.cryptomator.cryptolib.api.Cryptor;
+import org.cryptomator.cryptolib.api.FileHeaderCryptor;
 import org.cryptomator.cryptolib.api.FileNameCryptor;
 import org.cryptomator.cryptolib.api.Masterkey;
 import org.cryptomator.cryptolib.api.PerpetualMasterkey;
@@ -37,6 +38,11 @@ public FileHeaderCryptorImpl fileHeaderCryptor() {
 		return fileHeaderCryptor;
 	}
 
+	@Override
+	public FileHeaderCryptor fileHeaderCryptor(int revision) {
+		throw new UnsupportedOperationException();
+	}
+
 	@Override
 	public FileNameCryptorImpl fileNameCryptor() {
 		assertNotDestroyed();
diff --git a/src/main/java/org/cryptomator/cryptolib/v2/CryptorImpl.java b/src/main/java/org/cryptomator/cryptolib/v2/CryptorImpl.java
index 02e063b..5ed4e6e 100644
--- a/src/main/java/org/cryptomator/cryptolib/v2/CryptorImpl.java
+++ b/src/main/java/org/cryptomator/cryptolib/v2/CryptorImpl.java
@@ -1,6 +1,7 @@
 package org.cryptomator.cryptolib.v2;
 
 import org.cryptomator.cryptolib.api.Cryptor;
+import org.cryptomator.cryptolib.api.FileHeaderCryptor;
 import org.cryptomator.cryptolib.api.FileNameCryptor;
 import org.cryptomator.cryptolib.api.Masterkey;
 import org.cryptomator.cryptolib.api.PerpetualMasterkey;
@@ -38,6 +39,11 @@ public FileHeaderCryptorImpl fileHeaderCryptor() {
 		return fileHeaderCryptor;
 	}
 
+	@Override
+	public FileHeaderCryptor fileHeaderCryptor(int revision) {
+		throw new UnsupportedOperationException();
+	}
+
 	@Override
 	public FileNameCryptorImpl fileNameCryptor() {
 		assertNotDestroyed();
diff --git a/src/main/java/org/cryptomator/cryptolib/v3/CryptorImpl.java b/src/main/java/org/cryptomator/cryptolib/v3/CryptorImpl.java
index 68f615a..c0947d7 100644
--- a/src/main/java/org/cryptomator/cryptolib/v3/CryptorImpl.java
+++ b/src/main/java/org/cryptomator/cryptolib/v3/CryptorImpl.java
@@ -2,6 +2,7 @@
 
 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;
@@ -13,7 +14,6 @@ class CryptorImpl implements Cryptor {
 
 	private final RevolvingMasterkey masterkey;
 	private final FileContentCryptorImpl fileContentCryptor;
-	private final FileHeaderCryptorImpl fileHeaderCryptor;
 	private final SecureRandom random;
 
 	/**
@@ -22,7 +22,6 @@ class CryptorImpl implements Cryptor {
 	 */
 	CryptorImpl(RevolvingMasterkey masterkey, SecureRandom random) {
 		this.masterkey = masterkey;
-		this.fileHeaderCryptor = new FileHeaderCryptorImpl(masterkey, random);
 		this.fileContentCryptor = new FileContentCryptorImpl(random);
 		this.random = random;
 	}
@@ -35,8 +34,13 @@ public FileContentCryptorImpl fileContentCryptor() {
 
 	@Override
 	public FileHeaderCryptorImpl fileHeaderCryptor() {
+		return fileHeaderCryptor(masterkey.currentRevision());
+	}
+
+	@Override
+	public FileHeaderCryptorImpl fileHeaderCryptor(int revision) {
 		assertNotDestroyed();
-		return fileHeaderCryptor;
+		return new FileHeaderCryptorImpl(masterkey, random, revision);
 	}
 
 	@Override
diff --git a/src/main/java/org/cryptomator/cryptolib/v3/DirectoryContentCryptorImpl.java b/src/main/java/org/cryptomator/cryptolib/v3/DirectoryContentCryptorImpl.java
index 5fd9afc..4190c88 100644
--- a/src/main/java/org/cryptomator/cryptolib/v3/DirectoryContentCryptorImpl.java
+++ b/src/main/java/org/cryptomator/cryptolib/v3/DirectoryContentCryptorImpl.java
@@ -62,7 +62,7 @@ public DirectoryMetadataImpl decryptDirectoryMetadata(byte[] ciphertext) throws
 	public byte[] encryptDirectoryMetadata(DirectoryMetadata directoryMetadata) {
 		DirectoryMetadataImpl metadataImpl = DirectoryMetadataImpl.cast(directoryMetadata);
 		ByteBuffer cleartextBuf = ByteBuffer.wrap(metadataImpl.dirId());
-		FileHeader header = cryptor.fileHeaderCryptor().create();
+		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()];
diff --git a/src/main/java/org/cryptomator/cryptolib/v3/FileHeaderCryptorImpl.java b/src/main/java/org/cryptomator/cryptolib/v3/FileHeaderCryptorImpl.java
index a190cc9..1bc10bc 100644
--- a/src/main/java/org/cryptomator/cryptolib/v3/FileHeaderCryptorImpl.java
+++ b/src/main/java/org/cryptomator/cryptolib/v3/FileHeaderCryptorImpl.java
@@ -25,10 +25,12 @@ class FileHeaderCryptorImpl implements FileHeaderCryptor {
 
 	private final RevolvingMasterkey masterkey;
 	private final SecureRandom random;
+	private final int revision;
 
-	FileHeaderCryptorImpl(RevolvingMasterkey masterkey, SecureRandom random) {
+	FileHeaderCryptorImpl(RevolvingMasterkey masterkey, SecureRandom random, int revision) {
 		this.masterkey = masterkey;
 		this.random = random;
+		this.revision = revision;
 	}
 
 	@Override
@@ -38,7 +40,7 @@ public FileHeaderImpl create() {
 		byte[] contentKeyBytes = new byte[FileHeaderImpl.CONTENT_KEY_LEN];
 		random.nextBytes(contentKeyBytes);
 		DestroyableSecretKey contentKey = new DestroyableSecretKey(contentKeyBytes, Constants.CONTENT_ENC_ALG);
-		return new FileHeaderImpl(masterkey.currentRevision(), nonce, contentKey);
+		return new FileHeaderImpl(revision, nonce, contentKey);
 	}
 
 	@Override
diff --git a/src/test/java/org/cryptomator/cryptolib/v3/FileContentCryptorImplTest.java b/src/test/java/org/cryptomator/cryptolib/v3/FileContentCryptorImplTest.java
index 8ff0abb..087cd53 100644
--- a/src/test/java/org/cryptomator/cryptolib/v3/FileContentCryptorImplTest.java
+++ b/src/test/java/org/cryptomator/cryptolib/v3/FileContentCryptorImplTest.java
@@ -63,7 +63,7 @@ public class FileContentCryptorImplTest {
 	@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);
+		headerCryptor = new FileHeaderCryptorImpl(MASTERKEY, CSPRNG, -1540072521);
 		fileContentCryptor = new FileContentCryptorImpl(CSPRNG);
 		cryptor = Mockito.mock(Cryptor.class);
 		Mockito.when(cryptor.fileContentCryptor()).thenReturn(fileContentCryptor);
diff --git a/src/test/java/org/cryptomator/cryptolib/v3/FileHeaderCryptorBenchmark.java b/src/test/java/org/cryptomator/cryptolib/v3/FileHeaderCryptorBenchmark.java
index 87f6992..3a0e143 100644
--- a/src/test/java/org/cryptomator/cryptolib/v3/FileHeaderCryptorBenchmark.java
+++ b/src/test/java/org/cryptomator/cryptolib/v3/FileHeaderCryptorBenchmark.java
@@ -36,7 +36,7 @@ public class FileHeaderCryptorBenchmark {
 	private static final Map SEEDS = Collections.singletonMap(-1540072521, Base64.getDecoder().decode("fP4V4oAjsUw5DqackAvLzA0oP1kAQZ0f5YFZQviXSuU="));
 	private static final byte[] KDF_SALT =  Base64.getDecoder().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);
+	private static final FileHeaderCryptorImpl HEADER_CRYPTOR = new FileHeaderCryptorImpl(MASTERKEY, RANDOM_MOCK, -1540072521);
 
 	private ByteBuffer validHeaderCiphertextBuf;
 	private FileHeader header;
diff --git a/src/test/java/org/cryptomator/cryptolib/v3/FileHeaderCryptorImplTest.java b/src/test/java/org/cryptomator/cryptolib/v3/FileHeaderCryptorImplTest.java
index e659b0e..4f11fc9 100644
--- a/src/test/java/org/cryptomator/cryptolib/v3/FileHeaderCryptorImplTest.java
+++ b/src/test/java/org/cryptomator/cryptolib/v3/FileHeaderCryptorImplTest.java
@@ -31,7 +31,7 @@ public class FileHeaderCryptorImplTest {
 
 	@BeforeEach
 	public void setup() {
-		headerCryptor = new FileHeaderCryptorImpl(MASTERKEY, RANDOM_MOCK);
+		headerCryptor = new FileHeaderCryptorImpl(MASTERKEY, RANDOM_MOCK, -1540072521);
 
 		// reset cipher state to avoid InvalidAlgorithmParameterExceptions due to IV-reuse
 		GcmTestHelper.reset((mode, key, params) -> {

From 8865144a00a2e5608b615a6aeb02e5d4830ff699 Mon Sep 17 00:00:00 2001
From: Sebastian Stenzel 
Date: Fri, 7 Mar 2025 19:11:56 +0100
Subject: [PATCH 19/21] API: add `Masterkey.rootDirId()`

---
 src/main/java/org/cryptomator/cryptolib/api/Masterkey.java | 6 ++++++
 .../org/cryptomator/cryptolib/api/PerpetualMasterkey.java  | 7 +++++++
 .../java/org/cryptomator/cryptolib/api/UVFMasterkey.java   | 1 +
 .../cryptolib/v3/DirectoryContentCryptorImpl.java          | 4 ++--
 4 files changed, 16 insertions(+), 2 deletions(-)

diff --git a/src/main/java/org/cryptomator/cryptolib/api/Masterkey.java b/src/main/java/org/cryptomator/cryptolib/api/Masterkey.java
index 9baf583..407eacc 100644
--- a/src/main/java/org/cryptomator/cryptolib/api/Masterkey.java
+++ b/src/main/java/org/cryptomator/cryptolib/api/Masterkey.java
@@ -32,6 +32,12 @@ static PerpetualMasterkey from(DestroyableSecretKey encKey, DestroyableSecretKey
 		}
 	}
 
+	/**
+	 * 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
+	 */
+	byte[] rootDirId();
+
 	@Override
 	void destroy();
 
diff --git a/src/main/java/org/cryptomator/cryptolib/api/PerpetualMasterkey.java b/src/main/java/org/cryptomator/cryptolib/api/PerpetualMasterkey.java
index 5173266..46f9d62 100644
--- a/src/main/java/org/cryptomator/cryptolib/api/PerpetualMasterkey.java
+++ b/src/main/java/org/cryptomator/cryptolib/api/PerpetualMasterkey.java
@@ -68,6 +68,13 @@ 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;
diff --git a/src/main/java/org/cryptomator/cryptolib/api/UVFMasterkey.java b/src/main/java/org/cryptomator/cryptolib/api/UVFMasterkey.java
index 140881c..3e1393d 100644
--- a/src/main/java/org/cryptomator/cryptolib/api/UVFMasterkey.java
+++ b/src/main/java/org/cryptomator/cryptolib/api/UVFMasterkey.java
@@ -69,6 +69,7 @@ public int currentRevision() {
 		return latestSeed;
 	}
 
+	@Override
 	public byte[] rootDirId() {
 		return HKDFHelper.hkdfSha512(kdfSalt, seeds.get(initialSeed), ROOT_DIRID_KDF_CONTEXT, 32);
 	}
diff --git a/src/main/java/org/cryptomator/cryptolib/v3/DirectoryContentCryptorImpl.java b/src/main/java/org/cryptomator/cryptolib/v3/DirectoryContentCryptorImpl.java
index 4190c88..500444d 100644
--- a/src/main/java/org/cryptomator/cryptolib/v3/DirectoryContentCryptorImpl.java
+++ b/src/main/java/org/cryptomator/cryptolib/v3/DirectoryContentCryptorImpl.java
@@ -28,8 +28,8 @@ public DirectoryContentCryptorImpl(RevolvingMasterkey masterkey, SecureRandom ra
 
 	@Override
 	public DirectoryMetadataImpl rootDirectoryMetadata() {
-		// TODO
-		return null;
+		byte[] dirId = masterkey.rootDirId();
+		return new DirectoryMetadataImpl(masterkey.firstRevision(), dirId);
 	}
 
 	@Override

From d41b6e7d0c5d38bb7e8be541227ac07628682092 Mon Sep 17 00:00:00 2001
From: Sebastian Stenzel 
Date: Fri, 7 Mar 2025 19:13:35 +0100
Subject: [PATCH 20/21] add convenience method `dirPath(dirUvfMetadata)`

---
 .../cryptolib/api/DirectoryContentCryptor.java        |  8 ++++++++
 .../cryptolib/v3/DirectoryContentCryptorImpl.java     | 11 +++++++++++
 2 files changed, 19 insertions(+)

diff --git a/src/main/java/org/cryptomator/cryptolib/api/DirectoryContentCryptor.java b/src/main/java/org/cryptomator/cryptolib/api/DirectoryContentCryptor.java
index 0d391d1..a8ab740 100644
--- a/src/main/java/org/cryptomator/cryptolib/api/DirectoryContentCryptor.java
+++ b/src/main/java/org/cryptomator/cryptolib/api/DirectoryContentCryptor.java
@@ -23,6 +23,14 @@ public interface DirectoryContentCryptor {
 	 */
 	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);
diff --git a/src/main/java/org/cryptomator/cryptolib/v3/DirectoryContentCryptorImpl.java b/src/main/java/org/cryptomator/cryptolib/v3/DirectoryContentCryptorImpl.java
index 500444d..8d7227c 100644
--- a/src/main/java/org/cryptomator/cryptolib/v3/DirectoryContentCryptorImpl.java
+++ b/src/main/java/org/cryptomator/cryptolib/v3/DirectoryContentCryptorImpl.java
@@ -71,6 +71,17 @@ public byte[] encryptDirectoryMetadata(DirectoryMetadata directoryMetadata) {
 		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

From d8c567b3ff134f415ed0d08fb38db303cdf57a33 Mon Sep 17 00:00:00 2001
From: Sebastian Stenzel 
Date: Fri, 7 Mar 2025 19:14:36 +0100
Subject: [PATCH 21/21] add test to generate reference directory structure

---
 .../cryptolib/v3/UVFIntegrationTest.java      | 110 ++++++++++++++++--
 1 file changed, 98 insertions(+), 12 deletions(-)

diff --git a/src/test/java/org/cryptomator/cryptolib/v3/UVFIntegrationTest.java b/src/test/java/org/cryptomator/cryptolib/v3/UVFIntegrationTest.java
index cb0f700..3e28e5e 100644
--- a/src/test/java/org/cryptomator/cryptolib/v3/UVFIntegrationTest.java
+++ b/src/test/java/org/cryptomator/cryptolib/v3/UVFIntegrationTest.java
@@ -2,19 +2,26 @@
 
 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.io.UncheckedIOException;
 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;
@@ -47,12 +54,14 @@ public static void setUp() {
 	}
 
 	@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);
@@ -60,27 +69,104 @@ public void testRootDirHash() {
 	}
 
 	@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 {
-		ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
-		try (EncryptingWritableByteChannel ch = new EncryptingWritableByteChannel(Channels.newChannel(baos), cryptor)) {
-			int written = ch.write(StandardCharsets.UTF_8.encode("Hello, World!"));
-			Assertions.assertEquals(13, written);
-		}
-		byte[] result = baos.toByteArray();
-		Assertions.assertArrayEquals(new byte[]{0x75, 0x76, 0x66, 0x00}, Arrays.copyOf(result, 4));
-		Assertions.assertArrayEquals(Base64.getUrlDecoder().decode("QBsJFo"), Arrays.copyOfRange(result, 4, 8));
+		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==");
-		ByteBuffer result = ByteBuffer.allocate(100);
-		try (DecryptingReadableByteChannel ch = new DecryptingReadableByteChannel(Channels.newChannel(new ByteArrayInputStream(input)), cryptor, true)) {
+		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 {
+		try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
+			 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);
 		}
-		result.flip();
-		Assertions.assertEquals("Hello, World!", StandardCharsets.UTF_8.decode(result).toString());
+		return result.array();
 	}
 
 }