You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
On the recipient side of the RFC 9629 KEMRecipientInfo path, three sender-controlled fields produce an unchecked exception out of an API declared throws CMSException. Two of them fire at parse time in every CMSEnvelopedData and CMSAuthEnvelopedData constructor and streaming parser, before any key is involved; the third fires at unwrap. In each case the malformed input is rejected either way, so nothing decrypts that should not, but the exception type escapes the documented contract. This is the recipient-side counterpart of #2398, which was on the generator side: here the fields arrive in the message, and the methods they escape from are declared throws CMSException. Separately, the recipient never checks the wire kekLength against the wrap algorithm, which RFC 9629 states as a MUST. All four are in the RFC 9629 recipient path and the fixes touch KEMRecipientInfo and JceKEMRecipient only, so I have kept them together.
Environment
bcprov / bcutil / bcpkix 1.86.0.20698 (current 1.86 beta), main at commit 402aed6
JDK 27
Steps to reproduce
Build one valid ML-KEM-768 EnvelopedData, then rewrite individual KEMRecipientInfo fields at the ASN.1 level:
importjava.io.ByteArrayInputStream;
importjava.math.BigInteger;
importjava.security.KeyPair;
importjava.security.KeyPairGenerator;
importjava.security.Security;
importjava.util.ArrayList;
importjava.util.List;
importorg.bouncycastle.asn1.*;
importorg.bouncycastle.asn1.cms.CMSObjectIdentifiers;
importorg.bouncycastle.asn1.cms.ContentInfo;
importorg.bouncycastle.asn1.cms.EnvelopedData;
importorg.bouncycastle.asn1.cms.OtherRecipientInfo;
importorg.bouncycastle.asn1.cms.RecipientInfo;
importorg.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers;
importorg.bouncycastle.asn1.x509.AlgorithmIdentifier;
importorg.bouncycastle.cms.*;
importorg.bouncycastle.cms.jcajce.JceCMSContentEncryptorBuilder;
importorg.bouncycastle.cms.jcajce.JceKEMEnvelopedRecipient;
importorg.bouncycastle.cms.jcajce.JceKEMRecipientInfoGenerator;
importorg.bouncycastle.jce.provider.BouncyCastleProvider;
publicclassCmsKemRecipientInfoRepro {
publicstaticvoidmain(String[] args) throwsException {
Security.addProvider(newBouncyCastleProvider());
KeyPairkp = KeyPairGenerator.getInstance("ML-KEM-768", "BC").generateKeyPair();
// a valid ML-KEM-768 EnvelopedData with one KEMRecipientInfo (AES-256-KW wrap)CMSEnvelopedDataGeneratorgen = newCMSEnvelopedDataGenerator();
gen.addRecipientInfoGenerator(newJceKEMRecipientInfoGenerator(newbyte[]{1, 2, 3, 4}, kp.getPublic(), CMSAlgorithm.AES256_WRAP).setProvider("BC"));
byte[] good = gen.generate(newCMSProcessableByteArray("hello".getBytes()),
newJceCMSContentEncryptorBuilder(CMSAlgorithm.AES256_CBC).setProvider("BC").build()).getEncoded();
// pull the KEMRecipientInfo SEQUENCE out so individual fields can be rewrittenEnvelopedDataenv = EnvelopedData.getInstance(ContentInfo.getInstance(good).getContent());
RecipientInfori = RecipientInfo.getInstance(env.getRecipientInfos().getObjectAt(0));
ASN1Sequencekem = ASN1Sequence.getInstance(OtherRecipientInfo.getInstance(ri.getInfo()).getValue());
List<ASN1Encodable> el = newArrayList<ASN1Encodable>();
for (inti = 0; i != kem.size(); i++) el.add(kem.getObjectAt(i));
// elements: [0]version [1]rid [2]kem [3]kemct [4]kdf [5]kekLength [6]wrap [7]encryptedKey// D1: wrap = an OID CMSUtils.getKekSize does not know (BC accepts it elsewhere); parses fine, then getContent()List<ASN1Encodable> d1 = newArrayList<ASN1Encodable>(el);
d1.set(6, newAlgorithmIdentifier(PKCSObjectIdentifiers.id_alg_CMS3DESwrap));
try {
RecipientInformationr = (RecipientInformation) newCMSEnvelopedData(rebuild(env, d1)).getRecipientInfos().getRecipients().iterator().next();
r.getContent(newJceKEMEnvelopedRecipient(kp.getPrivate()).setProvider("BC"));
} catch (Throwablet) { report("D1 unsupported wrap OID, RecipientInformation.getContent()", t); }
// D2: kekLength = 2^40, encoded in more than four content bytes; intValueExact rejects it before the 1..65535 range check runsList<ASN1Encodable> d2 = newArrayList<ASN1Encodable>(el);
d2.set(5, newASN1Integer(BigInteger.ONE.shiftLeft(40)));
try { newCMSEnvelopedData(rebuild(env, d2)); } catch (Throwablet) { report("D2 kekLength = 2^40, new CMSEnvelopedData(byte[])", t); }
try { newCMSEnvelopedDataParser(newByteArrayInputStream(rebuild(env, d2))); } catch (Throwablet) { report("D2 kekLength = 2^40, CMSEnvelopedDataParser", t); }
// D3: 8 elements with a [0]-tagged ukm at index 6 and no encryptedKey; size check passes, index 8 is readList<ASN1Encodable> d3 = newArrayList<ASN1Encodable>(el.subList(0, 6));
d3.add(newDERTaggedObject(true, 0, newDEROctetString(newbyte[]{9, 9})));
d3.add(el.get(6));
try { newCMSEnvelopedData(rebuild(env, d3)); } catch (Throwablet) { report("D3 8 elements with [0] ukm, new CMSEnvelopedData(byte[])", t); }
}
staticbyte[] rebuild(EnvelopedDataenv, List<ASN1Encodable> el) throwsException {
ASN1EncodableVectorv = newASN1EncodableVector();
for (ASN1Encodablee : el) v.add(e);
RecipientInfori = newRecipientInfo(newOtherRecipientInfo(CMSObjectIdentifiers.id_ori_kem, newDERSequence(v)));
EnvelopedDatan = newEnvelopedData(env.getOriginatorInfo(), newDERSet(ri), env.getEncryptedContentInfo(), env.getUnprotectedAttrs());
returnnewContentInfo(CMSObjectIdentifiers.envelopedData, n).getEncoded();
}
staticvoidreport(Stringlabel, Throwablet) {
System.out.println(label + "\n -> " + t.getClass().getName() + ": " + t.getMessage());
}
}
Actual behaviour
D1 unsupported wrap OID, RecipientInformation.getContent()
-> java.lang.IllegalArgumentException: unknown wrap algorithm
D2 kekLength = 2^40, new CMSEnvelopedData(byte[])
-> java.lang.ArithmeticException: ASN.1 Integer out of int range
D2 kekLength = 2^40, CMSEnvelopedDataParser
-> java.lang.ArithmeticException: ASN.1 Integer out of int range
D3 8 elements with [0] ukm, new CMSEnvelopedData(byte[])
-> java.lang.ArrayIndexOutOfBoundsException: Index 8 out of bounds for length 8
RecipientInformation.getContent, new CMSEnvelopedData(byte[]) and CMSEnvelopedDataParser are all declared throws CMSException. The D2 and D3 exceptions also escape new CMSAuthEnvelopedData(byte[]) and CMSAuthEnvelopedDataParser, which share the same KEMRecipientInfo decoding.
Expected behaviour
A CMSException in each case. new CMSEnvelopedData(byte[]) already does this for the neighbouring inputs: a kekLength of 0, -1 or 65536 gives CMSException: Malformed content. from it, and a wrap OID that is supported but does not match the ciphertext (AES-256-KWP on an AES-KW encrypted key) gives CMSException: exception unwrapping key ... checksum failed from getContent. Through new CMSAuthEnvelopedData(byte[]) and both streaming parsers those same in-range values already escape as a raw IllegalArgumentException; that is a general gap not specific to KEM, which I will file separately.
Root cause
D1, unsupported wrap OID at unwrap.JceKEMRecipient.extractSecretKey calls helper.createKEMUnwrapper(...) at JceKEMRecipient.java:189, outside the try at lines 201-215, which catches only OperatorException. The JceCMSKEMKeyUnwrapper constructor (JceCMSKEMKeyUnwrapper.java:42) immediately calls CMSUtils.getKekSize (cms/jcajce/CMSUtils.java:266-285), which knows the six AES-KW and AES-KWP OIDs and otherwise throws IllegalArgumentException("unknown wrap algorithm") at line 283 (the method carries a TODO: add table). RFC 9629 leaves the wrap algorithm identifier open-ended, so an unsupported one is an ordinary condition to report through the contract.
D2, oversized kekLength at parse. The KEMRecipientInfo constructor (KEMRecipientInfo.java:106 and :110) calls kekLength.intValueExact() in order to apply the 1..65535 range check, but ASN1Integer.intValueExact (ASN1Integer.java:243-252) throws ArithmeticException for any integer with more than four content bytes before the comparison happens, so the range check that exists to reject such values never runs. new CMSEnvelopedData translates ClassCastException and IllegalArgumentException from recipient parsing to CMSException (CMSEnvelopedData.java:133-140), but not ArithmeticException; new CMSAuthEnvelopedData translates none of them, since its equivalent catch (CMSAuthEnvelopedData.java:79-90) covers only AuthEnvelopedData.getInstance and the recipient store is built outside it; and the CMSEnvelopedDataParser and CMSAuthEnvelopedDataParser constructors have no try at all. RFC 9629 section 3 says of the bound: "The upper bound on the integer value is provided to make it clear to implementers that support for very large integer values is not needed." Rejection is intended; it is only the exception type that is wrong.
D3, ukm present with encryptedKey absent, at parse.KEMRecipientInfo.java:90 accepts a sequence of size 8 or 9 unconditionally. Lines 116-125 then consume a [0]-tagged element 6 as ukm and read wrap at index 7 and encryptedKey at index 8 regardless of the actual size, so an 8-element sequence whose element 6 is tagged reaches ASN1Sequence.getObjectAt (ASN1Sequence.java:292-295, a bare elements[index]) with index 8. The mirror image is lenient the other way: a 9-element sequence with no tagged element 6 parses, silently ignores the trailing element, and decrypts. The size check is not tied to whether ukm is present.
kekLength not checked against wrap (conformance).JceCMSKEMKeyUnwrapper.java:42 derives kekLength from the wrap OID and line 98 builds CMSORIforKEMOtherInfo from that derived value; the wire kekLength is never compared to it. KEMRecipientInfo in fact exposes no accessor for it: the value is decoded, range-checked and discarded (the accessors cover the recipient identifier, kem, kemct, kdf, wrap, ukm and encryptedKey), which is also why nothing downstream can check it. A kekLength of 16 with AES-256-KW is accepted and decrypts. RFC 9629 section 3: "Implementations MUST confirm that the value provided is consistent with the key-encryption algorithm identified in the wrap field below." There is no security consequence, since the KDF binds the value BC actually uses and a sender with a mismatching value fails to unwrap, but the MUST is unmet. JceKEMRecipient.java:187 already decodes the KEMRecipientInfo at the point where the comparison belongs.
Impact
Robustness and contract. D2 and D3 are reachable by anyone who can hand the application a CMS message to parse; no key is needed and the exception fires before any recipient is selected, so a single malformed RecipientInfo in a multi-recipient message ends the parse with an exception the caller was not told to expect. D1 needs the caller to attempt decryption. Everything fails closed. The project already treats this as a defect in the same constructor: the comment at CMSEnvelopedData.java:112-115 explains that an escaping NullPointerException "would escape this ctor's declared throws CMSException" and reports it as CMSException for exactly that reason.
D2: test the kekLength range on the BigInteger (or on the encoded length) before calling intValueExact, so an oversized value takes the IllegalArgumentException path. CMSAuthEnvelopedData and the two streaming parsers could also be given the same ClassCastException / IllegalArgumentException translation new CMSEnvelopedData has, though that is the general gap mentioned above rather than a KEM item.
D3: tie the size check to the ukm tag: a 9-element sequence requires a tagged element 6, an 8-element sequence requires an untagged one. No conformant encoder emits an extra trailing element in the SEQUENCE, so the tightening cannot reject a valid message.
kekLength: add a getKekLength() accessor to KEMRecipientInfo, then compare it with unwrapper.getKekLength() (already public on JceCMSKEMKeyUnwrapper) in JceKEMRecipient.extractSecretKey and reject a mismatch with CMSException.
The program above is complete and self-contained; it needs bcprov, bcutil and bcpkix on the classpath.
Summary
On the recipient side of the RFC 9629
KEMRecipientInfopath, three sender-controlled fields produce an unchecked exception out of an API declaredthrows CMSException. Two of them fire at parse time in everyCMSEnvelopedDataandCMSAuthEnvelopedDataconstructor and streaming parser, before any key is involved; the third fires at unwrap. In each case the malformed input is rejected either way, so nothing decrypts that should not, but the exception type escapes the documented contract. This is the recipient-side counterpart of #2398, which was on the generator side: here the fields arrive in the message, and the methods they escape from are declaredthrows CMSException. Separately, the recipient never checks the wirekekLengthagainst thewrapalgorithm, which RFC 9629 states as a MUST. All four are in the RFC 9629 recipient path and the fixes touchKEMRecipientInfoandJceKEMRecipientonly, so I have kept them together.Environment
Steps to reproduce
Build one valid ML-KEM-768 EnvelopedData, then rewrite individual
KEMRecipientInfofields at the ASN.1 level:Actual behaviour
RecipientInformation.getContent,new CMSEnvelopedData(byte[])andCMSEnvelopedDataParserare all declaredthrows CMSException. The D2 and D3 exceptions also escapenew CMSAuthEnvelopedData(byte[])andCMSAuthEnvelopedDataParser, which share the sameKEMRecipientInfodecoding.Expected behaviour
A
CMSExceptionin each case.new CMSEnvelopedData(byte[])already does this for the neighbouring inputs: akekLengthof 0, -1 or 65536 givesCMSException: Malformed content.from it, and a wrap OID that is supported but does not match the ciphertext (AES-256-KWP on an AES-KW encrypted key) givesCMSException: exception unwrapping key ... checksum failedfromgetContent. Throughnew CMSAuthEnvelopedData(byte[])and both streaming parsers those same in-range values already escape as a rawIllegalArgumentException; that is a general gap not specific to KEM, which I will file separately.Root cause
D1, unsupported wrap OID at unwrap.
JceKEMRecipient.extractSecretKeycallshelper.createKEMUnwrapper(...)atJceKEMRecipient.java:189, outside thetryat lines 201-215, which catches onlyOperatorException. TheJceCMSKEMKeyUnwrapperconstructor (JceCMSKEMKeyUnwrapper.java:42) immediately callsCMSUtils.getKekSize(cms/jcajce/CMSUtils.java:266-285), which knows the six AES-KW and AES-KWP OIDs and otherwise throwsIllegalArgumentException("unknown wrap algorithm")at line 283 (the method carries aTODO: add table). RFC 9629 leaves the wrap algorithm identifier open-ended, so an unsupported one is an ordinary condition to report through the contract.D2, oversized kekLength at parse. The
KEMRecipientInfoconstructor (KEMRecipientInfo.java:106and:110) callskekLength.intValueExact()in order to apply the1..65535range check, butASN1Integer.intValueExact(ASN1Integer.java:243-252) throwsArithmeticExceptionfor any integer with more than four content bytes before the comparison happens, so the range check that exists to reject such values never runs.new CMSEnvelopedDatatranslatesClassCastExceptionandIllegalArgumentExceptionfrom recipient parsing toCMSException(CMSEnvelopedData.java:133-140), but notArithmeticException;new CMSAuthEnvelopedDatatranslates none of them, since its equivalent catch (CMSAuthEnvelopedData.java:79-90) covers onlyAuthEnvelopedData.getInstanceand the recipient store is built outside it; and theCMSEnvelopedDataParserandCMSAuthEnvelopedDataParserconstructors have notryat all. RFC 9629 section 3 says of the bound: "The upper bound on the integer value is provided to make it clear to implementers that support for very large integer values is not needed." Rejection is intended; it is only the exception type that is wrong.D3, ukm present with encryptedKey absent, at parse.
KEMRecipientInfo.java:90accepts a sequence of size 8 or 9 unconditionally. Lines 116-125 then consume a[0]-tagged element 6 asukmand readwrapat index 7 andencryptedKeyat index 8 regardless of the actual size, so an 8-element sequence whose element 6 is tagged reachesASN1Sequence.getObjectAt(ASN1Sequence.java:292-295, a bareelements[index]) with index 8. The mirror image is lenient the other way: a 9-element sequence with no tagged element 6 parses, silently ignores the trailing element, and decrypts. The size check is not tied to whetherukmis present.kekLength not checked against wrap (conformance).
JceCMSKEMKeyUnwrapper.java:42deriveskekLengthfrom the wrap OID and line 98 buildsCMSORIforKEMOtherInfofrom that derived value; the wirekekLengthis never compared to it.KEMRecipientInfoin fact exposes no accessor for it: the value is decoded, range-checked and discarded (the accessors cover the recipient identifier, kem, kemct, kdf, wrap, ukm and encryptedKey), which is also why nothing downstream can check it. AkekLengthof 16 with AES-256-KW is accepted and decrypts. RFC 9629 section 3: "Implementations MUST confirm that the value provided is consistent with the key-encryption algorithm identified in the wrap field below." There is no security consequence, since the KDF binds the value BC actually uses and a sender with a mismatching value fails to unwrap, but the MUST is unmet.JceKEMRecipient.java:187already decodes theKEMRecipientInfoat the point where the comparison belongs.Impact
Robustness and contract. D2 and D3 are reachable by anyone who can hand the application a CMS message to parse; no key is needed and the exception fires before any recipient is selected, so a single malformed
RecipientInfoin a multi-recipient message ends the parse with an exception the caller was not told to expect. D1 needs the caller to attempt decryption. Everything fails closed. The project already treats this as a defect in the same constructor: the comment atCMSEnvelopedData.java:112-115explains that an escapingNullPointerException"would escape this ctor's declared throws CMSException" and reports it asCMSExceptionfor exactly that reason.Suggested direction
IllegalArgumentExceptionaround thecreateKEMUnwrappercall atJceKEMRecipient.java:189and rethrow it asCMSExceptionnaming the wrap OID;getKekSizecan keep throwingIllegalArgumentException, and could name the OID as the CMS RFC 9629 KEM: getEncapsulationLength throws NullPointerException for a KEM absent from its table (e.g. a BIKE recipient key) #2398 fix did for the KEM. Moving the call inside the existingtryis not enough on its own, since thattrycatches onlyOperatorException.kekLengthrange on theBigInteger(or on the encoded length) before callingintValueExact, so an oversized value takes theIllegalArgumentExceptionpath.CMSAuthEnvelopedDataand the two streaming parsers could also be given the sameClassCastException/IllegalArgumentExceptiontranslationnew CMSEnvelopedDatahas, though that is the general gap mentioned above rather than a KEM item.ukmtag: a 9-element sequence requires a tagged element 6, an 8-element sequence requires an untagged one. No conformant encoder emits an extra trailing element in the SEQUENCE, so the tightening cannot reject a valid message.getKekLength()accessor toKEMRecipientInfo, then compare it withunwrapper.getKekLength()(already public onJceCMSKEMKeyUnwrapper) inJceKEMRecipient.extractSecretKeyand reject a mismatch withCMSException.The program above is complete and self-contained; it needs bcprov, bcutil and bcpkix on the classpath.