diff --git a/internet/pcap/capture.go b/internet/pcap/capture.go index 74dd8e8..43ac12c 100644 --- a/internet/pcap/capture.go +++ b/internet/pcap/capture.go @@ -52,11 +52,12 @@ type PacketBreakdown struct { // 0: Ethernet (3 base + VLAN tag = 4) // 1: L3 max(IPv4=12, ARP=9, IPv6=8) = 12 // 2: L4 max(TCP=10, ICMP=8, UDP=4) = 10 -// 3: App max(DHCP=15, NTP=13, HTTP=2, DNS=1) = 16 -// 4-5: overflow/remaining = 2 +// 3: App max(DHCP=15, NTP=13, HTTP=2, DNS=1, TLS record=4) = 16 +// 4: TLS handshake message (ClientHello=8) = 8 +// 5-7: extra TLS records/overflow/remaining = 4,2,2 func (pc *PacketBreakdown) initFrames() []Frame { - const nframes = 6 - var fieldCaps = [nframes]int{4, 12, 10, 16, 2, 2} + const nframes = 8 + var fieldCaps = [nframes]int{4, 12, 10, 16, 8, 4, 2, 2} frames := make([]Frame, nframes) for i := range frames { frames[i].Fields = make([]FrameField, 0, fieldCaps[i]) @@ -328,9 +329,18 @@ func (pc *PacketBreakdown) CaptureTCP(dst []Frame, pkt []byte, bitOffset int) ([ } payload := tfrm.Payload() if len(payload) > 0 { - debuglog("pcap:tcp:http-start") - dst, err = pc.CaptureHTTP(dst, pkt, end) - debuglog("pcap:tcp:http-done") + // Application protocol is picked by inspecting the payload rather than + // by port, so that TLS on a port other than 443 and HTTP on a port + // other than 80 are both broken down correctly. + if payloadIsTLS(payload) { + debuglog("pcap:tcp:tls-start") + dst, err = pc.CaptureTLS(dst, pkt, end) + debuglog("pcap:tcp:tls-done") + } else { + debuglog("pcap:tcp:http-start") + dst, err = pc.CaptureHTTP(dst, pkt, end) + debuglog("pcap:tcp:http-done") + } if err != nil { reclaimRemainingFrame(&dst, unknownPayloadProto, FieldClassPayload, end, octet*len(pkt)) } @@ -835,10 +845,13 @@ const ( // FlagContainer is used for [FrameField]s whose SubFields represent // the entirety of the FrameField's data. i.e: DNS Questions/Answers. FlagContainer + // FlagEncrypted marks fields whose bytes are ciphertext. i.e: TLS application_data fragment. + FlagEncrypted ) func (ff Flags) IsLegacy() bool { return ff&FlagLegacy != 0 } func (ff Flags) IsRightAligned() bool { return ff&FlagRightAligned != 0 } +func (ff Flags) IsEncrypted() bool { return ff&FlagEncrypted != 0 } type Frame struct { PacketBitOffset int @@ -1009,6 +1022,9 @@ func (frm Frame) LenBits() (totalBitlen int) { func (ff FrameField) String() string { if ff.Class == FieldClassPayload { + if ff.Flags.IsEncrypted() { + return "Encrypted len=" + strconv.Itoa(ff.BitLength/8) + } return "Payload len=" + strconv.Itoa(ff.BitLength/8) } if ff.Name != "" { diff --git a/internet/pcap/capture_bench_test.go b/internet/pcap/capture_bench_test.go index 8f3aa4d..28cd218 100644 --- a/internet/pcap/capture_bench_test.go +++ b/internet/pcap/capture_bench_test.go @@ -9,6 +9,7 @@ import ( "github.com/soypat/lneto/dns" "github.com/soypat/lneto/ethernet" "github.com/soypat/lneto/ipv4" + "github.com/soypat/lneto/tcp" "github.com/soypat/lneto/udp" ) @@ -106,6 +107,39 @@ func buildDNSPacket(b testing.TB) []byte { return pkt } +// buildTLSPacket builds an Ethernet+IPv4+TCP packet carrying a real +// ClientHello record, exercising the string-heavy TLS path: cipher suite and +// extension subfields, SNI and ALPN text. +func buildTLSPacket(b testing.TB) []byte { + const ( + ethSize = 14 + ipv4Size = 20 + tcpSize = 20 + ) + hello := captureClientHelloRecord(b, "example.com", []string{"h2", "http/1.1"}) + pkt := make([]byte, ethSize+ipv4Size+tcpSize+len(hello)) + + efrm, _ := ethernet.NewFrame(pkt) + *efrm.DestinationHardwareAddr() = [6]byte{0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe} + *efrm.SourceHardwareAddr() = [6]byte{0xff, 0xff, 0xff, 0xff, 0xff, 0xff} + efrm.SetEtherType(ethernet.TypeIPv4) + + ifrm, _ := ipv4.NewFrame(pkt[ethSize:]) + ifrm.SetVersionAndIHL(4, 5) + ifrm.SetID(0x1234) + ifrm.SetTTL(64) + ifrm.SetProtocol(lneto.IPProtoTCP) + ifrm.SetTotalLength(uint16(ipv4Size + tcpSize + len(hello))) + + tfrm, _ := tcp.NewFrame(pkt[ethSize+ipv4Size:]) + tfrm.SetSourcePort(51000) + tfrm.SetDestinationPort(443) + tfrm.SetOffsetAndFlags(5, tcp.FlagPSH|tcp.FlagACK) + + copy(pkt[ethSize+ipv4Size+tcpSize:], hello) + return pkt +} + func configureBenchFormatter(f *Formatter) { f.SubfieldLimit = benchSubfieldLimit f.FrameSep = "\n" @@ -123,6 +157,7 @@ func BenchmarkPcap(b *testing.B) { }{ {"DHCP", buildDHCPPacket(b)}, {"DNS", buildDNSPacket(b)}, + {"TLS", buildTLSPacket(b)}, } for _, tc := range cases { b.Run(tc.name, func(b *testing.B) { @@ -182,6 +217,7 @@ func BenchmarkPcapPhases(b *testing.B) { }{ {"DHCP", buildDHCPPacket(b)}, {"DNS", buildDNSPacket(b)}, + {"TLS", buildTLSPacket(b)}, } for _, tc := range cases { b.Run(tc.name, func(b *testing.B) { diff --git a/internet/pcap/capture_tls.go b/internet/pcap/capture_tls.go new file mode 100644 index 0000000..41130e0 --- /dev/null +++ b/internet/pcap/capture_tls.go @@ -0,0 +1,483 @@ +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.captureTLSHello(finfo, body, true) + case tls.HandshakeTypeServerHello: + pc.captureTLSHello(finfo, body, false) + 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 +} + +// captureTLSHello appends the fields of a ClientHello (isClient) or ServerHello +// body to finfo. The two differ only in that the client offers vectors of +// cipher suites and compression methods where the server names exactly one of +// each. Offsets are relative to the start of the handshake message, so the +// handshake header size is added throughout. +// +// The walk is deliberately more permissive than [tls.NewClientHelloFrame]: a +// capture must show what a malformed hello contains, so it reports the fields +// it did decode and stops at the first inconsistent length instead of +// discarding the message. +func (pc *PacketBreakdown) captureTLSHello(finfo *Frame, body []byte, isClient bool) { + const hdr = tls.SizeHeaderHandshake + const fixed = 2 + tls.SizeRandom + 1 // legacy_version + random + session id length + if len(body) < fixed { + finfo.Errors = append(finfo.Errors, lneto.ErrTruncatedFrame) + return + } + finfo.Fields = append(finfo.Fields, FrameField{ + // Pinned to 0x0303 by TLS 1.3 whatever version is really negotiated; + // the real version travels in the supported_versions extension. + Class: FieldClassVersion, + FrameBitOffset: hdr * octet, + BitLength: 2 * octet, + Flags: FlagLegacy, + }, FrameField{ + Name: "Random", + Class: FieldClassID, + FrameBitOffset: (hdr + 2) * octet, + BitLength: tls.SizeRandom * octet, + }) + + off := 2 + tls.SizeRandom + sidLen := int(body[off]) + off++ + if sidLen > len(body)-off { + finfo.Errors = append(finfo.Errors, lneto.ErrTruncatedFrame) + return + } + if sidLen > 0 { + // TLS 1.3 has no session resumption by 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: (hdr + off) * octet, + BitLength: sidLen * octet, + }) + } + off += sidLen + + if isClient { + if len(body)-off < 2 { + finfo.Errors = append(finfo.Errors, lneto.ErrTruncatedFrame) + return + } + suitesLen := int(binary.BigEndian.Uint16(body[off:])) + off += 2 + if suitesLen > len(body)-off { + finfo.Errors = append(finfo.Errors, lneto.ErrTruncatedFrame) + return + } + pc.appendTLSCipherSuites(finfo, body[off:off+suitesLen], hdr+off) + off += suitesLen + + compLen := int(body[off]) + off++ + if compLen > len(body)-off { + finfo.Errors = append(finfo.Errors, lneto.ErrTruncatedFrame) + return + } + finfo.Fields = append(finfo.Fields, FrameField{ + Name: "compression methods", + Class: FieldClassOptions, + FrameBitOffset: (hdr + off) * octet, + BitLength: compLen * octet, + Flags: FlagLegacy, + }) + off += compLen + } else { + if len(body)-off < 3 { + finfo.Errors = append(finfo.Errors, lneto.ErrTruncatedFrame) + return + } + suite := tls.CipherSuite(binary.BigEndian.Uint16(body[off:])) + finfo.Fields = append(finfo.Fields, FrameField{ + Name: suite.StringConst(), + Class: FieldClassType, + FrameBitOffset: (hdr + off) * octet, + BitLength: 2 * octet, + }, FrameField{ + Name: "compression method", + Class: FieldClassOptions, + FrameBitOffset: (hdr + off + 2) * octet, + BitLength: octet, + Flags: FlagLegacy, + }) + off += 3 + } + + if len(body)-off < 2 { + return // No extensions block. Not a legal 1.3 hello, but not a framing error either. + } + extsLen := int(binary.BigEndian.Uint16(body[off:])) + off += 2 + if extsLen > len(body)-off { + finfo.Errors = append(finfo.Errors, lneto.ErrTruncatedFrame) + extsLen = len(body) - off + } + pc.appendTLSExtensions(finfo, body[off:off+extsLen], hdr+off) +} + +// appendTLSCipherSuites appends a cipher_suites container field whose subfields +// name each offered suite. base is the byte offset of suites within the frame. +// GREASE values show up as their numeric type, which is what a capture should +// display: they carry no meaning and are not an error. +func (pc *PacketBreakdown) appendTLSCipherSuites(finfo *Frame, suites []byte, base int) { + // 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: base * octet, + BitLength: len(suites) * octet, + } + if pc.SubfieldLimit <= 0 { + return + } + for off := 0; off+2 <= len(suites); off += 2 { + if len(sfield.SubFields) >= pc.SubfieldLimit { + finfo.Errors = append(finfo.Errors, ErrLimitExceeded) + return + } + suite := tls.CipherSuite(binary.BigEndian.Uint16(suites[off:])) + sfield.SubFields = append(sfield.SubFields, FrameField{ + Name: suite.StringConst(), + Class: FieldClassType, + FrameBitOffset: (base + off) * octet, + BitLength: 2 * octet, + }) + } +} + +// appendTLSExtensions appends an extensions container field whose subfields are +// the individual extensions. base is the byte offset of exts within the frame. +func (pc *PacketBreakdown) appendTLSExtensions(finfo *Frame, exts []byte, base int) { + extfield := internal.SliceReclaim(&finfo.Fields) + *extfield = FrameField{ + Name: "extensions", + Class: FieldClassOptions, + SubFields: extfield.SubFields[:0], + FrameBitOffset: base * octet, + BitLength: len(exts) * octet, + } + if pc.SubfieldLimit <= 0 { + return + } + for off := 0; off+4 <= len(exts); { + ext := tls.ExtensionType(binary.BigEndian.Uint16(exts[off:])) + n := int(binary.BigEndian.Uint16(exts[off+2:])) + off += 4 + if n > len(exts)-off { + finfo.Errors = append(finfo.Errors, lneto.ErrTruncatedFrame) + return + } + if len(extfield.SubFields) >= pc.SubfieldLimit { + finfo.Errors = append(finfo.Errors, ErrLimitExceeded) + return + } + extfield.SubFields = append(extfield.SubFields, tlsExtensionField(ext, exts[off:off+n], base+off)) + off += n + } +} + +// tlsExtensionField describes a single hello extension. Extensions carrying a +// human readable value point at that value instead of at the whole extension +// body, 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.ExtensionType, data []byte, base int) FrameField { + field := FrameField{ + Name: ext.StringConst(), + Class: FieldClassOptions, + FrameBitOffset: base * octet, + BitLength: len(data) * octet, + } + switch ext { + case tls.ExtServerName: + // server_name_list(2) + name_type(1) + HostName length(2), then the name. + // Only host_name(0) is defined, and no client has ever sent a second entry. + const nameOff = 5 + if len(data) >= nameOff && data[2] == 0 { + n := int(binary.BigEndian.Uint16(data[3:5])) + if n <= len(data)-nameOff { + field.Class = FieldClassText + field.FrameBitOffset = (base + nameOff) * octet + field.BitLength = n * octet + } + } + + case tls.ExtALPN: + // Each protocol name is length prefixed. Quoting the whole list keeps + // every name visible, with the length bytes showing up as escapes. + if len(data) >= 2 { + field.Class = FieldClassText + field.FrameBitOffset = (base + 2) * octet + field.BitLength = (len(data) - 2) * 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, + }, +} diff --git a/internet/pcap/capture_tls_test.go b/internet/pcap/capture_tls_test.go new file mode 100644 index 0000000..1d69862 --- /dev/null +++ b/internet/pcap/capture_tls_test.go @@ -0,0 +1,414 @@ +package pcap + +import ( + stdtls "crypto/tls" + "math/rand" + "net" + "strings" + "testing" + "time" + + "github.com/soypat/lneto/ethernet" + "github.com/soypat/lneto/internal/ltesto" + "github.com/soypat/lneto/tcp" + "github.com/soypat/lneto/x/tls" +) + +// captureClientHelloRecord drives a standard library TLS client far enough to +// emit its first flight and returns the complete handshake record, header +// included. A real client gives realistic extension ordering, a 32-byte +// middlebox compatibility session ID and a post-quantum key share. +func captureClientHelloRecord(t testing.TB, serverName string, protos []string) []byte { + t.Helper() + client, server := net.Pipe() + defer client.Close() + defer server.Close() + go func() { + c := stdtls.Client(client, &stdtls.Config{ + ServerName: serverName, + MinVersion: stdtls.VersionTLS13, + MaxVersion: stdtls.VersionTLS13, + NextProtos: protos, + }) + _ = c.Handshake() // Will fail; only the first flight is needed. + }() + server.SetReadDeadline(time.Now().Add(10 * time.Second)) + var buf [4096]byte + n, err := server.Read(buf[:]) + if err != nil { + t.Fatalf("reading ClientHello: %v", err) + } + rec, err := tls.NewRecordFrame(buf[:n]) + if err != nil { + t.Fatal(err) + } + if !rec.Complete() { + t.Fatalf("ClientHello record split across reads: have %d want %d", n, rec.RecordLength()) + } + return append([]byte{}, rec.RawData()...) +} + +// TestCaptureTLSClientHello runs a real ClientHello through the full +// Ethernet/IPv4/TCP path to check that TLS is detected by payload content +// rather than by port, and that the SNI hostname is recovered. +func TestCaptureTLSClientHello(t *testing.T) { + const mtu = ethernet.MaxMTU + const serverName = "example.com" + payload := captureClientHelloRecord(t, serverName, []string{"h2", "http/1.1"}) + + var buf [mtu]byte + var gen ltesto.PacketGen + rng := rand.New(rand.NewSource(1)) + gen.RandomizeAddrs(rng) + pkt := gen.AppendRandomIPv4TCPPacket(buf[:0], rng, tcp.Segment{ + SEQ: 100, + ACK: 200, + DATALEN: tcp.Size(len(payload)), + WND: 1024, + Flags: tcp.FlagPSH | tcp.FlagACK, + }) + copy(pkt[len(pkt)-len(payload):], payload) + + var pbreak PacketBreakdown + pbreak.SubfieldLimit = 32 + frames, err := pbreak.CaptureEthernet(nil, pkt, 0) + if err != nil { + t.Fatal(err) + } + // Ethernet+IPv4+TCP+TLS record+TLS ClientHello = 5 frames. + if len(frames) != 5 { + for i := range frames { + t.Logf("frame[%d]=%s", i, frames[i].String()) + } + t.Fatalf("want 5 frames, got %d", len(frames)) + } + if frames[3].Protocol != "TLS" { + t.Errorf("frame 3 protocol=%q want TLS", frames[3].Protocol) + } + if frames[4].Protocol != "TLS ClientHello" { + t.Errorf("frame 4 protocol=%q want TLS ClientHello", frames[4].Protocol) + } + if len(frames[3].Errors) > 0 || len(frames[4].Errors) > 0 { + t.Errorf("unexpected errors: %v %v", frames[3].Errors, frames[4].Errors) + } + // A handshake record frame describes the record header only; its fragment is + // broken down by the handshake frame that follows, so that no bytes are + // claimed by two frames at once. + if got := frames[3].LenBits() / 8; got != tls.SizeHeaderRecord { + t.Errorf("TLS record frame len=%d want %d", got, tls.SizeHeaderRecord) + } + if got := frames[4].LenBits() / 8; got != len(payload)-tls.SizeHeaderRecord { + t.Errorf("handshake frame len=%d want %d", got, len(payload)-tls.SizeHeaderRecord) + } + + ctype := fieldByName(frames[3], "handshake") + if ctype == nil { + t.Fatal("no handshake content type field") + } + if v, _ := frames[3].FieldAsUint(indexOfField(frames[3], "handshake"), pkt); v != uint64(tls.ContentTypeHandshake) { + t.Errorf("content type=%d want %d", v, tls.ContentTypeHandshake) + } + + // SNI and ALPN live as subfields of the extensions container. + exts := fieldByName(frames[4], "extensions") + if exts == nil { + t.Fatal("no extensions field in ClientHello") + } + var sni, alpn *FrameField + for i := range exts.SubFields { + switch exts.SubFields[i].Name { + case tls.ExtServerName.String(): + sni = &exts.SubFields[i] + case tls.ExtALPN.String(): + alpn = &exts.SubFields[i] + } + } + if sni == nil { + t.Fatal("no server_name extension field") + } + got := string(pkt[(frames[4].PacketBitOffset+sni.FrameBitOffset)/8:][:sni.BitLength/8]) + if got != serverName { + t.Errorf("server_name=%q want %q", got, serverName) + } + if alpn == nil { + t.Error("no application_layer_protocol_negotiation extension field") + } + + // Formatted output is the point of the exercise: the hostname and the + // negotiated suites must be readable in one line. + var f Formatter + f.SubfieldLimit = 32 + out, err := f.FormatFrames(nil, frames, pkt) + if err != nil { + t.Fatal(err) + } + str := string(out) + for _, want := range []string{"TLS", "handshake=", "client_hello=", serverName, + "TLS_AES_128_GCM_SHA256", "http/1.1"} { + if !strings.Contains(str, want) { + t.Errorf("formatted output missing %q:\n%s", want, str) + } + } +} + +// TestCaptureTLSRecordSequence checks the multi-record path: a server flight +// coalesces ServerHello, the compatibility ChangeCipherSpec and the first +// protected record into a single segment. +func TestCaptureTLSRecordSequence(t *testing.T) { + var b tls.Builder + var buf [512]byte + b.Reset(buf[:]) + + // ServerHello record. + b.AddU8(uint8(tls.ContentTypeHandshake)) + b.AddU16(tls.VersionTLS12) + b.OpenU16() + b.AddU8(uint8(tls.HandshakeTypeServerHello)) + b.OpenU24() + b.AddU16(tls.VersionTLS12) // legacy_version + for range tls.SizeRandom { + b.AddU8(0xab) // server_random + } + b.OpenU8() // legacy_session_id echo + for range 32 { + b.AddU8(0xcd) + } + b.Close() + b.AddU16(uint16(tls.SuiteAES128GCMSHA256)) + b.AddU8(0) // legacy_compression_method + b.OpenU16() // extensions + b.AddU16(uint16(tls.ExtSupportedVersions)) + b.OpenU16() + b.AddU16(tls.VersionTLS13) + b.Close() + b.AddU16(uint16(tls.ExtKeyShare)) + b.OpenU16() + b.AddU16(uint16(tls.GroupX25519)) + b.OpenU16() + for range 32 { + b.AddU8(0xee) + } + b.Close() + b.Close() + b.Close() // extensions + b.Close() // handshake body + b.Close() // record fragment + + // change_cipher_spec record. + b.AddU8(uint8(tls.ContentTypeChangeCipherSpec)) + b.AddU16(tls.VersionTLS12) + b.OpenU16() + b.AddU8(1) + b.Close() + + // First protected record: outwardly application_data. + b.AddU8(uint8(tls.ContentTypeApplicationData)) + b.AddU16(tls.VersionTLS12) + b.OpenU16() + for range 24 { + b.AddU8(0x5a) + } + b.Close() + + pkt, err := b.Bytes() + if err != nil { + t.Fatal(err) + } + if !payloadIsTLS(pkt) { + t.Fatal("payloadIsTLS did not recognize a ServerHello record") + } + + var pbreak PacketBreakdown + pbreak.SubfieldLimit = 8 + frames, err := pbreak.CaptureTLS(nil, pkt, 0) + if err != nil { + t.Fatal(err) + } + want := []string{"TLS", "TLS ServerHello", "TLS", "TLS"} + if len(frames) != len(want) { + for i := range frames { + t.Logf("frame[%d]=%s", i, frames[i].String()) + } + t.Fatalf("got %d frames want %d", len(frames), len(want)) + } + for i, wantProto := range want { + if frames[i].Protocol != wantProto { + t.Errorf("frame[%d] protocol=%q want %q", i, frames[i].Protocol, wantProto) + } + if len(frames[i].Errors) > 0 { + t.Errorf("frame[%d] errors=%v", i, frames[i].Errors) + } + } + if fieldByName(frames[1], tls.SuiteAES128GCMSHA256.String()) == nil { + t.Error("ServerHello cipher suite field not named after the selected suite") + } + i, err := frames[3].FieldByClass(FieldClassPayload) + if err != nil { + t.Error("no payload field in application_data record:", err) + } else if !frames[3].Fields[i].Flags.IsEncrypted() { + t.Error("application_data fragment not flagged as encrypted") + } + // Record frames must start exactly where the previous record ended, and the + // last one must end at the packet end: no record skipped, none double read. + off := 0 + for i := range frames { + if frames[i].Protocol != "TLS" { + continue + } + if frames[i].PacketBitOffset != off*octet { + t.Errorf("frame[%d] starts at bit %d want %d", i, frames[i].PacketBitOffset, off*octet) + } + rec, err := tls.NewRecordFrame(pkt[off:]) + if err != nil { + t.Fatal(err) + } + off += rec.RecordLength() + } + if off != len(pkt) { + t.Errorf("records cover %d bytes of %d", off, len(pkt)) + } +} + +// TestCaptureTLSIncomplete checks the stream cases a stateless breakdown cannot +// reassemble: a record whose fragment continues in the next TCP segment, and a +// handshake message whose body continues in the next record. +func TestCaptureTLSIncomplete(t *testing.T) { + t.Run("record", func(t *testing.T) { + pkt := []byte{byte(tls.ContentTypeApplicationData), 0x03, 0x03, 0x04, 0x00, 1, 2, 3} + var pbreak PacketBreakdown + frames, err := pbreak.CaptureTLS(nil, pkt, 0) + if err != nil { + t.Fatal(err) + } + if len(frames) != 1 { + t.Fatalf("got %d frames want 1", len(frames)) + } + if len(frames[0].Errors) != 1 || frames[0].Errors[0] != tls.ErrNeedMore { + t.Errorf("errors=%v want %v", frames[0].Errors, tls.ErrNeedMore) + } + if got := frames[0].LenBits() / 8; got != len(pkt) { + t.Errorf("frame covers %d bytes want %d", got, len(pkt)) + } + }) + t.Run("handshake", func(t *testing.T) { + // A 5-byte record carrying a Certificate message header that declares a + // 1000-byte body: the rest arrives in later records. + pkt := []byte{ + byte(tls.ContentTypeHandshake), 0x03, 0x03, 0x00, 0x06, + byte(tls.HandshakeTypeCertificate), 0x00, 0x03, 0xe8, 0xaa, 0xbb, + } + var pbreak PacketBreakdown + frames, err := pbreak.CaptureTLS(nil, pkt, 0) + if err != nil { + t.Fatal(err) + } + if len(frames) != 2 { + t.Fatalf("got %d frames want 2", len(frames)) + } + if frames[1].Protocol != "TLS Handshake" { + t.Errorf("protocol=%q want TLS Handshake", frames[1].Protocol) + } + if len(frames[1].Errors) != 1 || frames[1].Errors[0] != tls.ErrNeedMore { + t.Errorf("errors=%v want %v", frames[1].Errors, tls.ErrNeedMore) + } + if got := frames[1].LenBits() / 8; got != 6 { + t.Errorf("handshake frame covers %d bytes want 6", got) + } + }) +} + +// TestCaptureTLSAlert checks the cleartext alert path, which is the only way a +// TLS 1.3 handshake failure is visible to a capture. +func TestCaptureTLSAlert(t *testing.T) { + pkt := []byte{ + byte(tls.ContentTypeAlert), 0x03, 0x03, 0x00, 0x02, + byte(tls.AlertLevelFatal), byte(tls.AlertHandshakeFailure), + } + var pbreak PacketBreakdown + frames, err := pbreak.CaptureTLS(nil, pkt, 0) + if err != nil { + t.Fatal(err) + } + if len(frames) != 1 { + t.Fatalf("got %d frames want 1", len(frames)) + } + if fieldByName(frames[0], tls.AlertHandshakeFailure.String()) == nil { + t.Errorf("no field named %q in %s", tls.AlertHandshakeFailure.String(), frames[0].String()) + } + var f Formatter + f.DisableLegacyFilter = true + out, err := f.FormatFrame(nil, frames[0], pkt) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(out), "handshake_failure") { + t.Errorf("formatted alert missing description: %s", out) + } +} + +// TestPayloadIsTLS guards the heuristic that routes a TCP payload to CaptureTLS +// against the HTTP traffic it shares the datapath with. +func TestPayloadIsTLS(t *testing.T) { + for _, tc := range []struct { + name string + payload []byte + want bool + }{ + {"client hello", []byte{22, 3, 1, 0, 10}, true}, + {"app data", []byte{23, 3, 3, 0x40, 0x00}, true}, + {"alert", []byte{21, 3, 3, 0, 2}, true}, + {"http request", []byte("GET / HTTP/1.1\r\n"), false}, + {"http response", []byte("HTTP/1.1 200 OK\r\n"), false}, + {"bad content type", []byte{25, 3, 3, 0, 10}, false}, + {"bad version", []byte{22, 2, 1, 0, 10}, false}, + {"future version", []byte{22, 3, 5, 0, 10}, false}, + {"zero length", []byte{22, 3, 3, 0, 0}, false}, + {"oversize length", []byte{23, 3, 3, 0xff, 0xff}, false}, + {"short", []byte{22, 3, 3}, false}, + } { + if got := payloadIsTLS(tc.payload); got != tc.want { + t.Errorf("%s: payloadIsTLS=%v want %v", tc.name, got, tc.want) + } + } +} + +// TestCaptureTLSRecordLimit checks that a segment packed with tiny records +// cannot make a single packet produce unbounded frames. +func TestCaptureTLSRecordLimit(t *testing.T) { + var pkt []byte + for range maxTLSRecordsPerPacket + 3 { + pkt = append(pkt, byte(tls.ContentTypeApplicationData), 3, 3, 0, 1, 0x99) + } + var pbreak PacketBreakdown + frames, err := pbreak.CaptureTLS(nil, pkt, 0) + if err != nil { + t.Fatal(err) + } + if len(frames) != maxTLSRecordsPerPacket+1 { + t.Fatalf("got %d frames want %d", len(frames), maxTLSRecordsPerPacket+1) + } + last := frames[len(frames)-1] + if last.Protocol != "TLS records?" { + t.Errorf("last frame protocol=%q want TLS records?", last.Protocol) + } + if end := (last.PacketBitOffset + last.LenBits()) / 8; end != len(pkt) { + t.Errorf("remaining frame ends at %d want %d", end, len(pkt)) + } +} + +func fieldByName(frm Frame, name string) *FrameField { + i := indexOfField(frm, name) + if i < 0 { + return nil + } + return &frm.Fields[i] +} + +func indexOfField(frm Frame, name string) int { + for i := range frm.Fields { + if frm.Fields[i].Name == name { + return i + } + } + return -1 +} diff --git a/internet/pcap/format.go b/internet/pcap/format.go index f9d816c..fa5093c 100644 --- a/internet/pcap/format.go +++ b/internet/pcap/format.go @@ -136,6 +136,9 @@ func (f *Formatter) formatField(dst []byte, pktStartOff int, field FrameField, p name := field.Name if name == "" { name = field.Class.String() + if field.Flags.IsEncrypted() { + name = "encrypted" + } } hasSpaces := strings.IndexByte(name, ' ') >= 0 if hasSpaces { @@ -191,8 +194,10 @@ func (f *Formatter) formatField(dst []byte, pktStartOff int, field FrameField, p nameOff := field.FrameBitOffset / 8 dst = dnsAppendDottedName(dst, dnsMsg, nameOff) case FieldClassDst, FieldClassSrc, FieldClassSize, FieldClassAddress, FieldClassOperation: - // IP, MAC addresses and ports. - if field.BitLength <= 16 { + // IP, MAC addresses and ports. Sizes are always numbers, never + // addresses, so a wide one such as the TLS 24-bit handshake length is + // still printed in decimal. + if field.BitLength <= 16 || field.Class == FieldClassSize && field.BitLength <= 64 { v, err := f.fieldAsUint(pkt, fieldBitStart, field.BitLength, field.Flags.IsRightAligned()) if err != nil { return dst, err diff --git a/x/tls/definitions.go b/x/tls/definitions.go index eaf7d13..42b89a7 100644 --- a/x/tls/definitions.go +++ b/x/tls/definitions.go @@ -106,6 +106,7 @@ const ( ExtMaxFragmentLength ExtensionType = 1 // max_fragment_length ExtStatusRequest ExtensionType = 5 // status_request ExtSupportedGroups ExtensionType = 10 // supported_groups + ExtECPointFormats ExtensionType = 11 // ec_point_formats ExtSignatureAlgorithms ExtensionType = 13 // signature_algorithms ExtALPN ExtensionType = 16 // application_layer_protocol_negotiation ExtSignedCertificateTimestamp ExtensionType = 18 // signed_certificate_timestamp diff --git a/x/tls/stringers.go b/x/tls/stringers.go new file mode 100644 index 0000000..2faee42 --- /dev/null +++ b/x/tls/stringers.go @@ -0,0 +1,366 @@ +// Code generated by "stringer -type=ContentType,HandshakeType,ExtensionType,AlertDescription,AlertLevel,NamedGroup,SignatureScheme,CipherSuite -linecomment -output stringers.go ."; DO NOT EDIT. + +package tls + +import "strconv" + +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[ContentTypeInvalid-0] + _ = x[ContentTypeChangeCipherSpec-20] + _ = x[ContentTypeAlert-21] + _ = x[ContentTypeHandshake-22] + _ = x[ContentTypeApplicationData-23] +} + +const ( + _ContentType_name_0 = "invalid" + _ContentType_name_1 = "change_cipher_specalerthandshakeapplication_data" +) + +var ( + _ContentType_index_1 = [...]uint8{0, 18, 23, 32, 48} +) + +func (i ContentType) String() string { + switch { + case i == 0: + return _ContentType_name_0 + case 20 <= i && i <= 23: + i -= 20 + return _ContentType_name_1[_ContentType_index_1[i]:_ContentType_index_1[i+1]] + default: + return "ContentType(" + strconv.FormatInt(int64(i), 10) + ")" + } +} +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[HandshakeTypeClientHello-1] + _ = x[HandshakeTypeServerHello-2] + _ = x[HandshakeTypeNewSessionTicket-4] + _ = x[HandshakeTypeEndOfEarlyData-5] + _ = x[HandshakeTypeEncryptedExtensions-8] + _ = x[HandshakeTypeCertificate-11] + _ = x[HandshakeTypeCertificateRequest-13] + _ = x[HandshakeTypeCertificateVerify-15] + _ = x[HandshakeTypeFinished-20] + _ = x[HandshakeTypeKeyUpdate-24] + _ = x[HandshakeTypeMessageHash-254] +} + +const ( + _HandshakeType_name_0 = "client_helloserver_hello" + _HandshakeType_name_1 = "new_session_ticketend_of_early_data" + _HandshakeType_name_2 = "encrypted_extensions" + _HandshakeType_name_3 = "certificate" + _HandshakeType_name_4 = "certificate_request" + _HandshakeType_name_5 = "certificate_verify" + _HandshakeType_name_6 = "finished" + _HandshakeType_name_7 = "key_update" + _HandshakeType_name_8 = "message_hash" +) + +var ( + _HandshakeType_index_0 = [...]uint8{0, 12, 24} + _HandshakeType_index_1 = [...]uint8{0, 18, 35} +) + +func (i HandshakeType) String() string { + switch { + case 1 <= i && i <= 2: + i -= 1 + return _HandshakeType_name_0[_HandshakeType_index_0[i]:_HandshakeType_index_0[i+1]] + case 4 <= i && i <= 5: + i -= 4 + return _HandshakeType_name_1[_HandshakeType_index_1[i]:_HandshakeType_index_1[i+1]] + case i == 8: + return _HandshakeType_name_2 + case i == 11: + return _HandshakeType_name_3 + case i == 13: + return _HandshakeType_name_4 + case i == 15: + return _HandshakeType_name_5 + case i == 20: + return _HandshakeType_name_6 + case i == 24: + return _HandshakeType_name_7 + case i == 254: + return _HandshakeType_name_8 + default: + return "HandshakeType(" + strconv.FormatInt(int64(i), 10) + ")" + } +} +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[ExtServerName-0] + _ = x[ExtMaxFragmentLength-1] + _ = x[ExtStatusRequest-5] + _ = x[ExtSupportedGroups-10] + _ = x[ExtECPointFormats-11] + _ = x[ExtSignatureAlgorithms-13] + _ = x[ExtALPN-16] + _ = x[ExtSignedCertificateTimestamp-18] + _ = x[ExtPadding-21] + _ = x[ExtExtendedMasterSecret-23] + _ = x[ExtCompressCertificate-27] + _ = x[ExtRecordSizeLimit-28] + _ = x[ExtSessionTicket-35] + _ = x[ExtPreSharedKey-41] + _ = x[ExtEarlyData-42] + _ = x[ExtSupportedVersions-43] + _ = x[ExtCookie-44] + _ = x[ExtPSKKeyExchangeModes-45] + _ = x[ExtCertificateAuthorities-47] + _ = x[ExtSignatureAlgorithmsCert-50] + _ = x[ExtKeyShare-51] + _ = x[ExtApplicationSettings-17513] + _ = x[ExtEncryptedClientHello-65037] + _ = x[ExtRenegotiationInfo-65281] +} + +const _ExtensionType_name = "server_namemax_fragment_lengthstatus_requestsupported_groupsec_point_formatssignature_algorithmsapplication_layer_protocol_negotiationsigned_certificate_timestamppaddingextended_master_secretcompress_certificaterecord_size_limitsession_ticketpre_shared_keyearly_datasupported_versionscookiepsk_key_exchange_modescertificate_authoritiessignature_algorithms_certkey_shareapplication_settingsencrypted_client_hellorenegotiation_info" + +var _ExtensionType_map = map[ExtensionType]string{ + 0: _ExtensionType_name[0:11], + 1: _ExtensionType_name[11:30], + 5: _ExtensionType_name[30:44], + 10: _ExtensionType_name[44:60], + 11: _ExtensionType_name[60:76], + 13: _ExtensionType_name[76:96], + 16: _ExtensionType_name[96:134], + 18: _ExtensionType_name[134:162], + 21: _ExtensionType_name[162:169], + 23: _ExtensionType_name[169:191], + 27: _ExtensionType_name[191:211], + 28: _ExtensionType_name[211:228], + 35: _ExtensionType_name[228:242], + 41: _ExtensionType_name[242:256], + 42: _ExtensionType_name[256:266], + 43: _ExtensionType_name[266:284], + 44: _ExtensionType_name[284:290], + 45: _ExtensionType_name[290:312], + 47: _ExtensionType_name[312:335], + 50: _ExtensionType_name[335:360], + 51: _ExtensionType_name[360:369], + 17513: _ExtensionType_name[369:389], + 65037: _ExtensionType_name[389:411], + 65281: _ExtensionType_name[411:429], +} + +func (i ExtensionType) String() string { + if str, ok := _ExtensionType_map[i]; ok { + return str + } + return "ExtensionType(" + strconv.FormatInt(int64(i), 10) + ")" +} +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[AlertCloseNotify-0] + _ = x[AlertUnexpectedMessage-10] + _ = x[AlertBadRecordMAC-20] + _ = x[AlertRecordOverflow-22] + _ = x[AlertHandshakeFailure-40] + _ = x[AlertBadCertificate-42] + _ = x[AlertUnsupportedCertificate-43] + _ = x[AlertCertificateRevoked-44] + _ = x[AlertCertificateExpired-45] + _ = x[AlertCertificateUnknown-46] + _ = x[AlertIllegalParameter-47] + _ = x[AlertUnknownCA-48] + _ = x[AlertAccessDenied-49] + _ = x[AlertDecodeError-50] + _ = x[AlertDecryptError-51] + _ = x[AlertProtocolVersion-70] + _ = x[AlertInsufficientSecurity-71] + _ = x[AlertInternalError-80] + _ = x[AlertInappropriateFallback-86] + _ = x[AlertUserCanceled-90] + _ = x[AlertMissingExtension-109] + _ = x[AlertUnsupportedExtension-110] + _ = x[AlertUnrecognizedName-112] + _ = x[AlertBadCertificateStatusResponse-113] + _ = x[AlertUnknownPSKIdentity-115] + _ = x[AlertCertificateRequired-116] + _ = x[AlertNoApplicationProtocol-120] +} + +const _AlertDescription_name = "close_notifyunexpected_messagebad_record_macrecord_overflowhandshake_failurebad_certificateunsupported_certificatecertificate_revokedcertificate_expiredcertificate_unknownillegal_parameterunknown_caaccess_denieddecode_errordecrypt_errorprotocol_versioninsufficient_securityinternal_errorinappropriate_fallbackuser_canceledmissing_extensionunsupported_extensionunrecognized_namebad_certificate_status_responseunknown_psk_identitycertificate_requiredno_application_protocol" + +var _AlertDescription_map = map[AlertDescription]string{ + 0: _AlertDescription_name[0:12], + 10: _AlertDescription_name[12:30], + 20: _AlertDescription_name[30:44], + 22: _AlertDescription_name[44:59], + 40: _AlertDescription_name[59:76], + 42: _AlertDescription_name[76:91], + 43: _AlertDescription_name[91:114], + 44: _AlertDescription_name[114:133], + 45: _AlertDescription_name[133:152], + 46: _AlertDescription_name[152:171], + 47: _AlertDescription_name[171:188], + 48: _AlertDescription_name[188:198], + 49: _AlertDescription_name[198:211], + 50: _AlertDescription_name[211:223], + 51: _AlertDescription_name[223:236], + 70: _AlertDescription_name[236:252], + 71: _AlertDescription_name[252:273], + 80: _AlertDescription_name[273:287], + 86: _AlertDescription_name[287:309], + 90: _AlertDescription_name[309:322], + 109: _AlertDescription_name[322:339], + 110: _AlertDescription_name[339:360], + 112: _AlertDescription_name[360:377], + 113: _AlertDescription_name[377:408], + 115: _AlertDescription_name[408:428], + 116: _AlertDescription_name[428:448], + 120: _AlertDescription_name[448:471], +} + +func (i AlertDescription) String() string { + if str, ok := _AlertDescription_map[i]; ok { + return str + } + return "AlertDescription(" + strconv.FormatInt(int64(i), 10) + ")" +} +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[AlertLevelWarning-1] + _ = x[AlertLevelFatal-2] +} + +const _AlertLevel_name = "warningfatal" + +var _AlertLevel_index = [...]uint8{0, 7, 12} + +func (i AlertLevel) String() string { + i -= 1 + if i >= AlertLevel(len(_AlertLevel_index)-1) { + return "AlertLevel(" + strconv.FormatInt(int64(i+1), 10) + ")" + } + return _AlertLevel_name[_AlertLevel_index[i]:_AlertLevel_index[i+1]] +} +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[GroupSECP256R1-23] + _ = x[GroupSECP384R1-24] + _ = x[GroupSECP521R1-25] + _ = x[GroupX25519-29] + _ = x[GroupX448-30] + _ = x[GroupX25519MLKEM768-4588] +} + +const ( + _NamedGroup_name_0 = "secp256r1secp384r1secp521r1" + _NamedGroup_name_1 = "x25519x448" + _NamedGroup_name_2 = "x25519mlkem768" +) + +var ( + _NamedGroup_index_0 = [...]uint8{0, 9, 18, 27} + _NamedGroup_index_1 = [...]uint8{0, 6, 10} +) + +func (i NamedGroup) String() string { + switch { + case 23 <= i && i <= 25: + i -= 23 + return _NamedGroup_name_0[_NamedGroup_index_0[i]:_NamedGroup_index_0[i+1]] + case 29 <= i && i <= 30: + i -= 29 + return _NamedGroup_name_1[_NamedGroup_index_1[i]:_NamedGroup_index_1[i+1]] + case i == 4588: + return _NamedGroup_name_2 + default: + return "NamedGroup(" + strconv.FormatInt(int64(i), 10) + ")" + } +} +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[SigRSAPKCS1SHA256-1025] + _ = x[SigRSAPKCS1SHA384-1281] + _ = x[SigRSAPKCS1SHA512-1537] + _ = x[SigECDSAP256SHA256-1027] + _ = x[SigECDSAP384SHA384-1283] + _ = x[SigECDSAP521SHA512-1539] + _ = x[SigRSAPSSRSAESHA256-2052] + _ = x[SigRSAPSSRSAESHA384-2053] + _ = x[SigRSAPSSRSAESHA512-2054] + _ = x[SigEd25519-2055] + _ = x[SigRSAPSSPSSSHA256-2057] +} + +const ( + _SignatureScheme_name_0 = "rsa_pkcs1_sha256" + _SignatureScheme_name_1 = "ecdsa_secp256r1_sha256" + _SignatureScheme_name_2 = "rsa_pkcs1_sha384" + _SignatureScheme_name_3 = "ecdsa_secp384r1_sha384" + _SignatureScheme_name_4 = "rsa_pkcs1_sha512" + _SignatureScheme_name_5 = "ecdsa_secp521r1_sha512" + _SignatureScheme_name_6 = "rsa_pss_rsae_sha256rsa_pss_rsae_sha384rsa_pss_rsae_sha512ed25519" + _SignatureScheme_name_7 = "rsa_pss_pss_sha256" +) + +var ( + _SignatureScheme_index_6 = [...]uint8{0, 19, 38, 57, 64} +) + +func (i SignatureScheme) String() string { + switch { + case i == 1025: + return _SignatureScheme_name_0 + case i == 1027: + return _SignatureScheme_name_1 + case i == 1281: + return _SignatureScheme_name_2 + case i == 1283: + return _SignatureScheme_name_3 + case i == 1537: + return _SignatureScheme_name_4 + case i == 1539: + return _SignatureScheme_name_5 + case 2052 <= i && i <= 2055: + i -= 2052 + return _SignatureScheme_name_6[_SignatureScheme_index_6[i]:_SignatureScheme_index_6[i+1]] + case i == 2057: + return _SignatureScheme_name_7 + default: + return "SignatureScheme(" + strconv.FormatInt(int64(i), 10) + ")" + } +} +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[SuiteAES128GCMSHA256-4865] + _ = x[SuiteAES256GCMSHA384-4866] + _ = x[SuiteChaCha20Poly1305SHA256-4867] + _ = x[SuiteAES128CCMSHA256-4868] + _ = x[SuiteAES128CCM8SHA256-4869] +} + +const _CipherSuite_name = "TLS_AES_128_GCM_SHA256TLS_AES_256_GCM_SHA384TLS_CHACHA20_POLY1305_SHA256TLS_AES_128_CCM_SHA256TLS_AES_128_CCM_8_SHA256" + +var _CipherSuite_index = [...]uint8{0, 22, 44, 72, 94, 118} + +func (i CipherSuite) String() string { + i -= 4865 + if i >= CipherSuite(len(_CipherSuite_index)-1) { + return "CipherSuite(" + strconv.FormatInt(int64(i+4865), 10) + ")" + } + return _CipherSuite_name[_CipherSuite_index[i]:_CipherSuite_index[i+1]] +} diff --git a/x/tls/stringers_const.go b/x/tls/stringers_const.go new file mode 100644 index 0000000..7bcd0df --- /dev/null +++ b/x/tls/stringers_const.go @@ -0,0 +1,122 @@ +package tls + +// StringConst methods name values as String does but always return a +// compile-time constant, unlike String which formats an unrecognized value with +// strconv. Wire values are attacker controlled, so decoders and logs on +// embedded targets use StringConst and print the number alongside it. + +const ( + // nameGREASE names the reserved values of RFC 8701, which carry no meaning. + nameGREASE = "GREASE" + // nameUnknown names a value this package does not recognize. Other RFCs may + // well define it: this is TLS 1.3 only. + nameUnknown = "unknown" +) + +// StringConst returns the content type's name, or "unknown". +func (v ContentType) StringConst() string { + switch v { + case ContentTypeInvalid, ContentTypeChangeCipherSpec, ContentTypeAlert, + ContentTypeHandshake, ContentTypeApplicationData: + return v.String() + } + return nameUnknown +} + +// StringConst returns the handshake message type's name, or "unknown". +func (v HandshakeType) StringConst() string { + switch v { + case HandshakeTypeClientHello, HandshakeTypeServerHello, HandshakeTypeNewSessionTicket, + HandshakeTypeEndOfEarlyData, HandshakeTypeEncryptedExtensions, HandshakeTypeCertificate, + HandshakeTypeCertificateRequest, HandshakeTypeCertificateVerify, HandshakeTypeFinished, + HandshakeTypeKeyUpdate, HandshakeTypeMessageHash: + return v.String() + } + return nameUnknown +} + +// StringConst returns the extension type's name, "GREASE" or "unknown". +func (v ExtensionType) StringConst() string { + switch v { + case ExtServerName, ExtMaxFragmentLength, ExtStatusRequest, ExtSupportedGroups, + ExtECPointFormats, ExtSignatureAlgorithms, ExtALPN, ExtSignedCertificateTimestamp, + ExtPadding, ExtExtendedMasterSecret, ExtCompressCertificate, ExtRecordSizeLimit, + ExtSessionTicket, ExtPreSharedKey, ExtEarlyData, ExtSupportedVersions, ExtCookie, + ExtPSKKeyExchangeModes, ExtCertificateAuthorities, ExtSignatureAlgorithmsCert, + ExtKeyShare, ExtApplicationSettings, ExtEncryptedClientHello, ExtRenegotiationInfo: + return v.String() + } + if IsGREASE(uint16(v)) { + return nameGREASE + } + return nameUnknown +} + +// StringConst returns the alert level's name, or "unknown". +func (v AlertLevel) StringConst() string { + switch v { + case AlertLevelWarning, AlertLevelFatal: + return v.String() + } + return nameUnknown +} + +// StringConst returns the alert description's name, or "unknown". +func (v AlertDescription) StringConst() string { + switch v { + case AlertCloseNotify, AlertUnexpectedMessage, AlertBadRecordMAC, AlertRecordOverflow, + AlertHandshakeFailure, AlertBadCertificate, AlertUnsupportedCertificate, + AlertCertificateRevoked, AlertCertificateExpired, AlertCertificateUnknown, + AlertIllegalParameter, AlertUnknownCA, AlertAccessDenied, AlertDecodeError, + AlertDecryptError, AlertProtocolVersion, AlertInsufficientSecurity, + AlertInternalError, AlertInappropriateFallback, AlertUserCanceled, + AlertMissingExtension, AlertUnsupportedExtension, AlertUnrecognizedName, + AlertBadCertificateStatusResponse, AlertUnknownPSKIdentity, + AlertCertificateRequired, AlertNoApplicationProtocol: + return v.String() + } + return nameUnknown +} + +// StringConst returns the named group's name, "GREASE" or "unknown". +func (v NamedGroup) StringConst() string { + switch v { + case GroupSECP256R1, GroupSECP384R1, GroupSECP521R1, + GroupX25519, GroupX448, GroupX25519MLKEM768: + return v.String() + } + if IsGREASE(uint16(v)) { + return nameGREASE + } + return nameUnknown +} + +// StringConst returns the signature scheme's name, "GREASE" or "unknown". +func (v SignatureScheme) StringConst() string { + switch v { + case SigRSAPKCS1SHA256, SigRSAPKCS1SHA384, SigRSAPKCS1SHA512, + SigECDSAP256SHA256, SigECDSAP384SHA384, SigECDSAP521SHA512, + SigRSAPSSRSAESHA256, SigRSAPSSRSAESHA384, SigRSAPSSRSAESHA512, + SigEd25519, SigRSAPSSPSSSHA256: + return v.String() + } + if IsGREASE(uint16(v)) { + return nameGREASE + } + return nameUnknown +} + +// StringConst returns the cipher suite's name, "GREASE" or "unknown". +// Every TLS 1.2 suite a browser still offers is undefined here: this package +// implements TLS 1.3 only. +func (v CipherSuite) StringConst() string { + switch v { + case SuiteAES128GCMSHA256, SuiteAES256GCMSHA384, SuiteChaCha20Poly1305SHA256, + SuiteAES128CCMSHA256, SuiteAES128CCM8SHA256: + return v.String() + } + if IsGREASE(uint16(v)) { + return nameGREASE + } + return nameUnknown +} diff --git a/x/tls/stringers_const_test.go b/x/tls/stringers_const_test.go new file mode 100644 index 0000000..262055d --- /dev/null +++ b/x/tls/stringers_const_test.go @@ -0,0 +1,73 @@ +package tls_test + +import ( + "strings" + "testing" + + "github.com/soypat/lneto/x/tls" +) + +// StringConst must name defined values as String does, GREASE values "GREASE" +// and everything else "unknown", over the whole range and without allocating. +// A constant added without extending a StringConst switch fails here. +func TestStringConst(t *testing.T) { + for _, tc := range []struct { + name string + n int + grease bool // type carries GREASE values + str func(int) string + cst func(int) string + }{ + {"ContentType", 1 << 8, false, + func(i int) string { return tls.ContentType(i).String() }, + func(i int) string { return tls.ContentType(i).StringConst() }}, + {"HandshakeType", 1 << 8, false, + func(i int) string { return tls.HandshakeType(i).String() }, + func(i int) string { return tls.HandshakeType(i).StringConst() }}, + {"AlertLevel", 1 << 8, false, + func(i int) string { return tls.AlertLevel(i).String() }, + func(i int) string { return tls.AlertLevel(i).StringConst() }}, + {"AlertDescription", 1 << 8, false, + func(i int) string { return tls.AlertDescription(i).String() }, + func(i int) string { return tls.AlertDescription(i).StringConst() }}, + {"ExtensionType", 1 << 16, true, + func(i int) string { return tls.ExtensionType(i).String() }, + func(i int) string { return tls.ExtensionType(i).StringConst() }}, + {"NamedGroup", 1 << 16, true, + func(i int) string { return tls.NamedGroup(i).String() }, + func(i int) string { return tls.NamedGroup(i).StringConst() }}, + {"SignatureScheme", 1 << 16, true, + func(i int) string { return tls.SignatureScheme(i).String() }, + func(i int) string { return tls.SignatureScheme(i).StringConst() }}, + {"CipherSuite", 1 << 16, true, + func(i int) string { return tls.CipherSuite(i).String() }, + func(i int) string { return tls.CipherSuite(i).StringConst() }}, + } { + t.Run(tc.name, func(t *testing.T) { + for i := range tc.n { + str, cst := tc.str(i), tc.cst(i) + if !strings.ContainsRune(str, '(') { // stringer names undefined values "Type(9)". + if cst != str { + t.Fatalf("%s(%d)=%q want %q", tc.name, i, cst, str) + } + continue + } + want := "unknown" + if tc.grease && tls.IsGREASE(uint16(i)) { + want = "GREASE" + } + if cst != want { + t.Fatalf("%s(%d)=%q want %q", tc.name, i, cst, want) + } + } + allocs := testing.AllocsPerRun(1, func() { + for i := range tc.n { + _ = tc.cst(i) + } + }) + if allocs != 0 { + t.Errorf("%s allocated %v times", tc.name, allocs) + } + }) + } +}