Skip to content

Certificate parsing in 1.85 rejects empty issuer DNs with no opt-out, breaking libp2p TLS interop #2387

Description

@lucassaldanha

Summary

Since 1.85, parsing an X.509 certificate whose issuer is an empty DN throws. There is no system
property or other mechanism to opt out, and the JDK's own CertificateFactory rejects these
certificates too, so for callers that must handle them there is no parser left to fall back to.

This is a behaviour change on the read path only, and it breaks a certificate profile that is in
production use across the libp2p ecosystem (Ethereum consensus clients, IPFS, Filecoin, Polkadot).

The write-side enforcement added in the same release looks correct and is not what this issue is
about — the request is narrowly for an opt-out on the parse path.

Tested with each release below, parsing the certificate in the reproduction section:

bcprov/bcpkix version Certificate.getInstance(der) / new X509CertificateHolder(der)
1.78.1, 1.79, 1.80, 1.81, 1.82, 1.83, 1.84 accepted
1.85 IllegalArgumentException: certificate issuer is an empty distinguished name

X509CertificateHolder surfaces it wrapped, per the 1.85 exception-handling change:

org.bouncycastle.cert.CertIOException: malformed data: certificate issuer is an empty distinguished name
  caused by java.lang.IllegalArgumentException: certificate issuer is an empty distinguished name

Cause

