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 @@ -16,6 +16,7 @@ Date: 2026, TBD
### 2.1.2 Defects Fixed

- 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.
- TimeStampToken parsed the attacker-controlled TSTInfo content (org.bouncycastle.tsp.TimeStampToken, called from TimeStampResponse(byte[]) / (InputStream)) inside a try that caught only CMSException, so a well-formed RFC 3161 TimeStampResp whose embedded token carried a malformed TSTInfo (an empty or short SEQUENCE, a non-SEQUENCE, or truncated DER) let an IllegalArgumentException / ClassCastException / NoSuchElementException escape the constructor's declared throws TSPException, IOException contract. The parse failure is now surfaced as TSPException, matching the sibling TimeStampResponse(DLSequence) constructor and the existing getSignedAttributes guard in the same method.
- Neither the HSS nor the XMSS^MT private key decoder checked its declared index against the traversal state stored beside it, although the two are independent records of the same position in the key and so can be compared. For HSS the records are the top-level index and the component keys' one-time indices q; for XMSS^MT they are the global index and the per-layer BDS states. A stored key whose index had been rolled back while its state stayed advanced - a partial write, a restore from backup, a buggy storage layer - was therefore accepted, and it then signed a second message under a one-time key the key had already used, producing a signature that verified, so nothing anywhere surfaced the reuse. RFC 8554 sec. 1 and RFC 8391 sec. 1.1 both require each one-time key to be used exactly once, and this is the failure those requirements exist to prevent; the single-tree XMSS decoder has tied its BDS state to its index since that state was first validated, and this brings the two multi-tree schemes into line. HSS decode now requires the declared index to equal the position the component q values imply - a level above the last contributes (q - 1) leaves of the levels beneath it, since its q has already advanced past the subtree it signed - and XMSS^MT decode now requires each present layer's BDS index to equal the leaf index that layer derives from the global index, allowing the one position where a layer has moved into a new subtree and its state legitimately still carries the previous subtree's final index. A layer with no state yet is unaffected, since those are built lazily at signing time. Related, and the same shape of omission: an XMSS or XMSS^MT private key encoding carries the tree root twice - the key's own root field and the root node of the BDS state stored beside it, which for XMSS^MT is the top layer's - and the two were never compared either. A corrupted root was accepted and then poisoned every signature the key made, because the root is hashed into the message digest: the signature did not verify and nothing indicated why. Decode now requires the two copies to agree. The BDS node values themselves are not checkable the way the LMS tree cache above is - a BDS authentication path, stack, retain or keep node does not have its children stored alongside it, so recomputing one means building a subtree, which is the work the state exists to avoid. Both checks are integer comparisons over the levels of the key, too small to measure against the surrounding decode, and both were verified not to reject any legitimate key by walking every index a key can reach: the full key space of the two-level HSS and the h=4/d=2, h=6/d=2, h=6/d=3, h=9/d=3 and h=8/d=4 XMSS^MT parameter sets, plus a three-level HSS key across a subtree boundary and an HSS shard. Since those node values cannot be recomputed, the encoded state now carries a checksum over itself instead, with the owning key's public seed hashed in front of it. Any corruption of the stored state is refused at decode rather than being loaded and then producing signatures that silently do not verify, and because the public seed is bound in, a state transplanted between two keys of the same parameter set is refused too, even though it is internally consistent and arrives with its own matching root. The public seed is bound rather than the secret seed or the PRF key deliberately: the state's own root and index are inside the encoding and so are already covered, hashing secret material would make the stored checksum a commitment to it for no gain in detection, and the PRF key does not influence the state at all. **This is an error-detecting code and not integrity protection** - anyone able to rewrite the stored key recomputes it, so it establishes that the state is unchanged since it was written, never that it was correct when written, and the allocation bounds on the encoding remain the guard against a crafted one. It costs one SHA-256 over the state, measured at 5 to 8 microseconds each way for the h=10 and h=16 parameter sets, and 32 bytes of encoding. The state encoding was added earlier in this same cycle and has not been released, so the checksum is simply part of it rather than a new version: a state written by a 1.86 beta is rejected, which is recovered from by re-exporting the key. The deprecated org.bouncycastle.pqc.crypto.lms copy carries the HSS check as well (github #2414).
- The S/MIME example smoke test in the misc module (org.bouncycastle.mail.smime.examples.test.AllTests) drove SendSignedAndEncryptedMail against smtp.gmail.com, and that example finishes with Transport.send() under JavaMail's default settings, which have no connect timeout. Where outbound port 25 is refused the failure was swallowed and the test passed; where it is silently dropped, as on many home networks, the connect blocked and ./gradlew build hung in :misc:test indefinitely with "0 tests completed". The test now delivers to an SMTP stub on a loopback port, with connect / read / write timeouts as a backstop, and asserts the message arrived (github #2407).
- Composite ML-KEM encapsulation took the traditional component public key bytes it feeds the KEM combiner from the recipient key's own encoding, while decapsulation recomputes the point from the private key and so always produced an uncompressed one. Section 4 of draft-ietf-lamps-pq-composite-kem requires an EC component to be carried as an uncompressed point, but a component key that encodes itself compressed - a BC EC key whose point format has been set through org.bouncycastle.jce.interfaces.ECPointEncoder, or a key from a provider that preserves a compressed encoding - was passed through as it came. Both sides then combined a different tradPK and derived different shared secrets, with no error reported on either: encapsulation and decapsulation both succeeded and the recipient simply could not decrypt. The EC component is now normalised to an uncompressed point wherever the engine serialises one, which covers the ephemeral key that forms the ciphertext as well. X25519 and X448 components have a single encoding and were unaffected, as were EC keys left in their default (uncompressed) format, whose shared secrets are unchanged. CompositePublicKey.getEncoded() took its component bytes the same way, so such a key also encoded to a composite key other implementations reject and whose bytes changed across an encode / decode / encode round trip - 1238 bytes rather than 1270 for MLKEM768-ECDH-P256, and for the composite ML-DSA keys sharing that method, 2006 rather than 2038 for MLDSA65-ECDSA-P256. It now normalises the component the same way. This is a write-side change only: a composite key carrying a compressed EC component is still decoded, since the component key factories accept either form, and continues to verify signatures as before - it simply re-encodes in the normalised form. The shared normalisation is org.bouncycastle.jcajce.provider.asymmetric.util.ECUtil.getUncompressedSubjectPublicKeyBytes.
Expand Down
13 changes: 13 additions & 0 deletions pkix/src/main/java/org/bouncycastle/tsp/TimeStampToken.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import java.io.IOException;
import java.io.OutputStream;
import java.util.Collection;
import java.util.NoSuchElementException;

