From 502ecf9241885d65c327edab6d88fb38622130b3 Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Wed, 3 Jun 2026 15:39:43 -0600 Subject: [PATCH] Deprecate AesBytesEncryptor This commit separates AesBytesEncryptor into two separate implememtations, allowing for a migration away from default arrangements that used a null IV Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com> --- .../crypto/encrypt/AesBytesEncryptor.java | 2 + .../crypto/encrypt/AesCbcBytesEncryptor.java | 165 ++++++++++++++ .../crypto/encrypt/AesGcmBytesEncryptor.java | 172 +++++++++++++++ .../security/crypto/encrypt/Encryptors.java | 28 ++- .../encrypt/AesCbcBytesEncryptorTests.java | 203 ++++++++++++++++++ .../encrypt/AesGcmBytesEncryptorTests.java | 146 +++++++++++++ .../crypto/encrypt/CryptoAssumptions.java | 10 +- .../features/integrations/cryptography.adoc | 58 +++-- 8 files changed, 750 insertions(+), 34 deletions(-) create mode 100644 crypto/src/main/java/org/springframework/security/crypto/encrypt/AesCbcBytesEncryptor.java create mode 100644 crypto/src/main/java/org/springframework/security/crypto/encrypt/AesGcmBytesEncryptor.java create mode 100644 crypto/src/test/java/org/springframework/security/crypto/encrypt/AesCbcBytesEncryptorTests.java create mode 100644 crypto/src/test/java/org/springframework/security/crypto/encrypt/AesGcmBytesEncryptorTests.java diff --git a/crypto/src/main/java/org/springframework/security/crypto/encrypt/AesBytesEncryptor.java b/crypto/src/main/java/org/springframework/security/crypto/encrypt/AesBytesEncryptor.java index 4093e7d102..5ba1ab5c77 100644 --- a/crypto/src/main/java/org/springframework/security/crypto/encrypt/AesBytesEncryptor.java +++ b/crypto/src/main/java/org/springframework/security/crypto/encrypt/AesBytesEncryptor.java @@ -37,7 +37,9 @@ import org.springframework.security.crypto.util.EncodingUtils; * * @author Keith Donald * @author Dave Syer + * @deprecated Use {@link AesCbcBytesEncryptor} or {@link AesGcmBytesEncryptor} instead. */ +@Deprecated public final class AesBytesEncryptor implements BytesEncryptor { private final SecretKey secretKey; diff --git a/crypto/src/main/java/org/springframework/security/crypto/encrypt/AesCbcBytesEncryptor.java b/crypto/src/main/java/org/springframework/security/crypto/encrypt/AesCbcBytesEncryptor.java new file mode 100644 index 0000000000..7cae0f3de7 --- /dev/null +++ b/crypto/src/main/java/org/springframework/security/crypto/encrypt/AesCbcBytesEncryptor.java @@ -0,0 +1,165 @@ +/* + * Copyright 2004-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.crypto.encrypt; + +import java.util.Objects; + +import javax.crypto.Cipher; +import javax.crypto.SecretKey; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.PBEKeySpec; +import javax.crypto.spec.SecretKeySpec; + +import org.springframework.security.crypto.codec.Hex; +import org.springframework.security.crypto.keygen.BytesKeyGenerator; +import org.springframework.security.crypto.keygen.KeyGenerators; +import org.springframework.security.crypto.util.EncodingUtils; + +/** + * {@link BytesEncryptor} that uses 256-bit AES/CBC/PKCS5Padding with a random 16-byte + * initialization vector. The IV is prepended to the ciphertext on encrypt and stripped on + * decrypt. + * + *

+ * Note that CBC mode provides confidentiality but not integrity or authenticity. + * Applications that require authenticated encryption should prefer + * {@link AesGcmBytesEncryptor}. See the + * OWASP Cryptographic Storage Cheat Sheet for guidance on choosing a cipher mode. + * + *

+ * When key derivation is used via {@link #withPassword(String, CharSequence)}, the key is + * derived using PBKDF2WithHmacSHA256 with {@code DEFAULT_PBKDF2_ITERATIONS} iterations + * per the + * OWASP Password Storage Cheat Sheet. Because derivation is intentionally expensive, + * the encryptor instance should be created once and reused rather than constructed + * per-operation. + * + * @author Josh Cummings + * @since 5.7.26 + * @see AesGcmBytesEncryptor + * @see AesBytesEncryptor + */ +public final class AesCbcBytesEncryptor implements BytesEncryptor { + + private static final String ALGORITHM = "AES/CBC/PKCS5Padding"; + + private static final int IV_LENGTH_BYTES = 16; + + private static final int DEFAULT_PBKDF2_ITERATIONS = 600_000; + + private final SecretKey secretKey; + + private final Cipher encryptor; + + private final Cipher decryptor; + + private final BytesKeyGenerator ivGenerator; + + private AesCbcBytesEncryptor(SecretKey secretKey, BytesKeyGenerator ivGenerator) { + this.secretKey = new SecretKeySpec(secretKey.getEncoded(), "AES"); + this.encryptor = CipherUtils.newCipher(ALGORITHM); + this.decryptor = CipherUtils.newCipher(ALGORITHM); + this.ivGenerator = ivGenerator; + } + + /** + * Creates an encryptor that derives its key from the given password and hex-encoded + * salt using PBKDF2WithHmacSHA1. + * @param password the password value + * @param salt the hex-encoded salt value + */ + public static Builder withPassword(String password, CharSequence salt) { + return new Builder(password, salt); + } + + /** + * Creates an encryptor using the supplied {@link SecretKey}. + * @param secretKey the secret (symmetric) key + */ + public static Builder withSecretKey(SecretKey secretKey) { + return new Builder(secretKey); + } + + @Override + public byte[] encrypt(byte[] bytes) { + synchronized (this.encryptor) { + byte[] iv = this.ivGenerator.generateKey(); + CipherUtils.initCipher(this.encryptor, Cipher.ENCRYPT_MODE, this.secretKey, new IvParameterSpec(iv)); + byte[] ciphertext = CipherUtils.doFinal(this.encryptor, bytes); + return EncodingUtils.concatenate(iv, ciphertext); + } + } + + @Override + public byte[] decrypt(byte[] encryptedBytes) { + int ivLength = this.ivGenerator.getKeyLength(); + byte[] iv = EncodingUtils.subArray(encryptedBytes, 0, ivLength); + byte[] ciphertext = EncodingUtils.subArray(encryptedBytes, ivLength, encryptedBytes.length); + synchronized (this.decryptor) { + CipherUtils.initCipher(this.decryptor, Cipher.DECRYPT_MODE, this.secretKey, new IvParameterSpec(iv)); + return CipherUtils.doFinal(this.decryptor, ciphertext); + } + } + + private static SecretKey deriveKey(String password, CharSequence salt) { + return CipherUtils.newSecretKey("PBKDF2WithHmacSHA256", + new PBEKeySpec(password.toCharArray(), Hex.decode(salt), DEFAULT_PBKDF2_ITERATIONS, 256)); + } + + /** + * A Builder for {@link AesCbcBytesEncryptor}. + */ + public static final class Builder { + + private final SecretKey secretKey; + + private BytesKeyGenerator ivGenerator = KeyGenerators.secureRandom(IV_LENGTH_BYTES); + + private Builder(SecretKey secretKey) { + this.secretKey = secretKey; + } + + private Builder(String password, CharSequence salt) { + this.secretKey = deriveKey(password, salt); + } + + /** + * Sets the {@link BytesKeyGenerator} to use for generating the initialization + * vector. + * @param ivGenerator the {@link BytesKeyGenerator} to use for generating the + * initialization vector + * @return this builder + */ + public Builder ivGenerator(BytesKeyGenerator ivGenerator) { + Objects.requireNonNull(ivGenerator, "ivGenerator cannot be null"); + this.ivGenerator = ivGenerator; + return this; + } + + /** + * Builds the {@link AesCbcBytesEncryptor}. + * @return the {@link AesCbcBytesEncryptor} + */ + public AesCbcBytesEncryptor build() { + return new AesCbcBytesEncryptor(this.secretKey, this.ivGenerator); + } + + } + +} diff --git a/crypto/src/main/java/org/springframework/security/crypto/encrypt/AesGcmBytesEncryptor.java b/crypto/src/main/java/org/springframework/security/crypto/encrypt/AesGcmBytesEncryptor.java new file mode 100644 index 0000000000..980221cb33 --- /dev/null +++ b/crypto/src/main/java/org/springframework/security/crypto/encrypt/AesGcmBytesEncryptor.java @@ -0,0 +1,172 @@ +/* + * Copyright 2004-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.crypto.encrypt; + +import java.util.Objects; + +import javax.crypto.Cipher; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.PBEKeySpec; +import javax.crypto.spec.SecretKeySpec; + +import org.springframework.security.crypto.codec.Hex; +import org.springframework.security.crypto.keygen.BytesKeyGenerator; +import org.springframework.security.crypto.keygen.KeyGenerators; +import org.springframework.security.crypto.util.EncodingUtils; + +/** + * {@link BytesEncryptor} that uses 256-bit AES/GCM/NoPadding with a random 16-byte + * initialization vector and a 128-bit authentication tag. The IV is prepended to the + * ciphertext on encrypt and stripped on decrypt. GCM provides authenticated encryption + * (AEAD): both confidentiality and integrity are protected, and decryption throws if the + * ciphertext has been tampered with. + * + *

+ * This class uses a 16-byte (128-bit) IV rather than the 12-byte (96-bit) IV recommended + * by NIST SP 800-38D for GCM. Both lengths are cryptographically valid; the 16-byte + * choice maintains consistency with the rest of the Spring Security crypto module. For + * additional guidance, please see the + * OWASP Cryptographic Storage Cheat Sheet. + * + *

+ * When key derivation is used via {@link #withPassword(String, CharSequence)}, the key is + * derived using PBKDF2WithHmacSHA256 with {@code DEFAULT_PBKDF2_ITERATIONS} iterations + * per the + * OWASP Password Storage Cheat Sheet. Because derivation is intentionally expensive, + * the encryptor instance should be created once and reused rather than constructed + * per-operation. + * + * @author Josh Cummings + * @since 5.7.26 + * @see AesCbcBytesEncryptor + * @see AesBytesEncryptor + */ +public final class AesGcmBytesEncryptor implements BytesEncryptor { + + private static final String ALGORITHM = "AES/GCM/NoPadding"; + + private static final int IV_LENGTH_BYTES = 16; + + private static final int TAG_LENGTH_BITS = 128; + + private static final int DEFAULT_PBKDF2_ITERATIONS = 600_000; + + private final SecretKey secretKey; + + private final Cipher encryptor; + + private final Cipher decryptor; + + private final BytesKeyGenerator ivGenerator; + + private AesGcmBytesEncryptor(SecretKey secretKey, BytesKeyGenerator ivGenerator) { + this.secretKey = new SecretKeySpec(secretKey.getEncoded(), "AES"); + this.encryptor = CipherUtils.newCipher(ALGORITHM); + this.decryptor = CipherUtils.newCipher(ALGORITHM); + this.ivGenerator = ivGenerator; + } + + /** + * Creates an encryptor that derives its key from the given password and hex-encoded + * salt using PBKDF2WithHmacSHA1. + * @param password the password value + * @param salt the hex-encoded salt value + */ + public static Builder withPassword(String password, CharSequence salt) { + return new Builder(password, salt); + } + + /** + * Creates an encryptor using the supplied {@link SecretKey}. + * @param secretKey the secret (symmetric) key + */ + public static Builder withSecretKey(SecretKey secretKey) { + return new Builder(secretKey); + } + + @Override + public byte[] encrypt(byte[] bytes) { + synchronized (this.encryptor) { + byte[] iv = this.ivGenerator.generateKey(); + CipherUtils.initCipher(this.encryptor, Cipher.ENCRYPT_MODE, this.secretKey, + new GCMParameterSpec(TAG_LENGTH_BITS, iv)); + byte[] ciphertext = CipherUtils.doFinal(this.encryptor, bytes); + return EncodingUtils.concatenate(iv, ciphertext); + } + } + + @Override + public byte[] decrypt(byte[] encryptedBytes) { + int ivLength = this.ivGenerator.getKeyLength(); + byte[] iv = EncodingUtils.subArray(encryptedBytes, 0, ivLength); + byte[] ciphertext = EncodingUtils.subArray(encryptedBytes, ivLength, encryptedBytes.length); + synchronized (this.decryptor) { + CipherUtils.initCipher(this.decryptor, Cipher.DECRYPT_MODE, this.secretKey, + new GCMParameterSpec(TAG_LENGTH_BITS, iv)); + return CipherUtils.doFinal(this.decryptor, ciphertext); + } + } + + private static SecretKey deriveKey(String password, CharSequence salt) { + return CipherUtils.newSecretKey("PBKDF2WithHmacSHA256", + new PBEKeySpec(password.toCharArray(), Hex.decode(salt), DEFAULT_PBKDF2_ITERATIONS, 256)); + } + + /** + * A Builder for {@link AesGcmBytesEncryptor}. + */ + public static final class Builder { + + private final SecretKey secretKey; + + private BytesKeyGenerator ivGenerator = KeyGenerators.secureRandom(IV_LENGTH_BYTES); + + private Builder(SecretKey secretKey) { + this.secretKey = secretKey; + } + + private Builder(String password, CharSequence salt) { + this.secretKey = deriveKey(password, salt); + } + + /** + * Sets the {@link BytesKeyGenerator} to use for generating the initialization + * vector. + * @param ivGenerator the {@link BytesKeyGenerator} to use for generating the + * initialization vector + * @return this builder + */ + public Builder ivGenerator(BytesKeyGenerator ivGenerator) { + Objects.requireNonNull(ivGenerator, "ivGenerator cannot be null"); + this.ivGenerator = ivGenerator; + return this; + } + + /** + * Builds the {@link AesGcmBytesEncryptor}. + * @return the {@link AesGcmBytesEncryptor} + */ + public AesGcmBytesEncryptor build() { + return new AesGcmBytesEncryptor(this.secretKey, this.ivGenerator); + } + + } + +} diff --git a/crypto/src/main/java/org/springframework/security/crypto/encrypt/Encryptors.java b/crypto/src/main/java/org/springframework/security/crypto/encrypt/Encryptors.java index 5239c300a9..71e35d2ec4 100644 --- a/crypto/src/main/java/org/springframework/security/crypto/encrypt/Encryptors.java +++ b/crypto/src/main/java/org/springframework/security/crypto/encrypt/Encryptors.java @@ -16,7 +16,6 @@ package org.springframework.security.crypto.encrypt; -import org.springframework.security.crypto.encrypt.AesBytesEncryptor.CipherAlgorithm; import org.springframework.security.crypto.keygen.KeyGenerators; /** @@ -42,9 +41,14 @@ public final class Encryptors { * not be shared * @param salt a hex-encoded, random, site-global salt value to use to generate the * key + * @deprecated Use {@link AesGcmBytesEncryptor#withPassword(String, CharSequence)} + * instead. */ + @Deprecated + @SuppressWarnings("deprecation") public static BytesEncryptor stronger(CharSequence password, CharSequence salt) { - return new AesBytesEncryptor(password.toString(), salt, KeyGenerators.secureRandom(16), CipherAlgorithm.GCM); + return new AesBytesEncryptor(password.toString(), salt, KeyGenerators.secureRandom(16), + AesBytesEncryptor.CipherAlgorithm.GCM); } /** @@ -53,18 +57,16 @@ public final class Encryptors { * Function #2). Salts the password to prevent dictionary attacks against the key. The * provided salt is expected to be hex-encoded; it should be random and at least 8 * bytes in length. Also applies a random 16-byte initialization vector to ensure each - * encrypted message will be unique. Requires Java 6. NOTE: This mode is not - * authenticated - * and does not provide any guarantees about the authenticity of the data. For a more - * secure alternative, users should prefer - * {@link #stronger(CharSequence, CharSequence)}. + * encrypted message will be unique. Requires Java 6. * @param password the password used to generate the encryptor's secret key; should * not be shared * @param salt a hex-encoded, random, site-global salt value to use to generate the * key - * - * @see Encryptors#stronger(CharSequence, CharSequence) + * @deprecated Use {@link AesCbcBytesEncryptor#withPassword(String, CharSequence)} + * instead. */ + @Deprecated + @SuppressWarnings("deprecation") public static BytesEncryptor standard(CharSequence password, CharSequence salt) { return new AesBytesEncryptor(password.toString(), salt, KeyGenerators.secureRandom(16)); } @@ -74,8 +76,12 @@ public final class Encryptors { * text is hex-encoded. * @param password the password used to generate the encryptor's secret key; should * not be shared + * @deprecated Use {@link AesGcmBytesEncryptor#withPassword(String, CharSequence)} + * instead. * @see Encryptors#stronger(CharSequence, CharSequence) */ + @Deprecated + @SuppressWarnings("deprecation") public static TextEncryptor delux(CharSequence password, CharSequence salt) { return new HexEncodingTextEncryptor(stronger(password, salt)); } @@ -85,8 +91,12 @@ public final class Encryptors { * text is hex-encoded. * @param password the password used to generate the encryptor's secret key; should * not be shared + * @deprecated Use {@link AesCbcBytesEncryptor#withPassword(String, CharSequence)} + * instead. * @see Encryptors#standard(CharSequence, CharSequence) */ + @Deprecated + @SuppressWarnings("deprecation") public static TextEncryptor text(CharSequence password, CharSequence salt) { return new HexEncodingTextEncryptor(standard(password, salt)); } diff --git a/crypto/src/test/java/org/springframework/security/crypto/encrypt/AesCbcBytesEncryptorTests.java b/crypto/src/test/java/org/springframework/security/crypto/encrypt/AesCbcBytesEncryptorTests.java new file mode 100644 index 0000000000..dfa187783d --- /dev/null +++ b/crypto/src/test/java/org/springframework/security/crypto/encrypt/AesCbcBytesEncryptorTests.java @@ -0,0 +1,203 @@ +/* + * Copyright 2004-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.crypto.encrypt; + +import java.nio.charset.StandardCharsets; + +import javax.crypto.SecretKey; +import javax.crypto.spec.PBEKeySpec; + +import org.junit.jupiter.api.Test; + +import org.springframework.security.crypto.codec.Hex; +import org.springframework.security.crypto.keygen.BytesKeyGenerator; +import org.springframework.security.crypto.keygen.KeyGenerators; +import org.springframework.security.crypto.password.Pbkdf2PasswordEncoder.SecretKeyFactoryAlgorithm; +import org.springframework.security.crypto.util.EncodingUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link AesCbcBytesEncryptor}. + */ +class AesCbcBytesEncryptorTests { + + private final String secret = "value"; + + private final String password = "password"; + + private final String hexSalt = "deadbeef"; + + @Test + void roundtripWhenUsingPasswordAndSaltThenEncryptsAndDecrypts() { + CryptoAssumptions.assumeCBCJCE(); + AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withPassword(this.password, this.hexSalt).build(); + byte[] encrypted = encryptor.encrypt(this.secret.getBytes()); + assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret); + } + + @Test + void roundtripWhenUsingSecretKeyThenEncryptsAndDecrypts() { + CryptoAssumptions.assumeCBCJCE(); + PBEKeySpec keySpec = new PBEKeySpec(this.password.toCharArray(), Hex.decode(this.hexSalt), 1024, 256); + SecretKey secretKey = CipherUtils.newSecretKey(SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA1.name(), keySpec); + AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withSecretKey(secretKey).build(); + byte[] encrypted = encryptor.encrypt(this.secret.getBytes()); + assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret); + } + + @Test + void encryptWhenUsingMockIvThenProducesKnownCiphertext() { + CryptoAssumptions.assumeCBCJCE(); + BytesKeyGenerator mockGenerator = mock(BytesKeyGenerator.class); + given(mockGenerator.generateKey()).willReturn(Hex.decode("4b0febebd439db7ca77153cb254520c3")); + given(mockGenerator.getKeyLength()).willReturn(16); + AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withPassword(this.password, this.hexSalt) + .ivGenerator(mockGenerator) + .build(); + byte[] encrypted = encryptor.encrypt(this.secret.getBytes()); + assertThat(Hex.encode(encrypted)).isEqualTo("4b0febebd439db7ca77153cb254520c3b7232ac29355d07869433f1ecf55fe94"); + assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret); + } + + @Test + void encryptProducesUniqueOutputAndIvIsPrePended() { + CryptoAssumptions.assumeCBCJCE(); + AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withPassword(this.password, this.hexSalt).build(); + byte[] first = encryptor.encrypt(this.secret.getBytes()); + byte[] second = encryptor.encrypt(this.secret.getBytes()); + assertThat(first).isNotEqualTo(second); + assertThat(first.length).isGreaterThan(16); + } + + @Test + @SuppressWarnings("deprecation") + void migratesFromDeprecatedNullIvCbcToAesCbcBytesEncryptor() { + CryptoAssumptions.assumeCBCJCE(); + AesBytesEncryptor deprecated = new AesBytesEncryptor(this.password, this.hexSalt); + byte[] encrypted = deprecated.encrypt(this.secret.getBytes()); + + AesCbcBytesEncryptor modern = AesCbcBytesEncryptor.withPassword(this.password, this.hexSalt).build(); + BytesEncryptor migrating = new MigratingBytesEncryptor("{CBC}", modern, deprecated); + + assertThat(new String(migrating.decrypt(encrypted))).isEqualTo(this.secret); + + byte[] migrated = migrating.encrypt(this.secret.getBytes()); + assertThat(migrated[0]).isEqualTo((byte) '{'); + assertThat(migrated[1]).isEqualTo((byte) 'C'); + assertThat(migrated[2]).isEqualTo((byte) 'B'); + assertThat(migrated[3]).isEqualTo((byte) 'C'); + assertThat(migrated[4]).isEqualTo((byte) '}'); + assertThat(new String(migrating.decrypt(migrated))).isEqualTo(this.secret); + } + + @Test + @SuppressWarnings("deprecation") + void withSecretWhenAesBytesEncryptorEncryptsThenDecrypts() { + CryptoAssumptions.assumeCBCJCE(); + PBEKeySpec keySpec = new PBEKeySpec(this.password.toCharArray(), Hex.decode(this.hexSalt), 1024, 256); + SecretKey key = CipherUtils.newSecretKey(SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA1.name(), keySpec); + AesBytesEncryptor deprecated = new AesBytesEncryptor(key, KeyGenerators.secureRandom(16), + AesBytesEncryptor.CipherAlgorithm.CBC); + AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withSecretKey(key).build(); + byte[] encrypted = deprecated.encrypt(this.secret.getBytes()); + assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret); + } + + @Test + @SuppressWarnings("deprecation") + void aesBytesEncryptorWhenEncryptsThenAesCbcBytesEncryptorDecrypts() { + CryptoAssumptions.assumeCBCJCE(); + PBEKeySpec keySpec = new PBEKeySpec(this.password.toCharArray(), Hex.decode(this.hexSalt), 1024, 256); + SecretKey key = CipherUtils.newSecretKey(SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA1.name(), keySpec); + AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withSecretKey(key).build(); + AesBytesEncryptor deprecated = new AesBytesEncryptor(key, KeyGenerators.secureRandom(16), + AesBytesEncryptor.CipherAlgorithm.CBC); + byte[] encrypted = encryptor.encrypt(this.secret.getBytes()); + assertThat(new String(deprecated.decrypt(encrypted))).isEqualTo(this.secret); + } + + @Test + void roundtripWhenUsingCustomIvGeneratorThenEncryptsAndDecrypts() { + CryptoAssumptions.assumeCBCJCE(); + BytesKeyGenerator customIvGenerator = mock(BytesKeyGenerator.class); + given(customIvGenerator.generateKey()).willReturn(Hex.decode("4b0febebd439db7ca77153cb254520c3")); + given(customIvGenerator.getKeyLength()).willReturn(16); + AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withPassword(this.password, this.hexSalt) + .ivGenerator(customIvGenerator) + .build(); + byte[] encrypted = encryptor.encrypt(this.secret.getBytes()); + assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret); + } + + @Test + @SuppressWarnings("deprecation") + void withPasswordDerivesADifferentKeyThanAesBytesEncryptor() { + CryptoAssumptions.assumeCBCJCE(); + AesBytesEncryptor deprecated = new AesBytesEncryptor(this.password, this.hexSalt, + KeyGenerators.secureRandom(16)); + AesCbcBytesEncryptor encryptor = AesCbcBytesEncryptor.withPassword(this.password, this.hexSalt).build(); + byte[] encrypted = deprecated.encrypt(this.secret.getBytes()); + assertThatIllegalStateException().isThrownBy(() -> encryptor.decrypt(encrypted)); + } + + private static final class MigratingBytesEncryptor implements BytesEncryptor { + + private final byte[] prefix; + + private final BytesEncryptor write; + + private final BytesEncryptor deprecated; + + MigratingBytesEncryptor(String prefix, BytesEncryptor write, BytesEncryptor deprecated) { + this.prefix = prefix.getBytes(StandardCharsets.US_ASCII); + this.write = write; + this.deprecated = deprecated; + } + + @Override + public byte[] encrypt(byte[] bytes) { + return EncodingUtils.concatenate(this.prefix, this.write.encrypt(bytes)); + } + + @Override + public byte[] decrypt(byte[] encryptedBytes) { + if (startsWith(encryptedBytes, this.prefix)) { + byte[] bytes = EncodingUtils.subArray(encryptedBytes, this.prefix.length, encryptedBytes.length); + return this.write.decrypt(bytes); + } + return this.deprecated.decrypt(encryptedBytes); + } + + private static boolean startsWith(byte[] data, byte[] prefix) { + if (data.length < prefix.length) { + return false; + } + for (int i = 0; i < prefix.length; i++) { + if (data[i] != prefix[i]) { + return false; + } + } + return true; + } + + } + +} diff --git a/crypto/src/test/java/org/springframework/security/crypto/encrypt/AesGcmBytesEncryptorTests.java b/crypto/src/test/java/org/springframework/security/crypto/encrypt/AesGcmBytesEncryptorTests.java new file mode 100644 index 0000000000..62f52bdcf1 --- /dev/null +++ b/crypto/src/test/java/org/springframework/security/crypto/encrypt/AesGcmBytesEncryptorTests.java @@ -0,0 +1,146 @@ +/* + * Copyright 2004-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.crypto.encrypt; + +import javax.crypto.SecretKey; +import javax.crypto.spec.PBEKeySpec; + +import org.junit.jupiter.api.Test; + +import org.springframework.security.crypto.codec.Hex; +import org.springframework.security.crypto.keygen.BytesKeyGenerator; +import org.springframework.security.crypto.keygen.KeyGenerators; +import org.springframework.security.crypto.password.Pbkdf2PasswordEncoder.SecretKeyFactoryAlgorithm; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link AesGcmBytesEncryptor}. + */ +class AesGcmBytesEncryptorTests { + + private final String secret = "value"; + + private final String password = "password"; + + private final String hexSalt = "deadbeef"; + + @Test + void roundtripWhenUsingPasswordAndSaltThenEncryptsAndDecrypts() { + CryptoAssumptions.assumeGCMJCE(); + AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withPassword(this.password, this.hexSalt).build(); + byte[] encrypted = encryptor.encrypt(this.secret.getBytes()); + assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret); + } + + @Test + void roundtripWhenUsingSecretKeyThenEncryptsAndDecrypts() { + CryptoAssumptions.assumeGCMJCE(); + PBEKeySpec keySpec = new PBEKeySpec(this.password.toCharArray(), Hex.decode(this.hexSalt), 1024, 256); + SecretKey secretKey = CipherUtils.newSecretKey(SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA1.name(), keySpec); + AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withSecretKey(secretKey).build(); + byte[] encrypted = encryptor.encrypt(this.secret.getBytes()); + assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret); + } + + @Test + void encryptWhenUsingMockIvThenProducesKnownCiphertext() { + CryptoAssumptions.assumeGCMJCE(); + BytesKeyGenerator mockGenerator = mock(BytesKeyGenerator.class); + given(mockGenerator.generateKey()).willReturn(Hex.decode("4b0febebd439db7ca77153cb254520c3")); + given(mockGenerator.getKeyLength()).willReturn(16); + AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withPassword(this.password, this.hexSalt) + .ivGenerator(mockGenerator) + .build(); + byte[] encrypted = encryptor.encrypt(this.secret.getBytes()); + assertThat(Hex.encode(encrypted)) + .isEqualTo("4b0febebd439db7ca77153cb254520c3e4d61ae38207b4e42b820d311dc3d4e0e2f37ed5ee"); + assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret); + } + + @Test + void encryptProducesUniqueOutputAndIvIsPrepended() { + CryptoAssumptions.assumeGCMJCE(); + AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withPassword(this.password, this.hexSalt).build(); + byte[] first = encryptor.encrypt(this.secret.getBytes()); + byte[] second = encryptor.encrypt(this.secret.getBytes()); + assertThat(first).isNotEqualTo(second); + assertThat(first.length).isGreaterThan(32); + } + + @Test + @SuppressWarnings("deprecation") + void withSecretWhenAesBytesEncryptorEncryptsThenDecrypts() { + CryptoAssumptions.assumeGCMJCE(); + PBEKeySpec keySpec = new PBEKeySpec(this.password.toCharArray(), Hex.decode(this.hexSalt), 1024, 256); + SecretKey key = CipherUtils.newSecretKey(SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA1.name(), keySpec); + AesBytesEncryptor deprecated = new AesBytesEncryptor(key, KeyGenerators.secureRandom(16), + AesBytesEncryptor.CipherAlgorithm.GCM); + AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withSecretKey(key).build(); + byte[] encrypted = deprecated.encrypt(this.secret.getBytes()); + assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret); + } + + @Test + @SuppressWarnings("deprecation") + void aesBytesEncryptorWhenEncryptsThenAesGcmBytesEncryptorDecrypts() { + CryptoAssumptions.assumeGCMJCE(); + PBEKeySpec keySpec = new PBEKeySpec(this.password.toCharArray(), Hex.decode(this.hexSalt), 1024, 256); + SecretKey key = CipherUtils.newSecretKey(SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA1.name(), keySpec); + AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withSecretKey(key) + .ivGenerator(KeyGenerators.secureRandom(12)) + .build(); + AesBytesEncryptor deprecated = new AesBytesEncryptor(key, KeyGenerators.secureRandom(12), + AesBytesEncryptor.CipherAlgorithm.GCM); + byte[] encrypted = encryptor.encrypt(this.secret.getBytes()); + assertThat(new String(deprecated.decrypt(encrypted))).isEqualTo(this.secret); + } + + @Test + void roundtripWhenUsingCustomIvGeneratorLengthThenEncryptsAndDecrypts() { + CryptoAssumptions.assumeGCMJCE(); + AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withPassword(this.password, this.hexSalt) + .ivGenerator(KeyGenerators.secureRandom(12)) + .build(); + byte[] encrypted = encryptor.encrypt(this.secret.getBytes()); + assertThat(new String(encryptor.decrypt(encrypted))).isEqualTo(this.secret); + } + + @Test + @SuppressWarnings("deprecation") + void withPasswordDerivesADifferentKeyThanAesBytesEncryptor() { + CryptoAssumptions.assumeGCMJCE(); + AesBytesEncryptor deprecated = new AesBytesEncryptor(this.password, this.hexSalt, + KeyGenerators.secureRandom(16), AesBytesEncryptor.CipherAlgorithm.GCM); + AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withPassword(this.password, this.hexSalt).build(); + byte[] encrypted = deprecated.encrypt(this.secret.getBytes()); + assertThatIllegalStateException().isThrownBy(() -> encryptor.decrypt(encrypted)); + } + + @Test + void decryptDetectsAuthenticationTagTampering() { + CryptoAssumptions.assumeGCMJCE(); + AesGcmBytesEncryptor encryptor = AesGcmBytesEncryptor.withPassword(this.password, this.hexSalt).build(); + byte[] encrypted = encryptor.encrypt(this.secret.getBytes()); + encrypted[17] ^= 0xFF; + assertThatIllegalStateException().isThrownBy(() -> encryptor.decrypt(encrypted)); + } + +} diff --git a/crypto/src/test/java/org/springframework/security/crypto/encrypt/CryptoAssumptions.java b/crypto/src/test/java/org/springframework/security/crypto/encrypt/CryptoAssumptions.java index e9653769e7..cc72a9b830 100644 --- a/crypto/src/test/java/org/springframework/security/crypto/encrypt/CryptoAssumptions.java +++ b/crypto/src/test/java/org/springframework/security/crypto/encrypt/CryptoAssumptions.java @@ -24,25 +24,23 @@ import javax.crypto.NoSuchPaddingException; import org.junit.jupiter.api.Assumptions; import org.opentest4j.TestAbortedException; -import org.springframework.security.crypto.encrypt.AesBytesEncryptor.CipherAlgorithm; - public final class CryptoAssumptions { private CryptoAssumptions() { } public static void assumeGCMJCE() { - assumeAes256(CipherAlgorithm.GCM); + assumeAes256("GCM"); } public static void assumeCBCJCE() { - assumeAes256(CipherAlgorithm.CBC); + assumeAes256("CBC"); } - private static void assumeAes256(CipherAlgorithm cipherAlgorithm) { + private static void assumeAes256(String cipherAlgorithm) { boolean aes256Available = false; try { - Cipher.getInstance(cipherAlgorithm.toString()); + Cipher.getInstance(cipherAlgorithm); aes256Available = Cipher.getMaxAllowedKeyLength("AES") >= 256; } catch (NoSuchAlgorithmException ex) { diff --git a/docs/modules/ROOT/pages/features/integrations/cryptography.adoc b/docs/modules/ROOT/pages/features/integrations/cryptography.adoc index 01c71c36f0..62563cb3d0 100644 --- a/docs/modules/ROOT/pages/features/integrations/cryptography.adoc +++ b/docs/modules/ROOT/pages/features/integrations/cryptography.adoc @@ -8,8 +8,8 @@ The code is distributed as part of the core module but has no dependencies on an [[spring-security-crypto-encryption]] == Encryptors -The javadoc:org.springframework.security.crypto.encrypt.Encryptors[] class provides factory methods for constructing symmetric encryptors. -This class lets you create javadoc:org.springframework.security.crypto.encrypt.BytesEncryptor[] instances to encrypt data in raw `byte[]` form. +Spring Security provides javadoc:org.springframework.security.crypto.encrypt.AesGcmBytesEncryptor[] and javadoc:org.springframework.security.crypto.encrypt.AesCbcBytesEncryptor[] for constructing symmetric encryptors. +These can be used to encrypt data in raw `byte[]` form. You can also construct javadoc:org.springframework.security.crypto.encrypt.TextEncryptor[] instances to encrypt text strings. Encryptors are thread-safe. @@ -20,7 +20,7 @@ Both `BytesEncryptor` and `TextEncryptor` are interfaces. `BytesEncryptor` has m [[spring-security-crypto-encryption-bytes]] === BytesEncryptor -You can use the `Encryptors.stronger` factory method to construct a `BytesEncryptor`: +Use `AesGcmBytesEncryptor` to construct a BytesEncryptor with authenticated encryption: .BytesEncryptor [tabs] @@ -29,24 +29,22 @@ Java:: + [source,java,role="primary"] ---- -Encryptors.stronger("password", "salt"); +AesGcmBytesEncryptor.withPassword("password", "salt").build(); ---- Kotlin:: + [source,kotlin,role="secondary"] ---- -Encryptors.stronger("password", "salt") +AesGcmBytesEncryptor.withPassword("password", "salt").build() ---- ====== -The `stronger` encryption method creates an encryptor by using 256-bit AES encryption with -Galois Counter Mode (GCM). -It derives the secret key by using PKCS #5's PBKDF2 (Password-Based Key Derivation Function #2). -This method requires Java 6. -The password used to generate the `SecretKey` should be kept in a secure place and should not be shared. -The salt is used to prevent dictionary attacks against the key in the event that your encrypted data is compromised. -A 16-byte random initialization vector is also applied so that each encrypted message is unique. +`AesGcmBytesEncryptor` uses 256-bit AES encryption with Galois Counter Mode (GCM), providing https://en.wikipedia.org/wiki/Authenticated_encryption[authenticated encryption] (AEAD). +It derives the secret key using PKCS #5's PBKDF2 (Password-Based Key Derivation Function #2). +The password used to generate the SecretKey should be kept in a secure place and not be shared. +The salt is used to prevent dictionary attacks against the key in the event your encrypted data is compromised. +A 16-byte random initialization vector is also applied so each encrypted message is unique. The provided salt should be in hex-encoded String form, be random, and be at least 8 bytes in length. You can generate such a salt by using a `KeyGenerator`: @@ -69,14 +67,33 @@ val salt = KeyGenerators.string().generateKey() // generates a random 8-byte sal ---- ====== -You can also use the `standard` encryption method, which is 256-bit AES in Cipher Block Chaining (CBC) Mode. +Users who require CBC mode may use `AesCbcBytesEncryptor`: + +.AesCbcBytesEncryptor +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +AesCbcBytesEncryptor.withPassword("password", "salt").build(); +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +AesCbcBytesEncryptor.withPassword("password", "salt").build() +---- +====== + This mode is not https://en.wikipedia.org/wiki/Authenticated_encryption[authenticated] and does not provide any guarantees about the authenticity of the data. -For a more secure alternative, use `Encryptors.stronger`. +For a more secure alternative, users should prefer `AesGcmBytesEncryptor`. [[spring-security-crypto-encryption-text]] === TextEncryptor -You can use the `Encryptors.text` factory method to construct a standard TextEncryptor: +Use `AesCbcBytesEncryptor` to encrypt text data: .TextEncryptor [tabs] @@ -85,19 +102,22 @@ Java:: + [source,java,role="primary"] ---- -Encryptors.text("password", "salt"); +AesCbcBytesEncryptor.withPassword("password", "salt").build(); ---- Kotlin:: + [source,kotlin,role="secondary"] ---- -Encryptors.text("password", "salt") +AesCbcBytesEncryptor.withPassword("password", "salt").build() ---- ====== -A `TextEncryptor` uses a standard `BytesEncryptor` to encrypt text data. -Encrypted results are returned as hex-encoded strings for easy storage on the filesystem or in a database. +`AesCbcBytesEncryptor` encrypts data as raw bytes. +You can hex-encode the result for easy storage on the filesystem or in the database. + +NOTE: Queryable text encryption (encrypting such that the same plaintext always produces the same ciphertext) is no longer recommended, as it relies on a fixed initialization vector and does not provide adequate security. +Instead, look to your data store for a mechanism to query encrypted data. [[spring-security-crypto-keygenerators]] == Key Generators