Skip to content

Commit e1cf818

Browse files
committed
ML-DSA and SLH-DSA KeyFactory.translateKey now convert keys from other providers via their encodings.
1 parent 463dfa7 commit e1cf818

5 files changed

Lines changed: 226 additions & 0 deletions

File tree

‎docs/releasenotes.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ Date: 2026, TBD
7171
- The CMS 1-Pass ECMQV key agreement schemes (mqvSinglePass-sha1kdf-scheme and the SHA-2 variants, RFC 5753) gave the X9.63 KDF the raw addedukm as its SharedInfo, or nothing when there was none, where RFC 5753 sec. 3.2.2, 3.2.3 and 7.2 require the DER-encoded ECC-CMS-SharedInfo carrying the addedukm as its entityUInfo, exactly as the ECDH schemes already did. Both JceKeyAgreeRecipientInfoGenerator and JceKeyAgreeRecipient had done this since 1.53, so BC read its own ECMQV messages but a conformant implementation could not, and BC could read a conformant ECMQV message only for the SHA-1 KDF with a NULL-parameter key wrap. Both sides now use ECC-CMS-SharedInfo, for EnvelopedData, AuthenticatedData and AuthEnvelopedData alike. Messages from earlier BC versions are still read, through the raw-ukm retry the recipient already made for ECDH, which the new property org.bouncycastle.cms.allow_legacy_keyagree_kdf (default true) now controls for both schemes; earlier BC versions cannot read ECMQV messages from this one unless the SHA-1 KDF is used with a NULL-parameter key wrap. The recipient's retry chain also now falls through from the RFC 3278 form to the raw-ukm form and reports the original failure, where a failed RFC 3278 retry used to end the attempt with its own exception.
7272
- JceKeyAgreeRecipientInfoGenerator created its 1-pass ECMQV ephemeral key pair once per generator rather than once per message, so a generator used for more than one message sent the same ephemeral key in each and derived the same key-encryption key for every recipient each time, withdrawing the ephemeral contribution RFC 5753 sec. 3.2 relies on. The messages were well formed and decrypted correctly. A fresh key pair is now generated for each KeyAgreeRecipientInfo, still shared by all of its recipients.
7373
- The ML-KEM KeyGenerator (KEMGenerateSpec/KEMExtractSpec), Cipher (wrap/unwrap), javax.crypto.KEM and KeyFactory.translateKey services accepted only BC's own ML-KEM key objects, and the KeyGenerator failed with a ClassCastException at generateKey() rather than at init, so an ML-KEM key from another provider could not be used with BC even though its standard encoding was one BC reads. This broke BCJSSE handshakes over the ML-KEM and hybrid groups whenever another provider ahead of BC decoded the peer's key or generated the ephemeral key pair. A foreign key is now converted from its X.509 or PKCS#8 encoding, with the usual parameter-set checks, and an unusable one is rejected at init (github #2466).
74+
- The ML-DSA and SLH-DSA KeyFactory.translateKey accepted only BC's own key objects and refused any other with "unsupported key type", so a key from another provider - such as the ML-DSA keys of the JDK's SUN provider from Java 24, which report only the family name "ML-DSA" - could not be translated, although its standard encoding is one BC reads. A foreign key is now converted from its X.509 or PKCS#8 encoding, as ML-KEM keys already are; a key for a parameter set the factory does not handle is refused with an InvalidKeyException carrying the decoding failure as its cause.
7475
- The raw JCA provider bounded the PBKDF2 iteration count taken from an encoding (org.bouncycastle.pbe.max_iteration_count, default 10,000,000) but not the counts of the legacy PBES1 (PKCS#5 scheme 1) and PKCS#12 PBE families beside it. Their AlgorithmParameters (PKCS12PBE and its OID aliases, PBKDF1) accepted any count, narrowing one beyond the int range with intValue() so that 2^32 arrived as 0, and every Cipher, Mac and SecretKeyFactory derivation ran with whatever count it was given - including a count decoded by another provider's AlgorithmParameters, as when javax.crypto.EncryptedPrivateKeyInfo.getKeySpec() decrypts a PKCS#12 PBE-protected key with BC. As these schemes carry the count in unauthenticated parameters and derive before anything can be checked, a supplied blob could hold a derivation for tens of minutes. The parameter parse now rejects a negative, beyond-int or over-limit count, and the derivations reject a negative or over-limit count, under the same property as PBKDF2. The PKCS#12 key store derives through the same code, so a org.bouncycastle.pkcs12.max_it_count raised above 10,000,000 now needs org.bouncycastle.pbe.max_iteration_count raised with it.
7576
- The ML-DSA public key constructor taking rho and t1 separately checked only for null, so an ML-DSA SubjectPublicKeyInfo in the SEQUENCE { OCTET STRING rho, OCTET STRING t1 } form with a t1 of the wrong length was accepted by PublicKeyFactory and the JCA KeyFactory, unlike the same key in the standard raw encoding. The component constructors for ML-DSA private keys (rho, K, tr, s1, s2, t0, t1, seed) and SLH-DSA private keys (skSeed, prf, pkSeed, pkRoot) likewise accepted components of any length. All three now reject a component whose length does not match the parameter set with an IllegalArgumentException.
7677
- The light-weight CryptoProWrapEngine (RFC 4357 sec. 6.3) diversified the key encryption key in the caller's own array, so after init the KeyParameter it was given held the diversified key, and initialising again with the same parameters - to unwrap what had just been wrapped, say - diversified it a second time and used a different key. It also failed with a NullPointerException when given no S-box, although init has a branch for that case. It now diversifies a copy, and given no S-box uses the GOST 28147 engine's default S-box for the diversification, the one the wrap itself then uses. The provider's GOST 28147 key wrap ciphers were unaffected, as they always supply an S-box and build a new KeyParameter on every init.

‎prov/src/main/java/org/bouncycastle/jcajce/provider/asymmetric/mldsa/MLDSAKeyFactorySpi.java‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import org.bouncycastle.crypto.params.MLDSAPrivateKeyParameters;
2121
import org.bouncycastle.crypto.params.MLDSAPublicKeyParameters;
2222
import org.bouncycastle.jcajce.provider.asymmetric.util.BasePQCKeyFactorySpi;
23+
import org.bouncycastle.jcajce.provider.util.SecurityExceptions;
2324
import org.bouncycastle.jcajce.spec.MLDSAPrivateKeySpec;
2425
import org.bouncycastle.jcajce.spec.MLDSAPublicKeySpec;
2526
import org.bouncycastle.util.Arrays;
@@ -117,6 +118,38 @@ public final Key engineTranslateKey(Key key)
117118
return key;
118119
}
119120

