From 3b20faae34218cdbb72008a3695f748b34cdff4a Mon Sep 17 00:00:00 2001
From: Maxim Thomas <maxim.thomas@gmail.com>
Date: Tue, 20 Oct 2026 06:45:08 +0000
Subject: [PATCH] [#1056] Add the RSASVE/KDF-SHA256/AESKWP key wrapping transformation for runtimes without RSA-OAEP (#1194)
---
opendj-server-legacy/src/test/java/org/opends/server/crypto/CryptoManagerTestCase.java | 256 ++++++++++++++++++++++++++++++++++++++++++++++++---
1 files changed, 240 insertions(+), 16 deletions(-)
diff --git a/opendj-server-legacy/src/test/java/org/opends/server/crypto/CryptoManagerTestCase.java b/opendj-server-legacy/src/test/java/org/opends/server/crypto/CryptoManagerTestCase.java
index 7f5bd60..aa2254e 100644
--- a/opendj-server-legacy/src/test/java/org/opends/server/crypto/CryptoManagerTestCase.java
+++ b/opendj-server-legacy/src/test/java/org/opends/server/crypto/CryptoManagerTestCase.java
@@ -37,18 +37,26 @@
import java.io.InputStream;
import java.io.OutputStream;
import java.lang.reflect.Proxy;
+import java.math.BigInteger;
+import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.AlgorithmParameters;
+import java.security.InvalidKeyException;
import java.security.Key;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
import java.security.MessageDigest;
import java.security.Provider;
import java.security.SecureRandom;
import java.security.Security;
import java.security.spec.AlgorithmParameterSpec;
import java.security.cert.X509Certificate;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Arrays;
+import java.util.Date;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
@@ -66,15 +74,21 @@
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
+import javax.crypto.Cipher;
import javax.crypto.CipherSpi;
import javax.crypto.KeyGeneratorSpi;
import javax.crypto.Mac;
import javax.crypto.SecretKey;
+import javax.crypto.spec.IvParameterSpec;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
+import org.bouncycastle.asn1.x500.X500Name;
+import org.bouncycastle.asn1.x509.Extension;
+import org.bouncycastle.asn1.x509.KeyUsage;
+import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.crypto.CryptoServicesRegistrar;
-import org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider;
+import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.forgerock.i18n.LocalizableMessage;
import org.forgerock.opendj.config.server.ConfigChangeResult;
import org.forgerock.opendj.ldap.Attribute;
@@ -879,6 +893,223 @@
}, sunWithoutMd5);
}
+ @DataProvider
+ public Object[][] rsaSveSpellings()
+ {
+ return new Object[][] { { RsaSveKeyTransport.NAME }, { RsaSveKeyTransport.NAME.toLowerCase() } };
+ }
+
+ /**
+ The RSASVE scheme is accepted where the runtime offers its algorithms, whatever the case of its
+ name, as a Java transformation would be: neither the support check nor the trial wrap of the
+ check asks the runtime for a cipher of that name, which no provider offers.
+ */
+ @Test(dataProvider = "rsaSveSpellings")
+ public void testRsaSveKeyWrappingIsAccepted(final String spelling) throws Exception
+ {
+ final CryptoManagerImpl cm = DirectoryServer.getCryptoManager();
+ final CryptoManagerCfg cfg = getServerContext().getRootConfig().getCryptoManager();
+ final List<LocalizableMessage> why = new ArrayList<>();
+
+ final boolean acceptable = withBcFipsFirst(() -> cm.isConfigurationChangeAcceptable(
+ withProperty(cfg, "getKeyWrappingTransformation", spelling), why));
+
+ assertThat(why).isEmpty();
+ assertThat(acceptable).isTrue();
+ }
+
+ /**
+ The RSASVE scheme is refused where the runtime lacks one of its algorithms, here AES-KWP as on
+ Java 11 without Bouncy Castle FIPS, and the refusal names the property to set and the missing
+ algorithm: the trial wrap which follows the support check would refuse it too, but without
+ naming the property.
+ */
+ @Test
+ public void testRsaSveKeyWrappingWithoutAesKwpIsRefusedNamingTheProperty() throws Exception
+ {
+ final CryptoManagerImpl cm = DirectoryServer.getCryptoManager();
+ final CryptoManagerCfg cfg = getServerContext().getRootConfig().getCryptoManager();
+ final List<LocalizableMessage> why = new ArrayList<>();
+
+ withoutKeyWrap(() ->
+ {
+ // SunJCE offers AES-KWP under that name too, and goes with AES/KWP/NoPadding.
+ assertThat(Security.getProviders("Cipher.AESWrapPad")).as("providers offering AESWrapPad").isNull();
+ assertThat(cm.isConfigurationChangeAcceptable(
+ withProperty(cfg, "getKeyWrappingTransformation", RsaSveKeyTransport.NAME), why)).isFalse();
+ return null;
+ });
+
+ assertThat(why).hasSize(1);
+ assertThat(why.get(0).ordinal()).isEqualTo(ERR_CRYPTOMGR_KEY_WRAPPING_TRANSFORMATION_UNSUPPORTED.ordinal());
+ assertThat(why.get(0).toString())
+ .contains(RsaSveKeyTransport.NAME).contains("key-wrapping-transformation").contains("AES/KWP/NoPadding");
+ }
+
+ /**
+ A key generated while the RSASVE scheme is configured is published wrapped with it, under its
+ name, and unwraps to the key the data was encrypted with.
+ */
+ @Test
+ public void testRsaSveWrappedKeyIsPublishedAndUnwraps() throws Exception
+ {
+ final CryptoManagerImpl cm = DirectoryServer.getCryptoManager();
+ final CryptoManagerCfg cfg = getServerContext().getRootConfig().getCryptoManager();
+ final CryptoManagerCfg rsaSve = withProperty(cfg, "getKeyWrappingTransformation", RsaSveKeyTransport.NAME);
+ // No other test generates a key for this cipher and key length: the key is generated here.
+ final String transformation = "AES/CBC/PKCS5Padding";
+ final int keyLengthBits = 192;
+ final byte[] data = "RSASVE key transport".getBytes(StandardCharsets.UTF_8);
+ final String instanceKeyID = cm.getInstanceKeyID();
+ // A key of the default cipher, wrapped with the default transformation, RSA-OAEP.
+ cm.encrypt(data);
+ final String oaepValue =
+ getValueWrappedFor(instanceKeyID, cfg.getCipherTransformation(), cfg.getCipherKeyLength());
+ assertThat(oaepValue.split(":")[1]).isEqualTo(cfg.getKeyWrappingTransformation());
+ final byte[] oaepKey = cm.decodeSymmetricKeyAttribute(oaepValue).getEncoded();
+
+ withBcFipsFirst(() ->
+ {
+ final List<LocalizableMessage> why = new ArrayList<>();
+ assertThat(cm.isConfigurationChangeAcceptable(rsaSve, why)).as("%s", why).isTrue();
+ cm.applyConfigurationChange(rsaSve);
+ final String value;
+ final SecretKey key;
+ try
+ {
+ final byte[] cipherText = cm.encrypt(transformation, keyLengthBits, data);
+
+ value = getValueWrappedFor(instanceKeyID, transformation, keyLengthBits);
+ assertThat(value.split(":")[1]).isEqualTo(RsaSveKeyTransport.NAME);
+
+ key = cm.decodeSymmetricKeyAttribute(value);
+ // The cipher text is the prologue version, the 16-byte key identifier, the IV, then the data.
+ final int ivOffset = 1 + 16;
+ final int ivLength = 16;
+ final Cipher cipher = Cipher.getInstance(transformation);
+ cipher.init(Cipher.DECRYPT_MODE, key, new IvParameterSpec(cipherText, ivOffset, ivLength));
+ assertThat(cipher.doFinal(cipherText, ivOffset + ivLength, cipherText.length - ivOffset - ivLength))
+ .isEqualTo(data);
+
+ assertThat(cm.decodeSymmetricKeyAttribute(oaepValue).getEncoded())
+ .as("RSA-OAEP value while the RSASVE scheme is configured").isEqualTo(oaepKey);
+ }
+ finally
+ {
+ cm.applyConfigurationChange(cfg);
+ // The admin data backend keeps the key across restarts, where a runtime without AES-KWP,
+ // such as Java 11 without Bouncy Castle FIPS, cannot unwrap it and logs an error for it.
+ for (final Entry entry : getKeysForCipher(transformation, keyLengthBits))
+ {
+ deleteEntry(entry);
+ }
+ }
+
+ // A value is unwrapped by the transformation it records, whatever the configured one, and
+ // the scheme name is not case sensitive there either.
+ assertThat(cm.decodeSymmetricKeyAttribute(value).getEncoded())
+ .as("RSASVE value while RSA-OAEP is configured").isEqualTo(key.getEncoded());
+ final String lowerCaseValue =
+ value.replace(":" + RsaSveKeyTransport.NAME + ":", ":" + RsaSveKeyTransport.NAME.toLowerCase() + ":");
+ assertThat(cm.decodeSymmetricKeyAttribute(lowerCaseValue).getEncoded())
+ .as("RSASVE value recording the scheme name in lower case").isEqualTo(key.getEncoded());
+ return null;
+ });
+ }
+
+ @DataProvider
+ public Object[][] keyUsages()
+ {
+ final String oaep = "RSA/ECB/OAEPWITHSHA-1ANDMGF1PADDING";
+ final int signing = KeyUsage.digitalSignature;
+ final int wrapping = KeyUsage.digitalSignature | KeyUsage.keyEncipherment;
+ // Transformation, key usage, whether it is critical, whether a key is wrapped.
+ return new Object[][] {
+ { oaep, signing, true, false },
+ { RsaSveKeyTransport.NAME, signing, true, false },
+ { RsaSveKeyTransport.NAME, wrapping, true, true },
+ { RsaSveKeyTransport.NAME, signing, false, true },
+ };
+ }
+
+ /**
+ No secret key is wrapped for an instance whose certificate has a critical key usage without
+ keyEncipherment, whatever the transformation: Cipher.init refuses such a certificate for
+ RSA-OAEP, and the RSASVE scheme, which takes the public key alone, checks the key usage the same
+ way. A key usage which is not critical restricts nothing.
+ */
+ @Test(dataProvider = "keyUsages")
+ public void testSecretKeyIsWrappedOnlyWhereTheKeyUsageAllowsIt(final String transformation, final int keyUsage,
+ final boolean critical, final boolean wrapped) throws Exception
+ {
+ final CryptoManagerImpl cm = DirectoryServer.getCryptoManager();
+ final CryptoManagerCfg cfg = getServerContext().getRootConfig().getCryptoManager();
+ final CryptoManagerCfg withTransformation = withProperty(cfg, "getKeyWrappingTransformation", transformation);
+ cm.encrypt("key usage".getBytes(StandardCharsets.UTF_8));
+ final String value =
+ getValueWrappedFor(cm.getInstanceKeyID(), cfg.getCipherTransformation(), cfg.getCipherKeyLength());
+ final byte[] certificate = rsaCertificate(keyUsage, critical);
+ final String keyID = CryptoManagerImpl.getInstanceKeyID(certificate);
+ final DN instanceKey = DN.valueOf(ATTR_CRYPTO_KEY_ID + "=" + keyID + ",cn=instance keys,cn=admin data");
+ final DN trustStoreEntry = DN.valueOf(DN_TRUST_STORE_ROOT).child(instanceKey.rdn());
+
+ withBcFipsFirst(() ->
+ {
+ final List<LocalizableMessage> why = new ArrayList<>();
+ assertThat(cm.isConfigurationChangeAcceptable(withTransformation, why)).as("%s", why).isTrue();
+ cm.applyConfigurationChange(withTransformation);
+ try
+ {
+ assertThat(getRootConnection().processAdd(instanceKeyEntry(instanceKey, ByteString.wrap(certificate)))
+ .getResultCode()).isEqualTo(ResultCode.SUCCESS);
+ if (wrapped)
+ {
+ assertThat(cm.reencodeSymmetricKeyAttribute(value, keyID)).startsWith(keyID + ":" + transformation + ":");
+ }
+ else
+ {
+ assertThatThrownBy(() -> cm.reencodeSymmetricKeyAttribute(value, keyID))
+ .isInstanceOf(CryptoManagerException.class)
+ .cause().isInstanceOf(InvalidKeyException.class).hasMessageContaining("key usage");
+ }
+ }
+ finally
+ {
+ cm.applyConfigurationChange(cfg);
+ getRootConnection().processDelete(instanceKey);
+ getRootConnection().processDelete(trustStoreEntry);
+ }
+ return null;
+ });
+ }
+
+ /** Returns a self-signed RSA certificate, DER encoded, with the given key usage. */
+ private static byte[] rsaCertificate(final int keyUsage, final boolean critical) throws Exception
+ {
+ final KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA", "SunRsaSign");
+ generator.initialize(2048);
+ final KeyPair keyPair = generator.generateKeyPair();
+ final Instant now = Instant.now();
+ final X500Name subject = new X500Name("CN=key usage " + keyUsage);
+ final JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder(subject,
+ BigInteger.valueOf(now.toEpochMilli()), Date.from(now.minus(1, ChronoUnit.DAYS)),
+ Date.from(now.plus(1, ChronoUnit.DAYS)), subject, keyPair.getPublic());
+ builder.addExtension(Extension.keyUsage, critical, new KeyUsage(keyUsage));
+ return builder.build(new JcaContentSignerBuilder("SHA256withRSA").build(keyPair.getPrivate())).getEncoded();
+ }
+
+ /** Returns the ds-cfg-symmetric-key value of the key of a cipher which is wrapped for an instance. */
+ private String getValueWrappedFor(final String instanceKeyID, final String transformation,
+ final int keyLengthBits) throws DirectoryException
+ {
+ final Set<String> values = getKeyForCipher(transformation, keyLengthBits)
+ .parseAttribute(ATTR_CRYPTO_SYMMETRIC_KEY).asSetOfString();
+ return values.stream()
+ .filter(v -> v.startsWith(instanceKeyID + ":"))
+ .findFirst()
+ .orElseThrow(() -> new AssertionError("no value wrapped for this instance in " + values));
+ }
+
/** The error the probe provider fails with, set by the case using it. */
private static volatile Error probeError;
@@ -1057,11 +1288,7 @@
*/
private static <T> T inApprovedOnlyBcFipsThread(final Callable<T> action) throws Exception
{
- final List<Provider> installed = Arrays.asList(Security.getProviders());
- final Provider previous = Security.getProvider("BCFIPS");
- Security.removeProvider("BCFIPS");
- Security.insertProviderAt(previous != null ? previous : new BouncyCastleFipsProvider(), 1);
- try
+ return withBcFipsFirst(() ->
{
final FutureTask<T> task = new FutureTask<>(() ->
{
@@ -1077,15 +1304,7 @@
{
throw new AssertionError("the action failed in approved-only mode", e.getCause());
}
- }
- finally
- {
- Security.removeProvider("BCFIPS");
- if (previous != null)
- {
- Security.insertProviderAt(previous, installed.indexOf(previous) + 1);
- }
- }
+ });
}
/**
@@ -1128,11 +1347,16 @@
private Entry getKeyForCipher(String cipher, int keyLength) throws DirectoryException
{
+ return getKeysForCipher(cipher, keyLength).get(0);
+ }
+
+ private List<? extends Entry> getKeysForCipher(String cipher, int keyLength) throws DirectoryException
+ {
SearchRequest request = newSearchRequest("cn=secret keys, cn=admin data", SearchScope.WHOLE_SUBTREE,
"&(ds-cfg-cipher-transformation-name=" + cipher + ")(ds-cfg-key-length-bits=" + keyLength + ")");
InternalClientConnection conn = getRootConnection();
InternalSearchOperation search = conn.processSearch(request);
- return search.getSearchEntries().get(0);
+ return search.getSearchEntries();
}
@Test
--
Gitblit v1.10.0