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-doc-generated-ref/src/main/asciidoc/install-guide/chap-install.adoc                                          |    6 
 opendj-server-legacy/src/main/java/org/opends/server/crypto/RsaSveKeyTransport.java                                 |  402 ++++++++++++++++
 opendj-server-legacy/src/test/java/org/opends/server/TestCaseUtils.java                                             |   58 ++
 opendj-server-legacy/src/main/java/org/opends/server/crypto/CryptoManagerImpl.java                                  |   69 ++
 opendj-maven-plugin/src/main/resources/config/xml/org/forgerock/opendj/server/config/CryptoManagerConfiguration.xml |   18 
 opendj-server-legacy/src/test/java/org/opends/server/crypto/CryptoManagerTestCase.java                              |  256 +++++++++
 opendj-server-legacy/src/test/java/org/opends/server/crypto/RsaSveKeyTransportTestCase.java                         |  688 +++++++++++++++++++++++++++
 7 files changed, 1,463 insertions(+), 34 deletions(-)

diff --git a/opendj-doc-generated-ref/src/main/asciidoc/install-guide/chap-install.adoc b/opendj-doc-generated-ref/src/main/asciidoc/install-guide/chap-install.adoc
index 565a143..2049991 100644
--- a/opendj-doc-generated-ref/src/main/asciidoc/install-guide/chap-install.adoc
+++ b/opendj-doc-generated-ref/src/main/asciidoc/install-guide/chap-install.adoc
@@ -844,7 +844,9 @@
 ====
 OpenDJ ships the Bouncy Castle FIPS provider. The `setup` command and the server register it themselves when the server key store is a BCFKS key store (`--useBcfksKeystore`), and the server registers it at start whenever the `org.openidentityplatform.opendj.fips.register` Java system property is `true`, for example through the `OPENDJ_JAVA_ARGS` environment variable. With this provider in its default mode the default configuration works as it is. The approved-only mode of the provider (the `org.bouncycastle.fips.approved_only` Java system property), which OpenDJ does not turn on, is not covered here.
 
-The crypto manager wraps the secret keys it shares with the other servers of a replication topology with each server's public key. The transformation it uses, the `key-wrapping-transformation` property of the crypto manager, is `RSA/ECB/OAEPWITHSHA-1ANDMGF1PADDING` by default: RSA-OAEP, the key transport scheme of NIST SP 800-56B, which Bouncy Castle FIPS provides. A Java runtime whose cryptography comes from a `SunPKCS11` provider alone, such as `SunPKCS11-NSS-FIPS` on a Linux system in FIPS mode, provides no RSA-OAEP at all: its only RSA cipher is `RSA/ECB/PKCS1Padding`, which NIST SP 800-131A Rev. 2 disallows for key transport. On such a runtime the server refuses to start with the default transformation, `setup` says so, and the choice of another one is yours to make: OpenDJ does not make it for you. If you make it, keep in mind that every server of a replication topology must use the same transformation, since each server unwraps what the others wrapped.
+The crypto manager wraps the secret keys it shares with the other servers of a replication topology with each server's public key. The transformation it uses, the `key-wrapping-transformation` property of the crypto manager, is `RSA/ECB/OAEPWITHSHA-1ANDMGF1PADDING` by default: RSA-OAEP, the key transport scheme of NIST SP 800-56B, which Bouncy Castle FIPS provides. A Java runtime whose cryptography comes from a `SunPKCS11` provider alone, such as `SunPKCS11-NSS-FIPS` on a Linux system in FIPS mode, provides no RSA-OAEP at all: its RSA cipher offers PKCS#1 v1.5 padding, `RSA/ECB/PKCS1Padding`, which NIST SP 800-131A Rev. 2 disallows for key transport, and no padding, `RSA/ECB/NoPadding`. On such a runtime the server refuses to start with the default transformation, `setup` says so, and the choice of another one is yours to make: OpenDJ does not make it for you. If you make it, keep in mind that every server of a replication topology must use the same transformation, since each server unwraps what the others wrapped.
+
+For such a runtime OpenDJ provides a key wrapping scheme of its own, `RSASVE/KDF-SHA256/AESKWP`, built on the hybrid key transport of NIST SP 800-56B Rev. 2, section 9.3. It encapsulates a random secret value with `RSA/ECB/NoPadding`, derives a key wrapping key from it with the one-step key derivation of NIST SP 800-56C Rev. 2 over SHA-256, and wraps the secret key with the AES key wrap with padding of NIST SP 800-38F (`AES/KWP/NoPadding`). No nonce of the receiving server enters the key derivation, because a server wraps keys for another server from its published certificate alone: the scheme is therefore not the KAS1-basic scheme of NIST SP 800-56B to the letter, and whether it is an approved key transport method for a FIPS 140-3 deployment is for the assessment of that deployment to decide. The runtime has to offer these three algorithms: when it lacks one, the server refuses the value and names the `key-wrapping-transformation` property. The `SunPKCS11` provider of OpenJDK 17, for example, offers no AES-KWP, so on an OpenJDK 17 runtime whose cryptography comes from that provider alone the server refuses the value at start; the provider offers AES-KWP from OpenJDK 19 on. A server which does not support the scheme cannot unwrap the secret keys wrapped with it, and neither can a server whose Java runtime lacks one of these algorithms: Java 11, for example, offers no AES-KWP unless the Bouncy Castle FIPS provider is registered. Such a server refuses nothing, since it does not use the scheme itself: it only logs an error for each key it cannot unwrap. So every server of the replication topology must run an OpenDJ version which supports the scheme, on a Java runtime which offers these three algorithms, before any server uses it. Setting the property back does not re-wrap the secret keys already wrapped with the scheme: every server of the replication topology must keep supporting it for as long as those keys are in use.
 
 . Install the server without starting it:
 +
@@ -871,7 +873,7 @@
 dn: cn=Crypto Manager,cn=config
 changetype: modify
 replace: ds-cfg-key-wrapping-transformation
-ds-cfg-key-wrapping-transformation: RSA/ECB/PKCS1Padding
+ds-cfg-key-wrapping-transformation: RSASVE/KDF-SHA256/AESKWP
 
 $ ldifmodify \
  --outputLDIF /path/to/opendj/config/config.ldif.new \
diff --git a/opendj-maven-plugin/src/main/resources/config/xml/org/forgerock/opendj/server/config/CryptoManagerConfiguration.xml b/opendj-maven-plugin/src/main/resources/config/xml/org/forgerock/opendj/server/config/CryptoManagerConfiguration.xml
index a317878..c3653b8 100644
--- a/opendj-maven-plugin/src/main/resources/config/xml/org/forgerock/opendj/server/config/CryptoManagerConfiguration.xml
+++ b/opendj-maven-plugin/src/main/resources/config/xml/org/forgerock/opendj/server/config/CryptoManagerConfiguration.xml
@@ -185,6 +185,24 @@
       The preferred key wrapping transformation for the directory server. This value must
       be the same for all server instances in a replication topology.
     </adm:synopsis>
+    <adm:description>
+      Besides the transformations of the Java runtime, the value
+      RSASVE/KDF-SHA256/AESKWP selects a scheme built on the hybrid key
+      transport of NIST SP 800-56B Rev. 2, section 9.3, without a nonce
+      of the receiving server, for a Java runtime which offers no
+      RSA-OAEP: it needs only RSA/ECB/NoPadding, SHA-256 and the AES key
+      wrap with padding (AES/KWP/NoPadding) from the runtime. The
+      SunPKCS11 provider of OpenJDK 17 offers no AES/KWP/NoPadding, so a
+      server whose runtime has that provider alone refuses this value;
+      the provider offers it from OpenJDK 19 on. A server which does not
+      support this value cannot unwrap the keys wrapped with it, and
+      neither can a server whose Java runtime lacks one of these
+      algorithms, such as Java 11 without Bouncy Castle FIPS: it only
+      logs an error for each key it cannot unwrap. So every server of
+      the replication topology must run a version which supports this
+      value, on a Java runtime which offers these algorithms, before any
+      server uses it.
+    </adm:description>
     <adm:requires-admin-action>
       <adm:none>
         <adm:synopsis>
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/crypto/CryptoManagerImpl.java b/opendj-server-legacy/src/main/java/org/opends/server/crypto/CryptoManagerImpl.java
index a2eb090..9c85361 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/crypto/CryptoManagerImpl.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/crypto/CryptoManagerImpl.java
@@ -26,6 +26,7 @@
 import java.security.GeneralSecurityException;
 import java.security.InvalidAlgorithmParameterException;
 import java.security.InvalidKeyException;
