Programming

TLS 1.3 mTLS with BouncyCastle: client cert missing

Troubleshoot BouncyCastle TLS 1.3 mTLS: verify Certificate/CertificateVerify messages, ensure PKCS#8 private key bytes, and prefer RSA-PSS signatures.

1 answer 1 view

Why doesn’t my Java / BouncyCastle mTLS client send the client certificate to nginx (TLS 1.3)?

Background

  • I have a self-signed client certificate in a Java keystore. Exporting the keypair and using Firefox -> nginx works: nginx accepts the client certificate.
  • Using my BouncyCastle-based client (TlsClientProtocol / DefaultTlsClient) the TLS handshake fails. If I disable client verification on nginx, the request succeeds, which indicates the client certificate is not being sent or is rejected by the server.

Relevant server and client logs (abridged)

Nginx debug (relevant line):

2026/01/15 12:26:32 [info] 4549#4549: *2 SSL_do_handshake() failed (SSL: error:0A000438:SSL routines::tlsv1 alert internal error:SSL alert number 80) while SSL handshaking, client: 192.168.178.57, server: 0.0.0.0:443

Java output (abridged):

TLSSocketConnectionFactory: Creating socket to host 192.168.178.88 on port 443
Creating CustomSSLSocket for mTLS...
Starting handshake for mTLS...
Getting TLS authentication for mTLS...
Notifying server certificate for mTLS...
Validating server certificate for mTLS...
Server certificate trusted via alias: cEbm.L3*
Getting client credentials for mTLS...
Chain cert subject: CN=MY SUBJECT CERTIFICATE
Chain cert subject: CN=MY ISSUER CERTIFICATE
Chain cert subject: CN=MY ROOT CA
Client will use signature algorithm: rsa with hash sha256
Successfully obtained client credentials for mTLS.
2026-01-15T13:26:33.899 [pool-6-thread-1] ERROR ... feign.RetryableException: internal_error(80) executing GET https://192.168.178.88:443/config/restapi_version

Key code snippets (abridged)

TLSSocketConnectionFactory.createSocket:

@Override
public Socket createSocket(Socket socket, final String host, int port, boolean arg3) throws IOException {
 if (socket == null) socket = new Socket();
 if (!socket.isConnected()) socket.connect(new InetSocketAddress(host, port));
 final TlsClientProtocol tlsClientProtocol = new TlsClientProtocol(socket.getInputStream(), socket.getOutputStream());
 return createSSLSocket(host, tlsClientProtocol);
}

CustomSSLSocket.startHandshake:

@Override
public void startHandshake() throws IOException {
 tlsClientProtocol.connect(new MyDefaultTlsClient(this.crypto, this.keyStoreManager, this.host));
}

MyTlsAuthentication.getClientCredentials (core logic):

@Override
public TlsCredentials getClientCredentials(CertificateRequest certificateRequest) {
 String alias = "CN-ClientCertificate"; // alias used
 KeyStore keyStore = keyStoreManager.getKeyStore();
 char[] password = keyStoreManager.getKeystorePass().toCharArray();

 java.security.cert.Certificate[] certChain = keyStore.getCertificateChain(alias);
 PrivateKey privateKey = (PrivateKey) keyStore.getKey(alias, password);

 // convert certs
 TlsCertificate[] bcTlsCertChain = new TlsCertificate[certChain.length];
 for (int i = 0; i < certChain.length; i++) {
 bcTlsCertChain[i] = crypto.createCertificate(certChain[i].getEncoded());
 }
 org.bouncycastle.tls.Certificate bcTlsCertificate = new org.bouncycastle.tls.Certificate(bcTlsCertChain);

 AsymmetricKeyParameter bcKeyParam = PrivateKeyFactory.createKey(privateKey.getEncoded());

 // choose sigAlg from certificateRequest.getSupportedSignatureAlgorithms()
 return new BcDefaultTlsCredentialedSigner(cryptoParams, crypto, bcKeyParam, bcTlsCertificate, sigAlg);
}

Observations / facts

  • getClientCredentials returns a BcDefaultTlsCredentialedSigner and logs “Successfully obtained client credentials for mTLS.” yet nginx reports an SSL alert internal_error (alert 80) and closes the handshake.
  • Disabling client verification on nginx makes the call succeed, which suggests the server expects a client certificate that the client either did not send or which failed verification on the server side.

