Skip to content

Commit d47c0a3

Browse files
committed
CMS: resolve the RFC 9709 key-derivation wrapper in one place per package, reporting an absent or unreadable content-encryption algorithm as the CMSException the callers declare.
1 parent f157f0f commit d47c0a3

6 files changed

Lines changed: 159 additions & 40 deletions

File tree

‎docs/releasenotes.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ Date: 2026, TBD
3030
- Grain-128AEAD returned corrupted plaintext from a decryption driven in chunks. The stream cipher data operator splits a processBytes() call that spans the buffered authentication tag into two output segments, the bytes released from the tag buffer and then the bytes taken straight from the caller's input, and wrote the second segment at the caller's output offset instead of after the first, so the second segment overwrote the head of the first and the tail of the reported output was never written at all. The call still returned the full byte count, and because the engine's state update depends on the input and the keystream rather than on where the output lands, the tag still verified: the wrong plaintext came back with no error raised. Any chunk after the first that carried more than the 8 byte tag length was affected. Grain-128AEAD is the only engine that uses this operator, and one shot decryption, the encryption path and every other AEAD engine were unaffected. The second segment is now written at the advanced offset, matching the equivalent step of the general decryption path (github PR #2447).
3131
- The AEAD stream cipher data operator, which Grain-128AEAD alone uses, wrote its output without first checking that the caller's buffer was long enough, so a short output buffer surfaced as an ArrayIndexOutOfBoundsException from inside the engine rather than as the OutputLengthException the general path reports for every other AEAD engine. Both directions of processBytes(), and processByte(), now check before anything is written or buffered, and only when the call releases output, as the general path does.
3232
- The RFC 9709 content-encryption AlgorithmIdentifier, which carries the real algorithm inside the parameters of an outer id-alg-cek-hkdf-sha256, was unwrapped at only one of the points where a CMS recipient makes a decision about it. Key-size validation was corrected for plain key transport in 1.86, but the same call in the KEK, RSA-KTS and KEM recipients, and in the key-transport recipient's own ORI-KEM branch, still compared the recovered key against the outer identifier, which registers no key size, so setKeySizeValidation(true) silently checked nothing there; the setAllowedContentAlgorithms allow-list and the setMinimumTagSize floor were applied to the outer identifier on every recipient family, including the one already corrected, so neither constrained an RFC 9709 message. The unwrap now happens once for the key-size check and once for the two policy checks, and every recipient polices and validates the content-encryption algorithm the message actually carries. A recipient with no allow-list, no tag floor and no key-size validation configured behaves exactly as before; a caller who listed id-alg-cek-hkdf-sha256 in an allow-list in order to admit RFC 9709 messages must now list the content-encryption algorithms themselves (github PR #2446).
33+
- A CMS message whose EncryptedContentInfo named the RFC 9709 key derivation but carried no readable content-encryption AlgorithmIdentifier in its parameters was reported as a NullPointerException, or as an IllegalArgumentException from the ASN.1 decoder, out of methods declared to throw CMSException, RecipientInformation.getContent() among them. The four places that resolve the wrapper - the CEK derivation, the content cipher selection, the key-size check, and the recipient's allowed-algorithm and tag-size checks - now share one resolver, which reports an absent or unreadable inner algorithm as a CMSException.
3334

3435
### 2.1.3 Additional Features and Functionality
3536

‎pkix/src/main/java/org/bouncycastle/cms/AbstractRecipient.java‎

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
import java.util.Set;
55

66
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
7-
import org.bouncycastle.asn1.cms.CMSObjectIdentifiers;
87
import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
98

