Skip to content

Commit 6395c48

Browse files
committed
MLDSASigner.verifyMu now derives the expected mu from the message digest as generateMu does and compares in constant time, leaving the signer usable afterwards, and the ML-DSA-CALCULATE-MU service reports a mu of the wrong length with a SignatureException; the verify side's contract is documented on both.
1 parent ce1aa65 commit 6395c48

6 files changed

Lines changed: 154 additions & 12 deletions

File tree

‎core/src/main/java/org/bouncycastle/crypto/signers/MLDSASigner.java‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import org.bouncycastle.crypto.params.ParametersWithContext;
1414
import org.bouncycastle.crypto.params.ParametersWithRandom;
1515
import org.bouncycastle.crypto.signers.mldsa.MLDSAEngine;
16+
import org.bouncycastle.util.Arrays;
1617

1718
/**
1819
* ML-DSA (FIPS 204) signer/verifier.
@@ -171,18 +172,29 @@ public byte[] generateSignature()
171172
return sig;
172173
}
173174

175+
/**
176+
* Compare a mu value with the mu of the message fed through update(): SHAKE256 over tr, the context
177+
* and the message, which is what {@link #generateMu()} returns on the signing side for the same key,
178+
* context and message. The signer is reset afterwards, as it is after any other verification.
179+
*
180+
* @param mu the candidate mu, 64 bytes.
181+
* @return true if mu is the message's mu under this key and context, false otherwise.
182+
* @throws DataLengthException if mu is not 64 bytes long.
183+
*/
174184
public boolean verifyMu(byte[] mu)
175185
{
176186
if (mu.length != MLDSAEngine.CrhBytes)
177187
{
188+
reset();
189+
178190
throw new DataLengthException("mu value must be " + MLDSAEngine.CrhBytes + " bytes");
179191
}
180192

181-
boolean isTrue = engine.verifyInternalMu(mu);
193+
byte[] expected = engine.generateMu(msgDigest);
182194

183195
reset();
184196

185-
return isTrue;
197+
return Arrays.constantTimeAreEqual(expected, mu);
186198
}
187199

188200
public boolean verifySignature(byte[] signature)

‎core/src/main/java/org/bouncycastle/crypto/signers/mldsa/MLDSAEngine.java‎

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -499,15 +499,6 @@ public byte[] generateSignature(byte[] mu, SHAKEDigest shake256Digest, byte[] rh
499499
return null;
500500
}
501501

502-
public boolean verifyInternalMu(byte[] providedMu)
503-
{
504-
byte[] mu = new byte[CrhBytes];
505-
506-
shake256Digest.doFinal(mu, 0);
507-
508-
return Arrays.constantTimeAreEqual(mu, providedMu);
509-
}
510-
511502
public boolean verifyInternalMuSignature(byte[] mu, byte[] sig, int siglen, SHAKEDigest shake256Digest, byte[] rho, byte[] encT1)
512503
{
513504
byte[] buf = new byte[Math.max(CrhBytes + DilithiumK * DilithiumPolyW1PackedBytes, DilithiumCTilde)];

‎core/src/test/java/org/bouncycastle/pqc/crypto/test/MLDSATest.java‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,16 @@
1919
import org.bouncycastle.asn1.pkcs.PrivateKeyInfo;
2020
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
2121
import org.bouncycastle.crypto.AsymmetricCipherKeyPair;
22+
import org.bouncycastle.crypto.CipherParameters;
2223
import org.bouncycastle.crypto.CryptoException;
24+
import org.bouncycastle.crypto.DataLengthException;
2325
import org.bouncycastle.crypto.Signer;
2426
import org.bouncycastle.crypto.generators.MLDSAKeyPairGenerator;
2527
import org.bouncycastle.crypto.params.MLDSAKeyGenerationParameters;
2628
import org.bouncycastle.crypto.params.MLDSAParameters;
2729
import org.bouncycastle.crypto.params.MLDSAPrivateKeyParameters;
2830
import org.bouncycastle.crypto.params.MLDSAPublicKeyParameters;
31+
import org.bouncycastle.crypto.params.ParametersWithContext;
2932
import org.bouncycastle.crypto.params.ParametersWithRandom;
3033
import org.bouncycastle.crypto.signers.HashMLDSASigner;
3134
import org.bouncycastle.crypto.signers.MLDSASigner;
@@ -710,6 +713,60 @@ public void testHashExternalMu()
710713
}
711714
}
712715