121+
// a key from another provider is re-read from its standard encoding
122+
try
123+
{
124+
if (key instanceof PublicKey && "X.509".equals(key.getFormat()))
125+
{
126+
byte[] enc = key.getEncoded();
127+
if (enc != null)
128+
{
129+
return engineGeneratePublic(new X509EncodedKeySpec(enc));
130+
}
131+
}
132+
else if (key instanceof PrivateKey && "PKCS#8".equals(key.getFormat()))
133+
{
134+
byte[] enc = key.getEncoded();
135+
if (enc != null)
136+
{
137+
try
138+
{
139+
return engineGeneratePrivate(new PKCS8EncodedKeySpec(enc));
140+
}
141+
finally
142+
{
143+
Arrays.clear(enc);
144+
}
145+
}
146+
}
147+
}
148+
catch (InvalidKeySpecException e)
149+
{
150+
throw SecurityExceptions.invalidKeyException("unsupported key type: " + e.getMessage(), e);
151+
}
152+
120153
throw new InvalidKeyException("unsupported key type");
121154
}
122155

‎prov/src/main/java/org/bouncycastle/jcajce/provider/asymmetric/slhdsa/SLHDSAKeyFactorySpi.java‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@
1616
import org.bouncycastle.asn1.nist.NISTObjectIdentifiers;
1717
import org.bouncycastle.asn1.pkcs.PrivateKeyInfo;
1818
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
19+
import org.bouncycastle.jcajce.provider.util.SecurityExceptions;
1920
import org.bouncycastle.pqc.jcajce.provider.util.BaseKeyFactorySpi;
21+
import org.bouncycastle.util.Arrays;
2022

2123
public class SLHDSAKeyFactorySpi
2224
extends BaseKeyFactorySpi
@@ -113,6 +115,38 @@ public final Key engineTranslateKey(Key key)
113115
return key;
114116
}
115117

