Skip to main content

Debug With Wireshark

When you really need to see what httpcloak puts on the wire, dump TLS keys and decrypt in Wireshark. Lowest-level view you can get short of stepping through the library with a debugger.

info

For fingerprinting issues, Wireshark plus keylog is the ground truth. tls.peet.ws is handy but it's a server-side reconstruction, not the actual wire bytes. They mostly agree. When they don't, Wireshark wins.

What you'll see

Once decrypted, you can read:

  • The full TLS ClientHello with extension order, GREASE values, key shares, the works.
  • Every HTTP/2 frame: SETTINGS, WINDOW_UPDATE, HEADERS, PRIORITY, PRIORITY_UPDATE.
  • HPACK-decoded headers (Wireshark does the HPACK decode for you).
  • Every QUIC frame for HTTP/3, including PRIORITY_UPDATE and STREAM frames.
  • Server response framing, server settings, connection-level windowing.

For HTTP/2 fingerprint checks specifically: SETTINGS values, settings order, the first PRIORITY frame on a request stream, header order in HEADERS frames. All in plain bytes.

Step 1: Dump TLS keys from your code

Use WithKeyLogFile to write the SSLKEYLOGFILE format Wireshark wants.

package main

import (
"context"
"fmt"
"os"
"time"

"github.com/sardanioss/httpcloak"
)

func main() {
keyLogPath := "/tmp/sslkeys.log"
// Make sure the file is fresh, old keys won't decrypt new traffic.
os.Remove(keyLogPath)

s := httpcloak.NewSession("chrome-latest",
httpcloak.WithKeyLogFile(keyLogPath),
httpcloak.WithSessionTimeout(30*time.Second),
)
defer s.Close()

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

r, err := s.Get(ctx, "https://tls.peet.ws/api/all")
if err != nil {
fmt.Println("err:", err); os.Exit(1)
}
defer r.Close()
fmt.Println("status:", r.StatusCode, "proto:", r.Protocol)
fmt.Println("keys written to:", keyLogPath)
}

WithKeyLogFile overrides the global SSLKEYLOGFILE env var for this one session. Want to set it once for every session in the process? Export the env var before running:

export SSLKEYLOGFILE=/tmp/sslkeys.log
go run main.go

httpcloak picks it up at startup automatically.

Step 2: Verify the keylog file

After your program runs, the file should look like this (one line per secret, NSS keylog format):

CLIENT_HANDSHAKE_TRAFFIC_SECRET <client_random> <secret>
SERVER_HANDSHAKE_TRAFFIC_SECRET <client_random> <secret>
CLIENT_TRAFFIC_SECRET_0 <client_random> <secret>
SERVER_TRAFFIC_SECRET_0 <client_random> <secret>

Four lines per TLS 1.3 connection. Fewer than that and the connection didn't complete. Zero lines and either the file path is wrong or the session died before hitting the application data stage.

Quick sanity check:

$ wc -l /tmp/sslkeys.log
4 /tmp/sslkeys.log

$ awk '{print $1}' /tmp/sslkeys.log | sort -u
CLIENT_HANDSHAKE_TRAFFIC_SECRET
CLIENT_TRAFFIC_SECRET_0
SERVER_HANDSHAKE_TRAFFIC_SECRET
SERVER_TRAFFIC_SECRET_0

That's a healthy single-connection keylog.

For QUIC / HTTP/3, you'll see the same four labels plus maybe EARLY_TRAFFIC_SECRET if 0-RTT fired. Recent Wireshark versions read QUIC and TLS keys from the same file.

Step 3: Capture traffic

Start Wireshark BEFORE you run your program. Otherwise you miss the ClientHello and the rest of the trace is uselessly opaque.

Useful capture filters:

FilterWhat it captures
tcp port 443All HTTPS over TCP (H1, H2)
udp port 443All QUIC (H3)
tcp port 443 or udp port 443Both
host tls.peet.wsJust traffic to a specific host

For headless work, tshark is the CLI version:

sudo tshark -i any -f "tcp port 443 or udp port 443" -w /tmp/capture.pcapng

Run your Go / Python program while tshark captures, then Ctrl-C tshark.

Step 4: Point Wireshark at the keylog

In Wireshark:

  1. EditPreferencesProtocolsTLS.
  2. (Pre)-Master-Secret log filename/tmp/sslkeys.log.
  3. OK.

