Skip to content

Commit fcb5a4a

Browse files
committed
CMS: pass the user keying material to HKDF as the salt as well as in the ECC-CMS-SharedInfo entityUInfo for the RFC 8418 key agreement schemes, as sec. 2.2 requires, relates to github #2454.
1 parent c90d820 commit fcb5a4a

8 files changed

Lines changed: 206 additions & 13 deletions

File tree

‎docs/claude/conventions.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,32 @@
88
- Tests pass `-Dbc.test.data.home=<core/src/test/data>` for fixture lookups.
99
- The `:test` task runs each test class in its own JVM (`forkEvery = 1`).
1010

11+
## Key agreement tests run with and without a UKM
12+
13+
Any test of a CMS or JCA key agreement scheme should exercise it **both with and without a user
14+
keying material value**, wherever the scheme admits one. The two are different derivations, not one
15+
derivation with an extra field: a UKM typically feeds the KDF's info *and*, for HKDF-based schemes,
16+
its salt, so a test that only ever omits the UKM leaves the more complex of the two paths uncovered.
17+
18+
RFC 8418 sec. 2.2 is the worked example (github #2454). Its KEK is
19+
20+
```
21+
if ukm is provided, then salt = ukm, else salt is not provided
22+
PRK = HKDF-Extract(salt, K)
23+
KEK = HKDF-Expand(PRK, DER(ECC-CMS-SharedInfo), SizeInOctets(KEK))
24+
```
25+
26+
so the UKM has two roles. BC placed it in the ECC-CMS-SharedInfo `entityUInfo` but never passed it
27+
as the HKDF salt, and 1.86 shipped that way because `testRFC8418X25519AndX448` round-tripped all six
28+
curve/scheme combinations **with no UKM set**. Nothing was wrong with the no-UKM path, so nothing
29+
failed.
30+
31+
Note also that a self round trip cannot see this class of defect at all - BC agreed with itself
32+
either way. Where a spec states the derivation, derive the key independently in the test and
33+
unwrap/verify with it, and assert that the *wrong* derivation does **not** work
34+
(`NewEnvelopedDataTest.checkRFC8418Kek` is the model). A known-answer check against the spec's own
35+
recipe is what makes the UKM half meaningful.
36+
1137
## X.509 ASN.1 changes — check the RFC first
1238

1339
Anything under `core/src/main/java/org/bouncycastle/asn1/x509/` is a wire-format ASN.1 type from a specific PKI RFC. Before changing or extending one of these classes (parsing rules, structural constraints, defaults, error messages thrown for malformed input), verify the proposed behaviour against the authoritative RFC:

‎docs/releasenotes.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ Date: 2026, TBD
3333
- 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.
3434
- The two YubiKey OpenPGP smart-card decryptor factories zeroized the user PIN array the KeyPassphraseProvider handed them rather than a copy of it, in a finally block after each private-key operation. Both providers BC ships return the application's own array by reference - DefaultKeyPassphraseProvider hands back the char[] it has cached for the key, and the provider inside OpenPGPApi.editKey returns its argument - so the first card operation destroyed the caller's PIN and the next private-key operation presented an all-zero PIN to the card, which the card refuses at the cost of a PIN retry. The PIN is now fetched as a clone the card operation owns - in OpenPGPSmartCard.requireUserPin, where the smart-card restructuring of this release put the fetch the two factories used to make - and KeyPassphraseProvider.getKeyPassword records that the array it returns stays owned by the provider (github PR #2444).
3535
- The CRMF PKIPublicationInfo structure accepted, and could be built with, publication information RFC 4211 sec. 6.3 forbids: pubInfos MUST NOT be present if the action is dontPublish, and the field is SEQUENCE SIZE (1..MAX), so a present one is never empty. Both contradictions are now rejected with an IllegalArgumentException, on parsing and on construction from an array of SinglePubInfo. An absent pubInfos with the pleasePublish action, which is how the RFC spells "don't care", is unaffected, and no constructor in the library could produce either rejected form.
36+
- The CMS RFC 8418 key agreement schemes (dhSinglePass-stdDH-hkdf-sha256/384/512, used with X25519 and X448) derived the key-encryption key with the user keying material in the entityUInfo of the ECC-CMS-SharedInfo but never as the HKDF salt, where RFC 8418 sec. 2.2 requires both - its recipe is salt = ukm, PRK = HKDF-Extract(salt, K), KEK = HKDF-Expand(PRK, DER(ECC-CMS-SharedInfo), SizeInOctets(KEK)). A message carrying a ukm therefore did not interoperate with a conforming implementation in either direction. The ukm is now passed as the salt as well, on both the generating and the receiving side, for those three schemes. A message with a ukm written by 1.86, the only release with RFC 8418 support, is not readable by this release and vice versa; messages without a ukm, and the X9.63-KDF key agreement schemes, are unaffected. The round-trip test now covers both the ukm and no-ukm cases for all six curve and scheme combinations, and checks the key-encryption key against the RFC's own recipe rather than only against BC itself (github #2454).
3637

3738
### 2.1.3 Additional Features and Functionality
3839

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ protected boolean isEC(ASN1ObjectIdentifier algorithmOID)
1111
return CMSUtils.isEC(algorithmOID);
1212
}
1313

14+
protected boolean isHKDF(ASN1ObjectIdentifier algorithmOID)
15+
{
16+
return CMSUtils.isHKDF(algorithmOID);
17+
}
18+
1419
protected boolean isMQV(ASN1ObjectIdentifier algorithmOID)
1520
{
1621
return CMSUtils.isMQV(algorithmOID);

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ class CMSUtils
7070
private static final Set desAlgs = new HashSet();
7171
private static final Set mqvAlgs = new HashSet();
7272
private static final Set ecAlgs = new HashSet();
73+
private static final Set hkdfAlgs = new HashSet();
7374
private static final Set gostAlgs = new HashSet();
7475

7576
static
@@ -100,6 +101,10 @@ class CMSUtils
100101
ecAlgs.add(PKCSObjectIdentifiers.dhSinglePass_stdDH_hkdf_sha384_scheme);
101102
ecAlgs.add(PKCSObjectIdentifiers.dhSinglePass_stdDH_hkdf_sha512_scheme);
102103

104+
hkdfAlgs.add(PKCSObjectIdentifiers.dhSinglePass_stdDH_hkdf_sha256_scheme);
105+
hkdfAlgs.add(PKCSObjectIdentifiers.dhSinglePass_stdDH_hkdf_sha384_scheme);
106+
hkdfAlgs.add(PKCSObjectIdentifiers.dhSinglePass_stdDH_hkdf_sha512_scheme);
107+
103108
// BSI TR-03111 ECKA-EG with X9.63 KDF. Structurally identical to
104109
// dhSinglePass_stdDH_*kdf_scheme (ECDH + X9.63 KDF + RFC 5753
105110
// ECC-CMS-SharedInfo per BSI TR-03109-3 / ICAO 9303-11); dispatch
@@ -130,6 +135,11 @@ static boolean isEC(ASN1ObjectIdentifier algorithm)
130135
return ecAlgs.contains(algorithm);
131136
}
132137

138+
static boolean isHKDF(ASN1ObjectIdentifier algorithm)
139+
{
140+
return hkdfAlgs.contains(algorithm);
141+
}
142+
133143
static boolean isGOST(ASN1ObjectIdentifier algorithm)
134144
{
135145
return gostAlgs.contains(algorithm);

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,11 @@ protected boolean isEC(ASN1ObjectIdentifier algorithmOID)
6767
return CMSUtils.isEC(algorithmOID);
6868
}
6969

70+
protected boolean isHKDF(ASN1ObjectIdentifier algorithmOID)
71+
{
72+
return CMSUtils.isHKDF(algorithmOID);
73+
}
74+
7075
protected boolean isMQV(ASN1ObjectIdentifier algorithmOID)
7176
{
7277
return CMSUtils.isMQV(algorithmOID);

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,16 @@ private SecretKey calculateAgreedWrapKey(AlgorithmIdentifier keyEncAlg, Algorith
242242
{
243243
ukmKeyingMaterial = kmGen.generateKDFMaterial(wrapAlg, keySizeProvider.getKeySize(wrapAlg), null);
244244
}
245-
userKeyingMaterialSpec = new UserKeyingMaterialSpec(ukmKeyingMaterial);
245+
// RFC 8418 sec. 2.2: for the HKDF schemes a ukm is both the entityUInfo of the
246+
// ECC-CMS-SharedInfo and the HKDF salt
247+
if (userKeyingMaterial != null && isHKDF(keyEncAlg.getAlgorithm()))
248+
{
249+
userKeyingMaterialSpec = new UserKeyingMaterialSpec(ukmKeyingMaterial, userKeyingMaterial.getOctets());
250+
}
251+
else
252+
{
253+
userKeyingMaterialSpec = new UserKeyingMaterialSpec(ukmKeyingMaterial);
254+
}
246255
}
247256
else if (isRFC2631(keyEncAlg.getAlgorithm()))
248257
{

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,16 @@ else if (isEC(keyAgreementOID))
180180
byte[] ukmKeyingMaterial = ecc_cms_Generator.generateKDFMaterial(keyEncryptionAlgorithm,
181181
keySizeProvider.getKeySize(keyEncryptionOID), userKeyingMaterial);
182182

183-
agreementParamSpec = new UserKeyingMaterialSpec(ukmKeyingMaterial);
183+
// RFC 8418 sec. 2.2: for the HKDF schemes a ukm is both the entityUInfo of the
184+
// ECC-CMS-SharedInfo and the HKDF salt
185+
if (userKeyingMaterial != null && isHKDF(keyAgreementOID))
186+
{
187+
agreementParamSpec = new UserKeyingMaterialSpec(ukmKeyingMaterial, userKeyingMaterial);
188+
}
189+
else
190+
{
191+
agreementParamSpec = new UserKeyingMaterialSpec(ukmKeyingMaterial);
192+
}
184193
}
185194
else if (isRFC2631(keyAgreementOID))
186195
{

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

Lines changed: 139 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@
77
import java.security.AlgorithmParameters;
88
import java.security.GeneralSecurityException;
99
import java.security.Key;
10+
import java.security.InvalidKeyException;
1011
import java.security.KeyFactory;
1112
import java.security.KeyPair;
1213
import java.security.KeyPairGenerator;
1314
import java.security.NoSuchAlgorithmException;
1415
import java.security.NoSuchProviderException;
1516
import java.security.PrivateKey;
17+
import java.security.PublicKey;
1618
import java.security.SecureRandom;
1719
import java.security.Security;
1820
import java.security.cert.CertificateEncodingException;
@@ -31,6 +33,8 @@
3133
import javax.crypto.SecretKey;
3234
import javax.crypto.spec.OAEPParameterSpec;
3335
import javax.crypto.spec.PSource;
36+
import javax.crypto.Cipher;
37+
import javax.crypto.KeyAgreement;
3438
import javax.crypto.spec.SecretKeySpec;
3539

3640
import junit.framework.Assert;
@@ -60,7 +64,9 @@
6064
import org.bouncycastle.asn1.cms.EncryptedContentInfo;
6165
import org.bouncycastle.asn1.cms.EnvelopedData;
6266
import org.bouncycastle.asn1.cms.GCMParameters;
67+
import org.bouncycastle.asn1.cms.KeyAgreeRecipientInfo;
6368
import org.bouncycastle.asn1.cms.OtherRecipientInfo;
69+
import org.bouncycastle.asn1.cms.RecipientEncryptedKey;
6470
import org.bouncycastle.asn1.cms.RecipientInfo;
6571
import org.bouncycastle.asn1.cryptopro.CryptoProObjectIdentifiers;
6672
import org.bouncycastle.asn1.gm.GMObjectIdentifiers;
@@ -72,6 +78,7 @@
7278
import org.bouncycastle.asn1.pkcs.RC2CBCParameter;
7379
import org.bouncycastle.asn1.rosstandart.RosstandartObjectIdentifiers;
7480
import org.bouncycastle.asn1.x500.X500Name;
81+
import org.bouncycastle.asn1.cms.ecc.ECCCMSSharedInfo;
7582
import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
7683
import org.bouncycastle.asn1.x509.Extension;
7784
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
@@ -130,6 +137,13 @@
130137
import org.bouncycastle.operator.OutputEncryptor;
131138
import org.bouncycastle.operator.jcajce.JcaAlgorithmParametersConverter;
132139
import org.bouncycastle.pqc.jcajce.provider.BouncyCastlePQCProvider;
140+
import org.bouncycastle.crypto.Digest;
141+
import org.bouncycastle.crypto.digests.SHA256Digest;
142+
import org.bouncycastle.crypto.digests.SHA384Digest;
143+
import org.bouncycastle.crypto.digests.SHA512Digest;
144+
import org.bouncycastle.crypto.generators.HKDFBytesGenerator;
145+
import org.bouncycastle.crypto.params.HKDFParameters;
146+
import org.bouncycastle.util.Pack;
133147
import org.bouncycastle.util.Strings;
134148
import org.bouncycastle.util.encoders.Base64;
135149
import org.bouncycastle.util.encoders.Hex;
@@ -4412,15 +4426,26 @@ private void passwordUTF8Test(String algorithm, PasswordRecipient.PRF prf)
44124426
public void testRFC8418X25519AndX448()
44134427
throws Exception
44144428
{
4415-
doRFC8418Round("X25519", CMSAlgorithm.ECDH_HKDF_SHA256);
4416-
doRFC8418Round("X25519", CMSAlgorithm.ECDH_HKDF_SHA384);
4417-
doRFC8418Round("X25519", CMSAlgorithm.ECDH_HKDF_SHA512);
4418-
doRFC8418Round("X448", CMSAlgorithm.ECDH_HKDF_SHA256);
4419-
doRFC8418Round("X448", CMSAlgorithm.ECDH_HKDF_SHA384);
4420-
doRFC8418Round("X448", CMSAlgorithm.ECDH_HKDF_SHA512);
4429+
doRFC8418Round("X25519", CMSAlgorithm.ECDH_HKDF_SHA256, null);
4430+
doRFC8418Round("X25519", CMSAlgorithm.ECDH_HKDF_SHA384, null);
4431+
doRFC8418Round("X25519", CMSAlgorithm.ECDH_HKDF_SHA512, null);
4432+
doRFC8418Round("X448", CMSAlgorithm.ECDH_HKDF_SHA256, null);
4433+
doRFC8418Round("X448", CMSAlgorithm.ECDH_HKDF_SHA384, null);
4434+
doRFC8418Round("X448", CMSAlgorithm.ECDH_HKDF_SHA512, null);
4435+
4436+
// with a ukm the KEK derivation is also checked against the RFC's own recipe: a round trip
4437+
// against ourselves cannot tell whether the ukm reached the HKDF salt
4438+
byte[] ukm = Hex.decode("cafed1ea0529");
4439+
4440+
doRFC8418Round("X25519", CMSAlgorithm.ECDH_HKDF_SHA256, ukm);
4441+
doRFC8418Round("X25519", CMSAlgorithm.ECDH_HKDF_SHA384, ukm);
4442+
doRFC8418Round("X25519", CMSAlgorithm.ECDH_HKDF_SHA512, ukm);
4443+
doRFC8418Round("X448", CMSAlgorithm.ECDH_HKDF_SHA256, ukm);
4444+
doRFC8418Round("X448", CMSAlgorithm.ECDH_HKDF_SHA384, ukm);
4445+
doRFC8418Round("X448", CMSAlgorithm.ECDH_HKDF_SHA512, ukm);
44214446
}
44224447

4423-
private void doRFC8418Round(String curve, ASN1ObjectIdentifier kaOid)
4448+
private void doRFC8418Round(String curve, ASN1ObjectIdentifier kaOid, byte[] ukm)
44244449
throws Exception
44254450
{
44264451
byte[] data = Hex.decode("504b492d4320434d5320456e76656c6f706564446174612053616d706c65");
@@ -4431,11 +4456,19 @@ private void doRFC8418Round(String curve, ASN1ObjectIdentifier kaOid)
44314456

44324457
byte[] reciKeyId = new byte[]{1, 2, 3, 4, 5};
44334458

4459+
JceKeyAgreeRecipientInfoGenerator recGen =
4460+
new JceKeyAgreeRecipientInfoGenerator(kaOid, origKP.getPrivate(), origKP.getPublic(), CMSAlgorithm.AES128_WRAP);
4461+
4462+
recGen.addRecipient(reciKeyId, reciKP.getPublic());
4463+
recGen.setProvider(BC);
4464+
4465+
if (ukm != null)
4466+
{
4467+
recGen.setUserKeyingMaterial(ukm);
4468+
}
4469+
44344470
CMSEnvelopedDataGenerator edGen = new CMSEnvelopedDataGenerator();
4435-
edGen.addRecipientInfoGenerator(
4436-
new JceKeyAgreeRecipientInfoGenerator(kaOid, origKP.getPrivate(), origKP.getPublic(), CMSAlgorithm.AES128_WRAP)
4437-
.addRecipient(reciKeyId, reciKP.getPublic())
4438-
.setProvider(BC));
4471+
edGen.addRecipientInfoGenerator(recGen);
44394472

44404473
CMSEnvelopedData ed = edGen.generate(
44414474
new CMSProcessableByteArray(data),
@@ -4451,6 +4484,101 @@ private void doRFC8418Round(String curve, ASN1ObjectIdentifier kaOid)
44514484
byte[] recData = recipient.getContent(
44524485
new JceKeyAgreeEnvelopedRecipient(reciKP.getPrivate()).setProvider(BC));
44534486
assertTrue(curve + "/" + kaOid + " mismatch", Arrays.equals(data, recData));
4487+
4488+
if (ukm != null)
4489+
{
4490+
checkRFC8418Kek(ed, curve, kaOid, reciKP.getPrivate(), origKP.getPublic(), ukm);
4491+
}
4492+
}
4493+
4494+
/**
4495+
* RFC 8418 sec. 2.2 fixes the KEK as
4496+
* <pre>
4497+
* if ukm is provided, then salt = ukm, else salt is not provided
4498+
* PRK = HKDF-Extract(salt, K)
4499+
* KEK = HKDF-Expand(PRK, DER(ECC-CMS-SharedInfo), SizeInOctets(KEK))
4500+
* </pre>
4501+
* so a ukm is both the entityUInfo of the ECC-CMS-SharedInfo and the HKDF salt. This derives
4502+
* the KEK independently of the CMS code and unwraps the message's encryptedKey with it, and
4503+
* checks that the derivation leaving the salt out - which is what 1.86 produced - no longer
4504+
* agrees with the message.
4505+
*/
4506+
private void checkRFC8418Kek(CMSEnvelopedData ed, String curve, ASN1ObjectIdentifier kaOid,
4507+
PrivateKey reciPrivKey, PublicKey origPubKey, byte[] ukm)
4508+
throws Exception
4509+
{
4510+
EnvelopedData env = EnvelopedData.getInstance(ContentInfo.getInstance(ed.getEncoded()).getContent());
4511+
KeyAgreeRecipientInfo kari = KeyAgreeRecipientInfo.getInstance(
4512+
RecipientInfo.getInstance(env.getRecipientInfos().getObjectAt(0)).getInfo());
4513+
4514+
assertTrue("ukm not carried in the message", Arrays.equals(ukm, kari.getUserKeyingMaterial().getOctets()));
4515+
4516+
AlgorithmIdentifier wrapAlg = AlgorithmIdentifier.getInstance(kari.getKeyEncryptionAlgorithm().getParameters());
4517+
byte[] encryptedKey = RecipientEncryptedKey.getInstance(
4518+
kari.getRecipientEncryptedKeys().getObjectAt(0)).getEncryptedKey().getOctets();
4519+
4520+
// K, the raw shared secret
4521+
KeyAgreement agreement = KeyAgreement.getInstance(curve, BC);
4522+
4523+
agreement.init(reciPrivKey);
4524+
agreement.doPhase(origPubKey, true);
4525+
4526+
byte[] k = agreement.generateSecret();
4527+
4528+
// info, DER(ECC-CMS-SharedInfo) with the ukm as entityUInfo and the KEK size as suppPubInfo
4529+
byte[] sharedInfo = new ECCCMSSharedInfo(wrapAlg, ukm, Pack.intToBigEndian(128)).getEncoded(ASN1Encoding.DER);
4530+
4531+
String label = curve + "/" + kaOid;
4532+
4533+
assertTrue(label + ": the KEK derived with salt = ukm does not open the message",
4534+
unwrapsWith(deriveRFC8418Kek(kaOid, k, ukm, sharedInfo), encryptedKey));
4535+
assertFalse(label + ": the KEK derived with no salt still opens the message",
4536+
unwrapsWith(deriveRFC8418Kek(kaOid, k, null, sharedInfo), encryptedKey));
4537+
}
4538+
4539+
private byte[] deriveRFC8418Kek(ASN1ObjectIdentifier kaOid, byte[] secret, byte[] salt, byte[] sharedInfo)
4540+
{
4541+
Digest digest;
4542+
4543+
if (kaOid.equals(CMSAlgorithm.ECDH_HKDF_SHA256))
4544+
{
4545+
digest = new SHA256Digest();
4546+
}
4547+
else if (kaOid.equals(CMSAlgorithm.ECDH_HKDF_SHA384))
4548+
{
4549+
digest = new SHA384Digest();
4550+
}
4551+
else
4552+
{
4553+
digest = new SHA512Digest();
4554+
}
4555+
4556+
HKDFBytesGenerator kdf = new HKDFBytesGenerator(digest);
4557+
4558+
kdf.init(new HKDFParameters(secret, salt, sharedInfo));
4559+
4560+
byte[] kek = new byte[16];
4561+
4562+
kdf.generateBytes(kek, 0, kek.length);
4563+
4564+
return kek;
4565+
}
4566+
4567+
private boolean unwrapsWith(byte[] kek, byte[] encryptedKey)
4568+
throws Exception
4569+
{
4570+
Cipher cipher = Cipher.getInstance("AESWrap", BC);
4571+
4572+
cipher.init(Cipher.UNWRAP_MODE, new SecretKeySpec(kek, "AES"));
4573+
4574+
try
4575+
{
4576+
return cipher.unwrap(encryptedKey, "AES", Cipher.SECRET_KEY) != null;
4577+
}
4578+
catch (InvalidKeyException e)
4579+
{
4580+
return false;
4581+
}
44544582
}
44554583

44564584
private void verifyECKeyAgreeVectors(PrivateKey privKey, String wrapAlg, byte[] message)

0 commit comments

Comments
 (0)