109
/**
@@ -67,18 +66,9 @@ protected final void checkTagSize(AlgorithmIdentifier contentAlgorithm)
6766
protected final void checkContentAlgorithm(AlgorithmIdentifier contentAlgorithm)
6867
throws CMSException
6968
{
70-
AlgorithmIdentifier encAlgId;
71-
72-
// RFC 9709: the EncryptedContentInfo carries an outer id-alg-cek-hkdf-sha256 wrapping the
73-
// real inner content-encryption AlgorithmIdentifier - it is the inner one these checks apply to.
74-
if (contentAlgorithm.getAlgorithm().equals(CMSObjectIdentifiers.id_alg_cek_hkdf_sha256))
75-
{
76-
encAlgId = AlgorithmIdentifier.getInstance(contentAlgorithm.getParameters());
77-
}
78-
else
79-
{
80-
encAlgId = contentAlgorithm;
81-
}
69+
// an RFC 9709 key derivation wraps the real content-encryption algorithm in its parameters,
70+
// and it is that one these checks apply to
71+
AlgorithmIdentifier encAlgId = CMSUtils.getContentEncryptionAlgorithm(contentAlgorithm);
8272

8373
if (!isContentAlgorithmAllowed(encAlgId.getAlgorithm()))
8474
{

‎pkix/src/main/java/org/bouncycastle/cms/CMSUtils.java‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -555,6 +555,42 @@ static long getDefiniteLengthAEADOutput(AlgorithmIdentifier encAlgId, long input
555555
* Return the AEAD tag length carried in AuthEnvelopedData's mac field, or
556556
* -1 when the algorithm is not recognised.
557557
*/
558+
/**
559+
* Return the content-encryption algorithm an EncryptedContentInfo actually names, resolving the
560+
* RFC 9709 id-alg-cek-hkdf-sha256 wrapper to the AlgorithmIdentifier carried in its parameters.
561+
* <p>
562+
* org.bouncycastle.cms.jcajce.CMSUtils carries the same method for the JCE side of the package,
563+
* which cannot see this one - keep the two in step.
564+
*
565+
* @param contentAlgorithm the content-encryption AlgorithmIdentifier taken from the message.
566+
* @return the algorithm the content is encrypted under.
567+
* @throws CMSException if the key derivation carries no readable content-encryption algorithm.
568+
*/
569+
static AlgorithmIdentifier getContentEncryptionAlgorithm(AlgorithmIdentifier contentAlgorithm)
570+
throws CMSException
571+
{
572+
if (!CMSObjectIdentifiers.id_alg_cek_hkdf_sha256.equals(contentAlgorithm.getAlgorithm()))
573+
{
574+
return contentAlgorithm;
575+
}
576+
577+
ASN1Encodable params = contentAlgorithm.getParameters();
578+
579+
if (params == null)
580+
{
581+
throw new CMSException("RFC 9709 key derivation names no content-encryption algorithm");
582+
}
583+
584+
try
585+
{
586+
return AlgorithmIdentifier.getInstance(params);
587+
}
588+
catch (IllegalArgumentException e)
589+
{
590+
throw new CMSException("unable to read RFC 9709 content-encryption algorithm: " + e.getMessage(), e);
591+
}
592+
}
593+
558594
static int getAEADMacLength(AlgorithmIdentifier encAlgId)
559595
{
560596
ASN1ObjectIdentifier algorithm = encAlgId.getAlgorithm();

‎pkix/src/main/java/org/bouncycastle/cms/jcajce/CMSUtils.java‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
2121
import org.bouncycastle.asn1.ASN1OctetString;
2222
import org.bouncycastle.asn1.ASN1Sequence;
23+
import org.bouncycastle.asn1.cms.CMSObjectIdentifiers;
2324
import org.bouncycastle.asn1.cms.IssuerAndSerialNumber;
2425
import org.bouncycastle.asn1.cryptopro.CryptoProObjectIdentifiers;
2526
import org.bouncycastle.asn1.iso.ISOIECObjectIdentifiers;
@@ -38,6 +39,42 @@
3839

3940
class CMSUtils
4041
{
42+
/**
43+
* Return the content-encryption algorithm an EncryptedContentInfo actually names, resolving the
44+
* RFC 9709 id-alg-cek-hkdf-sha256 wrapper to the AlgorithmIdentifier carried in its parameters.
45+
* <p>
46+
* org.bouncycastle.cms.CMSUtils carries the same method for the lightweight side of the package,
47+
* which cannot see this one - keep the two in step.
48+
*
49+
* @param contentAlgorithm the content-encryption AlgorithmIdentifier taken from the message.
50+
* @return the algorithm the content is encrypted under.
51+
* @throws CMSException if the key derivation carries no readable content-encryption algorithm.
52+
*/
53+
static AlgorithmIdentifier getContentEncryptionAlgorithm(AlgorithmIdentifier contentAlgorithm)
54+
throws CMSException
55+
{
56+
if (!CMSObjectIdentifiers.id_alg_cek_hkdf_sha256.equals(contentAlgorithm.getAlgorithm()))
57+
{
58+
return contentAlgorithm;
59+
}
60+
61+
ASN1Encodable params = contentAlgorithm.getParameters();
62+
63+
if (params == null)
64+
{
65+
throw new CMSException("RFC 9709 key derivation names no content-encryption algorithm");
66+
}
67+
68+
try
69+
{
70+
return AlgorithmIdentifier.getInstance(params);
71+
}
72+
catch (IllegalArgumentException e)
73+
{
74+
throw new CMSException("unable to read RFC 9709 content-encryption algorithm: " + e.getMessage(), e);
75+
}
76+
}
77+
4178
private static final Map asymmetricWrapperAlgNames = new HashMap();
4279

4380
private static Map<ASN1ObjectIdentifier,String> wrapAlgNames = new HashMap();

‎pkix/src/main/java/org/bouncycastle/cms/jcajce/EnvelopedDataHelper.java‎

Lines changed: 7 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ public Key getJceKey(AlgorithmIdentifier algId, GenericKey key)
234234
keyData = (byte[])key.getRepresentation();
235235
}
236236

237-
AlgorithmIdentifier encAlgId = AlgorithmIdentifier.getInstance(algId.getParameters());
237+
AlgorithmIdentifier encAlgId = CMSUtils.getContentEncryptionAlgorithm(algId);
238238

239239
// TODO: at the moment assumes HKDF with SHA256
240240
HKDFBytesGenerator kdf = new HKDFBytesGenerator(new SHA256Digest());
@@ -260,19 +260,8 @@ public Key getJceKey(AlgorithmIdentifier algId, GenericKey key)
260260
public void keySizeCheck(AlgorithmIdentifier keyAlgorithm, Key key)
261261
throws CMSException
262262
{
263-
AlgorithmIdentifier encAlgId;
264-
265-
// RFC 9709: the EncryptedContentInfo carries an outer id-alg-cek-hkdf-sha256 wrapping the
266-
// real inner content-encryption AlgorithmIdentifier - it is the inner one that fixes the
267-
// key size the recovered CEK is expected to have.
268-
if (keyAlgorithm.getAlgorithm().equals(CMSObjectIdentifiers.id_alg_cek_hkdf_sha256))
269-
{
270-
encAlgId = AlgorithmIdentifier.getInstance(keyAlgorithm.getParameters());
271-
}
272-
else
273-
{
274-
encAlgId = keyAlgorithm;
275-
}
263+
// an RFC 9709 key derivation wraps the algorithm that fixes the size of the recovered CEK
264+
AlgorithmIdentifier encAlgId = CMSUtils.getContentEncryptionAlgorithm(keyAlgorithm);
276265

277266
int expectedKeySize = EnvelopedDataHelper.KEY_SIZE_PROVIDER.getKeySize(encAlgId);
278267
if (expectedKeySize > 0)
@@ -436,19 +425,10 @@ public Object doInJCE()
436425
InvalidKeyException, InvalidParameterSpecException, NoSuchAlgorithmException,
437426
NoSuchPaddingException, NoSuchProviderException
438427
{
439-
AlgorithmIdentifier encAlgId;
440-
// RFC 9709: the EncryptedContentInfo carries an outer id-alg-cek-hkdf-sha256
441-
// wrapping the real inner content-encryption AlgorithmIdentifier. The HKDF
442-
// derivation of the CEK happens upstream in getJceKey(AlgorithmIdentifier, ...);
443-
// here we only need to unwrap to the inner algId to select the right cipher.
444-
if (encryptionAlgID.getAlgorithm().equals(CMSObjectIdentifiers.id_alg_cek_hkdf_sha256))
445-
{
446-
encAlgId = AlgorithmIdentifier.getInstance(encryptionAlgID.getParameters());
447-
}
448-
else
449-
{
450-
encAlgId = encryptionAlgID;
451-
}
428+
// the HKDF derivation of the CEK happens upstream in getJceKey(AlgorithmIdentifier, ...);
429+
// here the RFC 9709 wrapper is resolved only to select the right cipher
430+
AlgorithmIdentifier encAlgId = CMSUtils.getContentEncryptionAlgorithm(encryptionAlgID);
431+
452432
Cipher cipher = createCipher(encAlgId.getAlgorithm());
453433
ASN1Encodable sParams = encAlgId.getParameters();
454434
String encAlg = encAlgId.getAlgorithm().getId();

‎pkix/src/test/java/org/bouncycastle/cms/test/NewEnvelopedDataTest.java‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2038,6 +2038,81 @@ public void testKeyTransWithHKDFMinimumTagSize()
20382038
}
20392039
}
20402040

2041+
public void testHKDFWithMalformedContentEncryptionAlgorithm()
2042+
throws Exception
2043+
{
2044+
byte[] data = "WallaWallaWashington".getBytes();
2045+
2046+
SecretKey kek = new SecretKeySpec(new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}, "AES");
2047+
byte[] kekId = new byte[]{1, 2, 3, 4, 5};
2048+
2049+
CMSEnvelopedDataGenerator edGen = new CMSEnvelopedDataGenerator();
2050+
2051+
edGen.addRecipientInfoGenerator(new JceKEKRecipientInfoGenerator(kekId, kek).setProvider(BC));
2052+
2053+
CMSEnvelopedData ed = edGen.generate(
2054+
new CMSProcessableByteArray(data),
2055+
new JceCMSContentEncryptorBuilder(CMSAlgorithm.AES128_CBC)
2056+
.setEnableSha256HKdf(true)
2057+
.setProvider(BC).build());
2058+
2059+
// the key derivation is meant to carry the content-encryption AlgorithmIdentifier in its
2060+
// parameters, and every path that resolves it reports a message that carries nothing
2061+
// readable there as the CMSException getContent() declares
2062+
checkMalformedHKdf(ed, kek, new AlgorithmIdentifier(CMSObjectIdentifiers.id_alg_cek_hkdf_sha256),
2063+
"RFC 9709 key derivation names no content-encryption algorithm");
2064+
2065+
checkMalformedHKdf(ed, kek, new AlgorithmIdentifier(CMSObjectIdentifiers.id_alg_cek_hkdf_sha256, new ASN1Integer(7)),
2066+
"unable to read RFC 9709 content-encryption algorithm: unknown object in getInstance: org.bouncycastle.asn1.ASN1Integer");
2067+
}
2068+
2069+
private void checkMalformedHKdf(CMSEnvelopedData ed, SecretKey kek, AlgorithmIdentifier contentAlgorithm, String message)
2070+
throws Exception
2071+
{
2072+
EnvelopedData env = EnvelopedData.getInstance(ContentInfo.getInstance(ed.getEncoded()).getContent());
2073+
EncryptedContentInfo eci = env.getEncryptedContentInfo();
2074+
2075+
ContentInfo doctored = new ContentInfo(CMSObjectIdentifiers.envelopedData,
2076+
new EnvelopedData(env.getOriginatorInfo(), env.getRecipientInfos(),
2077+
new EncryptedContentInfo(eci.getContentType(), contentAlgorithm, eci.getEncryptedContent()),
2078+
env.getUnprotectedAttrs()));
2079+
2080+
CMSEnvelopedData malformed = new CMSEnvelopedData(doctored.getEncoded(ASN1Encoding.DER));
2081+
2082+
RecipientInformation recipient = (RecipientInformation)malformed.getRecipientInfos().getRecipients().iterator().next();
2083+
2084+
try
2085+
{
2086+
recipient.getContent(new JceKEKEnvelopedRecipient(kek).setProvider(BC));
2087+
fail("content recovered under an unreadable content-encryption algorithm");
2088+
}
2089+
catch (CMSException e)
2090+
{
2091+
assertEquals(message, e.getMessage());
2092+
}
2093+
2094+
try
2095+
{
2096+
recipient.getContent(new JceKEKEnvelopedRecipient(kek).setKeySizeValidation(true).setProvider(BC));
2097+
fail("content recovered under an unreadable content-encryption algorithm");
2098+
}
2099+
catch (CMSException e)
2100+
{
2101+
assertEquals(message, e.getMessage());
2102+
}
2103+
2104+
try
2105+
{
2106+
recipient.getContent(new JceKEKEnvelopedRecipient(kek).setProvider(BC)
2107+
.setAllowedContentAlgorithms(Collections.singleton(CMSAlgorithm.AES128_CBC)));
2108+
fail("content recovered under an unreadable content-encryption algorithm");
2109+
}
2110+
catch (CMSException e)
2111+
{
2112+
assertEquals(message, e.getMessage());
2113+
}
2114+
}
2115+
20412116
public void testKeyTransOAEPDefault()
20422117
throws Exception
20432118
{

0 commit comments

Comments
 (0)