bctls-klog is the Bouncy Castle TLS API built with key logging present. It reports the secrets
that protect a TLS connection — in the terms of
RFC 9850, the SSLKEYLOGFILE format — so that a packet
capture of a connection can be decrypted by an analyser such as Wireshark.
This must not be used in production. RFC 9850 sec. 1.1 is unambiguous: the format "is intended for use in systems where TLS only protects test data ... this mechanism MUST NOT be used in a production system." Anything holding these secrets can decrypt the traffic they belong to, and for TLS 1.2 the master secret authenticates it as well. Treat this jar as one that may hand out the keys to every TLS connection the JVM makes.
Bouncy Castle reports secrets; it does not store them. There is no file, no SSLKEYLOGFILE
environment variable, and no encoding chosen for you. You implement a one-method interface and
decide what becomes of each record — which is also the only place the RFC's access-control
requirements can actually be honoured.97
Turning key logging on takes two deliberate acts, and both are required:
- Run
bctls-klogin place ofbctls. The standardbctlsjar contains no key logging at all, and no property will give it any. - Name your implementation in the
java.securityfile of the JVM you are running.
RFC 9850 sec. 1.1 recommends that a deployed binary not be able to disclose its own keys, and suggests conditional compilation to that end. Java has no conditional compilation, so the equivalent is a separate artifact. Swapping the jar is the decision that matters; the property only selects where the secrets go.
Because the reporting sits in the TLS key schedule itself, it covers both the low-level
org.bouncycastle.tls API and the BCJSSE provider (SSLSocket, SSLEngine, HttpsURLConnection,
…) with no difference in setup.
bctls-klog is a drop-in replacement for bctls: same module name (org.bouncycastle.tls), same
packages, plus the exported org.bouncycastle.tls.keylog.
Never put bctls and bctls-klog on the same class path. They define the same classes, and
which one wins would come down to class path order. Replace one with the other. For the same reason
bctls-klog is deliberately absent from the Bouncy Castle BOM — the two are alternatives, not
companions.
Maven:
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bctls-klog-jdk18on</artifactId>
<version>1.86</version>
</dependency>Gradle:
implementation 'org.bouncycastle:bctls-klog-jdk18on:1.86'It needs the same companions as bctls — bcprov-jdk18on, bcutil-jdk18on and bcpkix-jdk18on.
To build it from this source tree instead:
./gradlew :tls-klog:jar
# -> tls-klog/build/libs/bctls-klog-jdk18on-<version>.jar
public interface TlsKeyLog
{
void log(String label, byte[] clientRandom, byte[] secret);
}Each call is exactly one record of RFC 9850 sec. 2: a label, the ClientHello random identifying the
connection, and the secret. Writing the RFC's literal file format means emitting
label SP client_random SP secret with both byte arrays in hexadecimal.
The smallest thing that works, to see it running:
package com.example;
import org.bouncycastle.tls.keylog.TlsKeyLog;
import org.bouncycastle.util.encoders.Hex;
public class SysoutKeyLog implements TlsKeyLog
{
public synchronized void log(String label, byte[] clientRandom, byte[] secret)
{
System.out.println(label + " " + Hex.toHexString(clientRandom) + " " + Hex.toHexString(secret));
}
}The example below is the one to copy. It is hex-encoded, thread-safe, size-bounded, and creates its file owner-readable only. Each of those addresses something from Pitfalls below, so read that section before adapting it.
package com.example;
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.PosixFilePermissions;
import org.bouncycastle.tls.keylog.TlsKeyLog;
import org.bouncycastle.util.encoders.Hex;
/**
* Writes an RFC 9850 SSLKEYLOGFILE. The destination comes from the "example.keylog.file" system
* property: the TlsKeyLog interface has no configuration hook, so a sink reads its own settings.
*/
public class FileKeyLog
implements TlsKeyLog
{
/** A remote peer can drive records without authenticating, so the file needs a ceiling. */
private static final long MAX_BYTES = 64L * 1024 * 1024;
private final Object lock = new Object();
private BufferedWriter writer;
private long written;
private boolean full;
public FileKeyLog() throws IOException
{
Path path = Paths.get(System.getProperty("example.keylog.file", "tls-keys.log"));
// Create it before opening, so it is never briefly world-readable.
if (!Files.exists(path))
{
try
{
Files.createFile(path,
PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")));
}
catch (UnsupportedOperationException e)
{
Files.createFile(path); // not a POSIX filesystem; restrict it by other means
}
}
this.writer = Files.newBufferedWriter(path, StandardCharsets.UTF_8,
StandardOpenOption.WRITE, StandardOpenOption.APPEND);
}
public void log(String label, byte[] clientRandom, byte[] secret)
{
// Hex, always: clientRandom is chosen by the peer and must not reach the file raw.
String record = label + " " + Hex.toHexString(clientRandom) + " " + Hex.toHexString(secret);
synchronized (lock)
{
if (full)
{
return;
}
try
{
writer.write(record);
writer.newLine();
writer.flush(); // an analyser may be reading this file already
written += record.length() + 1;
if (written >= MAX_BYTES)
{
full = true;
writer.close();
}
}
catch (IOException e)
{
// Give up quietly rather than throw on every subsequent handshake.
full = true;
}
}
}
}Requirements on the class itself, all enforced at load time:
- public, and implementing
org.bouncycastle.tls.keylog.TlsKeyLog - a public no-argument constructor (it may throw; key logging is then disabled and the failure logged)
- on the application's class path
The type is checked before the class is initialised or constructed, so naming something that is not
a TlsKeyLog disables logging rather than running any of its code.
Add the property to the java.security file of the JVM you are running (or a file supplied with
-Djava.security.properties=):
org.bouncycastle.tls.keylog.class=com.example.FileKeyLogIt is read with Security.getProperty(...), resolved once, on the first secret of the first
handshake in the JVM. If the property is unset, nothing is loaded and no secret leaves the library.
When it does load, the library says so once through java.util.logging, at WARNING:
TLS key logging is enabled: connection secrets are being reported to com.example.FileKeyLog
as described by RFC 9850. This must not be done in production.
This property is not a privilege boundary.
Security.setPropertyis only permission-checked under aSecurityManager, which is disabled by default from JDK 17 and cannot be enabled at all from JDK 24 — so in a modern JVM any code in the process can set it before the first handshake. The boundary that holds is the artifact, not the setting.
Point Wireshark at the file: Preferences → Protocols → TLS → (Pre)-Master-Secret log filename. Or from the command line:
tshark -o tls.keylog_file:/path/to/tls-keys.log -r capture.pcap
The capture and the log must come from the same connections — the client_random in each record is
what ties the two together.
| RFC 9850 label | When |
|---|---|
CLIENT_RANDOM |
(D)TLS 1.2 and earlier, on full and resumed handshakes |
CLIENT_HANDSHAKE_TRAFFIC_SECRET |
TLS 1.3, once the ServerHello is sent |
SERVER_HANDSHAKE_TRAFFIC_SECRET |
TLS 1.3, once the ServerHello is sent |
CLIENT_TRAFFIC_SECRET_0 |
TLS 1.3, after the server's Finished |
SERVER_TRAFFIC_SECRET_0 |
TLS 1.3, after the server's Finished |
EXPORTER_SECRET |
TLS 1.3, after the server's Finished |
Both peers report independently, so running both ends of a connection in one JVM yields two identical records per label.
Not currently reported:
CLIENT_EARLY_TRAFFIC_SECRET/EARLY_EXPORTER_SECRET— defined inTlsKeyLogLabeland wired to the key schedule, but unreachable until TLS 1.3 early data is implemented.ECH_SECRET/ECH_CONFIG(RFC 9850 sec. 2.3) — Encrypted ClientHello is not implemented.- Post-KeyUpdate traffic secrets — RFC 9850 defines no labels for these.
Secrets are reported as they are derived, so a handshake that later fails has still reported whatever it got to — which is usually the point, when the failing handshake is what you are debugging.
clientRandom is chosen by an attacker. It is 32 bytes taken verbatim from ClientHello.random.
When your JVM is the server, every byte belongs to whoever connected — and to nobody
authenticated, because a server derives and reports its TLS 1.3 handshake secrets as soon as it has
sent its ServerHello. Three things follow:
- Hex-encode it. Written raw into a log or file, a peer can embed line terminators to split and forge records, or path separators to escape a directory. The same goes for any file name or map key derived from it.
- It is not unique. Nothing obliges a peer to vary it, so two connections can carry the same value. A sink using it as a primary key or file name will have one connection's secrets collide with or overwrite another's.
- It is not rate-limited. An unauthenticated peer can drive an unbounded number of records by opening handshakes it never completes. Bound the size of whatever you write, rotate it, or both.
Thread safety is yours. One instance serves the whole JVM, and log is called on whichever
thread is running the handshake. Records arrive in key-schedule order within a connection but
interleaved arbitrarily across concurrent connections.
Failures. An Exception thrown out of log is caught and reported through java.util.logging,
leaving the connection alone. An Error is not caught and will abort the handshake in progress —
which is the practical reason to bound what you accumulate rather than let it reach
OutOfMemoryError.
Do not open a TLS connection from inside log. It would re-enter the key log from within the
handshake it is logging.
The arrays are yours. Both are private copies; keep or modify them freely.
Protect the output. RFC 9850 sec. 3 asks that access be confined by file permissions or an equivalent. Whatever you write to is as sensitive as every private key in the JVM.
Nothing is reported at all. Work down the list:
- Is the running jar actually
bctls-klog? Confirm the package is present:unzip -l bctls-*.jar | grep 'tls/keylog/'— the standardbctlsmatches nothing. - Are both
bctlsandbctls-klogon the class path? Removebctls. - Is the property in the
java.securitythe JVM is really using? - Enable
java.util.loggingatWARNINGfororg.bouncycastle.tls.KeyLog. Loading either announces itself or explains why it failed. - Did the first handshake already happen before the property was set? Resolution happens once.
"is not a org.bouncycastle.tls.keylog.TlsKeyLog" — the named class does not implement the interface, or a second copy of the interface came from a different class loader.
Records appear but Wireshark decrypts nothing — check the capture covers the whole handshake
including ClientHello, and that a non-PFS TLS 1.2 suite is not in play with only CLIENT_RANDOM
available for a different connection.
Key logging cannot be enabled in a standard build. To prove a given artifact is the standard one, check for the key-log package — not for the string "keylog", which is a near-miss:
unzip -l bctls-jdk18on-<version>.jar | grep 'tls/keylog/' # no output
A case-insensitive search will turn up org/bouncycastle/tls/KeyLog.class in the standard jar, and
that is expected rather than alarming. It is the seam: a single inert class, a few hundred bytes,
whose methods are empty. It reads no property, references nothing in this package, and no
configuration changes that — the standard jar has no org/bouncycastle/tls/keylog/ package at all,
which is what the command above looks for.