TlsUtils.checkTlsFeatures (TlsUtils.java:4838) requires a feature named in the leaf's id-pe-tlsfeature extension, and offered by the client itself, to be a key of serverExtensions too (:4874), throwing certificate_unknown otherwise (:4876-4877). processServerCertificate (:4925) calls it at :4951, ahead of the isTLSv13 branch at :4956. On the TLS 1.3 path serverExtensions is the EncryptedExtensions table (TlsClientProtocol.java:1543), which status_request can never be a key of: RFC 8446 sec. 4.2 lists that extension for ClientHello, CertificateRequest and Certificate only, and sec. 4.4.2.1 puts the answer in an extension of the CertificateEntry instead (paraphrasing both). BC enforces the same table (TlsUtils.java:6172-6184, TlsProtocol.java:2119-2123).
Only the status_request arm is affected; a feature that is permitted in EncryptedExtensions, server_name or ALPN say, is still answerable there (:6154-6171). But a TLS 1.3 client that offered status_request refuses every leaf whose feature list names it, correctly stapled or not, and BC's own 1.3 server cannot satisfy it: getServerExtensions adds no echo on that path and says why (AbstractTlsServer.java:561-564).
Lines are origin/main ab16374d37; these classes are byte identical at r1rv86 and in the 1.87-SNAPSHOT beta sources. It is the stock path: AbstractTlsClient.getCertificateStatusRequest (:125) supplies a request unconditionally and getClientExtensions adds it (:290-293). Through the provider too: a BCJSSE client on defaults fails the same way over TLS 1.3, and with jdk.tls.client.enableStatusRequestExtension=false (ProvTlsClient.java:63-64) it completes with no staple.
Full harness
import java.io.*; import java.math.BigInteger; import java.util.Date;
import java.security.KeyPair; import java.security.KeyPairGenerator;
import org.bouncycastle.asn1.*; import org.bouncycastle.asn1.ocsp.*;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.crypto.params.*; import org.bouncycastle.crypto.util.*;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.bouncycastle.tls.*; import org.bouncycastle.tls.crypto.TlsCryptoParameters;
import org.bouncycastle.tls.crypto.impl.bc.*;
public class MustStaple13 {
static AsymmetricKeyParameter k; static byte[] leaf;
public static void main(String[] a) throws Exception {
KeyPair kp = KeyPairGenerator.getInstance("RSA").generateKeyPair();
k = PrivateKeyFactory.createKey(kp.getPrivate().getEncoded());
X500Name n = new X500Name("CN=t"); long t = System.currentTimeMillis();
JcaX509v3CertificateBuilder b = new JcaX509v3CertificateBuilder(n, BigInteger.ONE,
new Date(t - 60000L), new Date(t + 86400000L), n, kp.getPublic());
// id-pe-tlsfeature = SEQUENCE { INTEGER 5 }; drop the next line for the control run
b.addExtension(new ASN1ObjectIdentifier("1.3.6.1.5.5.7.1.24"), false, new DERSequence(new ASN1Integer(5)));
leaf = b.build(new JcaContentSignerBuilder("SHA256withRSA").build(kp.getPrivate())).getEncoded();
PipedInputStream cr = new PipedInputStream(16384), sr = new PipedInputStream(16384);
TlsClientProtocol cp = new TlsClientProtocol(cr, new PipedOutputStream(sr));
final TlsServerProtocol sp = new TlsServerProtocol(sr, new PipedOutputStream(cr));
Thread s = new Thread() { public void run() { try { sp.accept(new Srv()); }
catch (Throwable e) { System.out.println("server: " + e); } } };
s.setDaemon(true); s.start();
try { cp.connect(new Cli()); }
catch (Throwable x) { System.out.println(x.getClass().getName() + ": " + x.getMessage());
StackTraceElement[] f = x.getStackTrace();
for (int i = 0; i < Math.min(4, f.length); ++i) System.out.println(" at " + f[i]); }
}
static class Cli extends DefaultTlsClient { Cli() { super(new BcTlsCrypto()); }
public TlsAuthentication getAuthentication() { return new TlsAuthentication() {
public void notifyServerCertificate(TlsServerCertificate s) {
System.out.println("handshake OK; staple: " + (s.getCertificateStatusAt(0) != null)); }
public TlsCredentials getClientCredentials(CertificateRequest r) { return null; } }; }
}
static class Srv extends DefaultTlsServer { Srv() { super(new BcTlsCrypto()); }
public TlsCredentials getCredentials() throws IOException {
BcTlsCrypto c = (BcTlsCrypto)context.getCrypto();
return new BcDefaultTlsCredentialedSigner(new TlsCryptoParameters(context), c, k,
new Certificate(new byte[0], new CertificateEntry[]{
new CertificateEntry(c.createCertificate(leaf), null)}),
SignatureAndHashAlgorithm.rsa_pss_rsae_sha256); }
// return null below for the run where the server declines to staple
public CertificateStatus getCertificateStatus() { return new CertificateStatus(
CertificateStatusType.ocsp, new OCSPResponse(new OCSPResponseStatus(0), null)); }
}
}
On the 1.86 and 1.87-SNAPSHOT jars, JDK 25, with the same output on both:
org.bouncycastle.tls.TlsFatalAlert: certificate_unknown(46); Server extensions missing TLS Feature 5
at org.bouncycastle.tls.TlsUtils.checkTlsFeatures(Unknown Source)
at org.bouncycastle.tls.TlsUtils.processServerCertificate(Unknown Source)
at org.bouncycastle.tls.TlsClientProtocol.handleServerCertificate(Unknown Source)
at org.bouncycastle.tls.TlsClientProtocol.receive13ServerCertificate(Unknown Source)
Dropping the extension line completes the handshake with a staple; the server declining to staple gives the same alert, so the check never reaches the staple.
It is fail closed, with no stapling bypass. The cost is reachability: a default BC client cannot complete TLS 1.3 with a must-staple server, and clearing the property avoids the alert only by giving up stapling. TLS 1.2, run separately, is unaffected where the server echoes status_request; where the server has allowMultiCertStatus() on, which is not the default, and the client offered both extensions, the status_request_v2 echo takes precedence (AbstractTlsServer.java:599-616) and the check fails there too, which looks like a separate question about which extension answers the feature.
Proposed change
Two shapes, and the choice is yours. The smaller one moves nothing: checkTlsFeatures already receives serverCertificate, getCertificateEntryAt(0).getExtensions() (Certificate.java:135) is where RFC 8446 sec. 4.4.2.1 puts the answer, and Certificate.parse leaves entry extensions null below 1.3 (:330-338), so counting a status_request key found there as satisfying the feature is a no-op for 1.2 and leaves the signature, the alert order and CheckTlsFeaturesExtensionTest alone. It tests key presence only: an entry whose status_request body does not decode passes it, and the handshake still ends a few lines on, when read13CertificateStatuses (called at TlsUtils.java:4962) hands the body to TlsExtensionsUtils.readStatusRequestExtension13 and that raises decode_error, so nothing undecodable reaches the application. The stricter one hoists the 1.3 read at :4962 above the check at :4951 and counts the feature as answered only when the decoded CertificateStatus for the leaf is non-null. As far as I can see the two accept and reject the same handshakes, a present key either decoding or ending the handshake (TlsUtils.java:5752-5756), so the difference is whether the check reads the decoded status, which costs it a new parameter or an overload beside the one that the unit test calls.
Tls13CertificateStatusTest drives 1.3 stapling handshakes end to end already, but builds no leaf carrying id-pe-tlsfeature, so nothing there reaches this check. Happy to put a patch together for whichever you prefer, with a must-staple case there asserting the handshake completes and getCertificateStatusAt(0) is non-null.
TlsUtils.checkTlsFeatures(TlsUtils.java:4838) requires a feature named in the leaf'sid-pe-tlsfeatureextension, and offered by the client itself, to be a key ofserverExtensionstoo (:4874), throwingcertificate_unknownotherwise (:4876-4877).processServerCertificate(:4925) calls it at:4951, ahead of theisTLSv13branch at:4956. On the TLS 1.3 pathserverExtensionsis the EncryptedExtensions table (TlsClientProtocol.java:1543), whichstatus_requestcan never be a key of: RFC 8446 sec. 4.2 lists that extension for ClientHello, CertificateRequest and Certificate only, and sec. 4.4.2.1 puts the answer in an extension of the CertificateEntry instead (paraphrasing both). BC enforces the same table (TlsUtils.java:6172-6184,TlsProtocol.java:2119-2123).Only the
status_requestarm is affected; a feature that is permitted in EncryptedExtensions,server_nameor ALPN say, is still answerable there (:6154-6171). But a TLS 1.3 client that offeredstatus_requestrefuses every leaf whose feature list names it, correctly stapled or not, and BC's own 1.3 server cannot satisfy it:getServerExtensionsadds no echo on that path and says why (AbstractTlsServer.java:561-564).Lines are origin/main
ab16374d37; these classes are byte identical atr1rv86and in the 1.87-SNAPSHOT beta sources. It is the stock path:AbstractTlsClient.getCertificateStatusRequest(:125) supplies a request unconditionally andgetClientExtensionsadds it (:290-293). Through the provider too: a BCJSSE client on defaults fails the same way over TLS 1.3, and withjdk.tls.client.enableStatusRequestExtension=false(ProvTlsClient.java:63-64) it completes with no staple.Full harness
On the 1.86 and 1.87-SNAPSHOT jars, JDK 25, with the same output on both:
Dropping the extension line completes the handshake with a staple; the server declining to staple gives the same alert, so the check never reaches the staple.
It is fail closed, with no stapling bypass. The cost is reachability: a default BC client cannot complete TLS 1.3 with a must-staple server, and clearing the property avoids the alert only by giving up stapling. TLS 1.2, run separately, is unaffected where the server echoes
status_request; where the server hasallowMultiCertStatus()on, which is not the default, and the client offered both extensions, thestatus_request_v2echo takes precedence (AbstractTlsServer.java:599-616) and the check fails there too, which looks like a separate question about which extension answers the feature.Proposed change
Two shapes, and the choice is yours. The smaller one moves nothing:
checkTlsFeaturesalready receivesserverCertificate,getCertificateEntryAt(0).getExtensions()(Certificate.java:135) is where RFC 8446 sec. 4.4.2.1 puts the answer, andCertificate.parseleaves entry extensions null below 1.3 (:330-338), so counting astatus_requestkey found there as satisfying the feature is a no-op for 1.2 and leaves the signature, the alert order andCheckTlsFeaturesExtensionTestalone. It tests key presence only: an entry whosestatus_requestbody does not decode passes it, and the handshake still ends a few lines on, whenread13CertificateStatuses(called atTlsUtils.java:4962) hands the body toTlsExtensionsUtils.readStatusRequestExtension13and that raisesdecode_error, so nothing undecodable reaches the application. The stricter one hoists the 1.3 read at:4962above the check at:4951and counts the feature as answered only when the decodedCertificateStatusfor the leaf is non-null. As far as I can see the two accept and reject the same handshakes, a present key either decoding or ending the handshake (TlsUtils.java:5752-5756), so the difference is whether the check reads the decoded status, which costs it a new parameter or an overload beside the one that the unit test calls.Tls13CertificateStatusTestdrives 1.3 stapling handshakes end to end already, but builds no leaf carryingid-pe-tlsfeature, so nothing there reaches this check. Happy to put a patch together for whichever you prefer, with a must-staple case there asserting the handshake completes andgetCertificateStatusAt(0)is non-null.