From 27958d5ceab77fc8941bd3b4cfd2e325817f51b7 Mon Sep 17 00:00:00 2001 From: Sebastian Stenzel Date: Fri, 9 Feb 2024 16:12:27 +0100 Subject: [PATCH 1/8] 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 df34f332416533bdf3ad02b474bccfebd21ad349 Mon Sep 17 00:00:00 2001 From: Sebastian Stenzel Date: Fri, 29 Nov 2024 13:56:22 +0100 Subject: [PATCH 2/8] 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 b807d7f..c939f3e 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 f740b47..b1d792b 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 0677d07..0b06c1a 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 e9ddfe13ff3d0aa3a6b2a3889c5f426d9429514c Mon Sep 17 00:00:00 2001
From: Sebastian Stenzel 
Date: Fri, 29 Nov 2024 13:56:57 +0100
Subject: [PATCH 3/8] 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 c939f3e..2b5eb24 100644
--- a/src/main/java/org/cryptomator/cryptolib/v1/FileContentCryptorImpl.java
+++ b/src/main/java/org/cryptomator/cryptolib/v1/FileContentCryptorImpl.java
@@ -79,7 +79,7 @@ public void encryptChunk(ByteBuffer cleartextChunk, ByteBuffer ciphertextChunk,
 
 	@Override
 	public void encryptChunk(ByteBuffer cleartextChunk, ByteBuffer ciphertextChunk, long chunkNumber, FileHeader header, byte[] chunkNonce) {
-		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 a7877b8..5adc948 100644
--- a/src/main/java/org/cryptomator/cryptolib/v2/FileContentCryptorImpl.java
+++ b/src/main/java/org/cryptomator/cryptolib/v2/FileContentCryptorImpl.java
@@ -77,7 +77,7 @@ public void encryptChunk(ByteBuffer cleartextChunk, ByteBuffer ciphertextChunk,
 
 	@Override
 	public void encryptChunk(ByteBuffer cleartextChunk, ByteBuffer ciphertextChunk, long chunkNumber, FileHeader header, byte[] nonce) {
-		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 57b87936f946539f888b426c1fc00d1c37f4d7d9 Mon Sep 17 00:00:00 2001
From: Sebastian Stenzel 
Date: Fri, 29 Nov 2024 15:24:25 +0100
Subject: [PATCH 4/8] 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 55db37fc98c7717fdad73b49a5019e425c990a9f Mon Sep 17 00:00:00 2001
From: Sebastian Stenzel 
Date: Fri, 29 Nov 2024 15:27:03 +0100
Subject: [PATCH 5/8] 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 469a3c3..be42898 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 d0705d303f2f50b6e50934786e244de64d272105 Mon Sep 17 00:00:00 2001
From: Sebastian Stenzel 
Date: Thu, 5 Dec 2024 10:31:39 +0100
Subject: [PATCH 6/8] 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 24dbce06498d09d144249a87652dba84779a950d Mon Sep 17 00:00:00 2001
From: Sebastian Stenzel 
Date: Fri, 6 Dec 2024 17:25:39 +0100
Subject: [PATCH 7/8] 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 b777b7b..788afbd 100644
--- a/pom.xml
+++ b/pom.xml
@@ -33,7 +33,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 5af935d0d957b66970b802f742a540bf6799b45b Mon Sep 17 00:00:00 2001
From: chenkins 
Date: Mon, 30 Dec 2024 15:19:52 +0100
Subject: [PATCH 8/8] Apply nonce in v3 as in v2 - necessary?

---
 .../cryptolib/v3/FileContentCryptorImpl.java  | 31 +++++++++++++------
 .../v3/FileContentCryptorImplBenchmark.java   |  4 +--
 .../v3/FileContentCryptorImplTest.java        |  2 +-
 3 files changed, 24 insertions(+), 13 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..8897813 100644
--- a/src/main/java/org/cryptomator/cryptolib/v3/FileContentCryptorImpl.java
+++ b/src/main/java/org/cryptomator/cryptolib/v3/FileContentCryptorImpl.java
@@ -47,6 +47,13 @@ public int ciphertextChunkSize() {
 
 	@Override
 	public ByteBuffer encryptChunk(ByteBuffer cleartextChunk, long chunkNumber, FileHeader header) {
+		byte[] nonce = new byte[org.cryptomator.cryptolib.v3.Constants.GCM_NONCE_SIZE];
+		random.nextBytes(nonce);
+		return encryptChunk(cleartextChunk, chunkNumber, header, nonce);
+	}
+
+	@Override
+	public ByteBuffer encryptChunk(ByteBuffer cleartextChunk, long chunkNumber, FileHeader header, byte[] chunkNonce) {
 		ByteBuffer ciphertextChunk = ByteBuffer.allocate(CHUNK_SIZE);
 		encryptChunk(cleartextChunk, ciphertextChunk, chunkNumber, header);
 		ciphertextChunk.flip();
@@ -55,14 +62,21 @@ 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) {
-			throw new IllegalArgumentException("Invalid cleartext chunk size: " + cleartextChunk.remaining() + ", expected range [1, " + PAYLOAD_SIZE + "]");
+		byte[] nonce = new byte[org.cryptomator.cryptolib.v3.Constants.GCM_NONCE_SIZE];
+		random.nextBytes(nonce);
+		encryptChunk(cleartextChunk, ciphertextChunk, chunkNumber, header, nonce);
+	}
+
+	@Override
+	public void encryptChunk(ByteBuffer cleartextChunk, ByteBuffer ciphertextChunk, long chunkNumber, FileHeader header, byte[] chunkNonce) {
+		if (cleartextChunk.remaining() < 0 || cleartextChunk.remaining() > org.cryptomator.cryptolib.v3.Constants.PAYLOAD_SIZE) {
+			throw new IllegalArgumentException("Invalid cleartext chunk size: " + cleartextChunk.remaining() + ", expected range [1, " + org.cryptomator.cryptolib.v3.Constants.PAYLOAD_SIZE + "]");
 		}
-		if (ciphertextChunk.remaining() < CHUNK_SIZE) {
-			throw new IllegalArgumentException("Invalid cipehrtext chunk size: " + ciphertextChunk.remaining() + ", must fit up to " + CHUNK_SIZE + " bytes.");
+		if (ciphertextChunk.remaining() < org.cryptomator.cryptolib.v3.Constants.CHUNK_SIZE) {
+			throw new IllegalArgumentException("Invalid cipehrtext chunk size: " + ciphertextChunk.remaining() + ", must fit up to " + org.cryptomator.cryptolib.v3.Constants.CHUNK_SIZE + " bytes.");
 		}
-		FileHeaderImpl headerImpl = FileHeaderImpl.cast(header);
-		encryptChunk(cleartextChunk, ciphertextChunk, chunkNumber, headerImpl.getNonce(), headerImpl.getContentKey());
+		org.cryptomator.cryptolib.v3.FileHeaderImpl headerImpl = org.cryptomator.cryptolib.v3.FileHeaderImpl.cast(header);
+		encryptChunk(cleartextChunk, ciphertextChunk, chunkNumber, headerImpl.getNonce(), headerImpl.getContentKey(), chunkNonce);
 	}
 
 	@Override
@@ -90,11 +104,8 @@ public void decryptChunk(ByteBuffer ciphertextChunk, ByteBuffer cleartextChunk,
 	}
 
 	// visible for testing
-	void encryptChunk(ByteBuffer cleartextChunk, ByteBuffer ciphertextChunk, long chunkNumber, byte[] headerNonce, DestroyableSecretKey fileKey) {
+	void encryptChunk(ByteBuffer cleartextChunk, ByteBuffer ciphertextChunk, long chunkNumber, byte[] headerNonce, DestroyableSecretKey fileKey, byte[] nonce) {
 		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))) {
diff --git a/src/test/java/org/cryptomator/cryptolib/v3/FileContentCryptorImplBenchmark.java b/src/test/java/org/cryptomator/cryptolib/v3/FileContentCryptorImplBenchmark.java
index 39a4f33..f8a9b67 100644
--- a/src/test/java/org/cryptomator/cryptolib/v3/FileContentCryptorImplBenchmark.java
+++ b/src/test/java/org/cryptomator/cryptolib/v3/FileContentCryptorImplBenchmark.java
@@ -39,7 +39,7 @@ public class FileContentCryptorImplBenchmark {
 	@Setup(Level.Trial)
 	public void prepareData() {
 		cleartextChunk.rewind();
-		fileContentCryptor.encryptChunk(cleartextChunk, ciphertextChunk, 0l, new byte[12], ENC_KEY);
+		fileContentCryptor.encryptChunk(cleartextChunk, ciphertextChunk, 0l, new byte[12], ENC_KEY, new byte[12]);
 		ciphertextChunk.flip();
 	}
 
@@ -54,7 +54,7 @@ public void shuffleData() {
 
 	@Benchmark
 	public void benchmarkEncryption() {
-		fileContentCryptor.encryptChunk(cleartextChunk, ciphertextChunk, chunkNumber, headerNonce, ENC_KEY);
+		fileContentCryptor.encryptChunk(cleartextChunk, ciphertextChunk, chunkNumber, headerNonce, ENC_KEY, new byte[12]);
 	}
 
 	@Benchmark
diff --git a/src/test/java/org/cryptomator/cryptolib/v3/FileContentCryptorImplTest.java b/src/test/java/org/cryptomator/cryptolib/v3/FileContentCryptorImplTest.java
index d60d58f..fe8201f 100644
--- a/src/test/java/org/cryptomator/cryptolib/v3/FileContentCryptorImplTest.java
+++ b/src/test/java/org/cryptomator/cryptolib/v3/FileContentCryptorImplTest.java
@@ -81,7 +81,7 @@ public void testDecryptedEncryptedEqualsPlaintext() throws AuthenticationFailedE
 		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);
+		fileContentCryptor.encryptChunk(UTF_8.encode("asd"), ciphertext, 42l, new byte[12], fileKey, new byte[GCM_NONCE_SIZE]);
 		ciphertext.flip();
 		fileContentCryptor.decryptChunk(ciphertext, cleartext, 42l, new byte[12], fileKey);
 		cleartext.flip();