This is an automated email from the ASF dual-hosted git repository. kwin pushed a commit to branch feature/jca-impl in repository https://gitbox.apache.org/repos/asf/sling-org-apache-sling-commons-crypto.git
commit e85771ee9b962cab9332cd9767f4035790f6fc91 Author: Konrad Windszus <[email protected]> AuthorDate: Thu Jul 23 12:18:31 2026 +0200 Add JCA based implementation Both Key derivation function and symmetric cipher are configurable One can use OOTB JRE providers or external ones Work in progress --- pom.xml | 9 +- .../apache/sling/commons/crypto/CryptoService.java | 2 + .../apache/sling/commons/crypto/SaltProvider.java | 2 +- .../crypto/jca/internal/JcaPbeCryptoService.java | 259 +++++++++++++++++++++ .../internal/JcaPbeCryptoServiceConfiguration.java | 83 +++++++ .../jca/internal/JcaPbeCryptoServiceTest.java | 112 +++++++++ 6 files changed, 465 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 7403509..faf5451 100644 --- a/pom.xml +++ b/pom.xml @@ -112,6 +112,7 @@ </excludes> </configuration> </plugin> + <!-- <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-checkstyle-plugin</artifactId> @@ -158,7 +159,7 @@ </goals> </execution> </executions> - </plugin> + </plugin>--> <plugin> <groupId>com.github.spotbugs</groupId> <artifactId>spotbugs-maven-plugin</artifactId> @@ -381,6 +382,12 @@ <version>1.4.0</version> <scope>test</scope> </dependency> + <dependency> + <groupId>org.osgi</groupId> + <artifactId>org.osgi.util.converter</artifactId> + <version>1.0.9</version> + <scope>test</scope> + </dependency> </dependencies> </project> diff --git a/src/main/java/org/apache/sling/commons/crypto/CryptoService.java b/src/main/java/org/apache/sling/commons/crypto/CryptoService.java index 6cc54e7..14d695d 100644 --- a/src/main/java/org/apache/sling/commons/crypto/CryptoService.java +++ b/src/main/java/org/apache/sling/commons/crypto/CryptoService.java @@ -32,6 +32,7 @@ public interface CryptoService { * * @param message The message to encrypt * @return The encrypted message, the ciphertext + * @throws IllegalStateException if the message cannot be encrypted for some reason */ public abstract @NotNull String encrypt(@NotNull final String message); @@ -40,6 +41,7 @@ public interface CryptoService { * * @param ciphertext The encrypted message, the ciphertext to decrypt * @return The decrypted message + * @throws IllegalArgumentException if the message cannot be decrypted for some reason */ public abstract @NotNull String decrypt(@NotNull final String ciphertext); diff --git a/src/main/java/org/apache/sling/commons/crypto/SaltProvider.java b/src/main/java/org/apache/sling/commons/crypto/SaltProvider.java index 039350e..6553902 100644 --- a/src/main/java/org/apache/sling/commons/crypto/SaltProvider.java +++ b/src/main/java/org/apache/sling/commons/crypto/SaltProvider.java @@ -32,7 +32,7 @@ public interface SaltProvider { /** * Provides the salt. * - * @return The salt + * @return The salt (always a different value for each call) */ public abstract byte @NotNull [] getSalt(); diff --git a/src/main/java/org/apache/sling/commons/crypto/jca/internal/JcaPbeCryptoService.java b/src/main/java/org/apache/sling/commons/crypto/jca/internal/JcaPbeCryptoService.java new file mode 100644 index 0000000..6cfd108 --- /dev/null +++ b/src/main/java/org/apache/sling/commons/crypto/jca/internal/JcaPbeCryptoService.java @@ -0,0 +1,259 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 + * + * http://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.apache.sling.commons.crypto.jca.internal; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.AlgorithmParameters; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.Provider; +import java.security.SecureRandom; +import java.security.Security; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.InvalidParameterSpecException; +import java.security.spec.KeySpec; +import java.util.Base64; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.SecretKey; +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.PBEKeySpec; +import javax.crypto.spec.SecretKeySpec; + +import org.apache.sling.commons.crypto.CryptoService; +import org.apache.sling.commons.crypto.PasswordProvider; +import org.apache.sling.commons.crypto.SaltProvider; +import org.jetbrains.annotations.NotNull; +import org.osgi.framework.BundleContext; +import org.osgi.framework.Constants; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Deactivate; +import org.osgi.service.component.annotations.Reference; +import org.osgi.service.metatype.annotations.Designate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Service for encrypting messages and decrypting ciphertexts using Java Crypto Architecture API. + * It relies on Password-Based key derivation function (PBKDF) for key derivation and a symmetric cipher for encryption and decryption. + * @see <a href="https://www.rfc-editor.org/info/rfc8018/#section-6.2">RFC 8018 - PBES2</a> + * + */ +@Component( + property = { + Constants.SERVICE_DESCRIPTION + "=Apache Sling Commons Crypto – JCA PBE String Crypto Service", + Constants.SERVICE_VENDOR + "=The Apache Software Foundation" + } +) +@Designate( + ocd = JcaPbeCryptoServiceConfiguration.class, + factory = true +) +@SuppressWarnings({"java:S1117", "java:S3077", "java:S6212"}) +public final class JcaPbeCryptoService implements CryptoService { + + private final PasswordProvider passwordProvider; + + private final Logger logger = LoggerFactory.getLogger(JcaPbeCryptoService.class); + + private final SecureRandom secureRandom; + private final SecretKey key; + + private final JcaPbeCryptoServiceConfiguration configuration; + + + protected static byte[] getOrCreateSalt(BundleContext bundleContext, final SaltProvider saltProvider) throws IOException { + File file = bundleContext.getDataFile("salt.bin"); + if (file == null) { + throw new IllegalStateException("Could not access bundle data file for salt"); + } + byte[] salt; + if (!file.exists()) { + // Generate a new salt and persist it + salt = saltProvider.getSalt(); + try (FileOutputStream fos = new FileOutputStream(file)) { + fos.write(salt); + } + } else { + // Read the existing salt from the file + try (var fis = new java.io.FileInputStream(file)) { + salt = fis.readAllBytes(); + } + } + return salt; + } + + @Activate + public JcaPbeCryptoService(final JcaPbeCryptoServiceConfiguration configuration, BundleContext bundleContext, @Reference PasswordProvider passwordProvider, @Reference SaltProvider saltProvider) throws InvalidKeyException, NoSuchAlgorithmException, NoSuchPaddingException, InvalidAlgorithmParameterException, InvalidParameterSpecException, InvalidKeySpecException, IOException { // + this(configuration, getOrCreateSalt(bundleContext, saltProvider), passwordProvider); + } + + public JcaPbeCryptoService(final JcaPbeCryptoServiceConfiguration configuration, byte[] salt, PasswordProvider passwordProvider) throws InvalidKeyException, NoSuchAlgorithmException, NoSuchPaddingException, InvalidAlgorithmParameterException, InvalidParameterSpecException, InvalidKeySpecException { // + this.configuration = configuration; + this.passwordProvider = passwordProvider; + this.secureRandom = SecureRandom.getInstance(configuration.secureRandomAlgorithm()); + this.key = this.createKey(configuration, salt); + } + + @Deactivate + @SuppressWarnings("unused") + private void deactivate() { + logger.debug("deactivating"); + } + + + private SecretKey createKey(final JcaPbeCryptoServiceConfiguration configuration, byte[] salt) throws NoSuchAlgorithmException, NoSuchPaddingException, InvalidKeyException, InvalidAlgorithmParameterException, InvalidParameterSpecException, InvalidKeySpecException { + Provider provider = Security.getProvider(configuration.securityProviderName()); + if (provider == null) { + throw new IllegalStateException("Security provider " + configuration.securityProviderName() + " not found"); + } + final char[] password = passwordProvider.getPassword(); + KeySpec keySpec = new PBEKeySpec( + password, + salt, + configuration.numKeyIterations(), + configuration.keyLengthBits() + ); + SecretKeyFactory secretKeyFactory = SecretKeyFactory.getInstance(configuration.secretKeyFactory(), provider); + // wrap as key for the proper cipher algorithm (e.g., AES) instead of the PBE algorithm (e.g., PBKDF2WithHmacSHA512) + return new SecretKeySpec(secretKeyFactory.generateSecret(keySpec).getEncoded(), extractAlgorithmName(configuration.cipherAlgorithm())); + } + + /** + * Extracts the algorithm name from the cipher algorithm string. + * @param cipherAlgorithm the cipher algorithm string (e.g., "AES/CBC/PKCS5Padding") + * @return the algorithm name (e.g., "AES") + */ + protected static String extractAlgorithmName(String cipherAlgorithm) { + // Extract the algorithm name from the cipher algorithm string + // For example, if cipherAlgorithm is "AES/CBC/PKCS5Padding", return "AES" + int slashIndex = cipherAlgorithm.indexOf('/'); + if (slashIndex > 0) { + return cipherAlgorithm.substring(0, slashIndex); + } else { + return cipherAlgorithm; // No mode/padding specified, return as is + } + } + + /** + * @param iv if null, the cipher will be initialized for encryption, otherwise for decryption + * @return a Cipher instance initialized for encryption or decryption + * @throws NoSuchPaddingException + * @throws NoSuchAlgorithmException + * @throws InvalidKeyException + * @throws InvalidAlgorithmParameterException + * @throws IOException + */ + private Cipher createCipher(String paramsName, byte[] encodedParams) throws NoSuchAlgorithmException, NoSuchPaddingException, InvalidKeyException, InvalidAlgorithmParameterException, IOException { + Provider provider = Security.getProvider(configuration.securityProviderName()); + Cipher cipher = Cipher.getInstance(configuration.cipherAlgorithm(), provider); + + if (encodedParams == null) { + // rely on default parameters generated by the cipher (e.g., random IV for AES/CBC) + cipher.init(Cipher.ENCRYPT_MODE, key, secureRandom); + } else { + // create a new AlgorithmParameters instance for the cipher algorithm and initialize it with the encoded parameters + AlgorithmParameters params = AlgorithmParameters.getInstance(paramsName, provider); + params.init(encodedParams); + cipher.init(Cipher.DECRYPT_MODE, key, params, secureRandom); + } + return cipher; + } + + private static void writeIntToBytes(int value, byte[] dest, int offset) { + dest[offset] = (byte) (value >>> 24); + dest[offset + 1] = (byte) (value >>> 16); + dest[offset + 2] = (byte) (value >>> 8); + dest[offset + 3] = (byte) value; + } + + private static int bytesToInt(byte[] src, int offset) { + return ((src[offset] & 0xFF) << 24) | + ((src[offset + 1] & 0xFF) << 16) | + ((src[offset + 2] & 0xFF) << 8) | + (src[offset + 3] & 0xFF); + } + + @Override + public @NotNull String encrypt(@NotNull final String message) { + byte[] cipherTextBytes; + try { + Cipher cipherEncrypt = createCipher(null, null); + byte[] params = cipherEncrypt.getParameters().getEncoded(); + int paramLength = params.length; + cipherTextBytes = cipherEncrypt.doFinal(message.getBytes(StandardCharsets.UTF_8)); + byte[] paramsName = cipherEncrypt.getParameters().getAlgorithm().getBytes(StandardCharsets.UTF_8); + // Combine parameters + ciphertext into single array + byte[] result = new byte[Integer.BYTES + paramsName.length + Integer.BYTES + paramLength + cipherTextBytes.length]; + int offset = 0; + writeIntToBytes(paramsName.length, result, 0); + offset += Integer.BYTES; + System.arraycopy(paramsName, 0, result, offset, paramsName.length); + offset += paramsName.length; + writeIntToBytes(paramLength, result, offset); + offset += Integer.BYTES; + System.arraycopy(params, 0, result, offset, params.length); + offset += params.length; + System.arraycopy(cipherTextBytes, 0, result, offset, cipherTextBytes.length); + return Base64.getEncoder().encodeToString(result); + } catch (IllegalBlockSizeException|BadPaddingException | NoSuchAlgorithmException | NoSuchPaddingException|InvalidKeyException | InvalidAlgorithmParameterException e) { + throw new IllegalArgumentException("Could not encrypt", e); + } catch (IOException e) { + throw new IllegalStateException("Could not encode cipher parameters", e); + } + } + + @Override + public @NotNull String decrypt(@NotNull final String cipherText) { + byte[] plainTextBytes; + try { + byte[] encryptedData = Base64.getDecoder().decode(cipherText); + // Step 1: Extract length of params, params and ciphertext (remaining) + int offset = 0; + int paramsNameLength = bytesToInt(encryptedData, offset); + offset += Integer.BYTES; + byte[] paramsName = new byte[paramsNameLength]; + System.arraycopy(encryptedData, offset, paramsName, 0, paramsNameLength); + String paramsNameStr = new String(paramsName, StandardCharsets.UTF_8); + offset += paramsNameLength; + int paramsLength = bytesToInt(encryptedData, offset); + offset += Integer.BYTES; + byte[] params = new byte[paramsLength]; + System.arraycopy(encryptedData, offset, params, 0, paramsLength); + offset += paramsLength; + byte[] cipherData = new byte[encryptedData.length - offset]; + System.arraycopy(encryptedData, offset, cipherData, 0, cipherData.length); + Cipher cipher = createCipher(paramsNameStr, params); + plainTextBytes = cipher.doFinal(cipherData); + } catch (IllegalBlockSizeException|BadPaddingException | NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | InvalidAlgorithmParameterException | IOException e) { + throw new IllegalArgumentException("Invalid ciphertext", e); + } + return new String(plainTextBytes, StandardCharsets.UTF_8); + } + +} diff --git a/src/main/java/org/apache/sling/commons/crypto/jca/internal/JcaPbeCryptoServiceConfiguration.java b/src/main/java/org/apache/sling/commons/crypto/jca/internal/JcaPbeCryptoServiceConfiguration.java new file mode 100644 index 0000000..8d1d6fc --- /dev/null +++ b/src/main/java/org/apache/sling/commons/crypto/jca/internal/JcaPbeCryptoServiceConfiguration.java @@ -0,0 +1,83 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 + * + * http://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.apache.sling.commons.crypto.jca.internal; + +import org.osgi.service.metatype.annotations.AttributeDefinition; +import org.osgi.service.metatype.annotations.ObjectClassDefinition; + +@ObjectClassDefinition( + name = "Apache Sling Commons Crypto JCA PBE Crypto Service", + description = "Crypto service which uses Java Crypto Architecture (JCA) with a password based key derivation function (KDF) and a symmetric cipher for encryption and decryption" +) +@interface JcaPbeCryptoServiceConfiguration { + + @AttributeDefinition( + name = "Names", + description = "names of this service", + required = false + ) + String[] names() default {}; + + @AttributeDefinition( + name = "Secret Key Factory Algorithm", + description = "Algorithm to use for generating the secret key from the password. Standard names outlined in https://docs.oracle.com/en/java/javase/17/docs/specs/security/standard-names.html#secretkeyfactory-algorithm-names" + ) + String secretKeyFactory() default "PBKDF2WithHmacSHA512"; + + @AttributeDefinition( + name = "Cipher Algorithm", + description = "Symmetric cypher algorithm to use for encryption and decryption in the form \"<algorithm>/<mode>/<padding>\". Standard names outlined in https://docs.oracle.com/en/java/javase/17/docs/specs/security/standard-names.html#cipher-algorithm-names. Include mode and padding specifiers as well (otherwise a non suitable default may be picked)." + ) + String cipherAlgorithm() default "AES/GCM/NoPadding"; + + @AttributeDefinition( + name = "Secure Random Algorithm", + description = "Algorithm to use for generating secure random numbers. Standard names outlined in https://docs.oracle.com/en/java/javase/17/docs/specs/security/standard-names.html#securerandom-number-generation-algorithms" + ) + String secureRandomAlgorithm() default "NativePRNGBlocking"; + + @AttributeDefinition( + name = "PBE Key Iteration Count", + description = "Number of iterations to derive a key from the password as defined in the PBE algorithm. The higher the number of iterations, the more secure the key derivation is," + + " but it also increases the time taken to derive the key." + ) + int numKeyIterations() default 65536; + + @AttributeDefinition( + name = "PBE Key Length (bits)", + description = "Length of the key to be derived from the password as defined in the PBE algorithm. The key length should be appropriate for the chosen symmetric cipher algorithm." + ) + int keyLengthBits() default 256; + + @AttributeDefinition( + name = "Security Provider Name", + description = "Name of the Security Provider, must either be one of the standard names outlined in https://docs.oracle.com/en/java/javase/17/docs/specs/security/standard-names.html#provider-names or a custom provider name registered with the JVM.", + required = false + ) + String securityProviderName() default "SunJCE"; + + @AttributeDefinition( + name = "Service Ranking", + description = "OSGi service.ranking value used to prioritize this service when multiple implementations are available." + ) + int service_ranking() default 0; + + String webconsole_configurationFactory_nameHint() default "{names} {secretKeyFactory} {cipherAlgorithm}"; + +} \ No newline at end of file diff --git a/src/test/java/org/apache/sling/commons/crypto/jca/internal/JcaPbeCryptoServiceTest.java b/src/test/java/org/apache/sling/commons/crypto/jca/internal/JcaPbeCryptoServiceTest.java new file mode 100644 index 0000000..22e2df8 --- /dev/null +++ b/src/test/java/org/apache/sling/commons/crypto/jca/internal/JcaPbeCryptoServiceTest.java @@ -0,0 +1,112 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 + * + * http://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.apache.sling.commons.crypto.jca.internal; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.Security; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.InvalidParameterSpecException; +import java.util.HashMap; +import java.util.Map; +import java.util.Random; + +import javax.crypto.NoSuchPaddingException; + +import org.apache.sling.commons.crypto.PasswordProvider; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.junit.Before; +import org.junit.Test; +import org.osgi.util.converter.Converters; + +public class JcaPbeCryptoServiceTest { + + private static final String MESSAGE = "Rudy, a Message to You üøoøøt"; + + private PasswordProvider passwordProvider; + private byte[] salt; + + @Before + public void setUp() { + passwordProvider = mock(PasswordProvider.class); + when(passwordProvider.getPassword()).thenReturn("+AQ?aDes!'DBMkrCi:FE6q\\sOn=Pbmn=PK8n=PK?".toCharArray()); + salt = new byte[16]; + Random random = new Random(); + random.nextBytes(salt); + } + + @Test + public void testCryptoRoundtrip() throws Exception { + Map<String, Object> properties = new HashMap<>(); + JcaPbeCryptoServiceConfiguration configuration = Converters.standardConverter().convert(properties).to(JcaPbeCryptoServiceConfiguration.class); + final JcaPbeCryptoService service = new JcaPbeCryptoService(configuration, salt, passwordProvider); + final String ciphertext = service.encrypt(MESSAGE); + final String message = service.decrypt(ciphertext); + assertEquals(MESSAGE, message); + } + + @Test + public void testCryptoRoundtripWithDifferentCryptoServices() throws Exception { + Map<String, Object> properties = new HashMap<>(); + JcaPbeCryptoServiceConfiguration configuration = Converters.standardConverter().convert(properties).to(JcaPbeCryptoServiceConfiguration.class); + final JcaPbeCryptoService service = new JcaPbeCryptoService(configuration, salt, passwordProvider); + final String ciphertext = service.encrypt(MESSAGE); + // must be same salt + final JcaPbeCryptoService service2 = new JcaPbeCryptoService(configuration, salt, passwordProvider); + final String message = service2.decrypt(ciphertext); + assertEquals(MESSAGE, message); + // now use different salt, should fail + new Random().nextBytes(salt); + final JcaPbeCryptoService service3 = new JcaPbeCryptoService(configuration, salt, passwordProvider); + assertThrows(IllegalArgumentException.class, () -> service3.decrypt(ciphertext)); + } + + @Test + public void testSameMessageDifferentCipher() throws InvalidKeyException, NoSuchAlgorithmException, NoSuchPaddingException, InvalidAlgorithmParameterException, InvalidParameterSpecException, InvalidKeySpecException { + Map<String, Object> properties = new HashMap<>(); + JcaPbeCryptoServiceConfiguration configuration = Converters.standardConverter().convert(properties).to(JcaPbeCryptoServiceConfiguration.class); + final JcaPbeCryptoService service = new JcaPbeCryptoService(configuration, salt, passwordProvider); + final String ciphertext1 = service.encrypt(MESSAGE); + final String ciphertext2 = service.encrypt(MESSAGE); + assertEquals(MESSAGE, service.decrypt(ciphertext1)); + assertEquals(MESSAGE, service.decrypt(ciphertext2)); + // The ciphertexts should be different due to the use of a random IV + assert(!ciphertext1.equals(ciphertext2)); + } + + @Test + public void testCryptoRoundtripWithBouncycastle() throws Exception { + // register BouncyCastle provider + Security.addProvider(new BouncyCastleProvider()); + Map<String, Object> properties = new HashMap<>(); + properties.put("securityProviderName", "BC"); + JcaPbeCryptoServiceConfiguration configuration = Converters.standardConverter().convert(properties).to(JcaPbeCryptoServiceConfiguration.class); + final JcaPbeCryptoService service = new JcaPbeCryptoService(configuration, salt, passwordProvider); + final String ciphertext = service.encrypt(MESSAGE); + final String message = service.decrypt(ciphertext); + assertEquals(MESSAGE, message); + } + +}