118+
// a key from another provider is re-read from its standard encoding
119+
try
120+
{
121+
if (key instanceof PublicKey && "X.509".equals(key.getFormat()))
122+
{
123+
byte[] enc = key.getEncoded();
124+
if (enc != null)
125+
{
126+
return engineGeneratePublic(new X509EncodedKeySpec(enc));
127+
}
128+
}
129+
else if (key instanceof PrivateKey && "PKCS#8".equals(key.getFormat()))
130+
{
131+
byte[] enc = key.getEncoded();
132+
if (enc != null)
133+
{
134+
try
135+
{
136+
return engineGeneratePrivate(new PKCS8EncodedKeySpec(enc));
137+
}
138+
finally
139+
{
140+
Arrays.clear(enc);
141+
}
142+
}
143+
}
144+
}
145+
catch (InvalidKeySpecException e)
146+
{
147+
throw SecurityExceptions.invalidKeyException("Unsupported key type: " + e.getMessage(), e);
148+
}
149+
116150
throw new InvalidKeyException("Unsupported key type");
117151
}
118152

‎prov/src/test/java/org/bouncycastle/pqc/jcajce/provider/test/MLDSATest.java‎

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import java.security.KeyPair;
1212
import java.security.KeyPairGenerator;
1313
import java.security.PrivateKey;
14+
import java.security.PublicKey;
1415
import java.security.SecureRandom;
1516
import java.security.Security;
1617
import java.security.Signature;
@@ -93,6 +94,126 @@ public void testParametersAndParamSpecs()
9394
}
9495
}
9596

97+
/**
98+
* KeyFactory.translateKey converts ML-DSA keys from another provider via their encodings. The
99+
* JDK's own keys report only the family name, "ML-DSA", whatever their parameter set.
100+
*/
101+
public void testTranslateForeignKeys()
102+
throws Exception
103+
{
104+
for (int i = 0; i != names.length; i++)
105+
{
106+
KeyPair kp = KeyPairGenerator.getInstance(names[i], "BC").generateKeyPair();
107+
108+
PublicKey fPub = new ForeignPublicKey("ML-DSA", kp.getPublic().getEncoded());
109+
PrivateKey fPriv = new ForeignPrivateKey("ML-DSA", kp.getPrivate().getEncoded());
110+
111+
String[] kfNames = { names[i].indexOf("WITH") > 0 ? "HASH-ML-DSA" : "ML-DSA", names[i] };
112+
for (int j = 0; j != kfNames.length; j++)
113+
{
114+
KeyFactory kf = KeyFactory.getInstance(kfNames[j], "BC");
115+
assertEquals(kp.getPublic(), kf.translateKey(fPub));
116+
assertEquals(kp.getPrivate(), kf.translateKey(fPriv));
117+
}
118+
}
119+
120+
// a parameter set the factory is not for is refused, with the decoding failure as the cause
121+
KeyPair kp65 = KeyPairGenerator.getInstance("ML-DSA-65", "BC").generateKeyPair();
122+
checkTranslateRefused(KeyFactory.getInstance("ML-DSA-44", "BC"),
123+
new ForeignPublicKey("ML-DSA", kp65.getPublic().getEncoded()), true);
124+
checkTranslateRefused(KeyFactory.getInstance("ML-DSA-44", "BC"),
125+
new ForeignPrivateKey("ML-DSA", kp65.getPrivate().getEncoded()), true);
126+
127+
// as is a key with no encoding, or not in the standard format
128+
checkTranslateRefused(KeyFactory.getInstance("ML-DSA", "BC"), new ForeignPublicKey("ML-DSA", null), false);
129+
checkTranslateRefused(KeyFactory.getInstance("ML-DSA", "BC"), new ForeignPrivateKey("ML-DSA", null), false);
130+
checkTranslateRefused(KeyFactory.getInstance("ML-DSA", "BC"),
131+
new ForeignPublicKey("ML-DSA", "RAW", kp65.getPublic().getEncoded()), false);
132+
}
133+
134+
static void checkTranslateRefused(KeyFactory kf, java.security.Key key, boolean hasCause)
135+
{
136+
try
137+
{
138+
kf.translateKey(key);
139+
fail("no exception");
140+
}
141+
catch (InvalidKeyException e)
142+
{
143+
assertTrue(e.getMessage(), e.getMessage().toLowerCase().startsWith("unsupported key type"));
144+
assertEquals(hasCause, e.getCause() instanceof InvalidKeySpecException);
145+
}
146+
}
147+
148+
/**
149+
* A public key as another provider might present it: only the algorithm family name, X.509 encoding.
150+
*/
151+
static class ForeignPublicKey
152+
implements PublicKey
153+
{
154+
private final String algorithm;
155+
private final String format;
156+
private final byte[] encoding;
157+
158+
ForeignPublicKey(String algorithm, byte[] encoding)
159+
{
160+
this(algorithm, "X.509", encoding);
161+
}
162+
163+
ForeignPublicKey(String algorithm, String format, byte[] encoding)
164+
{
165+
this.algorithm = algorithm;
166+
this.format = format;
167+
this.encoding = encoding;
168+
}
169+
170+
public String getAlgorithm()
171+
{
172+
return algorithm;
173+
}
174+
175+
public String getFormat()
176+
{
177+
return format;
178+
}
179+
180+
public byte[] getEncoded()
181+
{
182+
return Arrays.clone(encoding);
183+
}
184+
}
185+
186+
/**
187+
* A private key as another provider might present it: only the algorithm family name, PKCS#8 encoding.
188+
*/
189+
static class ForeignPrivateKey
190+
implements PrivateKey
191+
{
192+
private final String algorithm;
193+
private final byte[] encoding;
194+
195+
ForeignPrivateKey(String algorithm, byte[] encoding)
196+
{
197+
this.algorithm = algorithm;
198+
this.encoding = encoding;
199+
}
200+
201+
public String getAlgorithm()
202+
{
203+
return algorithm;
204+
}
205+
206+
public String getFormat()
207+
{
208+
return "PKCS#8";
209+
}
210+
211+
public byte[] getEncoded()
212+
{
213+
return Arrays.clone(encoding);
214+
}
215+
}
216+
96217
public void testKeyFactory()
97218
throws Exception
98219
{

‎prov/src/test/java/org/bouncycastle/pqc/jcajce/provider/test/SLHDSATest.java‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import java.io.ObjectInputStream;
66
import java.io.ObjectOutputStream;
77
import java.security.AlgorithmParameters;
8+
import java.security.PrivateKey;
9+
import java.security.PublicKey;
810
import java.security.KeyFactory;
911
import java.security.KeyPair;
1012
import java.security.KeyPairGenerator;
@@ -118,6 +120,41 @@ public void testParametersAndParamSpecs()
118120
}
119121
}
120122