import org.bouncycastle.asn1.ASN1Encoding;
import org.bouncycastle.asn1.cms.Attribute;
Expand Down Expand Up @@ -155,6 +156,18 @@ public TimeStampToken(CMSSignedData signedData)
{
throw new TSPException(e.getMessage(), e.getUnderlyingException());
}
catch (IllegalArgumentException e)
{
throw new TSPException("malformed timestamp token: " + e.getMessage(), e);
}
catch (ClassCastException e)
{
throw new TSPException("malformed timestamp token: " + e.getMessage(), e);
}
catch (NoSuchElementException e)
{
throw new TSPException("malformed timestamp token: " + e.getMessage(), e);
}
}

/**
Expand Down
1 change: 1 addition & 0 deletions pkix/src/test/java/org/bouncycastle/tsp/test/AllTests.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public static Test suite()
suite.addTestSuite(ParseTest.class);
suite.addTestSuite(PQCTSPTest.class);
suite.addTestSuite(NewTSPTest.class);
suite.addTestSuite(TimeStampTokenParseTest.class);
suite.addTestSuite(CMSTimeStampedDataTest.class);
suite.addTestSuite(CMSTimeStampedDataParserTest.class);
suite.addTestSuite(CMSTimeStampedDataGeneratorTest.class);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package org.bouncycastle.tsp.test;

import java.math.BigInteger;

import junit.framework.TestCase;
import org.bouncycastle.asn1.ASN1Integer;
import org.bouncycastle.asn1.ASN1Set;
import org.bouncycastle.asn1.DEROctetString;
import org.bouncycastle.asn1.DERSequence;
import org.bouncycastle.asn1.DERSet;
import org.bouncycastle.asn1.cmp.PKIStatus;
import org.bouncycastle.asn1.cmp.PKIStatusInfo;
import org.bouncycastle.asn1.cms.ContentInfo;
import org.bouncycastle.asn1.cms.IssuerAndSerialNumber;
import org.bouncycastle.asn1.cms.SignedData;
import org.bouncycastle.asn1.cms.SignerIdentifier;
import org.bouncycastle.asn1.cms.SignerInfo;
import org.bouncycastle.asn1.nist.NISTObjectIdentifiers;
import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers;
import org.bouncycastle.asn1.tsp.TimeStampResp;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
import org.bouncycastle.tsp.TSPException;
import org.bouncycastle.tsp.TimeStampResponse;

/**
* A well-formed RFC 3161 TimeStampResp whose embedded token carries malformed TSTInfo content
* must be reported through the documented {@code throws TSPException} of {@code TimeStampResponse},
* not as an unchecked exception from the internal ASN.1 parse.
*/
public class TimeStampTokenParseTest
extends TestCase
{
// empty SEQUENCE - TSTInfo(ASN1Sequence) reads its fixed fields with no hasMoreElements guard
public void testEmptySequenceTstInfo()
throws Exception
{
checkRejected(new DERSequence().getEncoded());
}

// valid DER, wrong type - ASN1Sequence.getInstance rejects a non-SEQUENCE
public void testNonSequenceTstInfo()
throws Exception
{
checkRejected(new ASN1Integer(0).getEncoded());
}

// structurally broken DER - length octet promises more than is present
public void testTruncatedTstInfo()
throws Exception
{
checkRejected(new byte[]{ 0x30, 0x05, 0x02, 0x01 });
}

private void checkRejected(byte[] tstInfoContent)
throws Exception
{
byte[] resp = makeResponse(tstInfoContent);

try
{
new TimeStampResponse(resp);
fail("malformed TSTInfo accepted");
}
catch (TSPException e)
{
// expected - the parse failure is surfaced as the declared checked exception
}
}

private static byte[] makeResponse(byte[] tstInfoContent)
throws Exception
{
AlgorithmIdentifier sha256 = new AlgorithmIdentifier(NISTObjectIdentifiers.id_sha256);

ContentInfo encapContentInfo = new ContentInfo(
PKCSObjectIdentifiers.id_ct_TSTInfo, new DEROctetString(tstInfoContent));

SignerIdentifier sid = new SignerIdentifier(
new IssuerAndSerialNumber(new X500Name("CN=Test TSA"), BigInteger.ONE));
SignerInfo signerInfo = new SignerInfo(
sid, sha256, (ASN1Set)null,
new AlgorithmIdentifier(PKCSObjectIdentifiers.rsaEncryption),
new DEROctetString(new byte[]{ 1, 2, 3, 4 }), (ASN1Set)null);

SignedData signedData = new SignedData(
new DERSet(sha256), encapContentInfo, null, null, new DERSet(signerInfo));

ContentInfo token = new ContentInfo(PKCSObjectIdentifiers.signedData, signedData);

TimeStampResp resp = new TimeStampResp(new PKIStatusInfo(PKIStatus.granted), token);

return resp.getEncoded();
}
}