+import java.security.KeyPair;
 import java.security.MessageDigest;
 import java.security.NoSuchAlgorithmException;
 import java.security.PrivateKey;
@@ -459,17 +460,25 @@
   }
 
   /**
-   * Checks that this Java runtime provides the key wrapping transformation. Only a refusal here
-   * names the key-wrapping-transformation property: the wrap which follows it also needs a MAC
-   * key of the mac-algorithm property, and changing the key wrapping property does not help when
-   * that is what the runtime refuses.
+   * Checks that this Java runtime provides the key wrapping transformation, or every algorithm the
+   * RSASVE scheme needs when the transformation names that scheme. Only a refusal here names the
+   * key-wrapping-transformation property: the wrap which follows it also needs a MAC key of the
+   * mac-algorithm property, and changing the key wrapping property does not help when that is what
+   * the runtime refuses.
    */
   private static boolean isKeyWrappingTransformationSupported(
       final String transformation, final List<LocalizableMessage> unacceptableReasons)
   {
     try
     {
-      Cipher.getInstance(transformation);
+      if (RsaSveKeyTransport.isScheme(transformation))
+      {
+        RsaSveKeyTransport.checkSupported();
+      }
+      else
+      {
+        Cipher.getInstance(transformation);
+      }
       return true;
     }
     catch (GeneralSecurityException ex)
@@ -847,6 +856,9 @@
    * wrappedKeyAlgorithm ::= e.g., DESede
    * hexifiedwrappedKey ::= 0123456789abcdef01...
    * </pre>
+   * The wrapping transformation is a transformation of the Java runtime, or
+   * RSASVE/KDF-SHA256/AESKWP, the scheme of {@link RsaSveKeyTransport}, whose
+   * wrapped key is the one {@link RsaSveKeyTransport#wrap} returns.
    *
    * @param wrappingKeyID The key identifier of the wrapping key. This
    * parameter is the first field in the encoded value and identifies
@@ -915,10 +927,17 @@
               = CertificateFactory.getInstance("X.509");
       final Certificate certificate = cf.generateCertificate(
               new ByteArrayInputStream(wrappingKeyCertificateData));
-      final Cipher wrapper
-              = Cipher.getInstance(wrappingTransformationName);
-      wrapper.init(Cipher.WRAP_MODE, certificate);
-      byte[] wrappedKey = wrapper.wrap(secretKey);
+      final byte[] wrappedKey;
+      if (RsaSveKeyTransport.isScheme(wrappingTransformationName)) {
+        wrappedKey = RsaSveKeyTransport.wrap(certificate,
+                secretKey, wrappingKeyID, secureRandom);
+      }
+      else {
+        final Cipher wrapper
+                = Cipher.getInstance(wrappingTransformationName);
+        wrapper.init(Cipher.WRAP_MODE, certificate);
+        wrappedKey = wrapper.wrap(secretKey);
+      }
       wrappedKeyElement = StaticUtils.bytesToHexNoSpace(wrappedKey);
     }
     catch (GeneralSecurityException ex) {
@@ -938,6 +957,8 @@
    * Takes an encoded ds-cfg-symmetric-key attribute value and the
    * associated key algorithm name, and returns an initialized
    * {@code java.security.Key} object.
+   * <p>
+   * Package private for testing.
    * @param symmetricKeyAttribute The encoded
    * ds-cfg-symmetric-key-attribute value.
    * @return A SecretKey object instantiated with the key data,
@@ -946,7 +967,7 @@
    * @throws CryptoManagerException If there is a problem decomposing
    * the supplied attribute value or unwrapping the encoded key.
    */
-  private SecretKey decodeSymmetricKeyAttribute(
+  SecretKey decodeSymmetricKeyAttribute(
           final String symmetricKeyAttribute)
           throws CryptoManagerException {
     // Initial decomposition.
@@ -983,7 +1004,9 @@
     }
 
     // Confirm key can be unwrapped at this instance.
-    final String instanceKeyID = getInstanceKeyID();
+    final byte[] instanceKeyCertificate =
+            getInstanceKeyCertificateFromLocalTruststore();
+    final String instanceKeyID = getInstanceKeyID(instanceKeyCertificate);
     if (! wrappingKeyIDElement.equals(instanceKeyID)) {
       return null;
     }
@@ -1008,11 +1031,25 @@
     // Unwrap secret key.
     SecretKey secretKey;
     try {
-      final Cipher unwrapper
-              = Cipher.getInstance(wrappingTransformationElement);
-      unwrapper.init(Cipher.UNWRAP_MODE, privateKey);
-      secretKey = (SecretKey)unwrapper.unwrap(wrappedKeyCipherTextElement,
-              wrappedKeyAlgorithmElement, Cipher.SECRET_KEY);
+      if (RsaSveKeyTransport.isScheme(wrappingTransformationElement)) {
+        // The public key comes from the instance key certificate whose
+        // identifier was just matched: a private key held by a token does
+        // not always expose its modulus.
+        final Certificate certificate = CertificateFactory.getInstance("X.509")
+                .generateCertificate(
+                        new ByteArrayInputStream(instanceKeyCertificate));
+        secretKey = RsaSveKeyTransport.unwrap(
+                new KeyPair(certificate.getPublicKey(), privateKey),
+                wrappedKeyCipherTextElement, wrappedKeyAlgorithmElement,
+                wrappingKeyIDElement);
+      }
+      else {
+        final Cipher unwrapper
+                = Cipher.getInstance(wrappingTransformationElement);
+        unwrapper.init(Cipher.UNWRAP_MODE, privateKey);
+        secretKey = (SecretKey)unwrapper.unwrap(wrappedKeyCipherTextElement,
+                wrappedKeyAlgorithmElement, Cipher.SECRET_KEY);
+      }
     } catch(GeneralSecurityException ex) {
       logger.traceException(ex);
       throw new CryptoManagerException(
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/crypto/RsaSveKeyTransport.java b/opendj-server-legacy/src/main/java/org/opends/server/crypto/RsaSveKeyTransport.java
new file mode 100644
index 0000000..67ff01b
--- /dev/null
+++ b/opendj-server-legacy/src/main/java/org/opends/server/crypto/RsaSveKeyTransport.java
@@ -0,0 +1,402 @@
+/*
+ * The contents of this file are subject to the terms of the Common Development and
+ * Distribution License (the License). You may not use this file except in compliance with the
+ * License.
+ *
+ * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
+ * specific language governing permission and limitations under the License.
+ *
+ * When distributing Covered Software, include this CDDL Header Notice in each file and include
+ * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
+ * Header, with the fields enclosed by brackets [] replaced by your own identifying
+ * information: "Portions copyright [year] [name of copyright owner]".
+ *
+ * Copyright 2026 3A Systems, LLC.
+ */
+package org.opends.server.crypto;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import java.math.BigInteger;
+import java.nio.ByteBuffer;
+import java.security.GeneralSecurityException;
+import java.security.InvalidKeyException;
+import java.security.KeyPair;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.PrivateKey;
+import java.security.PublicKey;
+import java.security.SecureRandom;
+import java.security.cert.Certificate;
+import java.security.cert.X509Certificate;
+import java.security.interfaces.RSAPublicKey;
+import java.util.Arrays;
+import java.util.Set;
+
+import javax.crypto.Cipher;
+import javax.crypto.NoSuchPaddingException;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.SecretKeySpec;
+
+/**
+ * The key wrapping scheme for a Java runtime which offers no RSA-OAEP, such as one whose only
+ * provider is SunPKCS11: a scheme of OpenDJ's own, built on the hybrid key transport of NIST
+ * SP 800-56B Rev. 2, section 9.3, but not the KAS1-basic scheme to the letter, since no nonce of
+ * the receiving instance enters the derivation. The RSA secret value encapsulation (RSASVE,
+ * section 7.2.1) establishes a secret value with the public key of the receiving instance, the
+ * one-step key derivation of NIST SP 800-56C Rev. 2 over SHA-256 turns it into a key wrapping key,
+ * and the AES key wrap with padding of NIST SP 800-38F (KWP) wraps the secret key with that key.
+ * The runtime has to offer a raw RSA cipher, a SHA-256 digest and AES-KWP, nothing more.
+ * <p>
+ * A wrapped key is the encapsulated secret value, on as many bytes as the RSA modulus and preceded
+ * by that length on two bytes, followed by the KWP-wrapped secret key. The derivation binds the
+ * scheme name, the identifier of the instance key the secret value is encapsulated with and the
+ * algorithm of the wrapped key, the fields stored next to the wrapped key in a ds-cfg-symmetric-key
+ * value, so that none of them can be changed without the unwrapping failing. No nonce of the
+ * receiving instance enters the derivation: keys are wrapped for an instance from its published
+ * certificate alone, without the instance taking part.
+ */
+final class RsaSveKeyTransport
+{
+  /**
+   * The name of the scheme: the value of the key-wrapping-transformation property which selects
+   * it, and the transformation recorded in the ds-cfg-symmetric-key values it produces.
+   */
+  static final String NAME = "RSASVE/KDF-SHA256/AESKWP";
+
+  private static final String RSA_TRANSFORMATION = "RSA/ECB/NoPadding";
+  private static final String DIGEST_ALGORITHM = "SHA-256";
+  /**
+   * The names of AES-KWP: first the only one Bouncy Castle FIPS knows, so that it does the key wrap
+   * when it is installed first, as in the FIPS mode of OpenDJ, even where the Java runtime offers
+   * AES-KWP too; then the standard one.
+   */
+  private static final String[] KEY_WRAP_TRANSFORMATIONS = { "AESWrapPad", "AES/KWP/NoPadding" };
+  /** The length in bits of the key wrapping key: one SHA-256 hash. */
+  private static final int KEY_WRAPPING_KEY_LENGTH_BITS = 256;
+  /** The counter of the only hash the key derivation computes. */
+  private static final byte[] KDF_COUNTER = { 0, 0, 0, 1 };
+  /** The object identifier of the key usage extension of an X.509 certificate. */
+  private static final String KEY_USAGE_OID = "2.5.29.15";
+  /** The position of keyEncipherment in the key usage of an X.509 certificate. */
+  private static final int KEY_ENCIPHERMENT = 2;
+
+  private RsaSveKeyTransport()
+  {
+    // Not instantiable.
+  }
+
+  /**
+   * Returns whether a key wrapping transformation names this scheme. Like the names of the Java
+   * transformations, the name is not case sensitive.
+   *
+   * @param transformation
+   *          The key wrapping transformation.
+   * @return Whether it names this scheme.
+   */
+  static boolean isScheme(final String transformation)
+  {
+    return NAME.equalsIgnoreCase(transformation);
+  }
+
+  /**
+   * Checks that this Java runtime offers every algorithm the scheme needs.
+   *
+   * @throws GeneralSecurityException
+   *           If it does not, naming the first algorithm missing.
+   */
+  static void checkSupported() throws GeneralSecurityException
+  {
+    Cipher.getInstance(RSA_TRANSFORMATION);
+    MessageDigest.getInstance(DIGEST_ALGORITHM);
+    getKeyWrapCipher();
+  }
+
+  /**
+   * Wraps a secret key for the instance which holds the private key of a certificate. Like
+   * {@code Cipher.init(Cipher.WRAP_MODE, certificate)}, it refuses an X.509 certificate whose key
+   * usage extension is critical and does not allow keyEncipherment.
+   *
+   * @param certificate
+   *          The certificate of the instance, with its RSA public key.
+   * @param secretKey
+   *          The secret key to wrap.
+   * @param wrappingKeyID
+   *          The identifier of the instance key, as stored next to the wrapped key.
+   * @param random
+   *          The random generator of the secret value.
+   * @return The wrapped key.
+   * @throws GeneralSecurityException
+   *           If the key usage of the certificate does not allow wrapping keys, if its public key
+   *           is not an RSA key, or if the runtime cannot wrap the key.
+   */
+  static byte[] wrap(final Certificate certificate, final SecretKey secretKey, final String wrappingKeyID,
+      final SecureRandom random) throws GeneralSecurityException
+  {
+    if (certificate instanceof X509Certificate)
+    {
+      final X509Certificate x509Certificate = (X509Certificate) certificate;
+      final Set<String> criticalExtensions = x509Certificate.getCriticalExtensionOIDs();
+      final boolean[] keyUsage = x509Certificate.getKeyUsage();
+      if (criticalExtensions != null && criticalExtensions.contains(KEY_USAGE_OID)
+          && keyUsage != null && keyUsage.length > KEY_ENCIPHERMENT && !keyUsage[KEY_ENCIPHERMENT])
+      {
+        throw new InvalidKeyException(NAME + " needs a certificate whose key usage allows keyEncipherment");
+      }
+    }
+    return wrap(certificate.getPublicKey(), secretKey, wrappingKeyID, random);
+  }
+
+  /**
+   * Wraps a secret key for the instance which holds the private key of an RSA public key.
+   *
+   * @param publicKey
+   *          The RSA public key of the instance.
+   * @param secretKey
+   *          The secret key to wrap.
+   * @param wrappingKeyID
+   *          The identifier of the instance key, as stored next to the wrapped key.
+   * @param random
+   *          The random generator of the secret value.
+   * @return The wrapped key.
+   * @throws GeneralSecurityException
+   *           If the public key is not an RSA key, or if the runtime cannot wrap the key.
+   */
+  static byte[] wrap(final PublicKey publicKey, final SecretKey secretKey, final String wrappingKeyID,
+      final SecureRandom random) throws GeneralSecurityException
+  {
+    final BigInteger modulus = getModulus(publicKey);
+    final int length = (modulus.bitLength() + 7) / 8;
+    final byte[] secretValue = generateSecretValue(modulus, length, random);
+    try
+    {
+      final Cipher rsa = Cipher.getInstance(RSA_TRANSFORMATION);
+      rsa.init(Cipher.ENCRYPT_MODE, publicKey);
+      // On as many bytes as the modulus whatever the provider: the unwrapping takes the length of
+      // the encapsulated value for the length of the secret value.
+      final byte[] encapsulated = toLength(rsa.doFinal(secretValue), length);
+      final Cipher keyWrap = getKeyWrapCipher();
+      keyWrap.init(Cipher.WRAP_MODE, deriveKeyWrappingKey(secretValue, wrappingKeyID, secretKey.getAlgorithm()));
+      final byte[] wrapped = keyWrap.wrap(secretKey);
+      return ByteBuffer.allocate(2 + encapsulated.length + wrapped.length)
+          .putShort((short) encapsulated.length)
+          .put(encapsulated)
+          .put(wrapped)
+          .array();
+    }
+    finally
+    {
+      Arrays.fill(secretValue, (byte) 0);
+    }
+  }
+
+  /**
+   * Unwraps a secret key which {@link #wrap} wrapped with the public key of this instance.
+   * <p>
+   * The encapsulated value is checked against the modulus before the private key decrypts it, and
+   * every failure from the decryption on is reported with one and the same message, the cause
+   * aside: what the private key recovers from a value does not show in how the value is refused.
+   * A runtime which lacks one of the ciphers, or whose RSA cipher cannot use the private key, is
+   * reported before that, with its own reason.
+   *
+   * @param instanceKeyPair
+   *          The RSA key pair of this instance. The modulus is taken from its public key, since a
+   *          private key held by a token does not always expose it.
+   * @param wrappedKey
+   *          The wrapped key.
+   * @param keyAlgorithm
+   *          The algorithm of the wrapped key, as stored next to it.
+   * @param wrappingKeyID
+   *          The identifier of the instance key, as stored next to the wrapped key.
+   * @return The secret key.
+   * @throws GeneralSecurityException
+   *           If the wrapped key is malformed, if it was not wrapped for this key pair with these
+   *           fields, or if the runtime cannot unwrap it.
+   */
+  static SecretKey unwrap(final KeyPair instanceKeyPair, final byte[] wrappedKey, final String keyAlgorithm,
+      final String wrappingKeyID) throws GeneralSecurityException
+  {
+    final PrivateKey privateKey = instanceKeyPair.getPrivate();
+    if (privateKey == null)
+    {
+      throw new InvalidKeyException(NAME + " needs the private key of this instance");
+    }
+    final BigInteger modulus = getModulus(instanceKeyPair.getPublic());
+    if (wrappedKey.length < 2)
+    {
+      throw new InvalidKeyException(NAME + " wrapped key is truncated");
+    }
+    final ByteBuffer buffer = ByteBuffer.wrap(wrappedKey);
+    final int length = buffer.getShort() & 0xFFFF;
+    if (length == 0 || buffer.remaining() <= length)
+    {
+      throw new InvalidKeyException(NAME + " wrapped key is truncated");
+    }
+    // RSASVE.RECOVER takes a value on as many bytes as the modulus, and RSADP an integer c with
+    // 1 < c < n - 1: a value which is neither does not reach the private key.
+    if (length != (modulus.bitLength() + 7) / 8)
+    {
+      throw new InvalidKeyException(NAME + " encapsulated value is not as long as the RSA modulus");
+    }
+    final byte[] encapsulated = new byte[length];
+    buffer.get(encapsulated);
+    final BigInteger c = new BigInteger(1, encapsulated);
+    if (c.compareTo(BigInteger.ONE) <= 0 || c.compareTo(modulus.subtract(BigInteger.ONE)) >= 0)
+    {
+      throw new InvalidKeyException(NAME + " encapsulated value is out of range");
+    }
+    final byte[] wrapped = new byte[buffer.remaining()];
+    buffer.get(wrapped);
+
+    // Neither cipher depends on the value, nor does the private key: a runtime which lacks one of
+    // the ciphers, or whose RSA cipher cannot use the private key, is told apart from a value which
+    // cannot be unwrapped.
+    final Cipher rsa = Cipher.getInstance(RSA_TRANSFORMATION);
+    rsa.init(Cipher.DECRYPT_MODE, privateKey);
+    final Cipher keyWrap = getKeyWrapCipher();
+    byte[] decrypted = null;
+    byte[] secretValue = null;
+    try
+    {
+      decrypted = rsa.doFinal(encapsulated);
+      // Bouncy Castle FIPS drops the leading zero bytes of the secret value.
+      secretValue = toLength(decrypted, length);
+      keyWrap.init(Cipher.UNWRAP_MODE, deriveKeyWrappingKey(secretValue, wrappingKeyID, keyAlgorithm));
+      return (SecretKey) keyWrap.unwrap(wrapped, keyAlgorithm, Cipher.SECRET_KEY);
+    }
+    catch (GeneralSecurityException | RuntimeException e)
+    {
+      throw new InvalidKeyException(NAME + " cannot unwrap the key", e);
+    }
+    finally
+    {
+      if (decrypted != null)
+      {
+        Arrays.fill(decrypted, (byte) 0);
+      }
+      if (secretValue != null)
+      {
+        Arrays.fill(secretValue, (byte) 0);
+      }
+    }
+  }
+
+  /** Returns the modulus of an RSA public key, refusing any other key. */
+  private static BigInteger getModulus(final PublicKey publicKey) throws InvalidKeyException
+  {
+    if (!(publicKey instanceof RSAPublicKey))
+    {
+      throw new InvalidKeyException(
+          NAME + " needs an RSA public key, not " + (publicKey != null ? publicKey.getAlgorithm() : null));
+    }
+    return ((RSAPublicKey) publicKey).getModulus();
+  }
+
+  /**
+   * Draws the secret value of RSASVE.GENERATE: a random integer z with 1 < z < n - 1, on as many
+   * bytes as the modulus n. A draw outside the interval is discarded and drawn again.
+   */
+  private static byte[] generateSecretValue(final BigInteger modulus, final int length, final SecureRandom random)
+  {
+    final BigInteger upperBound = modulus.subtract(BigInteger.ONE);
+    final byte[] secretValue = new byte[length];
+    while (true)
+    {
+      random.nextBytes(secretValue);
+      final BigInteger z = new BigInteger(1, secretValue);
+      if (z.compareTo(BigInteger.ONE) > 0 && z.compareTo(upperBound) < 0)
+      {
+        return secretValue;
+      }
+    }
+  }
+
+  /**
+   * Returns the unsigned big-endian integer of the bytes on exactly {@code length} bytes, the way
+   * NIST SP 800-56B converts an integer to a byte string: leading zero bytes are added or dropped.
+   */
+  private static byte[] toLength(final byte[] bytes, final int length) throws InvalidKeyException
+  {
+    int start = 0;
+    while (bytes.length - start > length && bytes[start] == 0)
+    {
+      start++;
+    }
+    final int significant = bytes.length - start;
+    if (significant > length)
+    {
+      throw new InvalidKeyException(NAME + " value is longer than the RSA modulus");
+    }
+    if (significant == length)
+    {
+      return start == 0 ? bytes : Arrays.copyOfRange(bytes, start, bytes.length);
+    }
+    final byte[] result = new byte[length];
+    System.arraycopy(bytes, start, result, length - significant, significant);
+    return result;
+  }
+
+  /**
+   * Derives the key wrapping key with the one-step key derivation of NIST SP 800-56C Rev. 2, with
+   * SHA-256 as the auxiliary function: one hash of the counter 1, the secret value and the fixed
+   * information yields its 256 bits.
+   */
+  private static SecretKey deriveKeyWrappingKey(final byte[] secretValue, final String wrappingKeyID,
+      final String keyAlgorithm) throws NoSuchAlgorithmException
+  {
+    final MessageDigest digest = MessageDigest.getInstance(DIGEST_ALGORITHM);
+    digest.update(KDF_COUNTER);
+    digest.update(secretValue);
+    digest.update(fixedInfo(wrappingKeyID, keyAlgorithm));
+    final byte[] key = digest.digest();
+    try
+    {
+      return new SecretKeySpec(key, "AES");
+    }
+    finally
+    {
+      Arrays.fill(key, (byte) 0);
+    }
+  }
+
+  /**
+   * Returns the fixed information of the key derivation, each variable-length field preceded by
+   * its length on four bytes: the scheme name as AlgorithmID; an empty PartyUInfo, since the
+   * wrapping instance is not identified; the identifier of the instance key as PartyVInfo; then,
+   * as SuppPubInfo, the algorithm of the wrapped key and the length in bits of the key wrapping key.
+   */
+  private static byte[] fixedInfo(final String wrappingKeyID, final String keyAlgorithm)
+  {
+    final byte[] algorithmID = NAME.getBytes(UTF_8);
+    final byte[] partyVInfo = wrappingKeyID.getBytes(UTF_8);
+    final byte[] wrappedKeyAlgorithm = keyAlgorithm.getBytes(UTF_8);
+    return ByteBuffer.allocate(5 * 4 + algorithmID.length + partyVInfo.length + wrappedKeyAlgorithm.length)
+        .putInt(algorithmID.length).put(algorithmID)
+        .putInt(0)
+        .putInt(partyVInfo.length).put(partyVInfo)
+        .putInt(wrappedKeyAlgorithm.length).put(wrappedKeyAlgorithm)
+        .putInt(KEY_WRAPPING_KEY_LENGTH_BITS)
+        .array();
+  }
+
+  /**
+   * Returns an AES-KWP cipher under the first of its names the runtime knows. When it knows none,
+   * the failure names the standard one.
+   */
+  static Cipher getKeyWrapCipher() throws GeneralSecurityException
+  {
+    GeneralSecurityException lastFailure = null;
+    for (final String transformation : KEY_WRAP_TRANSFORMATIONS)
+    {
+      try
+      {
+        return Cipher.getInstance(transformation);
+      }
+      catch (NoSuchAlgorithmException | NoSuchPaddingException e)
+      {
+        lastFailure = e;
+      }
+    }
+    throw lastFailure;
+  }
+}
diff --git a/opendj-server-legacy/src/test/java/org/opends/server/TestCaseUtils.java b/opendj-server-legacy/src/test/java/org/opends/server/TestCaseUtils.java
index 52a49da..46f6011 100644
--- a/opendj-server-legacy/src/test/java/org/opends/server/TestCaseUtils.java
+++ b/opendj-server-legacy/src/test/java/org/opends/server/TestCaseUtils.java
@@ -83,6 +83,7 @@
 import java.util.logging.Logger;
 
 import com.forgerock.opendj.ldap.tools.LDAPSearch;
+import org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider;
 import org.forgerock.i18n.LocalizableMessage;
 import org.forgerock.opendj.config.dsconfig.DSConfig;
 import org.forgerock.opendj.config.server.ConfigException;
@@ -386,6 +387,63 @@
     }
   }
 
+  /**
+   * Runs {@code action} on a runtime which offers raw RSA and SHA-256 but no AES-KWP, as Java 11
+   * does without Bouncy Castle FIPS. Where a provider offers {@code AES/KWP/NoPadding}, it is
+   * withdrawn with {@link #withoutJceService}, and its {@code AESWrapPad} alias with it; since that
+   * provider is SunJCE, a stand-in gives back the raw RSA cipher of SunJCE for the duration.
+   *
+   * @param action
+   *          What to run without AES-KWP.
+   * @throws Exception
+   *           If the action fails.
+   */
+  public static void withoutKeyWrap(final Callable<Void> action) throws Exception
+  {
+    if (Security.getProviders("Cipher.AES/KWP/NoPadding") == null)
+    {
+      action.call();
+    }
+    else
+    {
+      final Provider rawRsa = new Provider("RawRsaOfSunJce", "1.0", "RSA cipher of SunJCE") {};
+      rawRsa.put("Cipher.RSA", "com.sun.crypto.provider.RSACipher");
+      withoutJceService("Cipher", "AES/KWP/NoPadding", action, rawRsa);
+    }
+  }
+
+  /**
+   * Runs {@code action} with the Bouncy Castle FIPS provider installed first, in its default mode,
+   * and puts the providers back as they were afterwards.
+   *
+   * @param <T>
+   *          The type of what the action returns.
+   * @param action
+   *          What to run.
+   * @return What the action returns.
+   * @throws Exception
+   *           If the action fails.
+   */
+  public static <T> T withBcFipsFirst(final Callable<T> action) throws Exception
+  {
+    final List<Provider> installed = Arrays.asList(Security.getProviders());
+    final Provider previous = Security.getProvider(BouncyCastleFipsProvider.PROVIDER_NAME);
+    Security.removeProvider(BouncyCastleFipsProvider.PROVIDER_NAME);
+    Security.insertProviderAt(previous != null ? previous : new BouncyCastleFipsProvider(), 1);
+    try
+    {
+      return action.call();
+    }
+    finally
+    {
+      Security.removeProvider(BouncyCastleFipsProvider.PROVIDER_NAME);
+      if (previous != null)
+      {
+        Security.insertProviderAt(previous, installed.indexOf(previous) + 1);
+      }
+    }
+  }
+
   public static void startServer() throws Exception
   {
     System.setProperty(PROPERTY_RUNNING_UNIT_TESTS, "true");
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
diff --git a/opendj-server-legacy/src/test/java/org/opends/server/crypto/RsaSveKeyTransportTestCase.java b/opendj-server-legacy/src/test/java/org/opends/server/crypto/RsaSveKeyTransportTestCase.java
new file mode 100644
index 0000000..af31ca5
--- /dev/null
+++ b/opendj-server-legacy/src/test/java/org/opends/server/crypto/RsaSveKeyTransportTestCase.java
@@ -0,0 +1,688 @@
+/*
+ * The contents of this file are subject to the terms of the Common Development and
+ * Distribution License (the License). You may not use this file except in compliance with the
+ * License.
+ *
+ * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
+ * specific language governing permission and limitations under the License.
+ *
+ * When distributing Covered Software, include this CDDL Header Notice in each file and include
+ * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
+ * Header, with the fields enclosed by brackets [] replaced by your own identifying
+ * information: "Portions copyright [year] [name of copyright owner]".
+ *
+ * Copyright 2026 3A Systems, LLC.
+ */
+package org.opends.server.crypto;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.opends.server.TestCaseUtils.*;
+
+import java.io.ByteArrayOutputStream;
+import java.math.BigInteger;
+import java.nio.ByteBuffer;
+import java.security.AlgorithmParameters;
+import java.security.GeneralSecurityException;
+import java.security.InvalidKeyException;
+import java.security.Key;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.NoSuchAlgorithmException;
+import java.security.PrivateKey;
+import java.security.Provider;
+import java.security.PublicKey;
+import java.security.SecureRandom;
+import java.security.Security;
+import java.security.interfaces.RSAKey;
+import java.security.interfaces.RSAPrivateKey;
+import java.security.interfaces.RSAPublicKey;
+import java.security.spec.AlgorithmParameterSpec;
+import java.util.Arrays;
+import java.util.concurrent.Callable;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import javax.crypto.Cipher;
+import javax.crypto.CipherSpi;
+import javax.crypto.NoSuchPaddingException;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.SecretKeySpec;
+
+import org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider;
+import org.opends.server.util.StaticUtils;
+import org.testng.SkipException;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+/** Tests the RSASVE key transport scheme of the crypto manager. */
+@SuppressWarnings("javadoc")
+public class RsaSveKeyTransportTestCase extends CryptoTestCase
+{
+  private static final String KEY_ID = "0123456789ABCDEF0123456789ABCDEF";
+  /** The providers of the Java runtime, which offer AES/KWP/NoPadding from Java 17 on. */
+  private static final String JDK = "jdk";
+  /**
+   * Bouncy Castle FIPS first and no provider offering AES/KWP/NoPadding: AES-KWP only under the
+   * name AESWrapPad, and a raw RSA which drops the leading zero bytes of what it decrypts.
+   */
+  private static final String BC_FIPS = "bcfips";
+
+  /** Created before any test installs Bouncy Castle FIPS, so that it comes from the SUN provider. */
+  private final SecureRandom random = new SecureRandom();
+  private KeyPair keyPair;
+
+  @BeforeClass
+  public void generateKeyPair() throws Exception
+  {
+    keyPair = rsaKeyPair(2048);
+  }
+
+  @DataProvider
+  public Object[][] providers()
+  {
+    return new Object[][] { { JDK }, { BC_FIPS } };
+  }
+
+  @DataProvider
+  public Object[][] keys()
+  {
+    final Object[][] keys = { { "DES", 8 }, { "AES", 16 }, { "HmacSHA1", 20 }, { "DESede", 24 }, { "AES", 32 } };
+    final Object[][] rows = new Object[keys.length * 2][];
+    for (int i = 0; i < keys.length; i++)
+    {
+      rows[2 * i] = new Object[] { JDK, keys[i][0], keys[i][1] };
+      rows[2 * i + 1] = new Object[] { BC_FIPS, keys[i][0], keys[i][1] };
+    }
+    return rows;
+  }
+
+  /** Keys of every length unwrap to themselves: the 20 bytes of an HmacSHA1 key need the padding of KWP. */
+  @Test(dataProvider = "keys")
+  public void testWrappedKeyUnwrapsToTheSameKey(final String providers, final String algorithm, final int length)
+      throws Exception
+  {
+    final SecretKey key = new SecretKeySpec(bytes(length, 0x3C), algorithm);
+    inProviders(providers, () ->
+    {
+      final byte[] wrapped = RsaSveKeyTransport.wrap(keyPair.getPublic(), key, KEY_ID, random);
+      final SecretKey unwrapped = RsaSveKeyTransport.unwrap(keyPair, wrapped, algorithm, KEY_ID);
+      assertThat(unwrapped.getAlgorithm()).isEqualTo(algorithm);
+      assertThat(unwrapped.getEncoded()).isEqualTo(key.getEncoded());
+      return null;
+    });
+  }
+
+  /**
+   * The key wrapping key is derived from the encapsulated secret value: another private key
+   * recovers another value and cannot unwrap. The other modulus is the larger one, so that the
+   * encapsulated value is in its range and its private key decrypts it instead of refusing it.
+   */
+  @Test
+  public void testAnotherPrivateKeyCannotUnwrap() throws Exception
+  {
+    KeyPair other;
+    do
+    {
+      other = rsaKeyPair(2048);
+    }
+    while (modulus(other).compareTo(modulus(keyPair)) <= 0);
+    final KeyPair otherKeyPair = other;
+    final SecretKey key = new SecretKeySpec(bytes(16, 0x3C), "AES");
+    inProviders(BC_FIPS, () ->
+    {
+      final byte[] wrapped = RsaSveKeyTransport.wrap(keyPair.getPublic(), key, KEY_ID, random);
+      assertThatThrownBy(() -> RsaSveKeyTransport.unwrap(otherKeyPair, wrapped, "AES", KEY_ID))
+          .isInstanceOf(GeneralSecurityException.class);
+      return null;
+    });
+  }
+
+  /** The derivation binds the fields stored next to the wrapped key: changing either one fails the unwrapping. */
+  @Test
+  public void testFieldsStoredNextToTheWrappedKeyAreBound() throws Exception
+  {
+    final SecretKey key = new SecretKeySpec(bytes(16, 0x3C), "AES");
+    inProviders(BC_FIPS, () ->
+    {
+      final byte[] wrapped = RsaSveKeyTransport.wrap(keyPair.getPublic(), key, KEY_ID, random);
+      assertThatThrownBy(() -> RsaSveKeyTransport.unwrap(keyPair, wrapped, "AES", KEY_ID.toLowerCase()))
+          .as("another instance key identifier").isInstanceOf(GeneralSecurityException.class);
+      assertThatThrownBy(() -> RsaSveKeyTransport.unwrap(keyPair, wrapped, "HmacSHA256", KEY_ID))
+          .as("another key algorithm").isInstanceOf(GeneralSecurityException.class);
+      return null;
+    });
+  }
+
+  /**
+   * The wrapped key of a known secret value. What this version stores in cn=secret keys is what
+   * every later version has to unwrap, so the derivation and the key wrap are pinned to a value
+   * computed independently of this implementation: SHA-256 of the counter 1, the secret value and
+   * the fixed information, then AES-KWP of the key with it. The key wrap of both the Java runtime and
+   * Bouncy Castle FIPS has to give it.
+   */
+  @Test(dataProvider = "providers")
+  public void testKnownAnswer(final String providers) throws Exception
+  {
+    final byte[] secretValue = new byte[256];
+    for (int i = 0; i < secretValue.length; i++)
+    {
+      secretValue[i] = (byte) (i + 1);
+    }
+    final Draws draws = new Draws(secretValue);
+    final SecretKey key =
+        new SecretKeySpec(new byte[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15 }, "AES");
+    inProviders(providers, () ->
+    {
+      final byte[] wrapped = RsaSveKeyTransport.wrap(keyPair.getPublic(), key, KEY_ID, draws);
+      assertThat(ByteBuffer.wrap(wrapped).getShort()).isEqualTo((short) 256);
+      final byte[] wrappedKey = Arrays.copyOfRange(wrapped, 2 + 256, wrapped.length);
+      assertThat(StaticUtils.bytesToHexNoSpace(wrappedKey))
+          .isEqualToIgnoringCase("A29EDD3A5DDBD441B69FB866DDE179CF6CC59C86B54A1AF5");
+      return null;
+    });
+  }
+
+  @DataProvider
+  public Object[][] secretValuesOutsideTheInterval()
+  {
+    return new Object[][] {
+      { "0", new byte[256] },
+      { "1", toBytes(BigInteger.ONE) },
+      { "n - 1", toBytes(modulus(keyPair).subtract(BigInteger.ONE)) },
+      { "2^2048 - 1", bytes(256, 0xFF) } };
+  }
+
+  /** RSASVE.GENERATE draws z with 1 < z < n - 1: a value outside is discarded and drawn again. */
+  @Test(dataProvider = "secretValuesOutsideTheInterval")
+  public void testSecretValueOutsideTheIntervalIsDrawnAgain(final String value, final byte[] outside)
+      throws Exception
+  {
+    final Draws draws = new Draws(outside, bytes(256, 0x5A));
+    final SecretKey key = new SecretKeySpec(bytes(16, 0x3C), "AES");
+    inProviders(BC_FIPS, () ->
+    {
+      final byte[] wrapped = RsaSveKeyTransport.wrap(keyPair.getPublic(), key, KEY_ID, draws);
+      assertThat(draws.count()).as(value).isEqualTo(2);
+      assertThat(RsaSveKeyTransport.unwrap(keyPair, wrapped, "AES", KEY_ID).getEncoded())
+          .isEqualTo(key.getEncoded());
+      return null;
+    });
+  }
+
+  /** Bouncy Castle FIPS drops the leading zero bytes of a decrypted secret value, which the derivation needs. */
+  @Test
+  public void testSecretValueWithALeadingZeroByteUnwraps() throws Exception
+  {
+    final byte[] secretValue = bytes(256, 0x5A);
+    secretValue[0] = 0;
+    final Draws draws = new Draws(secretValue);
+    final SecretKey key = new SecretKeySpec(bytes(16, 0x3C), "AES");
+    inProviders(BC_FIPS, () ->
+    {
+      final byte[] wrapped = RsaSveKeyTransport.wrap(keyPair.getPublic(), key, KEY_ID, draws);
+      assertThat(RsaSveKeyTransport.unwrap(keyPair, wrapped, "AES", KEY_ID).getEncoded())
+          .isEqualTo(key.getEncoded());
+      return null;
+    });
+  }
+
+  /**
+   * A provider may drop the leading zero bytes of the encapsulated value as well, which the
+   * wrapping puts back: the value is stored on as many bytes as the modulus, the only length the
+   * unwrapping accepts.
+   */
+  @Test
+  public void testEncapsulatedValueWithALeadingZeroByteKeepsTheModulusLength() throws Exception
+  {
+    final BigInteger n = modulus(keyPair);
+    final BigInteger e = ((RSAPublicKey) keyPair.getPublic()).getPublicExponent();
+    final BigInteger upperBound = n.subtract(BigInteger.ONE);
+    // About one secret value in 256 has an encapsulated value z^e mod n below 2^2040.
+    BigInteger z;
+    do
+    {
+      z = new BigInteger(n.bitLength(), random);
+    }
+    while (z.compareTo(BigInteger.ONE) <= 0 || z.compareTo(upperBound) >= 0 || z.modPow(e, n).bitLength() > 2040);
+    final Draws draws = new Draws(toBytes(z));
+    final SecretKey key = new SecretKeySpec(bytes(16, 0x3C), "AES");
+    inProviders(BC_FIPS, () ->
+    {
+      final Provider droppingRsa = new Provider("LeadingZeroDroppingRsa", "1.0", "Raw RSA without leading zeros") {};
+      droppingRsa.put("Cipher.RSA", LeadingZeroDroppingRawRsa.class.getName());
+      Security.insertProviderAt(droppingRsa, 1);
+      try
+      {
+        final byte[] wrapped = RsaSveKeyTransport.wrap(keyPair.getPublic(), key, KEY_ID, draws);
+        assertThat(ByteBuffer.wrap(wrapped).getShort()).isEqualTo((short) 256);
+        assertThat(wrapped[2]).isEqualTo((byte) 0);
+        assertThat(RsaSveKeyTransport.unwrap(keyPair, wrapped, "AES", KEY_ID).getEncoded())
+            .isEqualTo(key.getEncoded());
+      }
+      finally
+      {
+        Security.removeProvider(droppingRsa.getName());
+      }
+      return null;
+    });
+  }
+
+  /** Nothing assumes a 2048-bit modulus: instance keys generated before 2015 have 1024 bits. */
+  @Test
+  public void testSmallerModulusRoundTrips() throws Exception
+  {
+    final KeyPair small = rsaKeyPair(1024);
+    final SecretKey key = new SecretKeySpec(bytes(16, 0x3C), "AES");
+    inProviders(JDK, () ->
+    {
+      final byte[] wrapped = RsaSveKeyTransport.wrap(small.getPublic(), key, KEY_ID, random);
+      assertThat(ByteBuffer.wrap(wrapped).getShort()).isEqualTo((short) 128);
+      assertThat(RsaSveKeyTransport.unwrap(small, wrapped, "AES", KEY_ID).getEncoded())
+          .isEqualTo(key.getEncoded());
+      return null;
+    });
+  }
+
+  /** An instance key which is not an RSA key is refused with an exception the crypto manager reports. */
+  @Test
+  public void testNonRsaPublicKeyIsRefusedWithASecurityException() throws Exception
+  {
+    final KeyPairGenerator generator = KeyPairGenerator.getInstance("EC");
+    generator.initialize(256);
+    final PublicKey ecKey = generator.generateKeyPair().getPublic();
+    final SecretKey key = new SecretKeySpec(bytes(16, 0x3C), "AES");
+    assertThatThrownBy(() -> RsaSveKeyTransport.wrap(ecKey, key, KEY_ID, random))
+        .isInstanceOf(InvalidKeyException.class);
+  }
+
+  /**
+   * The unwrapping refuses an instance key pair without its private key, as a trust store entry
+   * holding only a certificate gives, or whose public key is not an RSA key, with an exception the
+   * crypto manager reports and before any cipher.
+   */
+  @Test
+  public void testUnusableInstanceKeyPairIsRefusedWithASecurityException() throws Exception
+  {
+    final byte[] wrapped = { 0, 3, 1, 2, 3, 4 };
+    assertThatThrownBy(() -> RsaSveKeyTransport.unwrap(new KeyPair(keyPair.getPublic(), null), wrapped, "AES", KEY_ID))
+        .isInstanceOf(InvalidKeyException.class)
+        .hasMessageContaining("needs the private key");
+    final KeyPairGenerator generator = KeyPairGenerator.getInstance("EC");
+    generator.initialize(256);
+    final KeyPair ecKeyPair = new KeyPair(generator.generateKeyPair().getPublic(), keyPair.getPrivate());
+    assertThatThrownBy(() -> RsaSveKeyTransport.unwrap(ecKeyPair, wrapped, "AES", KEY_ID))
+        .isInstanceOf(InvalidKeyException.class)
+        .hasMessageContaining("needs an RSA public key");
+  }
+
+  @DataProvider
+  public Object[][] malformedWrappedKeys()
+  {
+    return new Object[][] {
+      { "empty", new byte[0] },
+      { "one byte", new byte[] { 1 } },
+      { "zero length", new byte[] { 0, 0, 1, 2, 3 } },
+      { "length beyond the end", new byte[] { 0, 9, 1, 2, 3 } },
+      { "no wrapped key after the encapsulated value", new byte[] { 0, 3, 1, 2, 3 } } };
+  }
+
+  /** A malformed value in cn=secret keys is refused with an exception the crypto manager reports. */
+  @Test(dataProvider = "malformedWrappedKeys")
+  public void testMalformedWrappedKeyIsRefusedWithASecurityException(final String what, final byte[] wrapped)
+  {
+    assertThatThrownBy(() -> RsaSveKeyTransport.unwrap(keyPair, wrapped, "AES", KEY_ID))
+        .as(what).isInstanceOf(InvalidKeyException.class).hasMessageContaining("truncated");
+  }
+
+  @DataProvider
+  public Object[][] encapsulatedValuesRefusedBeforeDecrypting()
+  {
+    final BigInteger n = modulus(keyPair);
+    return new Object[][] {
+      { "one byte shorter than the modulus", bytes(255, 0x5A), "not as long as the RSA modulus" },
+      { "longer than the modulus", bytes(300, 0x5A), "not as long as the RSA modulus" },
+      { "0", new byte[256], "out of range" },
+      { "1", toBytes(BigInteger.ONE), "out of range" },
+      { "n - 1", toBytes(n.subtract(BigInteger.ONE)), "out of range" },
+      { "n", toBytes(n), "out of range" },
+      { "2^2048 - 1", bytes(256, 0xFF), "out of range" } };
+  }
+
+  /**
+   * An encapsulated value which is not on as many bytes as the modulus, or not an integer c with
+   * 1 < c < n - 1, is refused before the private key decrypts it, whatever the providers: Bouncy
+   * Castle FIPS throws an ArrayIndexOutOfBoundsException for a value longer than the modulus.
+   */
+  @Test(dataProvider = "encapsulatedValuesRefusedBeforeDecrypting")
+  public void testEncapsulatedValueIsCheckedBeforeDecrypting(final String what, final byte[] encapsulated,
+      final String message) throws Exception
+  {
+    final byte[] wrapped = wrappedKey(encapsulated, bytes(24, 0x3C));
+    final Callable<Void> unwrap = () ->
+    {
+      assertThatThrownBy(() -> RsaSveKeyTransport.unwrap(keyPair, wrapped, "AES", KEY_ID))
+          .as(what).isInstanceOf(InvalidKeyException.class).hasMessageContaining(message);
+      return null;
+    };
+    unwrap.call();
+    inProviders(BC_FIPS, unwrap);
+  }
+
+  @DataProvider
+  public Object[][] encapsulatedValuesInRange()
+  {
+    final BigInteger n = modulus(keyPair);
+    final Object[][] values = {
+      { "2", toBytes(BigInteger.valueOf(2)) },
+      { "n - 2", toBytes(n.subtract(BigInteger.valueOf(2))) },
+      { "0x5A on every byte", bytes(256, 0x5A) } };
+    final Object[][] rows = new Object[values.length * 2][];
+    for (int i = 0; i < values.length; i++)
+    {
+      rows[2 * i] = new Object[] { JDK, values[i][0], values[i][1] };
+      rows[2 * i + 1] = new Object[] { BC_FIPS, values[i][0], values[i][1] };
+    }
+    return rows;
+  }
+
+  /**
+   * Whatever secret value the private key recovers from an encapsulated value in range, a wrapped
+   * key which does not unwrap is refused with one and the same message.
+   */
+  @Test(dataProvider = "encapsulatedValuesInRange")
+  public void testUnwrapFailureHasOneMessage(final String providers, final String what, final byte[] encapsulated)
+      throws Exception
+  {
+    final SecretKey key = new SecretKeySpec(bytes(16, 0x3C), "AES");
+    final Callable<Void> unwrap = () ->
+    {
+      final byte[] genuine = RsaSveKeyTransport.wrap(keyPair.getPublic(), key, KEY_ID, random);
+      final byte[] wrapped = wrappedKey(encapsulated, Arrays.copyOfRange(genuine, 2 + 256, genuine.length));
+      assertThatThrownBy(() -> RsaSveKeyTransport.unwrap(keyPair, wrapped, "AES", KEY_ID))
+          .as(what).isInstanceOf(InvalidKeyException.class)
+          .hasMessage(RsaSveKeyTransport.NAME + " cannot unwrap the key");
+      return null;
+    };
+    inProviders(providers, unwrap);
+  }
+
+  /**
+   * The support check names AES/KWP/NoPadding when the runtime offers raw RSA and SHA-256 but no
+   * AES-KWP, as a Java 11 runtime does without Bouncy Castle FIPS.
+   */
+  @Test
+  public void testCheckSupportedNamesTheMissingKeyWrap() throws Exception
+  {
+    withoutKeyWrap(() ->
+    {
+      assertThatThrownBy(RsaSveKeyTransport::checkSupported)
+          .isInstanceOf(GeneralSecurityException.class)
+          .hasMessageContaining("AES/KWP/NoPadding");
+      return null;
+    });
+  }
+
+  /**
+   * The unwrapping names AES/KWP/NoPadding on a runtime which lacks it, as a server which does not
+   * use the scheme but receives keys wrapped with it does: no cipher depends on the value, so such a
+   * runtime is not reported as a value which cannot be unwrapped.
+   */
+  @Test
+  public void testUnwrapNamesTheMissingKeyWrap() throws Exception
+  {
+    final byte[] wrapped = wrappedKey(toBytes(BigInteger.valueOf(2)), bytes(24, 0x3C));
+    withoutKeyWrap(() ->
+    {
+      assertThatThrownBy(() -> RsaSveKeyTransport.unwrap(keyPair, wrapped, "AES", KEY_ID))
+          .isInstanceOf(GeneralSecurityException.class)
+          .hasMessageContaining("AES/KWP/NoPadding");
+      return null;
+    });
+  }
+
+  /**
+   * The unwrapping reports why the RSA cipher refuses the private key, as a provider which cannot
+   * use the key of the trust store does: the private key does not depend on the value either, so
+   * such a runtime is not reported as a value which cannot be unwrapped.
+   */
+  @Test
+  public void testUnwrapReportsWhyTheRsaCipherRefusesThePrivateKey() throws Exception
+  {
+    final KeyPairGenerator generator = KeyPairGenerator.getInstance("EC");
+    generator.initialize(256);
+    final PrivateKey ecKey = generator.generateKeyPair().getPrivate();
+    final Throwable refusal =
+        catchThrowable(() -> Cipher.getInstance("RSA/ECB/NoPadding").init(Cipher.DECRYPT_MODE, ecKey));
+    assertThat(refusal).isInstanceOf(InvalidKeyException.class);
+    final byte[] wrapped = wrappedKey(toBytes(BigInteger.valueOf(2)), bytes(24, 0x3C));
+    assertThatThrownBy(() -> RsaSveKeyTransport.unwrap(new KeyPair(keyPair.getPublic(), ecKey), wrapped, "AES", KEY_ID))
+        .isInstanceOf(InvalidKeyException.class)
+        .hasMessage(refusal.getMessage());
+  }
+
+  /**
+   * With Bouncy Castle FIPS installed first, as in the FIPS mode of OpenDJ, Bouncy Castle FIPS does
+   * the key wrap even where the Java runtime offers AES/KWP/NoPadding too, a name it does not know.
+   */
+  @Test
+  public void testKeyWrapComesFromBcFipsWhenItIsFirst() throws Exception
+  {
+    assertThat(withBcFipsFirst(() -> RsaSveKeyTransport.getKeyWrapCipher().getProvider().getName()))
+        .isEqualTo(BouncyCastleFipsProvider.PROVIDER_NAME);
+  }
+
+  /** Runs the action with the providers of the Java runtime, or with AES-KWP from Bouncy Castle FIPS only. */
+  private static void inProviders(final String providers, final Callable<Void> action) throws Exception
+  {
+    final boolean jdkOffersKwp = Security.getProviders("Cipher.AES/KWP/NoPadding") != null;
+    if (JDK.equals(providers))
+    {
+      if (!jdkOffersKwp)
+      {
+        throw new SkipException("This Java runtime offers no AES/KWP/NoPadding");
+      }
+      action.call();
+    }
+    else if (jdkOffersKwp)
+    {
+      withoutJceService("Cipher", "AES/KWP/NoPadding", action, new BouncyCastleFipsProvider());
+    }
+    else
+    {
+      withBcFipsFirst(action);
+    }
+  }
+
+  private static KeyPair rsaKeyPair(final int bits) throws Exception
+  {
+    final KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA", "SunRsaSign");
+    generator.initialize(bits);
+    return generator.generateKeyPair();
+  }
+
+  private static BigInteger modulus(final KeyPair keyPair)
+  {
+    return ((RSAPublicKey) keyPair.getPublic()).getModulus();
+  }
+
+  private static byte[] bytes(final int length, final int value)
+  {
+    final byte[] bytes = new byte[length];
+    Arrays.fill(bytes, (byte) value);
+    return bytes;
+  }
+
+  /** Returns a wrapped key: the length of the encapsulated value on two bytes, that value, then the KWP-wrapped key. */
+  private static byte[] wrappedKey(final byte[] encapsulated, final byte[] wrapped)
+  {
+    return ByteBuffer.allocate(2 + encapsulated.length + wrapped.length)
+        .putShort((short) encapsulated.length)
+        .put(encapsulated)
+        .put(wrapped)
+        .array();
+  }
+
+  /** Returns the value on 256 bytes, big-endian, without the sign byte BigInteger may add. */
+  private static byte[] toBytes(final BigInteger value)
+  {
+    final byte[] bytes = value.toByteArray();
+    final byte[] result = new byte[256];
+    final int length = Math.min(bytes.length, result.length);
+    System.arraycopy(bytes, bytes.length - length, result, result.length - length, length);
+    return result;
+  }
+
+  /**
+   * A random generator which hands out the given values, one per draw, the last one again once
+   * they are all drawn, and counts the draws. Created outside any provider fixture, so that its
+   * own default generator comes from the SUN provider.
+   */
+  private static final class Draws extends SecureRandom
+  {
+    private static final long serialVersionUID = 1L;
+    private final byte[][] values;
+    private final AtomicInteger draws = new AtomicInteger();
+
+    private Draws(final byte[]... values)
+    {
+      this.values = values;
+    }
+
+    @Override
+    public void nextBytes(final byte[] bytes)
+    {
+      final byte[] value = values[Math.min(draws.getAndIncrement(), values.length - 1)];
+      assertThat(value).hasSize(bytes.length);
+      System.arraycopy(value, 0, bytes, 0, bytes.length);
+    }
+
+    int count()
+    {
+      return draws.get();
+    }
+  }
+
+  /**
+   * A raw RSA cipher which returns what it encrypts or decrypts on as few bytes as the value needs,
+   * without its leading zero bytes.
+   */
+  public static final class LeadingZeroDroppingRawRsa extends CipherSpi
+  {
+    private final ByteArrayOutputStream input = new ByteArrayOutputStream();
+    private RSAKey key;
+    private BigInteger exponent;
+
+    @Override
+    protected void engineSetMode(final String mode) throws NoSuchAlgorithmException
+    {
+      if (!"ECB".equalsIgnoreCase(mode))
+      {
+        throw new NoSuchAlgorithmException(mode);
+      }
+    }
+
+    @Override
+    protected void engineSetPadding(final String padding) throws NoSuchPaddingException
+    {
+      if (!"NoPadding".equalsIgnoreCase(padding))
+      {
+        throw new NoSuchPaddingException(padding);
+      }
+    }
+
+    @Override
+    protected int engineGetBlockSize()
+    {
+      return 0;
+    }
+
+    @Override
+    protected int engineGetOutputSize(final int inputLength)
+    {
+      return (key.getModulus().bitLength() + 7) / 8;
+    }
+
+    @Override
+    protected byte[] engineGetIV()
+    {
+      return null;
+    }
+
+    @Override
+    protected AlgorithmParameters engineGetParameters()
+    {
+      return null;
+    }
+
+    @Override
+    protected void engineInit(final int mode, final Key key, final SecureRandom random) throws InvalidKeyException
+    {
+      if (mode == Cipher.ENCRYPT_MODE && key instanceof RSAPublicKey)
+      {
+        exponent = ((RSAPublicKey) key).getPublicExponent();
+      }
+      else if (mode == Cipher.DECRYPT_MODE && key instanceof RSAPrivateKey)
+      {
+        exponent = ((RSAPrivateKey) key).getPrivateExponent();
+      }
+      else
+      {
+        throw new InvalidKeyException("Mode " + mode + " with " + key.getClass().getName());
+      }
+      this.key = (RSAKey) key;
+      input.reset();
+    }
+
+    @Override
+    protected void engineInit(final int mode, final Key key, final AlgorithmParameterSpec params,
+        final SecureRandom random) throws InvalidKeyException
+    {
+      engineInit(mode, key, random);
+    }
+
+    @Override
+    protected void engineInit(final int mode, final Key key, final AlgorithmParameters params,
+        final SecureRandom random) throws InvalidKeyException
+    {
+      engineInit(mode, key, random);
+    }
+
+    @Override
+    protected byte[] engineUpdate(final byte[] in, final int offset, final int length)
+    {
+      input.write(in, offset, length);
+      return new byte[0];
+    }
+
+    @Override
+    protected int engineUpdate(final byte[] in, final int offset, final int length, final byte[] out,
+        final int outOffset)
+    {
+      input.write(in, offset, length);
+      return 0;
+    }
+
+    @Override
+    protected byte[] engineDoFinal(final byte[] in, final int offset, final int length)
+    {
+      input.write(in, offset, length);
+      final byte[] result = new BigInteger(1, input.toByteArray()).modPow(exponent, key.getModulus()).toByteArray();
+      input.reset();
+      int start = 0;
+      while (start < result.length - 1 && result[start] == 0)
+      {
+        start++;
+      }
+      return Arrays.copyOfRange(result, start, result.length);
+    }
+
+    @Override
+    protected int engineDoFinal(final byte[] in, final int offset, final int length, final byte[] out,
+        final int outOffset)
+    {
+      final byte[] result = engineDoFinal(in, offset, length);
+      System.arraycopy(result, 0, out, outOffset, result.length);
+      return result.length;
+    }
+  }
+}

--
Gitblit v1.10.0