123+
/**
124+
* KeyFactory.translateKey converts SLH-DSA keys from another provider via their encodings.
125+
*/
126+
public void testTranslateForeignKeys()
127+
throws Exception
128+
{
129+
String[] kpNames = { "SLH-DSA-SHA2-128F", "SLH-DSA-SHAKE-128F-WITH-SHAKE128" };
130+
for (int i = 0; i != kpNames.length; i++)
131+
{
132+
KeyPair kp = KeyPairGenerator.getInstance(kpNames[i], "BC").generateKeyPair();
133+
134+
PublicKey fPub = new MLDSATest.ForeignPublicKey("SLH-DSA", kp.getPublic().getEncoded());
135+
PrivateKey fPriv = new MLDSATest.ForeignPrivateKey("SLH-DSA", kp.getPrivate().getEncoded());
136+
137+
String[] kfNames = { i == 0 ? "SLH-DSA" : "HASH-SLH-DSA", kpNames[i] };
138+
for (int j = 0; j != kfNames.length; j++)
139+
{
140+
KeyFactory kf = KeyFactory.getInstance(kfNames[j], "BC");
141+
assertEquals(kp.getPublic(), kf.translateKey(fPub));
142+
assertEquals(kp.getPrivate(), kf.translateKey(fPriv));
143+
}
144+
}
145+
146+
// a parameter set the factory is not for is refused, with the decoding failure as the cause
147+
KeyPair kp192 = KeyPairGenerator.getInstance("SLH-DSA-SHA2-192F", "BC").generateKeyPair();
148+
MLDSATest.checkTranslateRefused(KeyFactory.getInstance("SLH-DSA-SHA2-128F", "BC"),
149+
new MLDSATest.ForeignPublicKey("SLH-DSA", kp192.getPublic().getEncoded()), true);
150+
MLDSATest.checkTranslateRefused(KeyFactory.getInstance("SLH-DSA-SHA2-128F", "BC"),
151+
new MLDSATest.ForeignPrivateKey("SLH-DSA", kp192.getPrivate().getEncoded()), true);
152+
153+
// as is a key with no encoding
154+
MLDSATest.checkTranslateRefused(KeyFactory.getInstance("SLH-DSA", "BC"),
155+
new MLDSATest.ForeignPublicKey("SLH-DSA", null), false);
156+
}
157+
121158
public void testKeyFactory()
122159
throws Exception
123160
{

0 commit comments

Comments
 (0)