716+
public void testCalculateMuVerify()
717+
throws Exception
718+
{
719+
MLDSAKeyPairGenerator kpGen = new MLDSAKeyPairGenerator();
720+
kpGen.init(new MLDSAKeyGenerationParameters(new SecureRandom(), MLDSAParameters.ml_dsa_65));
721+
AsymmetricCipherKeyPair kp = kpGen.generateKeyPair();
722+
723+
byte[][] messages = { new byte[0], Strings.toByteArray("Hello World!") };
724+
byte[][] contexts = { null, Strings.toByteArray("context") };
725+
726+
for (int c = 0; c != contexts.length; c++)
727+
{
728+
for (int m = 0; m != messages.length; m++)
729+
{
730+
byte[] message = messages[m];
731+
CipherParameters priv = contexts[c] == null ? (CipherParameters)kp.getPrivate() : new ParametersWithContext(kp.getPrivate(), contexts[c]);
732+
CipherParameters pub = contexts[c] == null ? (CipherParameters)kp.getPublic() : new ParametersWithContext(kp.getPublic(), contexts[c]);
733+
734+
MLDSASigner signer = new MLDSASigner();
735+
signer.init(true, priv);
736+
signer.update(message, 0, message.length);
737+
byte[] mu = signer.generateMu();
738+
signer.update(message, 0, message.length);
739+
byte[] signature = signer.generateSignature();
740+
741+
MLDSASigner verifier = new MLDSASigner();
742+
verifier.init(false, pub);
743+
verifier.update(message, 0, message.length);
744+
assertTrue("mu rejected, message length " + message.length, verifier.verifyMu(mu));
745+
746+
byte[] flipped = Arrays.clone(mu);
747+
flipped[0] ^= 1;
748+
verifier.update(message, 0, message.length);
749+
assertFalse(verifier.verifyMu(flipped));
750+
751+
// checking a mu must leave the verifier able to check an ordinary signature
752+
verifier.update(message, 0, message.length);
753+
assertTrue(verifier.verifySignature(signature));
754+
755+
try
756+
{
757+
verifier.verifyMu(new byte[3]);
758+
fail("short mu accepted");
759+
}
760+
catch (DataLengthException e)
761+
{
762+
assertEquals("mu value must be 64 bytes", e.getMessage());
763+
}
764+
verifier.update(message, 0, message.length);
765+
assertTrue(verifier.verifyMu(mu));
766+
}
767+
}
768+
}
769+
713770
public void testMLDSARejection()
714771
throws Exception
715772
{

‎docs/releasenotes.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Date: 2026, TBD
1818
- X500Name.equals compared two names that are equal but not byte-identical - the same RDNs in another order, or in another string type - by searching the second name for each RDN of the first and canonicalising both values on every candidate comparison, so the comparison was quadratic in the number of RDNs, and CMSSignedData.getSignerInfos(), the CMSEnvelopedData, CMSAuthEnvelopedData and CMSEnvelopedDataParser constructors, TimeStampToken, the S/MIME classes built on them and X509CertificateHolderSelector.match() all compare names taken from the message before anything in it is verified: a 1.1 MB signed or enveloped message carrying two 40,000 RDN issuer names took about 90 seconds of CPU to parse. The signer and recipient stores also indexed their entries in a HashMap keyed on the signer or recipient id, whose hash code is easy to make collide, so a message with 16,000 signers or recipients chosen to collide took several seconds to index. AbstractX500NameStyle.areEqual now compares the RDNs positionally and otherwise sorts the canonical form of each RDN once and compares the two multisets, so a comparison is n log n in the number of RDNs, the two stores now scan their entries on lookup instead of indexing them, and the selector checks the serial number before the issuer name.
1919
- The hash code of an ASN.1 object is a short fold over its content octets that a sender can make collide at will, and several tables built while a message is parsed were keyed on one: the extension table behind Extensions, so every certificate, CRL, OCSP and timestamp request parse, the deprecated X509Extensions, ExtendedKeyUsage, the CMS AttributeTable built from a SignerInfo's signed and unsigned attributes (the unsigned ones lying outside the signature), the EST CSRAttributesResponse index, and the Set of digest AlgorithmIdentifiers that the CMSSignedDataParser constructor and CMSSignedData.getDigestAlgorithmIDs() build from the SignedData digestAlgorithms field. With every key sharing one hash code each insert scanned all those before it, so a 6 MB certificate or message holding 65,000 such entries took about 35 seconds of CPU to parse and the parser's digest algorithm set about 140 seconds. Those tables are now ordered maps keyed on the OID's string form and the digest algorithm sets are ordered on the identifier's DER encoding, so each is n log n in the number of entries whatever their hash codes. AttributeTable.add() and remove() also no longer share a multi-valued entry's Vector with the table they were derived from.
2020
- CMSSignedData digested the encapsulated or detached content once for every signer it verified, so a message carrying many SignerInfos over a large content cost their product in digesting before any signature was checked, where CMSSignedDataParser digests once per algorithm named in the digestAlgorithms field. The signers of a CMSSignedData now share the content digests computed under each digest algorithm identifier, so signers using the same algorithm digest the content once. The new org.bouncycastle.cms.max_digest_algorithms property bounds the number of distinct digest algorithms either class will compute over one message's content, each algorithm past the limit raising a CMSException; it is unlimited unless set.
21+
- The verify side of the ML-DSA-CALCULATE-MU signature service, and MLDSASigner.verifyMu beneath it, compared the supplied mu with a value derived from the key's tr and the context alone, never absorbing the message passed to update(), so a correct mu was accepted only for an empty message and rejected for every other, while the sign side of the same service computed mu correctly. The comparison also finalised the engine's shared digest, so an ordinary verifySignature on the same MLDSASigner afterwards failed for a valid signature, and a mu of the wrong length escaped Signature.verify() as an unchecked DataLengthException. verifyMu now derives the expected mu from the message digest exactly as generateMu does and compares in constant time, the signer stays usable afterwards, the service answers a wrong-length mu with a SignatureException, and the contract - the verify side accepts exactly the mu the sign side returns for the same key, context and data - is now in the javadoc of both.
2122
- The SM2 key pair generator of the BC provider, KeyPairGenerator.getInstance("SM2", "BC"), was built on the generic EC key pair generator rather than the lightweight SM2KeyPairGenerator the 1.73 release notes described, so it could draw a private key of n - 1, which Section 6.1 of GB/T 32918.1 excludes and which the SM2 signer rejects. It now uses SM2KeyPairGenerator and draws from [1, n - 2].
2223
- ESTResponse added each header line of an EST server's response to its header table by copying that header's value array, so a response repeating one header many times was quadratic to parse - about 7 seconds of CPU per megabyte of header lines - and a read limit set through JsseESTServiceBuilder.withReadLimit() only applied once the header block had been read. The values of each header are now collected once, and the header block counts against the read limit of the source.
2324
- The C509 encoding of an RFC 6955 proof-of-possession value (the sa-ecdhPop signature algorithms, values 14 to 16 of the C509 Signature Algorithms Registry) copied the DER DhSigStatic into the signature value byte string, where Section 4.2 of draft-ietf-cose-cbor-encoded-cert-21 defines the value as the bare hashValue when the DhSigStatic names no issuer and serial number and as the [issuer, certificateSerialNumber, hashValue] array when it does. The converter now produces both forms from the DER and reconstructs the DER from either; previously the array form was rejected outright and the byte-string form reconstructed a signature value four bytes short of the DER it came from, so a type 3 request carrying one could not verify. The privateKeyPossessionStatement certification request attribute (RFC 9883, Section 4.3.3 of the draft) was carried in the generic ~oid form on conversion and refused on reconstruction; it is now converted to and from its int-coded [issuer, certificateSerialNumber, cert] form, with an embedded signature certificate re-encoded as a type 3 C509 certificate. The certificate, certification request and template vectors of draft-ietf-cose-c509-test-vectors-02 now drive the util and pkix test suites.

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

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import org.bouncycastle.jcajce.MLDSAProxyPrivateKey;
2020
import org.bouncycastle.jcajce.interfaces.MLDSAPublicKey;
2121
import org.bouncycastle.jcajce.provider.asymmetric.util.BaseDeterministicOrRandomSignature;
22+
import org.bouncycastle.jcajce.provider.util.SecurityExceptions;
2223
import org.bouncycastle.jcajce.spec.MLDSAParameterSpec;
2324

2425
/**
@@ -273,6 +274,12 @@ protected boolean engineVerify(byte[] sigBytes)
273274
}
274275
}
275276

277+
/**
278+
* The ML-DSA-CALCULATE-MU service. Signing returns the 64 byte mu of the key, the context and the
279+
* data passed to update(), for an external signer to sign through ML-DSA-EXTERNAL-MU. Verifying takes
280+
* such a mu in place of a signature and reports whether it is the mu of the data passed to update()
281+
* under the key and context, which is exactly what the signing side of the service returns for them.
282+
*/
276283
public static class MLDSACalcMu
277284
extends SignatureSpi
278285
{
@@ -297,7 +304,14 @@ protected byte[] engineSign()
297304
protected boolean engineVerify(byte[] sigBytes)
298305
throws SignatureException
299306
{
300-
return signer.verifyMu(sigBytes);
307+
try
308+
{
309+
return signer.verifyMu(sigBytes);
310+
}
311+
catch (DataLengthException e)
312+
{
313+
throw SecurityExceptions.signatureException(e.getMessage(), e);
314+
}
301315
}
302316
}
303317
}

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

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -702,6 +702,73 @@ public void testMLDSARandomMsgAndMuSig()
702702
assertTrue(Arrays.areEqual(mu, mu2));
703703
}
704704

