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, }, }