Specific questions I’m trying to answer (actionable, focused)

  1. How can I conclusively determine whether my BouncyCastle client actually sends the Certificate and CertificateVerify messages during the TLS 1.3 handshake? Recommended steps: server-side logging, BouncyCastle debug options, packet capture filters (what exact messages to look for)?

  2. Is using PrivateKeyFactory.createKey(privateKey.getEncoded()) correct for converting a java.security.PrivateKey to an AsymmetricKeyParameter for the TLS light-weight API? Can differences in private-key encoding (PKCS#8 vs PKCS#1 / OpenSSL formats) cause the signer to silently fail or the server to reject CertificateVerify with an “internal_error”?

  3. TLS 1.3 often prefers RSA-PSS signatures. Could an incompatible signature algorithm selection (e.g. server requires rsa_pss_* but client uses rsa_pkcs1_sha256) cause the server to send an internal_error(80) rather than a clearer alert? How should I pick/force the SignatureAndHashAlgorithm for BcDefaultTlsCredentialedSigner to ensure compatibility with nginx?

  4. Are there known pitfalls or version-specific bugs in BouncyCastle’s TlsClientProtocol / BcDefaultTlsCredentialedSigner that prevent client auth in TLS 1.3 (client certificate not sent even when getClientCredentials returns non-null)? If so, which BC versions and workarounds are recommended?

  5. Could my code pattern (wrapping TlsClientProtocol into a custom SSLSocket and returning early/null in other factory methods) prevent parts of the handshake from executing in some edge cases? What should I check in my custom socket lifecycle?

  6. What targeted diagnostic steps, minimal reproducer, or small code changes would you recommend so I can isolate whether the problem is: (a) the client not sending the certificate, (b) the client sending it but using an incompatible signature/format, or © the server rejecting it for a reason not obvious in the nginx logs?

Any pointers to specific BouncyCastle debug flags, TLS traces to capture with Wireshark (what to filter for), or example code that successfully performs TLS 1.3 client-auth with BouncyCastle will be very helpful.

Most likely the problem is either (a) your BouncyCastle TLS client never actually emits the TLS 1.3 Certificate/CertificateVerify records, or (b) it does send them but the server rejects the CertificateVerify signature because of a key/encoding or signature-algorithm mismatch. Check key encoding (PKCS#8 vs PKCS#1), force/choose the right SignatureAndHashAlgorithm (prefer rsa_pss_* for TLS 1.3 if the server requests it), enable BC/nginx debug and capture a packet trace (filter for tls.handshake.type == 11 and 15) to see which of those it is.


Contents


Quick checklist: confirm whether the client sends certs (tls 1.3 / bouncycastle)

Start simple, rule things out in order:

  • Does nginx actually request a client certificate? Check your nginx config (ssl_verify_client on; and what CA list you provide). If disabling verify makes the request succeed, the server definitely received something different from what it wanted—or nothing at all. See nginx docs for how nginx logs handshake failures and how to enable debug logging: https://nginx.org/en/docs/http/ngx_http_ssl_module.html

  • Is getClientCredentials() being invoked early enough and returning a valid TlsCredentialedSigner? Your logs show “Successfully obtained client credentials for mTLS.” — that’s a good sign, but it doesn’t prove the client sent the certificate or produced a valid signature.

  • Next: do a packet capture and check for Certificate (handshake type 11) and CertificateVerify (type 15) frames (exact filter below). If the client never sends those two messages the server will reject the handshake.

If you want a one-line priority: 1) capture the handshake, 2) enable BC and nginx debug, 3) check private-key encoding and signature algorithm selection.


Capture & decode the TLS 1.3 Certificate / CertificateVerify messages (Wireshark + nginx)

What to capture

  • On the client or gateway run:
  • sudo tcpdump -i any host 192.168.178.88 and port 443 -w mtls.pcap
  • Open mtls.pcap in Wireshark and apply these filters:
  • tls.handshake.type == 11 (Certificate)
  • tls.handshake.type == 15 (CertificateVerify)

Important TLS 1.3 note

  • Certificate and CertificateVerify are sent encrypted after the ServerHello in TLS 1.3. To see their contents you must decrypt the session. The Wireshark TLS wiki summarizes filters and decryption: https://wiki.wireshark.org/TLS

How to get decrypt keys / logs

If you cannot easily produce key logs

  • You can still detect presence/absence: even when encrypted you will see an Encrypted Handshake/Encrypted Application Data record after ServerHello. If the client never sends an encrypted handshake record at that point it likely didn’t send Certificate/CertificateVerify. If you see the records but can’t decrypt them, proceed to BC debug and server-side verify logs.

Server-side logging

  • Enable nginx debug logging (error_log … debug) and inspect the exact SSL error. Nginx will log SSL_do_handshake() failures and the TLS alert number. Alert 80 means internal_error — often used when signature verification fails or when OpenSSL returns a verification/interoperability error (see nginx module docs): https://nginx.org/en/docs/http/ngx_http_ssl_module.html