Introduced by 93acf45 ("minor refactoring for issuer check fix.", 2026-05-05), which is after
r1rv84 and an ancestor of r1rv85, matching the version bracket above. b8a25e1 ("added X.509
certificate reviewer") later reworked the bare throw into reportProblem, leaving strict-mode
behaviour unchanged.

TBSCertificate.parse (1.85, core/src/main/java/org/bouncycastle/asn1/x509/TBSCertificate.java):

issuer = X500Name.getInstance(seq.getObjectAt(seqStart + 3));
// RFC 5280 sec. 4.1.2.4: certificate issuer MUST be a non-empty DN.
if (issuer.size() == 0)
{
    reportProblem(errors, "certificate issuer is an empty distinguished name");
}

In strict mode (errors == null, which is every ordinary parse) reportProblem throws
immediately. The same check is also in the public TBSCertificate constructor.

Unlike the non-DER TBSCertificate relaxation a few lines further down in the same class, this check
has no property gate:

public ASN1Primitive toASN1Primitive()
{
    if (seq != null)
    {
        if (Properties.getPropertyValue("org.bouncycastle.x509.allow_non-der_tbscert") != null)
        {
            if (Properties.isOverrideSet("org.bouncycastle.x509.allow_non-der_tbscert"))
            {
                return seq;
            }
        }
        ...

TBSCertificate.reviewStructure(seq) does parse in collect mode, but it discards the parsed
instance and returns only the error list, so it cannot be used as a lenient parser.

Stack trace on 1.85:

java.lang.IllegalArgumentException: certificate issuer is an empty distinguished name
	at org.bouncycastle.asn1.x509.TBSCertificate.reportProblem(Unknown Source)
	at org.bouncycastle.asn1.x509.TBSCertificate.parse(Unknown Source)
	at org.bouncycastle.asn1.x509.TBSCertificate.<init>(Unknown Source)
	at org.bouncycastle.asn1.x509.TBSCertificate.getInstance(Unknown Source)
	at org.bouncycastle.asn1.x509.Certificate.<init>(Unknown Source)

Reproduction

Self-contained; needs only bcprov-jdk18on + bcpkix-jdk18on. Prints parsed on 1.78.1–1.84,
throws on 1.85.

import org.bouncycastle.asn1.x509.Certificate;
import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.util.encoders.Hex;

public class Repro {

  // A libp2p TLS certificate as emitted by rust-libp2p: self-signed, issuer and subject are
  // both empty DNs, libp2p identity carried in extension 1.3.6.1.4.1.53594.1.1.
  private static final String CERT =
      "3082018230820129a00302010202144d1178a3bb828459ce1e266baa234ed8f0615c06300a06082a8648ce3d04030230"
          + "003020170d3735303130313030303030305a180f34303936303130313030303030305a30003059301306072a8648ce3d"
          + "020106082a8648ce3d03010703420004089ff3ab6e4b42cb2252a41aff3b8cb7c6f71f7050f6604ff138219f35652de1"
          + "f6a006f487cd15d88db31e12dcd3b080cd53aa5869a649a13762b6193029f61ca37f307d307b060a2b0601040183a25a"
          + "01010101ff046a30680424080112207f249e77411a3fa0c3f6305a8446cd45f9fb73ae2412f230f21943cf15dabc3d04"
          + "4025544b48ff50963b5f26b277906a08ba3f231d2d80f399801f856e21e3d9ec2b84c51f8063eb4ae70e52cd940ff82a"
          + "5aa29b82f3f82b5fb2ae67a9d5bba75c0b300a06082a8648ce3d0403020347003044022031580479526dd6a38a3cc1e9"
          + "0122ac9437d3633aa63f697165099e3d3c4cb3b70220525a60d13802089a9cbb0752646a2801df74d06d6f7785ff2193"
          + "1dca4e188e16";

  public static void main(final String[] args) throws Exception {
    final byte[] der = Hex.decode(CERT);

    Certificate.getInstance(der);
    new X509CertificateHolder(der);

    System.out.println("parsed");
  }
}

Why this profile exists, and why there is no fallback

The libp2p TLS handshake spec uses a
single self-signed certificate purely as a carrier for a peer's identity key, in an extension
(OID 1.3.6.1.4.1.53594.1.1). The certificate never participates in PKIX path validation: there is
no chain, no CA, no name chaining, and no issuer lookup. The spec defines verification exhaustively
as chain length exactly 1, NotBefore/NotAfter valid at receipt, a valid self-signature, and a
valid libp2p extension. It places no requirement on issuer or subject, and implementations are not
permitted to reject on those fields.

Therefore, the library should be able to parse empty-issuer certificates that arrive from
non-Java peers. It cannot use the JDK for this — on JDK 25, CertificateFactory.getInstance("X.509")
rejects the same certificate with CertificateParsingException: Empty issuer DN not allowed in X509Certificates. Bouncy Castle was the only parser that accepted them, which is why 1.85 turns
this into a hard blocker rather than a degradation.

Concretely: jvm-libp2p calls new X509CertificateHolder(cert.getEncoded()) inside its
X509TrustManager to read the identity extension and check the self-signature. On 1.85 that throws,
the trust manager fails, and the TLS/QUIC handshake aborts with CERTIFICATE_VERIFY_FAILED. This
reached us as QUIC handshake failures in Teku (an Ethereum consensus client) against rust-libp2p
based peers, when a routine dependency bump moved Bouncy Castle from 1.84 to 1.85. We have pinned to
1.84 for now, which also means forgoing the security fixes in 1.85.

Requested change

A system property gating the parse-path check only, defaulting to the current 1.85 behaviour, in the
style already used in this class. Something like:

issuer = X500Name.getInstance(seq.getObjectAt(seqStart + 3));
// RFC 5280 sec. 4.1.2.4: certificate issuer MUST be a non-empty DN.
if (issuer.size() == 0
    && !Properties.isOverrideSet("org.bouncycastle.x509.allow_empty_issuer"))
{
    reportProblem(errors, "certificate issuer is an empty distinguished name");
}

Properties is already imported and used in the class, and isOverrideSet returns false when the
property is unset, so strict parsing stays the default and nothing changes for existing callers. The
generator/builder side can stay unconditionally strict; callers in this situation need to read
such certificates, not create them.

If gating it is not acceptable, an alternative that would also work is a supported lenient entry
point that returns the parsed TBSCertificate/Certificate alongside the collected problems, so
the caller can decide which problems matter for its profile — essentially reviewStructure but
without discarding the instance.

To be clear, we are not disputing that RFC 5280 requires a non-empty issuer, or asking for the
default to change. The ask is an escape hatch for a widely deployed non-PKIX certificate profile
that Bouncy Castle accepted for its entire history up to 1.84.

Environment

  • bcprov-jdk18on / bcpkix-jdk18on 1.85 (also tested 1.78.1, 1.79, 1.80, 1.81, 1.82, 1.83, 1.84)
  • OpenJDK 25.0.3 (Temurin 25.0.3+9), macOS

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions