Files
lneto/internet/pcap/capture_tls.go
T
2026-08-08 23:17:27 -07:00

462 lines
16 KiB
Go

package pcap
import (
"encoding/binary"
"github.com/soypat/lneto"
"github.com/soypat/lneto/internal"
"github.com/soypat/lneto/x/tls"
)
// maxTLSRecordsPerPacket bounds how many record frames a single packet may
// produce. A TCP segment commonly coalesces several small records, but without
// a bound a segment full of 5-byte empty records would produce thousands of
// frames.
const maxTLSRecordsPerPacket = 8
// payloadIsTLS reports whether payload begins with what could be a TLS record
// header. TLS has no magic number, so this is a heuristic: the content type
// must be one of the four TLS 1.3 defines, the legacy record version must be
// 0x03xx and the declared fragment length must be legal.
//
// It is what lets TLS be captured on any port instead of only on 443, and it
// does not collide with HTTP, whose first byte is a method or version letter
// and never a valid content type.
func payloadIsTLS(payload []byte) bool {
if len(payload) < tls.SizeHeaderRecord {
return false
}
switch tls.ContentType(payload[0]) {
case tls.ContentTypeChangeCipherSpec, tls.ContentTypeAlert,
tls.ContentTypeHandshake, tls.ContentTypeApplicationData:
default:
return false
}
if payload[1] != 0x03 || payload[2] > 0x04 {
// Every record version in use is 3.x: TLS 1.0 through 1.3 inclusive.
return false
}
n := int(binary.BigEndian.Uint16(payload[3:5]))
return n > 0 && n <= tls.MaxCiphertext
}
// CaptureTLS breaks down the TLS records starting at bitOffset. A single entry
// point handles every kind of TLS traffic: a record names its own content type,
// so the caller does not need to know whether it is looking at a handshake, an
// alert or application data, which is what makes capturing a whole port 443
// conversation possible without tracking connection state.
//
// One [Frame] is produced per record, plus one more per cleartext handshake
// message carried inside a handshake record. Everything after the ServerHello
// is encrypted and appears on the wire as application_data; its fragment is
// reported as an opaque payload, since decrypting it needs keys a capture does
// not have.
//
// TLS is a byte stream: a record may span TCP segments and a handshake message
// may span records. Whatever arrived is reported and the affected frame carries
// [tls.ErrNeedMore]; reassembly is out of scope for a stateless breakdown.
func (pc *PacketBreakdown) CaptureTLS(dst []Frame, pkt []byte, bitOffset int) ([]Frame, error) {
debuglog("pcap:tls:start")
if bitOffset%8 != 0 {
return dst, errNotByteAligned
}
off := bitOffset / 8
for nrec := 0; off < len(pkt); nrec++ {
if nrec == maxTLSRecordsPerPacket {
reclaimRemainingFrame(&dst, "TLS records?", FieldClassPayload, off*octet, octet*len(pkt))
break
}
newdst, consumed, err := pc.captureTLSRecord(dst, pkt, off*octet)
dst = newdst
if err != nil {
if nrec == 0 {
// Not TLS after all; let the caller frame the payload.
return dst, err
}
// Bytes trailing the last complete record that are too few or too
// malformed to be a record header of their own.
reclaimRemainingFrame(&dst, unknownPayloadProto, FieldClassPayload, off*octet, octet*len(pkt))
break
}
off += consumed
}
debuglog("pcap:tls:done")
return dst, nil
}
// captureTLSRecord appends the frames of the single record at bitOffset and
// returns how many bytes of pkt the record occupied.
func (pc *PacketBreakdown) captureTLSRecord(dst []Frame, pkt []byte, bitOffset int) ([]Frame, int, error) {
rec := pkt[bitOffset/8:]
rfrm, err := tls.NewRecordFrame(rec)
if err != nil {
return dst, 0, err
}
rfrm.ValidateSize(pc.validator())
if pc.validator().HasError() {
return dst, 0, pc.validator().ErrPop()
}
debuglog("pcap:tls:validated")
const fragOff = tls.SizeHeaderRecord * octet
ctype := rfrm.ContentType()
finfo := reclaimFrame(&dst, "TLS", bitOffset, baseTLSRecordFields[:])
finfo.Fields[0].Name = ctype.StringConst()
frag := rfrm.Payload()
if frag == nil {
// Fragment continues in a later segment. Report what arrived.
avail := len(rec) - tls.SizeHeaderRecord
finfo.Errors = append(finfo.Errors, tls.ErrNeedMore)
if avail > 0 {
var flags Flags
if ctype == tls.ContentTypeApplicationData {
flags = FlagEncrypted
}
finfo.Fields = append(finfo.Fields, FrameField{
Class: FieldClassPayload,
FrameBitOffset: fragOff,
BitLength: avail * octet,
Flags: flags,
})
}
return dst, len(rec), nil
}
switch ctype {
case tls.ContentTypeHandshake:
// Handshake messages get frames of their own. finfo must not be touched
// past this point: appending to dst may move the Frame it points at.
dst = pc.captureTLSHandshake(dst, pkt, bitOffset+fragOff, len(frag))
case tls.ContentTypeAlert:
if len(frag) < 2 {
finfo.Errors = append(finfo.Errors, lneto.ErrTruncatedFrame)
break
}
// The level byte is advisory only: in TLS 1.3 every alert except
// close_notify and user_canceled is fatal whatever it says.
finfo.Fields = append(finfo.Fields, FrameField{
Name: tls.AlertLevel(frag[0]).StringConst(),
Class: FieldClassType,
FrameBitOffset: fragOff,
BitLength: octet,
Flags: FlagLegacy,
}, FrameField{
Name: tls.AlertDescription(frag[1]).StringConst(),
Class: FieldClassType,
FrameBitOffset: fragOff + octet,
BitLength: octet,
})
case tls.ContentTypeApplicationData:
// Either genuine application data or a protected handshake or alert
// record; which of the three is only knowable after decryption.
finfo.Fields = append(finfo.Fields, FrameField{
Class: FieldClassPayload,
FrameBitOffset: fragOff,
BitLength: len(frag) * octet,
Flags: FlagEncrypted,
})
default: // change_cipher_spec and unrecognized content types.
finfo.Fields = append(finfo.Fields, FrameField{
Class: FieldClassPayload,
FrameBitOffset: fragOff,
BitLength: len(frag) * octet,
})
}
return dst, rfrm.RecordLength(), nil
}
// captureTLSHandshake appends one frame per handshake message found in the
// fragLen bytes of handshake record fragment starting at bitOffset.
func (pc *PacketBreakdown) captureTLSHandshake(dst []Frame, pkt []byte, bitOffset, fragLen int) []Frame {
debuglog("pcap:tls:hs-start")
const hdr = tls.SizeHeaderHandshake
frag := pkt[bitOffset/8:][:fragLen]
fragEnd := bitOffset + fragLen*octet
for off := 0; off < fragLen; {
msgBitOff := bitOffset + off*octet
hfrm, err := tls.NewHandshakeFrame(frag[off:])
if err != nil {
// Fewer than 4 bytes left: a message header split across records.
reclaimRemainingFrame(&dst, "TLS Handshake?", FieldClassPayload, msgBitOff, fragEnd)
return dst
}
mtype := hfrm.MsgType()
finfo := reclaimFrame(&dst, tlsHandshakeProto(mtype), msgBitOff, baseTLSHandshakeFields[:])
finfo.Fields[0].Name = mtype.StringConst()
body := hfrm.Body()
if body == nil {
// Message body continues in the next record.
finfo.Errors = append(finfo.Errors, tls.ErrNeedMore)
if avail := fragLen - off - hdr; avail > 0 {
finfo.Fields = append(finfo.Fields, FrameField{
Class: FieldClassPayload,
FrameBitOffset: hdr * octet,
BitLength: avail * octet,
})
}
return dst
}
switch mtype {
case tls.HandshakeTypeClientHello:
pc.captureTLSClientHello(finfo, body)
case tls.HandshakeTypeServerHello:
pc.captureTLSServerHello(finfo, body)
default:
if len(body) > 0 {
finfo.Fields = append(finfo.Fields, FrameField{
Class: FieldClassPayload,
FrameBitOffset: hdr * octet,
BitLength: len(body) * octet,
})
}
}
off += hfrm.MessageLength()
}
debuglog("pcap:tls:hs-done")
return dst
}
// captureTLSClientHello appends the fields of a ClientHello body to finfo.
// Field positions come from the parsed message, so the only arithmetic here is
// shifting past the handshake header.
func (pc *PacketBreakdown) captureTLSClientHello(finfo *Frame, body []byte) {
msg, err := tls.ParseClientHello(body)
if err != nil {
appendTLSHelloError(finfo, body, err)
return
}
sp := msg.Spans()
appendTLSHelloHead(finfo, sp)
pc.appendTLSCipherSuites(finfo, msg, sp.CipherSuites)
finfo.Fields = append(finfo.Fields, FrameField{
Name: "compression methods",
Class: FieldClassOptions,
FrameBitOffset: helloBitOffset(sp.Compression.Off),
BitLength: sp.Compression.Len * octet,
Flags: FlagLegacy,
})
pc.appendTLSExtensions(finfo, msg.ExtensionList(), sp.Extensions)
}
// captureTLSServerHello appends the fields of a ServerHello body to finfo. It
// differs from the client's in naming one suite and one compression method
// where the client offers a list of each.
func (pc *PacketBreakdown) captureTLSServerHello(finfo *Frame, body []byte) {
msg, err := tls.ParseServerHello(body)
if err != nil {
appendTLSHelloError(finfo, body, err)
return
}
sp := msg.Spans()
appendTLSHelloHead(finfo, sp)
finfo.Fields = append(finfo.Fields, FrameField{
Name: msg.CipherSuite().StringConst(),
Class: FieldClassType,
FrameBitOffset: helloBitOffset(sp.CipherSuites.Off),
BitLength: sp.CipherSuites.Len * octet,
}, FrameField{
Name: "compression method",
Class: FieldClassOptions,
FrameBitOffset: helloBitOffset(sp.Compression.Off),
BitLength: sp.Compression.Len * octet,
Flags: FlagLegacy,
})
pc.appendTLSExtensions(finfo, msg.ExtensionList(), sp.Extensions)
}
// helloBitOffset converts an offset within a hello body to one within the
// handshake message frame.
func helloBitOffset(bodyOff int) int {
return (tls.SizeHeaderHandshake + bodyOff) * octet
}
// appendTLSHelloHead appends the fields both hellos begin with.
func appendTLSHelloHead(finfo *Frame, sp tls.HelloSpans) {
finfo.Fields = append(finfo.Fields, FrameField{
// legacy_version. TLS 1.3 pins it to 0x0303 and carries the real version
// in supported_versions.
Class: FieldClassVersion,
FrameBitOffset: helloBitOffset(0),
BitLength: 2 * octet,
Flags: FlagLegacy,
}, FrameField{
Name: "Random",
Class: FieldClassID,
FrameBitOffset: helloBitOffset(sp.Random.Off),
BitLength: sp.Random.Len * octet,
})
if sp.SessionID.Len > 0 {
// TLS 1.3 has no resumption by session ID; a non-empty value means
// middlebox compatibility mode, echoed verbatim by the server.
finfo.Fields = append(finfo.Fields, FrameField{
Name: "Session ID",
Class: FieldClassID,
FrameBitOffset: helloBitOffset(sp.SessionID.Off),
BitLength: sp.SessionID.Len * octet,
})
}
}
// appendTLSHelloError reports a hello that did not parse. The parsers reject a
// whole message on any inconsistent length, which is what makes their iterators
// error-free, so there are no field offsets to show: the error and the raw bytes
// are all a capture can say.
func appendTLSHelloError(finfo *Frame, body []byte, err error) {
finfo.Errors = append(finfo.Errors, err)
finfo.Fields = append(finfo.Fields, FrameField{
Class: FieldClassPayload,
FrameBitOffset: helloBitOffset(0),
BitLength: len(body) * octet,
})
}
// appendTLSCipherSuites appends a cipher_suites container field whose subfields
// name each offered suite. GREASE values show up as such, which is what a
// capture should display: they carry no meaning and are not an error.
func (pc *PacketBreakdown) appendTLSCipherSuites(finfo *Frame, msg tls.ClientHelloMsg, sp tls.Span) {
// Reclaim from the Fields backing array to reuse its SubFields backing array.
sfield := internal.SliceReclaim(&finfo.Fields)
*sfield = FrameField{
Name: "cipher suites",
Class: FieldClassOptions,
SubFields: sfield.SubFields[:0],
FrameBitOffset: helloBitOffset(sp.Off),
BitLength: sp.Len * octet,
}
if pc.SubfieldLimit <= 0 {
return
}
for off, suite := range msg.CipherSuites {
if len(sfield.SubFields) >= pc.SubfieldLimit {
finfo.Errors = append(finfo.Errors, ErrLimitExceeded)
return
}
sfield.SubFields = append(sfield.SubFields, FrameField{
Name: suite.StringConst(),
Class: FieldClassType,
FrameBitOffset: helloBitOffset(off),
BitLength: 2 * octet,
})
}
}
// appendTLSExtensions appends an extensions container field whose subfields are
// the individual extensions.
func (pc *PacketBreakdown) appendTLSExtensions(finfo *Frame, exts tls.ExtensionList, sp tls.Span) {
extfield := internal.SliceReclaim(&finfo.Fields)
*extfield = FrameField{
Name: "extensions",
Class: FieldClassOptions,
SubFields: extfield.SubFields[:0],
FrameBitOffset: helloBitOffset(sp.Off),
BitLength: sp.Len * octet,
}
if pc.SubfieldLimit <= 0 {
return
}
for off, ext := range exts.All {
if len(extfield.SubFields) >= pc.SubfieldLimit {
finfo.Errors = append(finfo.Errors, ErrLimitExceeded)
return
}
extfield.SubFields = append(extfield.SubFields, tlsExtensionField(ext, off))
}
}
// tlsExtensionField describes a single hello extension. Extensions carrying a
// human readable value point at that value, located by the extension's own
// iterators, and the bulky opaque ones are classed as payload so that a
// [Formatter.FilterClasses] can drop them without losing the rest of the hello.
func tlsExtensionField(ext tls.ExtensionFrame, dataOff int) FrameField {
field := FrameField{
Name: ext.Type().StringConst(),
Class: FieldClassOptions,
FrameBitOffset: helloBitOffset(dataOff),
BitLength: len(ext.Data()) * octet,
}
switch ext.Type() {
case tls.ExtServerName:
for off, name := range ext.ServerNames {
if name.Type != 0 {
continue // Only host_name is defined.
}
field.Class = FieldClassText
field.FrameBitOffset = helloBitOffset(off)
field.BitLength = len(name.Name) * octet
break
}
case tls.ExtALPN:
// Span the first name through the last so every offered protocol stays
// visible; the length bytes between them show up as escapes.
first, end := -1, 0
for off, proto := range ext.ALPNProtos {
if first < 0 {
first = off
}
end = off + len(proto)
}
if first >= 0 {
field.Class = FieldClassText
field.FrameBitOffset = helloBitOffset(first)
field.BitLength = (end - first) * octet
}
case tls.ExtKeyShare, tls.ExtPreSharedKey, tls.ExtPadding, tls.ExtSessionTicket,
tls.ExtCookie, tls.ExtEncryptedClientHello, tls.ExtSignedCertificateTimestamp:
// Opaque and large: a post-quantum key share alone runs past 1kB.
field.Class = FieldClassPayload
}
return field
}
// tlsHandshakeProto names the frame of a handshake message. Only the messages a
// capture can see in cleartext get a name of their own; the rest travel inside
// a protected record and never reach here undecrypted.
func tlsHandshakeProto(t tls.HandshakeType) string {
switch t {
case tls.HandshakeTypeClientHello:
return "TLS ClientHello"
case tls.HandshakeTypeServerHello:
return "TLS ServerHello"
}
return "TLS Handshake"
}
var baseTLSRecordFields = [...]FrameField{
{
// Name is filled in with the content type's name by captureTLSRecord.
Class: FieldClassType,
FrameBitOffset: 0,
BitLength: 1 * octet,
},
{
// legacy_record_version, which TLS 1.3 receivers ignore entirely.
Class: FieldClassVersion,
FrameBitOffset: 1 * octet,
BitLength: 2 * octet,
Flags: FlagLegacy,
},
{
Class: FieldClassSize,
FrameBitOffset: 3 * octet,
BitLength: 2 * octet,
},
}
var baseTLSHandshakeFields = [...]FrameField{
{
// Name is filled in with the message type's name by captureTLSHandshake.
Class: FieldClassType,
FrameBitOffset: 0,
BitLength: 1 * octet,
},
{
Class: FieldClassSize,
FrameBitOffset: 1 * octet,
BitLength: 3 * octet,
},
}