+ * The binary blobs produced by this library look like (numbers are bits): + *
+ * 0 8 16 24 + * +---------+---------+---------+--------------------+--------------------+ + * |proto |key |param |params |ciphertext | + * |version |version |length | ... | ... | + * |8 |8 |8 |[0,255] |[16,inf) | + * +---------+---------+---------+--------------------+--------------------+ + */ public class CryptVault { - static final String DEFAULT_CIPHER = "AES/CBC/PKCS5Padding"; - static final String DEFAULT_ALGORITHM = "AES"; - static final int DEFAULT_SALT_LENGTH = 16; - - private final CryptVersion[] cryptVersions = new CryptVersion[256]; - int defaultVersion = -1; - /** - * Helper method for the most used case. - * If you even need to change this, or need backwards compatibility, use the more advanced constructor instead. + * All the key versions as configured in the external configuration. */ - public CryptVault with256BitAesCbcPkcs5PaddingAnd128BitSaltKey(int version, byte[] secret) { - if (secret.length != 32) throw new IllegalArgumentException("invalid AES key size; should be 256 bits!"); + public KeyVersions keyVersions; - Key key = new SecretKeySpec(secret, DEFAULT_ALGORITHM); - CryptVersion cryptVersion = new CryptVersion(DEFAULT_SALT_LENGTH, DEFAULT_CIPHER, key, AESLengthCalculator); - return withKey(version, cryptVersion); + private CryptVault() { } - public CryptVault withKey(int version, CryptVersion cryptVersion) { - if (version < 0 || version > 255) throw new IllegalArgumentException("version must be a byte"); - if (cryptVersions[version] != null) throw new IllegalArgumentException("version " + version + " is already defined"); - - cryptVersions[version] = cryptVersion; - if (version > defaultVersion) defaultVersion = version; - return this; + /** + * Create a new instance initialized with the provided KeyVersions. + * + * @param keyVersions + * @return A new instance. + */ + public static CryptVault of(KeyVersions keyVersions) { + CryptVault cryptVault = new CryptVault(); + cryptVault.keyVersions = keyVersions; + return cryptVault; } /** - * specifies the version used in encrypting new data. default is highest version number. + * Encrypts the given binary blob under the transformation given by the + * default key version. Default encryption parameters are used. + *+ * Legacy key versions are only allowed to decrypt, not encrypt. + * + * @param cleartext Bytes to be encrypted. + * @return A self-contained, encrypted binary blob. + * @throws CryptOperationException */ - public CryptVault withDefaultKeyVersion(int defaultVersion) { - if (defaultVersion < 0 || defaultVersion > 255) throw new IllegalArgumentException("version must be a byte"); - if (cryptVersions[defaultVersion] == null) throw new IllegalArgumentException("version " + defaultVersion + " is undefined"); - - this.defaultVersion = defaultVersion; - return this; + public byte[] encrypt(byte[] cleartext) throws CryptOperationException { + return encrypt(keyVersions.getDefault(), cleartext); } - // FIXME: have a pool of ciphers (with locks & so), cipher init seems to be very costly (jmh it!) - Cipher cipher(String cipher) { - try { - return Cipher.getInstance(cipher); - } catch (Exception e) { - throw new IllegalStateException("init failed for cipher " + cipher, e); - } + /** + * Encrypts the given binary blob under the transformation defined in the + * given key version. Default encryption parameters are used. + *
+ * Legacy key versions are only allowed to decrypt, not encrypt. + * + * @param keyVersion The key version to encrypt the blob under. + * @param cleartext Bytes to be encrypted. + * @return A self-contained, encrypted binary blob. + * @throws CryptOperationException + */ + public byte[] encrypt(KeyVersion keyVersion, byte[] cleartext) throws CryptOperationException { + return encrypt(keyVersion, cleartext, null); } - private SecureRandom SECURE_RANDOM = new SecureRandom(); + /** + * Encrypts the given binary blob under the transformation defined in the + * given key version. Algorithm parameters can be tweaked by passing a + * custom {@code AlgorithmParameterSpec}. + *
+ * Legacy key versions are only allowed to decrypt, not encrypt. + * + * @param keyVersion The key version to encrypt the blob under. + * @param cleartext Bytes to be encrypted. + * @param algoParamSpec The encryption parameters. Can be null, in which case the defaults for the given algorithm will be used. + * @return A self-contained, encrypted binary blob. + * @throws CryptOperationException + */ + public byte[] encrypt(KeyVersion keyVersion, byte[] cleartext, @Nullable AlgorithmParameterSpec algoParamSpec) throws CryptOperationException { + if (keyVersion.legacy) + throw new CryptOperationException("cannot encrypt with legacy key version; hint: create new key version"); - // depending on securerandom implementation (that differs per platform and jvm), this might or might not be necessary. - @Scheduled(initialDelay = 3_600_000, fixedDelay = 3_600_000) - public void reinitSecureRandomHourly() { - SECURE_RANDOM = new SecureRandom(); - } + try { + Cipher cipher = Cipher.getInstance(keyVersion.transformation); - byte[] randomBytes(int numBytes) { - byte[] bytes = new byte[numBytes]; - SECURE_RANDOM.nextBytes(bytes); - return bytes; - } + String algorithm = keyVersion.transformation.split("/", 2)[0]; + SecretKeySpec aesKeySpec = new SecretKeySpec(keyVersion.key, algorithm); - public byte[] encrypt(byte[] data) { - return encrypt(defaultVersion, data); - } + cipher.init(Cipher.ENCRYPT_MODE, aesKeySpec, algoParamSpec); - public byte[] encrypt(int version, byte[] data) { - CryptVersion cryptVersion = cryptVersion(version); - try { - int cryptedLength = cryptVersion.encryptedLength.apply(data.length); - byte[] result = new byte[cryptedLength + cryptVersion.saltLength + 1]; - result[0] = toSignedByte(version); + byte[] ciphertext = cipher.doFinal(cleartext); - byte[] random = randomBytes(cryptVersion.saltLength); - IvParameterSpec iv_spec = new IvParameterSpec(random); - System.arraycopy(random, 0, result, 1, cryptVersion.saltLength); + byte[] encodedParams = (cipher.getParameters() == null) ? new byte[0] : cipher.getParameters().getEncoded(); - Cipher cipher = cipher(cryptVersion.cipher); - cipher.init(Cipher.ENCRYPT_MODE, cryptVersion.key, iv_spec); - int len = cipher.doFinal(data, 0, data.length, result, cryptVersion.saltLength + 1); + byte[] blob = new byte[1 + 1 + 1 + encodedParams.length + ciphertext.length]; + blob[0] = (byte) 0x0; // proto version + blob[1] = (byte) keyVersion.version; // key version (also defines transformation) + blob[2] = (byte) encodedParams.length; // paramLen + System.arraycopy(encodedParams, 0, blob, 3, encodedParams.length); + System.arraycopy(ciphertext, 0, blob, 3 + encodedParams.length, ciphertext.length); - return result; - } catch (ShortBufferException | IllegalBlockSizeException | BadPaddingException | InvalidAlgorithmParameterException | InvalidKeyException e) { + return blob; + } catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidKeyException | IllegalBlockSizeException | + BadPaddingException | IOException | InvalidAlgorithmParameterException e) { // wrap checked exception for easy use - throw new CryptOperationException("JCE exception caught while encrypting with version " + version, e); + throw new CryptOperationException("JCA exception caught while encrypting with key version " + keyVersion.version, e); } } - public byte[] decrypt(byte[] data) { - int version = fromSignedByte(data[0]); - CryptVersion cryptVersion = cryptVersion(version); - - try { - byte[] random = new byte[cryptVersion.saltLength]; - System.arraycopy(data, 1, random, 0, cryptVersion.saltLength); - IvParameterSpec iv_spec = new IvParameterSpec(random); - - Cipher cipher = cipher(cryptVersions[version].cipher); - cipher.init(Cipher.DECRYPT_MODE, cryptVersions[version].key, iv_spec); - return cipher.doFinal(data, cryptVersion.saltLength + 1, data.length - cryptVersion.saltLength - 1); - } catch (InvalidKeyException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) { - // wrap checked exception for easy use - throw new CryptOperationException("JCE exception caught while decrypting with key version " + version, e); + /** + * Decrypts a previously-encrypted, self-contained binary blob. To achieve + * compatibility with previous versions of this library, if the first byte + * is not a recognized protocol version (currently 00), a "legacy + * decryption" is attempted: the blob will be decrypted according to the + * legacy decryption process. + * + * @param blob The previously-encrypted binary blob. + * @return The recovered cleartext. + * @throws CryptOperationException + */ + public byte[] decrypt(byte[] blob) throws CryptOperationException { + int protoVersion = blob[0] & 0xFF; + if (protoVersion != 0) { + if (keyVersions.isLegacyVersion(blob[0])) { + return attemptLegacyDecrypt(blob); + } + throw new CryptOperationException("cryptvault protocol version in encrypted blob is unknown: " + protoVersion); } - } - public int expectedCryptedLength(int serializedLength) { - return expectedCryptedLength(defaultVersion, serializedLength); - } + int blobKeyVersion = blob[1] & 0xFF; + KeyVersion keyVersion = keyVersions.get(blobKeyVersion).orElseThrow( + () -> new CryptOperationException("key version in encrypted blob is unknown: " + blobKeyVersion)); - public int expectedCryptedLength(int version, int serializedLength) { - CryptVersion cryptVersion = cryptVersion(version); - return cryptVersion.saltLength + 1 + cryptVersion.encryptedLength.apply(serializedLength); - } + int paramLen = blob[2] & 0xFF; + byte[] paramsAsBytes = new byte[paramLen]; + System.arraycopy(blob, 3, paramsAsBytes, 0, paramLen); - private CryptVersion cryptVersion(int version) { try { - CryptVersion result = cryptVersions[version]; - if (result == null) throw new CryptOperationException("version " + version + " undefined"); - return result; - } catch (IndexOutOfBoundsException e) { - if (version < 0) throw new CryptOperationException("encryption keys are not initialized"); - throw new CryptOperationException("version must be a byte (0-255)"); - } - } + Cipher decryptionCipher = Cipher.getInstance(keyVersion.transformation); - /** - * amount of keys defined in this CryptVault - */ - public int size() { - int size = 0; - for (int i = 0; i < cryptVersions.length; i++) { - if (cryptVersions[i] != null) size++; - } - return size; - } + AlgorithmParameters algoParams = decryptionCipher.getParameters(); + AlgorithmParameters storedParams = null; + if (algoParams != null) { + storedParams = AlgorithmParameters.getInstance(algoParams.getAlgorithm()); + storedParams.init(paramsAsBytes); + } - /** - * AES simply pads to 128 bits - */ - static final Function
AESLengthCalculator = i -> (i | 0xf) + 1; + String algorithm = keyVersion.transformation.split("/", 2)[0]; + SecretKeySpec keySpec = new SecretKeySpec(keyVersion.key, algorithm); - /** - * because, you know... java - */ - public static byte toSignedByte(int val) { - return (byte) (val + Byte.MIN_VALUE); - } + decryptionCipher.init(Cipher.DECRYPT_MODE, keySpec, storedParams); - /** - * because, you know... java - */ - public static int fromSignedByte(byte val) { - return ((int) val - Byte.MIN_VALUE); + return decryptionCipher.doFinal( + blob, 3 + paramLen, blob.length - 3 - paramLen); + } catch (InvalidAlgorithmParameterException | NoSuchPaddingException | IllegalBlockSizeException | + NoSuchAlgorithmException | IOException | BadPaddingException | InvalidKeyException e) { + throw new CryptOperationException("JCA exception caught while decrypting with key version " + keyVersion.version, e); + } } - static { - // stupid JCE - JCEPolicy.allowUnlimitedStrength(); + byte[] attemptLegacyDecrypt(byte[] blob) throws RuntimeException { + int version = (int) blob[0] - Byte.MIN_VALUE; + var legacyKeyVersion = keyVersions.get(version).orElseThrow( + () -> new CryptOperationException(String.format("legacy version %d not registered", version)) + ); + + int keyVersionLength = 1; + int ivLength = 16; + byte[] ivBytes = new byte[ivLength]; + System.arraycopy(blob, keyVersionLength, ivBytes, 0, ivLength); + try { + var ivParamSpec = new IvParameterSpec(ivBytes); + + var cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); + var key = new SecretKeySpec(legacyKeyVersion.key, "AES"); + cipher.init(Cipher.DECRYPT_MODE, key, ivParamSpec); + return cipher.doFinal(blob, keyVersionLength + ivLength, blob.length - keyVersionLength - ivLength); + } catch (InvalidKeyException | InvalidAlgorithmParameterException | IllegalBlockSizeException | + BadPaddingException | NoSuchPaddingException | NoSuchAlgorithmException e) { + throw new CryptOperationException("JCA exception caught while attempting legacy decryption with key version " + version, e); + } } } diff --git a/src/main/java/com/bol/crypt/CryptVersion.java b/src/main/java/com/bol/crypt/CryptVersion.java deleted file mode 100644 index 6a50334..0000000 --- a/src/main/java/com/bol/crypt/CryptVersion.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.bol.crypt; - -import java.security.Key; -import java.util.function.Function; - -/** Immutable data class */ -public class CryptVersion { - public final int saltLength; - public final String cipher; - public final Key key; - public final Function encryptedLength; - - public CryptVersion(int saltLength, String cipher, Key key, Function encryptedLength) { - this.saltLength = saltLength; - this.cipher = cipher; - this.key = key; - this.encryptedLength = encryptedLength; - } -} diff --git a/src/main/java/com/bol/crypt/KeyVersion.java b/src/main/java/com/bol/crypt/KeyVersion.java new file mode 100644 index 0000000..8781460 --- /dev/null +++ b/src/main/java/com/bol/crypt/KeyVersion.java @@ -0,0 +1,60 @@ +package com.bol.crypt; + +import java.util.Arrays; +import java.util.Base64; + +public class KeyVersion { + /** + * The version. Can be no bigger than a byte, so only values [0, 256) are + * acceptable. + */ + public final int version; + /** + * A JCA "transformation" consisting of "algorithm/mode of + * operation/padding". E.g. {@code "AES/CBC/PKCS5Padding"}. + * Case-insensitive. + */ + public final String transformation; + /** + * The actual key used in encryption, i.e. no key derivation is performed + * on this key. The size depends on the algorithm used. E.g. in AES-256, + * the key size should be 256 bits/32 bytes. An incorrect key size will + * result in exceptions. + */ + public final byte[] key; + /** + * Whether this key was used with version 1 of this library. In that case, + * this key version can only be used for decryption. New encryptions should + * be done with a new key. + */ + public final boolean legacy; + + public KeyVersion(int version, String transformation, byte[] key, boolean legacy) { + this.version = version; + this.transformation = transformation; + this.key = key; + this.legacy = legacy; + } + + public KeyVersion(int version, String transformation, byte[] key) { + this(version, transformation, key, false); + } + + public KeyVersion(int version, String transformation, String keyBase64, boolean legacy) { + this(version, transformation, Base64.getDecoder().decode(keyBase64), legacy); + } + + public KeyVersion(int version, String transformation, String keyBase64) { + this(version, transformation, keyBase64, false); + } + + @Override + public String toString() { + return "KeyVersion{" + + "version=" + version + + ", transformation='" + transformation + '\'' + + ", key=" + Arrays.toString(key) + + ", legacy=" + legacy + + '}'; + } +} \ No newline at end of file diff --git a/src/main/java/com/bol/crypt/KeyVersions.java b/src/main/java/com/bol/crypt/KeyVersions.java new file mode 100644 index 0000000..849929b --- /dev/null +++ b/src/main/java/com/bol/crypt/KeyVersions.java @@ -0,0 +1,114 @@ +package com.bol.crypt; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +/** + * Stores different versioned configurations containing transformations and keys. + */ +public class KeyVersions { + private final List keyVersions = new ArrayList<>(); + private KeyVersion defaultVersion = null; + + /** + * Creates a new instance of this class initialized with the provided key + * versions. + * + * @param versions Key versions. + * @return A new instance. + */ + public static KeyVersions of(KeyVersion... versions) { + var instance = new KeyVersions(); + instance.addVersions(List.of(versions)); + return instance; + } + + /** + * Amount of keys defined. + */ + public int size() { + return keyVersions.size(); + } + + /** + * Get the key version indicated by {@code version} if it exists. + * @param version The version. Should be [1, 255] or will throw otherwise. + * @return The key version, if it was registered. An empty {@code Optional} + * otherwise. + * @throws IllegalArgumentException when version is not in [1, 255] + */ + public Optional get(int version) { + if (version < 1 || version > 255) throw new IllegalArgumentException("versions must be in range [1, 255]"); + return keyVersions.stream().filter((v) -> v.version == version).findFirst(); + } + + /** + * Gets the default key version. + * @return The default key version. + * @throws IllegalStateException when no default key version was previously set. + */ + public KeyVersion getDefault() { + if (defaultVersion == null) throw new IllegalStateException("no default version set"); + return defaultVersion; + } + + /** + * Adds a version. If the added version has a higher version number than the + * existing default version, the default becomes the newly-added version. + * This is compatible with earlier versions of this library. If you want to + * use a default version that is not the latest version, make sure to + * invoke {@code setDefault} after calling this method. + * + * @param keyVersion The version to add. + */ + public void addVersion(KeyVersion keyVersion) { + if (keyVersion.version < 0 || keyVersion.version > 255) { + throw new IllegalArgumentException("version must fit in a byte"); + } + if (get(keyVersion.version).isPresent()) { + throw new IllegalArgumentException("version " + keyVersion.version + " is already registered"); + } + + keyVersions.add(keyVersion); + + if (defaultVersion == null || keyVersion.version > defaultVersion.version) { + defaultVersion = keyVersion; + } + } + + /** + * Adds multiple versions in one swoop. + * + * @param versions The versions to add. + */ + public void addVersions(Collection versions) { + versions.forEach(this::addVersion); + } + + /** + * Set the default version. This is the version that is used in unqualified + * calls to {@code CryptVault#encrypt}. + * + * @param defaultVersion The new default. + */ + public void setDefault(KeyVersion defaultVersion) { + this.defaultVersion = defaultVersion; + } + + /** + * Reports whether the given version number is registered as being a legacy + * key version (< version 2 of this library). A legacy version can still be + * decrypted if it used the default algorithm and parameters. + * @param version A version number. It's a byte for convenience when taking + * it from a binary blob. + * @return Whether {@code version} is a recognized (i.e. registered) legacy + * key version. + */ + public boolean isLegacyVersion(byte version) { + // in legacy version, 0x80 (-128) was version 0, 0x81 (-127) was version 1, etc. + var legacyVersion = (int) version - Byte.MIN_VALUE; + return get(legacyVersion).map((kv) -> kv.legacy).orElse(false); + } +} diff --git a/src/test/java/com/bol/crypt/CryptVaultTest.java b/src/test/java/com/bol/crypt/CryptVaultTest.java index 6c16c8f..1012f12 100644 --- a/src/test/java/com/bol/crypt/CryptVaultTest.java +++ b/src/test/java/com/bol/crypt/CryptVaultTest.java @@ -3,31 +3,33 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import javax.crypto.spec.GCMParameterSpec; import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Arrays; import java.util.Base64; -import static com.bol.crypt.CryptVault.fromSignedByte; -import static com.bol.crypt.CryptVault.toSignedByte; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; public class CryptVaultTest { - private static final byte[] KEY = "VGltVGhlSW5jcmVkaWJsZURldmVsb3BlclNlY3JldCE=".getBytes(); - private static final String plainText = "The quick brown fox jumps over the lazy dog"; - private static final byte[] plainBytes = plainText.getBytes(StandardCharsets.UTF_8); + private static final String keyBase64 = "VGltVGhlSW5jcmVkaWJsZURldmVsb3BlclNlY3JldCE="; + private static final String plaintext = "The quick brown fox jumps over the lazy dog"; + private static final byte[] plainBytes = plaintext.getBytes(StandardCharsets.UTF_8); private CryptVault cryptVault; @BeforeEach public void setup() { - byte[] secretKeyBytes = Base64.getDecoder().decode(KEY); - cryptVault = new CryptVault() - .with256BitAesCbcPkcs5PaddingAnd128BitSaltKey(1, secretKeyBytes); + var keyVersions = new KeyVersions(); + keyVersions.addVersion(new KeyVersion(1, "AES/CBC/PKCS5Padding", keyBase64, false)); + cryptVault = CryptVault.of(keyVersions); } @Test public void consecutiveEncryptsDifferentResults() { - byte[] cryptedSecret1 = cryptVault.encrypt(1, plainBytes); - byte[] cryptedSecret2 = cryptVault.encrypt(1, plainBytes); + KeyVersion firstVersion = cryptVault.keyVersions.get(1).orElseThrow(); + byte[] cryptedSecret1 = cryptVault.encrypt(firstVersion, plainBytes); + byte[] cryptedSecret2 = cryptVault.encrypt(firstVersion, plainBytes); assertThat(cryptedSecret1.length).isEqualTo(cryptedSecret2.length); // version @@ -49,16 +51,17 @@ public void decryptionUndoesEncryption() { byte[] decryptedBytes = cryptVault.decrypt(encryptedBytes); String decryptedString = new String(decryptedBytes, StandardCharsets.UTF_8); - assertThat(decryptedString).isEqualTo(plainText); + assertThat(decryptedString).isEqualTo(plaintext); } @Test public void wrongKeyDecryptionFailure() { byte[] encryptedBytes = cryptVault.encrypt(plainBytes); - byte[] keyBytes = Base64.getDecoder().decode("VGhpcyBpcyB0aGUgd3Jvbmcga2V5LCBJJ20gc29ycnk="); - CryptVault otherVault = new CryptVault() - .with256BitAesCbcPkcs5PaddingAnd128BitSaltKey(1, keyBytes); + byte[] otherKeyBytes = Base64.getDecoder().decode("VGhpcyBpcyB0aGUgd3Jvbmcga2V5LCBJJ20gc29ycnk="); + var otherKeyVersions = new KeyVersions(); + otherKeyVersions.addVersion(new KeyVersion(1, "AES/CBC/PKCS5Padding", otherKeyBytes)); + CryptVault otherVault = CryptVault.of(otherKeyVersions); assertThrows(CryptOperationException.class, () -> otherVault.decrypt(encryptedBytes)); } @@ -66,7 +69,7 @@ public void wrongKeyDecryptionFailure() { @Test public void missingKeyVersionsDecryptionFailure() { byte[] encryptedBytes = cryptVault.encrypt(plainBytes); - encryptedBytes[0] = toSignedByte('2'); + encryptedBytes[1] = (byte) 2; assertThrows(CryptOperationException.class, () -> cryptVault.decrypt(encryptedBytes)); } @@ -75,25 +78,90 @@ public void missingKeyVersionsDecryptionFailure() { public void highestKeyVersionIsDefaultKey() { byte[] encryptedBytes = cryptVault.encrypt(plainBytes); - cryptVault.with256BitAesCbcPkcs5PaddingAnd128BitSaltKey(2, Base64.getDecoder().decode("IqWTpi549pJDZ1kuc9HppcMxtPfu2SP6Idlh+tz4LL4=")); + var secondKeyVersion = new KeyVersion(2, "AES/CBC/PKCS5Padding", Base64.getDecoder().decode("IqWTpi549pJDZ1kuc9HppcMxtPfu2SP6Idlh+tz4LL4=")); + cryptVault.keyVersions.addVersion(secondKeyVersion); byte[] encryptedBytes2 = cryptVault.encrypt(plainBytes); - assertThat(fromSignedByte(encryptedBytes[0])).isEqualTo(1); - assertThat(fromSignedByte(encryptedBytes2[0])).isEqualTo(2); + assertThat(encryptedBytes[1]).isEqualTo((byte) 1); + assertThat(encryptedBytes2[1]).isEqualTo((byte) 2); } @Test public void keyVersionIsDerivedFromCipher() { - cryptVault.with256BitAesCbcPkcs5PaddingAnd128BitSaltKey(2, Base64.getDecoder().decode("IqWTpi549pJDZ1kuc9HppcMxtPfu2SP6Idlh+tz4LL4=")); + var firstKeyVersion = cryptVault.keyVersions.get(1).orElseThrow(); + var secondKeyVersion = new KeyVersion(2, "ChaCha20-Poly1305", Base64.getDecoder().decode("IqWTpi549pJDZ1kuc9HppcMxtPfu2SP6Idlh+tz4LL4=")); + cryptVault.keyVersions.addVersion(secondKeyVersion); - byte[] encryptedBytes = cryptVault.encrypt(1, plainBytes); + byte[] encryptedUnderFirstKeyBytes = cryptVault.encrypt(firstKeyVersion, plainBytes); + byte[] encryptedUnderSecondKeyBytes = cryptVault.encrypt(secondKeyVersion, plainBytes); - byte[] encryptedBytes2 = cryptVault.encrypt(2, plainBytes); + assertThat(encryptedUnderFirstKeyBytes[1]).isEqualTo((byte) 1); + assertThat(encryptedUnderSecondKeyBytes[1]).isEqualTo((byte) 2); - assertThat(fromSignedByte(encryptedBytes[0])).isEqualTo(1); - assertThat(fromSignedByte(encryptedBytes2[0])).isEqualTo(2); + assertThat(cryptVault.decrypt(encryptedUnderFirstKeyBytes)).isEqualTo(plainBytes); + assertThat(cryptVault.decrypt(encryptedUnderSecondKeyBytes)).isEqualTo(plainBytes); + } + + @Test + public void differentCipherVersionsShouldLiveSideBySide() { + byte[] aesKey = "2~_J2#Kb=_xV3!wMmX3}LAny0fie7:hT".getBytes(StandardCharsets.UTF_8); + var aes256CtrTransformation = "AES/CTR/NoPadding"; + var aes256CtrVersion = new KeyVersion(1, aes256CtrTransformation, aesKey); + + var desKey = "jcs&@IwY".getBytes(); + var desCbcTransformation = "DES/CBC/PKCS5Padding"; + var desCbcVersion = new KeyVersion(2, desCbcTransformation, desKey); + + var cryptVault = CryptVault.of(KeyVersions.of(aes256CtrVersion, desCbcVersion)); + + byte[] aes256CtrBlob = cryptVault.encrypt(aes256CtrVersion, plainBytes); + assertThat(aes256CtrBlob[1]).isEqualTo((byte) 1); + assertThat(aes256CtrBlob.length).isEqualTo(1 + 1 + 1 + aes256CtrBlob[2] + plaintext.length()); + + byte[] chacha20Poly1305Blob = cryptVault.encrypt(desCbcVersion, plainBytes); + assertThat(chacha20Poly1305Blob[1]).isEqualTo((byte) 2); + int paddingLength = plaintext.length() % 16 == 0 ? 16 : 16 - plaintext.length() % 16; + assertThat(chacha20Poly1305Blob.length).isEqualTo(1 + 1 + 1 + chacha20Poly1305Blob[2] + plaintext.length() + paddingLength); + } + + @Test + public void ecbWithoutIv() { + byte[] key = "2~_J2#Kb=_xV3!wMmX3}LAny0fie7:hT".getBytes(StandardCharsets.UTF_8); + + String aes256EcbTransformation = "AES/ECB/Pkcs5Padding"; + var aes256EcbVersion = new KeyVersion(1, aes256EcbTransformation, key); + var cryptVersions = KeyVersions.of(aes256EcbVersion); + var vault = CryptVault.of(cryptVersions); + + byte[] ciphertext = vault.encrypt(plainBytes); + String decryptedText = new String(vault.decrypt(ciphertext)); + + assertThat(decryptedText).isEqualTo(plaintext); + } + + @Test + public void gcmWithSameIvEveryTime() { + byte[] key = "2~_J2#Kb=_xV3!wMmX3}LAny0fie7:hT".getBytes(StandardCharsets.UTF_8); + + var aes256GcmTransformation = "AES/GCM/NoPadding"; + var aes256GcmVersion = new KeyVersion(1, aes256GcmTransformation, key); + var keyVersions = KeyVersions.of(aes256GcmVersion); + var cryptVault = CryptVault.of(keyVersions); + + byte[] reusedIv = new byte[16]; + new SecureRandom().nextBytes(reusedIv); + + var algoParamSpec = new GCMParameterSpec(128, reusedIv); + + byte[] firstEncryptedBlob = cryptVault.encrypt(aes256GcmVersion, plainBytes, algoParamSpec); + assertThat(firstEncryptedBlob[1]).isEqualTo((byte) 1); + assertThat(Arrays.copyOfRange(firstEncryptedBlob, 3, 3 + firstEncryptedBlob[2])).contains(reusedIv); + + byte[] secondEncryptedBlob = cryptVault.encrypt(aes256GcmVersion, plainBytes, algoParamSpec); + assertThat(secondEncryptedBlob[1]).isEqualTo((byte) 1); + assertThat(Arrays.copyOfRange(secondEncryptedBlob, 1, 3 + secondEncryptedBlob[2])).contains(reusedIv); - assertThat(cryptVault.decrypt(encryptedBytes)).isEqualTo(plainBytes); - assertThat(cryptVault.decrypt(encryptedBytes2)).isEqualTo(plainBytes); + assertThat(new String(cryptVault.decrypt(firstEncryptedBlob))).isEqualTo(plaintext); + assertThat(new String(cryptVault.decrypt(secondEncryptedBlob))).isEqualTo(plaintext); } } diff --git a/src/test/java/com/bol/system/autoconfig/EncryptionConfiguredSystemTest.java b/src/test/java/com/bol/system/autoconfig/EncryptionConfiguredSystemTest.java new file mode 100644 index 0000000..2c40978 --- /dev/null +++ b/src/test/java/com/bol/system/autoconfig/EncryptionConfiguredSystemTest.java @@ -0,0 +1,78 @@ +package com.bol.system.autoconfig; + +import com.bol.config.CryptVaultAutoConfiguration; +import com.bol.crypt.CryptOperationException; +import com.bol.crypt.CryptVault; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.util.Base64; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@ActiveProfiles("autoconfig") +@EnableAutoConfiguration +@SpringBootTest(classes = {EncryptionConfiguredSystemTest.class, CryptVaultAutoConfiguration.class}) +public class EncryptionConfiguredSystemTest { + private static final byte[] cleartext = ("Lorem ipsum dolor sit amet, " + + "consectetur adipiscing elit, sed do eiusmod tempor incididunt ut " + + "labore et dolore magna aliqua.").getBytes(); + + @Autowired(required = false) + CryptVault cryptVault; + + @Test + public void sanityTest() { + assertThat(cryptVault).isNotNull(); + assertThat(cryptVault.keyVersions.size()).isEqualTo(6); + } + + @Test + public void specifyCipherInExternalConfig() { + var secondKeyVersion = cryptVault.keyVersions.get(2).orElseThrow(); + byte[] encryptedBlob = cryptVault.encrypt(secondKeyVersion, cleartext); + + // version + len(cleartext) w/o padding + assertThat(encryptedBlob.length).isEqualTo(1 + 1 + 1 + encryptedBlob[2] + cleartext.length); + } + + @Test + public void legacyKeyVersionShouldThrowWhenUsedForNewEncryption() { + // CryptVault 1 did not specify a CryptVault protocol version in the encrypted blob + var legacyVersion = cryptVault.keyVersions.get(1).orElseThrow(); + var t = assertThrows( + CryptOperationException.class, + () -> cryptVault.encrypt(legacyVersion, cleartext) + ); + + assertThat(t.getMessage()).startsWith("cannot encrypt with legacy key version"); + } + + @Test + public void legacyKeyVersionShouldBeAbleToDecryptLegacyEncryption() { + var legacyBlob = Base64.getDecoder().decode("gV4dQBm9mYJ1JC3DDs7Wj4cdbJKJALhIPktD4AT2sq4/"); + // in legacy version, 0x80 (-128) was version 0, 0x81 (-127) was version 1, etc. + assertThat(legacyBlob[0]).isEqualTo((byte) 0x81); + byte[] recoveredCleartextAsBytes = cryptVault.decrypt(legacyBlob); + assertThat(new String(recoveredCleartextAsBytes)).isEqualTo("lorem ipsum"); + } + + @Test + public void blobNotMarkedAsLegacyShouldFailDecryption() { + var legacyBlob = Base64.getDecoder().decode("gl4dQBm9mYJ1JC3DDs7Wj4cdbJKJALhIPktD4AT2sq4/"); + assertThat(legacyBlob[0]).isEqualTo((byte) 0x82); + var t = assertThrows(CryptOperationException.class, () -> cryptVault.decrypt(legacyBlob)); + assertThat(t.getMessage()).startsWith("cryptvault protocol version in encrypted blob is unknown:"); + } + + @Test + public void noTransformationSpecifiedShouldFallBackToAesCbcPkcs5Padding() { + var keyVersionWithoutTransformation = cryptVault.keyVersions.get(6).orElseThrow(); + byte[] recoveredCleartext = cryptVault.decrypt(cryptVault.encrypt(keyVersionWithoutTransformation, cleartext)); + assertThat(recoveredCleartext).isEqualTo(cleartext); + } +} diff --git a/src/test/java/com/bol/system/autoconfig/EncryptionConfiguredTest.java b/src/test/java/com/bol/system/autoconfig/EncryptionConfiguredTest.java deleted file mode 100644 index 1608932..0000000 --- a/src/test/java/com/bol/system/autoconfig/EncryptionConfiguredTest.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.bol.system.autoconfig; - -import com.bol.config.CryptVaultAutoConfiguration; -import com.bol.crypt.CryptVault; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.autoconfigure.EnableAutoConfiguration; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.junit4.SpringRunner; - -import static org.assertj.core.api.Assertions.assertThat; - -@ActiveProfiles("autoconfig") -@RunWith(SpringRunner.class) -@EnableAutoConfiguration -@SpringBootTest(classes = {EncryptionConfiguredTest.class, CryptVaultAutoConfiguration.class}) -public class EncryptionConfiguredTest { - - @Autowired(required = false) CryptVault cryptVault; - - @Test - public void sanityTest() { - assertThat(cryptVault).isNotNull(); - assertThat(cryptVault.size()).isEqualTo(1); - } -} diff --git a/src/test/java/com/bol/system/autoconfig/EncryptionNotConfiguredTest.java b/src/test/java/com/bol/system/autoconfig/EncryptionNotConfiguredSystemTest.java similarity index 59% rename from src/test/java/com/bol/system/autoconfig/EncryptionNotConfiguredTest.java rename to src/test/java/com/bol/system/autoconfig/EncryptionNotConfiguredSystemTest.java index 5ca4437..61b731f 100644 --- a/src/test/java/com/bol/system/autoconfig/EncryptionNotConfiguredTest.java +++ b/src/test/java/com/bol/system/autoconfig/EncryptionNotConfiguredSystemTest.java @@ -1,21 +1,19 @@ package com.bol.system.autoconfig; import com.bol.crypt.CryptVault; -import org.junit.Test; -import org.junit.runner.RunWith; +import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.junit4.SpringRunner; import static org.assertj.core.api.Assertions.assertThat; -@RunWith(SpringRunner.class) @EnableAutoConfiguration -@SpringBootTest(classes = EncryptionNotConfiguredTest.class) -public class EncryptionNotConfiguredTest { +@SpringBootTest(classes = EncryptionNotConfiguredSystemTest.class) +public class EncryptionNotConfiguredSystemTest { - @Autowired(required = false) CryptVault cryptVault; + @Autowired(required = false) + CryptVault cryptVault; @Test public void sanityTest() { diff --git a/src/test/resources/application-autoconfig.yml b/src/test/resources/application-autoconfig.yml index 482a509..c0095be 100644 --- a/src/test/resources/application-autoconfig.yml +++ b/src/test/resources/application-autoconfig.yml @@ -2,4 +2,19 @@ cryptvault: default-key: 1 keys: - version: 1 - key: hqHKBLV83LpCqzKpf8OvutbCs+O5wX5BPu3btWpEvXA= + key: 5DAYjqpqKHK8tzyS6IJEXtsQM/ZQAZ8BOJPgCsQe1sM= + legacy: yes + - version: 2 + key: h7giVR4xH4RDYj4VLSCkSUVyxQQoqEgvjWNIStjE0oM= + transformation: AES/CTR/NoPadding + - version: 3 + key: X89VgG8A9F2DNZCBUezWBetujpQfvG6bJxytTNWrWM8= + transformation: AES/ECB/Pkcs5Padding + - version: 4 + key: VWSMXzwVOT5S887/abibix0zMKghSDFswm2C2SLkFrk= + transformation: ChaCha20-Poly1305 + - version: 5 + key: j+kZRwQUSu0nseG6LUPecHUoHEintYn9u673rLAPU+s= + transformation: AES/GCM/NoPadding + - version: 6 + key: 2FHtWpob9UPrnz7FCT5LnlgmZ6ZqB5U8oQIBJkk+dAc=