705+
public void testMLDSACalculateMuVerify()
706+
throws Exception
707+
{
708+
KeyPairGenerator kpg = KeyPairGenerator.getInstance("ML-DSA", "BC");
709+
710+
kpg.initialize(MLDSAParameterSpec.ml_dsa_65, new SecureRandom());
711+
712+
KeyPair kp = kpg.generateKeyPair();
713+
byte[][] messages = { new byte[0], msg };
714+
byte[][] contexts = { null, Strings.toByteArray("context") };
715+
716+
for (int c = 0; c != contexts.length; c++)
717+
{
718+
for (int m = 0; m != messages.length; m++)
719+
{
720+
byte[] message = messages[m];
721+
722+
Signature calc = Signature.getInstance("ML-DSA-CALCULATE-MU", "BC");
723+
calc.initSign(kp.getPrivate(), new SecureRandom());
724+
if (contexts[c] != null)
725+
{
726+
calc.setParameter(new ContextParameterSpec(contexts[c]));
727+
}
728+
calc.update(message);
729+
byte[] mu = calc.sign();
730+
731+
// the verify side takes the mu in place of a signature and must accept the sign side's own
732+
Signature ver = Signature.getInstance("ML-DSA-CALCULATE-MU", "BC");
733+
ver.initVerify(kp.getPublic());
734+
if (contexts[c] != null)
735+
{
736+
ver.setParameter(new ContextParameterSpec(contexts[c]));
737+
}
738+
ver.update(message);
739+
assertTrue("mu rejected, message length " + message.length, ver.verify(mu));
740+
741+
byte[] flipped = Arrays.clone(mu);
742+
flipped[0] ^= 1;
743+
ver.update(message);
744+
assertFalse(ver.verify(flipped));
745+
746+
ver.update(message);
747+
try
748+
{
749+
ver.verify(new byte[3]);
750+
fail("short mu accepted");
751+
}
752+
catch (SignatureException e)
753+
{
754+
assertEquals("mu value must be 64 bytes", e.getMessage());
755+
}
756+
757+
// a rejected or malformed mu leaves the object ready for the next verification
758+
ver.update(message);
759+
assertTrue(ver.verify(mu));
760+
761+
if (contexts[c] != null)
762+
{
763+
Signature noContext = Signature.getInstance("ML-DSA-CALCULATE-MU", "BC");
764+
noContext.initVerify(kp.getPublic());
765+
noContext.update(message);
766+
assertFalse(noContext.verify(mu));
767+
}
768+
}
769+
}
770+
}
771+
705772
public void testMLDSAMuKatTest()
706773
throws Exception
707774
{

0 commit comments

Comments
 (0)