PrivateKey encoding and PrivateKeyFactory.createKey — PKCS#8 vs PKCS#1

Why this matters

  • The BC lightweight conversion PrivateKeyFactory.createKey(byte[] keyBytes) expects standard PKCS#8 DER-encoded private key bytes. If your PrivateKey is in PKCS#1 (traditional “BEGIN RSA PRIVATE KEY”) or the key bytes are not PKCS#8, signature creation can silently fail or produce an invalid signature that the server rejects. StackOverflow and BC examples call this out: https://stackoverflow.com/questions/18065170/how-do-i-do-tls-with-bouncycastle

Quick checks

  • In Java: System.out.println(privateKey.getFormat()); // expected: “PKCS#8”
  • If privateKey.getEncoded() is null or getFormat() != “PKCS#8”, you must convert to PKCS#8.

How to fix

  • If you exported keys using OpenSSL you can convert PKCS#1 -> PKCS#8:
  • openssl pkcs8 -topk8 -nocrypt -in key-pkcs1.pem -out key-pkcs8.pem
  • Or in Java/BouncyCastle parse PEM (PEMParser/JcaPEMKeyConverter) and re-encode as PKCS#8 before calling PrivateKeyFactory.createKey(byte[]).
  • After conversion use:
  • AsymmetricKeyParameter bcKeyParam = PrivateKeyFactory.createKey(pkcs8Bytes);

If encoding was wrong it explains why the client appears to “get credentials” but nginx rejects the signature with an internal_error.

References: StackOverflow example and diagnosis: https://stackoverflow.com/questions/18065170/how-do-i-do-tls-with-bouncycastle


Signature algorithms in TLS 1.3 — prefer RSA‑PSS; how to pick/force sigAlg

TLS 1.3 specifics

  • Servers advertise supported signature algorithms in the CertificateRequest. TLS 1.3 prefers RSA‑PSS variants for RSA keys (rsa_pss_rsae_sha256 etc). If the client signs with PKCS#1 v1.5 (rsa_pkcs1_sha256) while the server expects PSS, the server may reject the CertificateVerify. That can manifest as alert internal_error(80) in nginx logs.

What to do in your getClientCredentials()

Pseudocode example (concept):

Vector<SignatureAndHashAlgorithm> sigAlgs = certificateRequest.getSupportedSignatureAlgorithms();
SignatureAndHashAlgorithm chosen = pickPreferPSS(sigAlgs);
if (chosen == null) chosen = fallbackSha256Pkcs1();
return new BcDefaultTlsCredentialedSigner(cryptoParams, crypto, bcKeyParam, bcTlsCertificate, chosen);

If the server and client disagree on signature format, the server will see an invalid CertificateVerify even though the client “sent” something.


Known BouncyCastle TLS 1.3 pitfalls and recommended versions / workarounds

History and concrete guidance

Recommended actions

  • Upgrade BouncyCastle to a release that includes TLS 1.3 client-auth fixes.
  • If you don’t need a custom lightweight client, try the BCJSSE provider path; it aligns more closely with JSSE behavior and provides better debug flags: https://github.com/bcgit/bc-java/issues/1062

Custom socket and lifecycle checks (TlsClientProtocol wrapper)

Common mistakes when wrapping TlsClientProtocol

  • Reusing a TlsClientProtocol instance across multiple sockets.
  • Returning from createSocket early (not giving the handshake time to run) or closing streams prematurely.
  • Not calling tlsClientProtocol.connect(…) in the expected thread or with full streams available.

What to check in your code

  • Ensure createSocket returns a fresh socket object whose startHandshake() blocks until tlsClientProtocol.connect(…) completes.
  • Verify you do not wrap InputStream/OutputStream with layers that buffer and delay the bytes needed by the handshake.
  • Make sure you don’t reuse the same TlsClientProtocol over multiple TCP connections; it is per-connection.

A simple test

  • Replace the custom socket wrapper with a direct small main() that creates a Socket, calls new TlsClientProtocol(in, out).connect(new MyDefaultTlsClient(…)) and prints logs — if that works, the problem is in your wrapper lifecycle.

Targeted diagnostics and minimal reproducer to isolate A / B / C causes

