Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/releasenotes.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Date: 2026, TBD

### 2.1.2 Defects Fixed

- The RFC 9579 PBMAC1 integrity MAC on a PKCS#12 file (org.bouncycastle.pkcs.util.PKCS12Util and org.bouncycastle.jcajce.provider.keystore.pkcs12.PKCS12Util) derived the HMAC key from the file's attacker-controlled PBKDF2 keyLength with only an upper bound enforced, so a PFX declaring a keyLength of one octet was accepted and its MAC verified against a one-byte HMAC key, which is trivially brute-forced. RFC 9579 sec. 9 RECOMMENDs rejecting a keyLength below 20 octets and sec. 5 has it match the HMAC output (>= 32 for the SHA-2 PRFs supported), so validateKeyLength now rejects a keyLength below 20 octets before deriving; no conforming file is affected.
- The high-level OpenPGP API (org.bouncycastle.openpgp.api) let a subkey inherit the primary key's Key Flags when its own Subkey Binding signature carried no Key Flags subpacket, which made the two capability decisions taken for one subkey disagree. OpenPGPCertificate.OpenPGPComponentKey.isSigningKey() reads the effective flags, which fell back to the primary key's direct-key or primary user ID self-signature, so a subkey bound with no flags of its own counted as signing-capable; verifyEmbeddedPrimaryKeyBinding reads the binding signature's own flags, found no signing capability there, and so skipped the embedded Primary Key Binding (cross-certification) signature that RFC 9580 sec. 5.2.1.8 and sec. 10.1.3 require of a subkey that can issue signatures. A data signature made by such a subkey was therefore attributed to the certificate and reported valid by OpenPGPSignature.OpenPGPDocumentSignature.isValid() with the cross-certification requirement never applied, where GnuPG refuses the same certificate and message as not cross-certified. An attacker holding a third party's public signing subkey - which is public material - could bind it to their own primary key with a Subkey Binding signature they are able to make, carrying no Key Flags and no embedded Primary Key Binding signature, which they cannot make without the subkey's private key, and have that party's genuine signatures verify as valid under the attacker's own identity: misattribution of a real signature rather than a forgery of a new one, since the signature still has to be one the subkey actually made. Key Flags are a statement about the key the carrying signature refers to (RFC 9580 sec. 5.2.3.29), so a subkey no longer inherits them from the certificate-wide signatures of the primary key: a Subkey Binding signature that omits the subpacket now leaves the subkey with no capabilities rather than the primary's, which makes the flags the cross-certification check consults the same flags every other decision consults. Preferences and the other subpackets a direct-key signature carries are inherited as before, and the primary key itself - whose flags legitimately come from its direct-key or user ID self-signature - is unaffected. The low-level PGPSignature / PGPPublicKeyRing API performs no binding checks by design and is unchanged.
- The high-level OpenPGP API (org.bouncycastle.openpgp.api) used a version 6 key that carried no valid Direct Key signature, falling back to the primary user ID binding as it correctly does for a version 4 key. RFC 9580 sec. 5.2.3.10 requires the opposite: "An implementation MUST ensure that a valid Direct Key signature is present before using a version 6 key. This prevents certain attacks where an adversary strips a self-signature specifying a Key Expiration Time or certain preferences." The certificate grammar says the same structurally, the Direct Key signature being mandatory in the version 6 structure of sec. 10.1.1 and optional in the version 4 one of sec. 10.1.3. Because a version 6 certificate carries its key expiration, features and algorithm preferences on the Direct Key signature - the convention the RFC recommends and the one OpenPGPKeyGenerator follows, its user ID certification carrying no expiration at all - removing that single signature packet from a published certificate silently dropped the expiration along with the preferences and features: OpenPGPCertificate.getSignatureChainFor fell back to the user ID binding, the primary key was still reported bound, and getEncryptionKeys() and getSigningKeys() went on returning the subkeys of a key whose owner had set it to expire. The primary key fingerprint is unchanged by the removal, so a relying party pinning the key by fingerprint still treats it as the same key, and no private key or hash collision is involved; the natural moment for the strip is the key refresh that RFC 9580 names as the reason to refetch a key at all - to learn about changes in expiration, features, preferences and revocation - which is exactly the update it defeats. This is a downgrade rather than a forgery, nothing being attributed to a key that did not authorise it, and the concerning direction is encryption, to a key meant to have been retired. OpenPGPCertificate.isBoundBy now requires a valid Direct Key self-signature on a version 6 primary key before any component of the certificate - the primary key, its subkeys or its identities - is treated as bound, so a version 6 certificate stripped of it offers no keys at all rather than an unexpiring set. Version 4 certificates are unaffected: there the key expiration legitimately lives on the user ID self-signature and the fallback is correct, so it stays. The revocation-only version 6 certificate of sec. 10.1.2, which legitimately carries no Direct Key signature, is unaffected as well - its key was already refused as revoked, and reading the revocation does not go through the binding check.
- The lightweight LMSSigner and HSSSigner refused a key wrapped in ParametersWithRandom, which is how BcContentSignerBuilder passes a key whenever setSecureRandom() has been called - so BcHssLmsContentSignerBuilder built a working signer until a random was set and then failed with "Incorrect Key Parameters", and the two signers themselves raised ClassCastException on the same input. All three now unwrap it, as the promoted ML-DSA and SLH-DSA signers already did. The random is accepted and not used: LMS derives its message randomiser C from the key's seed and the one-time index, so it is deterministic and cannot repeat while q does not. Note SP 800-208 sec. 6.1 asks for C to come from an approved random bit generator, which this implementation does not do; that is unchanged here, and a supplied random is now ignored rather than refused.
Expand Down
17 changes: 13 additions & 4 deletions pkix/src/main/java/org/bouncycastle/pkcs/util/PKCS12Util.java
Original file line number Diff line number Diff line change
Expand Up @@ -207,19 +207,23 @@ public static ASN1OctetString getEncryptedContent(EncryptedData encryptedData) t
return content;
}

// A PBMAC1 MAC key is 20-64 bytes; anything beyond this is rejected as abusive.
// RFC 9579 sec. 9 RECOMMENDs rejecting a PBMAC1 KDF keyLength below 20 octets, since a short
// derived key makes the HMAC brute-forceable; sec. 5 has the key SHOULD match the HMAC output
// (>= 32 for the SHA-2 PRFs BC supports), so no conforming file falls below this floor.
private static final BigInteger MIN_KEY_LENGTH = BigInteger.valueOf(20);
private static final BigInteger MAX_KEY_LENGTH = BigInteger.valueOf(1024);

/**
* Validate a PBKDF2 keyLength from a PFX. As with the iteration count, the value arrives in a
* PFX whose MAC has not been checked yet and sizes the derivation output, so it has to be
* bounded before deriving; it is also multiplied by 8 at the call sites, which overflows to a
* negative bit count for a large enough value.
* negative bit count for a large enough value. A keyLength below 20 octets is rejected per
* RFC 9579 sec. 9.
*
* @param keyLength the keyLength from the wire.
* @return the validated keyLength in bytes.
* @throws IllegalStateException if the keyLength is absent, not positive, or larger than the
* maximum supported.
* @throws IllegalStateException if the keyLength is absent, not positive, below 20 octets, or
* larger than the maximum supported.
*/
public static int validateKeyLength(BigInteger keyLength)
{
Expand All @@ -228,6 +232,11 @@ public static int validateKeyLength(BigInteger keyLength)
throw new IllegalStateException("keyLength must be positive");
}

if (keyLength.compareTo(MIN_KEY_LENGTH) < 0)
{
throw new IllegalStateException("keyLength " + keyLength + " less than " + MIN_KEY_LENGTH);
}

if (keyLength.compareTo(MAX_KEY_LENGTH) > 0)
{
throw new IllegalStateException("keyLength " + keyLength + " greater than " + MAX_KEY_LENGTH);
Expand Down
29 changes: 29 additions & 0 deletions pkix/src/test/java/org/bouncycastle/pkcs/test/PKCS12UtilTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,35 @@ public void testDeprecatedClass_StillRejectsPBMAC1()
}
}