Wireshark re-decodes the capture right there. Any TLS connection whose client_random matches a line in the keylog gets fully decrypted.

QUIC needs zero extra setup. Same TLS keylog config covers it.

Step 5: Useful display filters

After decryption, the filters worth knowing:

FilterShows
tls.handshake.type == 1Just ClientHello frames
http2All HTTP/2 frames
http2.type == 4HTTP/2 SETTINGS frames
http2.type == 2HTTP/2 PRIORITY frames
http2.type == 1HTTP/2 HEADERS frames
http2.type == 12HTTP/2 PRIORITY_UPDATE (RFC 9218)
quicAll QUIC frames
http3All HTTP/3 frames
http3.frame_type == 0xf0700H3 PRIORITY_UPDATE
tls.handshake.extension.typeGroup by extension type

Things to look for

TLS ClientHello

Click the ClientHello, expand Secure Sockets LayerTLSHandshakeExtensions. You should see:

  • Extension order matching your preset's JA4 / peetprint.
  • GREASE extension at position 0 (Chrome-style presets only).
  • key_share carrying the same curves as your preset's key_share_curves list. Modern Chrome ships GREASE + X25519MLKEM768 + X25519.
  • ALPN listing h2 and http/1.1 (or just h3 for QUIC).

HTTP/2 SETTINGS

Filter: http2.type == 4. The first SETTINGS frame from your client should match your preset's settings:

  • Setting 1 (HEADER_TABLE_SIZE): 65536 for Chrome.
  • Setting 2 (ENABLE_PUSH): 0.
  • Setting 4 (INITIAL_WINDOW_SIZE): 6291456 for Chrome.
  • Setting 6 (MAX_HEADER_LIST_SIZE): 262144 for Chrome.

These should land in the order your preset's settings_order specifies. Wireshark shows them sequentially, so order is visible right in the tree view.

HTTP/2 PRIORITY on first request stream

For RFC 7540 priorities (Chrome shape), the HEADERS frame on stream 1 carries a PRIORITY flag (0x20). Expand HyperText Transfer Protocol 2StreamHeader. Look for:

  • Stream Dependency: 0
  • Weight: 256 (Chrome) or whatever your preset says
  • Exclusive Bit: set

If stream_priority_mode is chrome and this priority isn't showing up on the first request, something's busted.

HTTP/3 PRIORITY_UPDATE

For HTTP/3, hunt for QUIC stream frames carrying H3 PRIORITY_UPDATE (frame type 0xf0700). They should reference the actual request stream ID, not stream 0. Older versions had a regression where the request stream wasn't referenced correctly. Wireshark is the most direct way to confirm your install actually behaves.

HPACK / QPACK header order

Click the HEADERS / QPACK encoder stream. Wireshark decodes the headers into a list. The order should match your preset's hpack_header_order (H2) or QPACK ordering (H3). Pseudo-headers come first, specifically in this order:

:method :authority :scheme :path

That's the Chrome pseudo_order. Other shapes are visible too. Safari, for instance, puts :scheme before :authority.

tshark for CI

Want to assert these things in tests instead of eyeballing them:

# Extract just the SETTINGS frames as JSON
tshark -r capture.pcapng -Y "http2.type == 4" \
-T fields -e http2.settings.identifier -e http2.settings.value

# Print all extension types in the first ClientHello
tshark -r capture.pcapng -Y "tls.handshake.type == 1" \
-T fields -e tls.handshake.extension.type

Wire those into a test harness, capture a known-good run, compare extension types and order on every CI run.

Common gotchas

Old keylog file. Reusing a keylog from a previous run won't decrypt new connections because the client_random is different. Always start with a fresh file, or delete before each run.

Capture started after the handshake. Start tshark / Wireshark mid-connection and you'll miss the ClientHello. The keys are still valid, but Wireshark needs the handshake bytes to tie keys to the connection.

HTTP/3 falling back to TCP. If H3 dial fails and the library falls back to H2, you'll see TCP instead of UDP. Not a bug, but worth knowing. Check the Protocol field of your response: h3 means UDP, h2 means TCP.

Network namespace mismatch. Code running inside a container or netns while tshark runs on the host? You won't see the traffic. Run tshark in the same namespace.