Step-by-step prioritized diagnostics

  1. Packet capture: capture and open in Wireshark; filter for tls.handshake.type == 11 || tls.handshake.type == 15. If you see Certificate/CertificateVerify packets (after decryption) the client is sending them — go to step 3. (See Wireshark: https://wiki.wireshark.org/TLS)

  2. If you don’t see them:

  • Add verbose BC debug for JSSE path: start JVM with -Dorg.bouncycastle.jsse.provider.debug=true and/or enable any lightweight TLS debugging available. See https://github.com/bcgit/bc-java/issues/1062 and the BC user guide.
  • Add explicit logging in getClientCredentials() and wrap the signer: log before and after signature generation, log any exceptions.
  1. If client sends packets but server rejects:
  1. Server-side: set nginx error log level to debug and temporarily set ssl_verify_client optional to avoid immediate rejection and to collect $ssl_client_* nginx variables to see the raw cert and verification result (nginx docs: https://nginx.org/en/docs/http/ngx_http_ssl_module.html).

  2. Quick repro:

  • Try the same keystore with a known-working client (Firefox works per your notes). Try a SunJSSE-based Java client or BCJSSE to see if the same keystore + key performs client auth. If yes, that isolates the problem to your lightweight BC path.
  1. Fallback test:
  • If possible, create a tiny test server (OpenSSL s_server) and try the BC client against it while logging both sides; OpenSSL will show why signature verification failed, often with clearer output than nginx.

Practical example: getClientCredentials pattern and debug hooks (snippet)

Below is a compact pattern to log/choose sigAlg and produce a BcDefaultTlsCredentialedSigner (conceptual; adapt imports):

java
@Override
public TlsCredentials getClientCredentials(CertificateRequest certificateRequest) {
 System.out.println("certificateRequest = " + certificateRequest);
 String alias = "CN-ClientCertificate";
 java.security.cert.Certificate[] certChain = keyStore.getCertificateChain(alias);
 PrivateKey privateKey = (PrivateKey) keyStore.getKey(alias, password);

 // BC TLS certificates
 TlsCertificate[] bcChain = new TlsCertificate[certChain.length];
 for (int i = 0; i < certChain.length; i++) {
 bcChain[i] = crypto.createCertificate(certChain[i].getEncoded());
 System.out.println("chain cert subject: " + ((X509Certificate)certChain[i]).getSubjectDN());
 }
 org.bouncycastle.tls.Certificate bcTlsCertificate = new org.bouncycastle.tls.Certificate(bcChain);

 // Ensure PKCS#8 bytes
 byte[] keyBytes = privateKey.getEncoded();
 if (keyBytes == null) throw new RuntimeException("privateKey.getEncoded() == null; wrong format");
 AsymmetricKeyParameter bcKeyParam = PrivateKeyFactory.createKey(keyBytes);

 // Select signature algorithm from certificateRequest
 SignatureAndHashAlgorithm sigAlg = null;
 Vector sigAlgs = certificateRequest.getSupportedSignatureAlgorithms();
 if (sigAlgs != null) {
 for (Object o : sigAlgs) {
 SignatureAndHashAlgorithm a = (SignatureAndHashAlgorithm)o;
 System.out.println("server supports: " + a);
 // prefer PSS if present (pseudo check)
 if (/* a indicates rsa_pss_rsae_sha256 */) {
 sigAlg = a; break;
 }
 }
 }
 if (sigAlg == null) sigAlg = new SignatureAndHashAlgorithm(HashAlgorithm.sha256, SignatureAlgorithm.rsa);

 System.out.println("Using sigAlg: " + sigAlg);
 return new BcDefaultTlsCredentialedSigner(cryptoParams, crypto, bcKeyParam, bcTlsCertificate, sigAlg);
}

If you want guaranteed observability, implement a custom TlsCredentialedSigner that delegates to BcDefaultTlsCredentialedSigner and logs the signature byte[] returned — if signature creation throws or yields unexpected length, you’ll see it.

References for patterns and lightweight TLS client examples: https://7thzero.com/blog/how-to-use-bouncy-castle-lightweight-api-s-tlsclient and the StackOverflow TLS guide: https://stackoverflow.com/questions/18065170/how-do-i-do-tls-with-bouncycastle


Sources


Conclusion

Start with packet capture + nginx debug to answer the single key question: did the client send Certificate and CertificateVerify? If it didn’t, the problem is handshake flow or wrapper lifecycle; if it did, the most common root causes are private-key encoding (ensure PKCS#8) or a signature-algorithm mismatch (pick/force rsa_pss_* when the server requests it). Upgrade BouncyCastle to a release that includes TLS 1.3 client-auth fixes or test with the BCJSSE path for a quick comparison. In practice the fastest path is: capture the trace (tls.handshake.type == 11 or 15), enable BC debug and log signature generation inside your signer, and verify privateKey.getFormat() == “PKCS#8” — those three checks resolve almost all bouncycastle + tls 1.3 mtls nginx failures.

Authors
Verified by moderation
Moderation
TLS 1.3 mTLS with BouncyCastle: client cert missing