public void testValidateKeyLengthBounds()
{
// RFC 9579 sec. 9: a PBMAC1 keyLength below 20 octets is rejected.
try
{
org.bouncycastle.pkcs.util.PKCS12Util.validateKeyLength(java.math.BigInteger.valueOf(8));
fail("short keyLength accepted");
}
catch (IllegalStateException e)
{
assertEquals("keyLength 8 less than 20", e.getMessage());
}

// upper bound still enforced.
try
{
org.bouncycastle.pkcs.util.PKCS12Util.validateKeyLength(java.math.BigInteger.valueOf(2000));
fail("oversized keyLength accepted");
}
catch (IllegalStateException e)
{
assertEquals("keyLength 2000 greater than 1024", e.getMessage());
}

// the RFC 9579 20-octet floor and a conformant SHA-256 output length both pass.
assertEquals(20, org.bouncycastle.pkcs.util.PKCS12Util.validateKeyLength(java.math.BigInteger.valueOf(20)));
assertEquals(32, org.bouncycastle.pkcs.util.PKCS12Util.validateKeyLength(java.math.BigInteger.valueOf(32)));
}

private static PKCS12PfxPdu buildPfx(
org.bouncycastle.pkcs.PKCS12MacCalculatorBuilder macBuilder)
throws Exception
Expand Down
23 changes: 23 additions & 0 deletions pkix/src/test/java/org/bouncycastle/pkcs/test/PfxPduTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -1245,6 +1245,29 @@ public void testPfxPduPBMac1KeyLengthBound()
}
}

// RFC 9579 sec. 9: a PBMAC1 keyLength below 20 octets makes the HMAC brute-forceable and is
// rejected before the derivation it sizes runs.
public void testPfxPduPBMac1KeyLengthMinimum()
throws Exception
{
PBMAC1Params pbmac1Params = new PBMAC1Params(
new AlgorithmIdentifier(PKCSObjectIdentifiers.id_PBKDF2,
new PBKDF2Params(Strings.toByteArray("saltsalt"), 1, 8, new AlgorithmIdentifier(PKCSObjectIdentifiers.id_hmacWithSHA256))),
new AlgorithmIdentifier(PKCSObjectIdentifiers.id_hmacWithSHA512));

BcPKCS12PBMac1CalculatorBuilder builder = new BcPKCS12PBMac1CalculatorBuilder(pbmac1Params);

try
{
builder.build(passwd);
fail("no exception");
}
catch (IllegalStateException e)
{
assertEquals("keyLength 8 less than 20", e.getMessage());
}
}

public void testBcEncryptedPrivateKeyInfo()
throws Exception
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,10 @@ static ASN1OctetString getEncryptedContent(EncryptedData encryptedData) throws I
return content;
}

// A PBMAC1 MAC key is 20-64 bytes; anything beyond this is rejected as abusive.
// RFC 9579 sec. 9 RECOMMENDs rejecting a PBMAC1 KDF keyLength below 20 octets, since a short
// derived key makes the HMAC brute-forceable; sec. 5 has the key SHOULD match the HMAC output
// (>= 32 for the SHA-2 PRFs BC supports), so no conforming file falls below this floor.
private static final BigInteger MIN_KEY_LENGTH = BigInteger.valueOf(20);
private static final BigInteger MAX_KEY_LENGTH = BigInteger.valueOf(1024);

static int validateKeyLength(BigInteger keyLength)
Expand All @@ -341,6 +344,11 @@ static int validateKeyLength(BigInteger keyLength)
throw new IllegalStateException("keyLength must be positive");
}

if (keyLength.compareTo(MIN_KEY_LENGTH) < 0)
{
throw new IllegalStateException("keyLength " + keyLength + " less than " + MIN_KEY_LENGTH);
}

if (keyLength.compareTo(MAX_KEY_LENGTH) > 0)
{
throw new IllegalStateException("keyLength " + keyLength + " greater than " + MAX_KEY_LENGTH);
Expand Down