diff --git a/internet/pcap/capture_tls.go b/internet/pcap/capture_tls.go index 41130e0..c829148 100644 --- a/internet/pcap/capture_tls.go +++ b/internet/pcap/capture_tls.go @@ -200,9 +200,9 @@ func (pc *PacketBreakdown) captureTLSHandshake(dst []Frame, pkt []byte, bitOffse } switch mtype { case tls.HandshakeTypeClientHello: - pc.captureTLSHello(finfo, body, true) + pc.captureTLSClientHello(finfo, body) case tls.HandshakeTypeServerHello: - pc.captureTLSHello(finfo, body, false) + pc.captureTLSServerHello(finfo, body) default: if len(body) > 0 { finfo.Fields = append(finfo.Fields, FrameField{ @@ -218,212 +218,190 @@ func (pc *PacketBreakdown) captureTLSHandshake(dst []Frame, pkt []byte, bitOffse 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) +// 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{ - // Pinned to 0x0303 by TLS 1.3 whatever version is really negotiated; - // the real version travels in the supported_versions extension. + 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: hdr * octet, + FrameBitOffset: helloBitOffset(0), BitLength: 2 * octet, Flags: FlagLegacy, }, FrameField{ Name: "Random", Class: FieldClassID, - FrameBitOffset: (hdr + 2) * octet, - BitLength: tls.SizeRandom * octet, + FrameBitOffset: helloBitOffset(sp.Random.Off), + BitLength: sp.Random.Len * 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 + 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: (hdr + off) * octet, - BitLength: sidLen * octet, + FrameBitOffset: helloBitOffset(sp.SessionID.Off), + BitLength: sp.SessionID.Len * 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) +// 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. 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) { +// 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: base * octet, - BitLength: len(suites) * octet, + FrameBitOffset: helloBitOffset(sp.Off), + BitLength: sp.Len * octet, } if pc.SubfieldLimit <= 0 { return } - for off := 0; off+2 <= len(suites); off += 2 { + for off, suite := range msg.CipherSuites { 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, + FrameBitOffset: helloBitOffset(off), 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) { +// 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: base * octet, - BitLength: len(exts) * octet, + FrameBitOffset: helloBitOffset(sp.Off), + BitLength: sp.Len * 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 - } + 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, exts[off:off+n], base+off)) - off += n + extfield.SubFields = append(extfield.SubFields, tlsExtensionField(ext, off)) } } // 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 +// 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.ExtensionType, data []byte, base int) FrameField { +func tlsExtensionField(ext tls.ExtensionFrame, dataOff int) FrameField { field := FrameField{ - Name: ext.StringConst(), + Name: ext.Type().StringConst(), Class: FieldClassOptions, - FrameBitOffset: base * octet, - BitLength: len(data) * octet, + FrameBitOffset: helloBitOffset(dataOff), + BitLength: len(ext.Data()) * octet, } - switch ext { + switch ext.Type() { 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 + 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: - // 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 { + // 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 = (base + 2) * octet - field.BitLength = (len(data) - 2) * octet + field.FrameBitOffset = helloBitOffset(first) + field.BitLength = (end - first) * octet } case tls.ExtKeyShare, tls.ExtPreSharedKey, tls.ExtPadding, tls.ExtSessionTicket, diff --git a/x/tls/builder_test.go b/x/tls/builder_test.go index 942d295..10deaa7 100644 --- a/x/tls/builder_test.go +++ b/x/tls/builder_test.go @@ -18,8 +18,8 @@ func buildSupportedVersions(b *tls.Builder) { } func TestBuilderRoundTripThroughParser(t *testing.T) { - // Build an extensions block, then walk it back with the parser. Agreement - // between the two is the property that matters. + // Build a ServerHello extensions block, then walk it back with the parser. + // Agreement between the two is the property that matters. var b tls.Builder buf := make([]byte, 64) b.Reset(buf) @@ -27,20 +27,18 @@ func TestBuilderRoundTripThroughParser(t *testing.T) { buildSupportedVersions(&b) b.AddU16(uint16(tls.ExtKeyShare)) b.OpenU16() - b.OpenU16() // client_shares - b.AddU16(uint16(tls.GroupX25519)) + b.AddU16(uint16(tls.GroupX25519)) // server form: one entry, no list prefix b.OpenU16() b.AddBytes(make([]byte, 8)) b.Close() b.Close() b.Close() - b.Close() out, err := b.Bytes() if err != nil { t.Fatal(err) } - // Strip the outer block length the way ClientHelloFrame.Extensions does. + // Strip the outer block length the way ServerHelloMsg.ExtensionBytes does. if len(out) < 2 { t.Fatal("output too short") } @@ -49,22 +47,22 @@ func TestBuilderRoundTripThroughParser(t *testing.T) { t.Fatalf("outer length %d != %d", int(out[0])<<8|int(out[1]), len(exts)) } - var seen []tls.ExtensionType - err = tls.ForEachExtension(exts, func(ext tls.ExtensionType, data []byte) error { - seen = append(seen, ext) - if ext == tls.ExtKeyShare { - return tls.ForEachKeyShare(data, func(g tls.NamedGroup, key []byte) error { - if g != tls.GroupX25519 || len(key) != 8 { - t.Errorf("key share got %v len %d", g, len(key)) - } - return nil - }) - } - return nil - }) + list, err := tls.ParseServerExtensions(exts, 0) if err != nil { t.Fatal(err) } + var seen []tls.ExtensionType + for _, ext := range list.All { + seen = append(seen, ext.Type()) + if ext.Type() != tls.ExtKeyShare { + continue + } + for _, ks := range ext.KeyShares { + if ks.Group != tls.GroupX25519 || len(ks.Key) != 8 { + t.Errorf("key share got %v len %d", ks.Group, len(ks.Key)) + } + } + } if len(seen) != 2 || seen[0] != tls.ExtSupportedVersions || seen[1] != tls.ExtKeyShare { t.Errorf("walked %v", seen) } diff --git a/x/tls/extension.go b/x/tls/extension.go new file mode 100644 index 0000000..fc871fb --- /dev/null +++ b/x/tls/extension.go @@ -0,0 +1,458 @@ +package tls + +import ( + "encoding/binary" + + "github.com/soypat/lneto" +) + +// Iterators in this package have the signature of an [iter.Seq2] instead of +// returning one, so they are ranged over as a method value without a call: +// +// for off, ext := range list.All { +// for off, ks := range ext.KeyShares { +// } +// } +// +// A method value bound to a receiver stays on the caller's stack, while a +// closure returned from a method escapes to the heap. The int key is always the +// item's offset within the enclosing message body, and nested iterators inherit +// that base, so a decoder never has to add up prefix widths. +// +// Every length prefix was checked when the message was parsed, so no iterator +// can fail and none returns an error. + +// ExtensionFrame provides access to a single hello extension (RFC 8446 4.2): +// +// struct { +// ExtensionType extension_type; // 2 bytes +// opaque extension_data<0..2^16-1>; +// } Extension; +// +// The lists nested inside an extension are reached with the iterators below, +// each of which walks only its own extension type. +type ExtensionFrame struct { + buf []byte + // base is where buf starts within the enclosing message body. + base int + // server records that this came from a server hello. The server forms of + // key_share and supported_versions carry a single value, not a list. + server bool +} + +// NewExtensionFrame wraps buf as an [ExtensionFrame]. It checks the extension's +// own framing but not the structure nested inside extension_data; prefer +// [ParseClientExtensions] or [ParseServerExtensions], which check both. +func NewExtensionFrame(buf []byte) (ExtensionFrame, error) { + return newExtensionFrame(buf, 0, false) +} + +func newExtensionFrame(buf []byte, base int, server bool) (ExtensionFrame, error) { + if len(buf) < 4 { + return ExtensionFrame{}, lneto.ErrTruncatedFrame + } + n := int(binary.BigEndian.Uint16(buf[2:4])) + if 4+n > len(buf) { + return ExtensionFrame{}, lneto.ErrTruncatedFrame + } + return ExtensionFrame{buf: buf[:4+n], base: base, server: server}, nil +} + +// Type returns the extension type. +func (ef ExtensionFrame) Type() ExtensionType { + return ExtensionType(binary.BigEndian.Uint16(ef.buf[0:2])) +} + +// Length returns the declared extension_data length. +func (ef ExtensionFrame) Length() uint16 { + return binary.BigEndian.Uint16(ef.buf[2:4]) +} + +// Data returns the extension_data bytes. +func (ef ExtensionFrame) Data() []byte { return ef.buf[4:] } + +// RawData returns the extension bytes, type and length included. +func (ef ExtensionFrame) RawData() []byte { return ef.buf } + +// DataOffset is where Data starts within the enclosing message body. +func (ef ExtensionFrame) DataOffset() int { return ef.base + 4 } + +// ExtensionList is an extensions block whose framing has been checked, down to +// the lists inside the extensions this package recognizes. +type ExtensionList struct { + buf []byte + base int + server bool +} + +// ParseClientExtensions validates a ClientHello extensions block. exts is the +// block contents with the outer two-byte length already stripped, and base is +// where exts starts within the enclosing message body. +// +// Unknown extension types, GREASE among them, are accepted and left unchecked. +// A repeated known type is rejected with [lneto.ErrInvalidField]: RFC 8446 4.2 +// forbids duplicates, and tolerating them lets this parser and a middlebox act +// on different copies. +func ParseClientExtensions(exts []byte, base int) (ExtensionList, error) { + return parseExtensions(exts, base, false) +} + +// ParseServerExtensions validates a ServerHello extensions block. It differs +// from [ParseClientExtensions] in the key_share and supported_versions forms. +func ParseServerExtensions(exts []byte, base int) (ExtensionList, error) { + return parseExtensions(exts, base, true) +} + +func parseExtensions(exts []byte, base int, server bool) (ExtensionList, error) { + var seen extSeen + for off := 0; off < len(exts); { + ef, err := newExtensionFrame(exts[off:], base+off, server) + if err != nil { + return ExtensionList{}, err + } + if seen.mark(ef.Type()) { + return ExtensionList{}, lneto.ErrInvalidField + } + err = ef.validate() + if err != nil { + return ExtensionList{}, err + } + off += len(ef.RawData()) + } + return ExtensionList{buf: exts, base: base, server: server}, nil +} + +// All iterates the extensions in wire order, keyed by the offset of each +// extension's data within the enclosing message body. +func (l ExtensionList) All(yield func(off int, ext ExtensionFrame) bool) { + for off := 0; off < len(l.buf); { + ef, err := newExtensionFrame(l.buf[off:], l.base+off, l.server) + if err != nil { + return // Framing was checked at parse. + } + if !yield(ef.DataOffset(), ef) { + return + } + off += len(ef.RawData()) + } +} + +// Bytes returns the block contents. +func (l ExtensionList) Bytes() []byte { return l.buf } + +// validate checks the structure nested inside extension_data for the types this +// package walks. The switch mirrors the iterators below: an extension with no +// iterator has no shape to check here. +func (ef ExtensionFrame) validate() error { + data := ef.Data() + switch ef.Type() { + case ExtServerName: + return validateServerNames(data) + case ExtALPN: + return validateALPN(data) + case ExtSupportedGroups, ExtSignatureAlgorithms, ExtSignatureAlgorithmsCert: + body, err := vectorU16(data) + if err != nil { + return err + } + return validateU16List(body) + case ExtSupportedVersions: + if ef.server { + return validateU16List(data) // Bare uint16. + } + body, err := vectorU8(data) + if err != nil { + return err + } + return validateU16List(body) + case ExtKeyShare: + return ef.validateKeyShare() + } + return nil +} + +func validateU16List(b []byte) error { + if len(b)%2 != 0 { + return lneto.ErrInvalidLengthField + } + return nil +} + +func validateServerNames(data []byte) error { + body, err := vectorU16(data) + if err != nil { + return err + } + for off := 0; off < len(body); { + if len(body)-off < 3 { + return lneto.ErrTruncatedFrame + } + n := int(binary.BigEndian.Uint16(body[off+1 : off+3])) + off += 3 + if n > len(body)-off { + return lneto.ErrTruncatedFrame + } + off += n + } + return nil +} + +func validateALPN(data []byte) error { + body, err := vectorU16(data) + if err != nil { + return err + } + for off := 0; off < len(body); { + n := int(body[off]) + off++ + if n == 0 { + // A zero-length name would make a walk unable to advance. + return lneto.ErrInvalidLengthField + } else if n > len(body)-off { + return lneto.ErrTruncatedFrame + } + off += n + } + return nil +} + +func (ef ExtensionFrame) validateKeyShare() error { + data := ef.Data() + if ef.server { + // A ServerHello names one group and its key; a HelloRetryRequest names + // only the group. + if len(data) == 2 { + return nil + } + return validateKeyShareEntries(data) + } + body, err := vectorU16(data) + if err != nil { + return err + } + return validateKeyShareEntries(body) +} + +func validateKeyShareEntries(b []byte) error { + for off := 0; off < len(b); { + if len(b)-off < 4 { + return lneto.ErrTruncatedFrame + } + n := int(binary.BigEndian.Uint16(b[off+2 : off+4])) + off += 4 + if n > len(b)-off { + return lneto.ErrTruncatedFrame + } + off += n + } + return nil +} + +// KeyShare is one KeyShareEntry of a key_share extension: +// +// struct { +// NamedGroup group; +// opaque key_exchange<1..2^16-1>; +// } KeyShareEntry; +// +// A HelloRetryRequest names a group with no key, so Key may be empty. GREASE +// key shares carry a deliberately absurd key, commonly one byte, so no length +// constraint is placed on Key beyond fitting inside the extension. +type KeyShare struct { + Group NamedGroup + Key []byte +} + +// ServerName is one entry of an SNI server_name_list. Type 0 is host_name, the +// only type ever defined. +type ServerName struct { + Type uint8 + Name []byte +} + +// KeyShares iterates the key shares of a key_share extension, in the client or +// server form as the enclosing hello requires, keyed by the offset of the +// key_exchange bytes. Yields nothing for any other extension type. +func (ef ExtensionFrame) KeyShares(yield func(off int, ks KeyShare) bool) { + if ef.Type() != ExtKeyShare { + return + } + body, base := ef.Data(), ef.DataOffset() + if ef.server { + if len(body) == 2 { // HelloRetryRequest: selected_group only. + yield(base, KeyShare{Group: NamedGroup(binary.BigEndian.Uint16(body))}) + return + } + } else { + b, err := vectorU16(body) + if err != nil { + return + } + body, base = b, base+2 + } + for off := 0; off+4 <= len(body); { + group := NamedGroup(binary.BigEndian.Uint16(body[off : off+2])) + n := int(binary.BigEndian.Uint16(body[off+2 : off+4])) + off += 4 + if n > len(body)-off { + return + } + if !yield(base+off, KeyShare{Group: group, Key: body[off : off+n]}) { + return + } + off += n + } +} + +// ServerNames iterates the server_name_list of an SNI extension, keyed by the +// offset of the name. Yields nothing for any other extension type. +func (ef ExtensionFrame) ServerNames(yield func(off int, name ServerName) bool) { + if ef.Type() != ExtServerName { + return + } + body, err := vectorU16(ef.Data()) + if err != nil { + return + } + base := ef.DataOffset() + 2 + for off := 0; off+3 <= len(body); { + nameType := body[off] + n := int(binary.BigEndian.Uint16(body[off+1 : off+3])) + off += 3 + if n > len(body)-off { + return + } + if !yield(base+off, ServerName{Type: nameType, Name: body[off : off+n]}) { + return + } + off += n + } +} + +// ALPNProtos iterates the protocol names of an ALPN extension, keyed by the +// offset of the name. Chrome includes a GREASE entry here, so callers must match +// against their own offer list rather than assuming the first name is +// meaningful. Yields nothing for any other extension type. +func (ef ExtensionFrame) ALPNProtos(yield func(off int, proto []byte) bool) { + if ef.Type() != ExtALPN { + return + } + body, err := vectorU16(ef.Data()) + if err != nil { + return + } + base := ef.DataOffset() + 2 + for off := 0; off < len(body); { + n := int(body[off]) + off++ + if n == 0 || n > len(body)-off { + return + } + if !yield(base+off, body[off:off+n]) { + return + } + off += n + } +} + +// SupportedVersions iterates a supported_versions extension. The ClientHello +// form is a list behind a one-byte prefix, unlike every other hello list; the +// ServerHello form is a bare uint16, which yields a single value. Yields nothing +// for any other extension type. +func (ef ExtensionFrame) SupportedVersions(yield func(off int, version uint16) bool) { + if ef.Type() != ExtSupportedVersions { + return + } + body, base := ef.Data(), ef.DataOffset() + if !ef.server { + b, err := vectorU8(body) + if err != nil { + return + } + body, base = b, base+1 + } + for off := 0; off+2 <= len(body); off += 2 { + if !yield(base+off, binary.BigEndian.Uint16(body[off:off+2])) { + return + } + } +} + +// SupportedGroups iterates a supported_groups extension, keyed by the group's +// offset. Yields nothing for any other extension type. +func (ef ExtensionFrame) SupportedGroups(yield func(off int, group NamedGroup) bool) { + body, base, ok := ef.u16Vector(ExtSupportedGroups) + if !ok { + return + } + for off := 0; off+2 <= len(body); off += 2 { + if !yield(base+off, NamedGroup(binary.BigEndian.Uint16(body[off:off+2]))) { + return + } + } +} + +// SignatureSchemes iterates a signature_algorithms or signature_algorithms_cert +// extension, keyed by the scheme's offset. Yields nothing for any other type. +func (ef ExtensionFrame) SignatureSchemes(yield func(off int, scheme SignatureScheme) bool) { + want := ef.Type() + if want != ExtSignatureAlgorithms && want != ExtSignatureAlgorithmsCert { + return + } + body, base, ok := ef.u16Vector(want) + if !ok { + return + } + for off := 0; off+2 <= len(body); off += 2 { + if !yield(base+off, SignatureScheme(binary.BigEndian.Uint16(body[off:off+2]))) { + return + } + } +} + +// u16Vector returns the contents of a two-byte-prefixed vector and where it +// starts within the message body, or ok false for another extension type. +func (ef ExtensionFrame) u16Vector(want ExtensionType) (body []byte, base int, ok bool) { + if ef.Type() != want { + return nil, 0, false + } + body, err := vectorU16(ef.Data()) + if err != nil { + return nil, 0, false + } + return body, ef.DataOffset() + 2, true +} + +// vectorU16 strips a two-byte length prefix and returns the vector contents. +// It requires the prefix to describe the buffer exactly: trailing bytes mean +// the sender and this parser disagree on the structure, which is precisely the +// ambiguity parser-differential attacks exploit. +func vectorU16(b []byte) ([]byte, error) { + if len(b) < 2 { + return nil, lneto.ErrTruncatedFrame + } + n := int(binary.BigEndian.Uint16(b[0:2])) + if n != len(b)-2 { + if n > len(b)-2 { + return nil, lneto.ErrTruncatedFrame + } + return nil, errTrailingBytes + } + return b[2:], nil +} + +// vectorU8 strips a one-byte length prefix. See [vectorU16] for why trailing +// bytes are rejected. +func vectorU8(b []byte) ([]byte, error) { + if len(b) < 1 { + return nil, lneto.ErrTruncatedFrame + } + n := int(b[0]) + if n != len(b)-1 { + if n > len(b)-1 { + return nil, lneto.ErrTruncatedFrame + } + return nil, errTrailingBytes + } + return b[1:], nil +} diff --git a/x/tls/extension_test.go b/x/tls/extension_test.go new file mode 100644 index 0000000..a9743d6 --- /dev/null +++ b/x/tls/extension_test.go @@ -0,0 +1,283 @@ +package tls_test + +import ( + "errors" + "testing" + + "github.com/soypat/lneto" + "github.com/soypat/lneto/x/tls" +) + +func TestExtensionListWalksAndToleratesGREASE(t *testing.T) { + // Two extensions: a GREASE type with empty data, then supported_versions. + exts := []byte{ + 0x0a, 0x0a, 0x00, 0x00, // GREASE, len 0 + 0x00, 0x2b, 0x00, 0x03, 0x02, 0x03, 0x04, // supported_versions + } + list, err := tls.ParseClientExtensions(exts, 0) + if err != nil { + t.Fatal(err) + } + var types []tls.ExtensionType + for off, ext := range list.All { + if want := 4*len(types) + 4; off != want { + t.Errorf("%v at offset %d want %d", ext.Type(), off, want) + } + types = append(types, ext.Type()) + } + if len(types) != 2 || types[1] != tls.ExtSupportedVersions { + t.Fatalf("got %v", types) + } + if !tls.IsGREASE(uint16(types[0])) { + t.Errorf("first extension %#x not recognized as GREASE", types[0]) + } +} + +func TestParseExtensionsTruncated(t *testing.T) { + for _, tc := range [][]byte{ + {0x00}, // partial type + {0x00, 0x2b, 0x00}, // partial length + {0x00, 0x2b, 0x00, 0x05, 0x02, 0x03}, // length overruns + } { + _, err := tls.ParseClientExtensions(tc, 0) + if !errors.Is(err, lneto.ErrTruncatedFrame) { + t.Errorf("% x: got %v want ErrTruncatedFrame", tc, err) + } + } +} + +func TestParseExtensionsRejectsMalformedInnerList(t *testing.T) { + // Framing inside a recognized extension is checked at parse, which is what + // lets the iterators be error-free. + for _, tc := range [][]byte{ + {0x00, 0x0a, 0x00, 0x03, 0x00, 0x01, 0x1d}, // supported_groups, odd list + {0x00, 0x2b, 0x00, 0x03, 0x04, 0x03, 0x04}, // supported_versions, prefix overruns + {0x00, 0x10, 0x00, 0x04, 0x00, 0x02, 0x00, 0x00}, // alpn, zero-length name + {0x00, 0x00, 0x00, 0x05, 0x00, 0x03, 0x00, 0x00, 0x09}, // server_name, name overruns + } { + if _, err := tls.ParseClientExtensions(tc, 0); err == nil { + t.Errorf("% x accepted", tc) + } + } +} + +func TestExtensionIterationStopsOnBreak(t *testing.T) { + // Two extensions with no inner structure of their own. + exts := []byte{0x00, 0x15, 0x00, 0x00, 0x00, 0x17, 0x00, 0x00} + list, err := tls.ParseClientExtensions(exts, 0) + if err != nil { + t.Fatal(err) + } + n := 0 + for range list.All { + n++ + break + } + if n != 1 { + t.Errorf("walk continued after break: %d iterations", n) + } +} + +func TestKeySharesAcceptGREASEEntry(t *testing.T) { + // Chrome sends a GREASE key share whose key_exchange is a single byte. + // Rejecting it as malformed breaks Chrome outright. + exts := []byte{ + 0x00, 0x33, 0x00, 0x0d, // key_share, 13 bytes + 0x00, 0x0b, // client_shares length 11 + 0x1a, 0x1a, 0x00, 0x01, 0x00, // GREASE group, 1 byte body + 0x00, 0x1d, 0x00, 0x02, 0xab, 0xcd, // x25519, 2 byte body + } + list, err := tls.ParseClientExtensions(exts, 0) + if err != nil { + t.Fatal(err) + } + type share struct { + g tls.NamedGroup + n int + } + var got []share + for _, ext := range list.All { + for _, ks := range ext.KeyShares { + got = append(got, share{ks.Group, len(ks.Key)}) + } + } + if len(got) != 2 { + t.Fatalf("got %d shares want 2", len(got)) + } + if !tls.IsGREASE(uint16(got[0].g)) || got[0].n != 1 { + t.Errorf("GREASE share mishandled: %+v", got[0]) + } + if got[1].g != tls.GroupX25519 || got[1].n != 2 { + t.Errorf("x25519 share mishandled: %+v", got[1]) + } +} + +func TestServerKeyShareHelloRetryRequestForm(t *testing.T) { + // A HelloRetryRequest names a group with no key. + exts := []byte{0x00, 0x33, 0x00, 0x02, 0x00, 0x1d} + list, err := tls.ParseServerExtensions(exts, 0) + if err != nil { + t.Fatal(err) + } + n := 0 + for _, ext := range list.All { + for _, ks := range ext.KeyShares { + n++ + if ks.Group != tls.GroupX25519 || len(ks.Key) != 0 { + t.Errorf("got %v with %d key bytes", ks.Group, len(ks.Key)) + } + } + } + if n != 1 { + t.Errorf("walked %d shares want 1", n) + } +} + +func TestALPNProtos(t *testing.T) { + exts := []byte{ + 0x00, 0x10, 0x00, 0x0e, + 0x00, 0x0c, + 0x02, 'h', '2', + 0x08, 'h', 't', 't', 'p', '/', '1', '.', '1', + } + list, err := tls.ParseClientExtensions(exts, 0) + if err != nil { + t.Fatal(err) + } + var names []string + for _, ext := range list.All { + for _, p := range ext.ALPNProtos { + names = append(names, string(p)) + } + } + if len(names) != 2 || names[0] != "h2" || names[1] != "http/1.1" { + t.Errorf("got %q", names) + } +} + +func TestSupportedVersionsClientAndServerForms(t *testing.T) { + // The ClientHello form is a list behind a one-byte prefix; the ServerHello + // form is a bare uint16. + client := []byte{0x00, 0x2b, 0x00, 0x05, 0x04, 0x1a, 0x1a, 0x03, 0x04} + list, err := tls.ParseClientExtensions(client, 0) + if err != nil { + t.Fatal(err) + } + var vers []uint16 + for _, ext := range list.All { + for _, v := range ext.SupportedVersions { + vers = append(vers, v) + } + } + if len(vers) != 2 || vers[1] != tls.VersionTLS13 { + t.Errorf("client form got %#x", vers) + } + + server := []byte{0x00, 0x2b, 0x00, 0x02, 0x03, 0x04} + list, err = tls.ParseServerExtensions(server, 0) + if err != nil { + t.Fatal(err) + } + vers = vers[:0] + for _, ext := range list.All { + for _, v := range ext.SupportedVersions { + vers = append(vers, v) + } + } + if len(vers) != 1 || vers[0] != tls.VersionTLS13 { + t.Errorf("server form got %#x", vers) + } +} + +func TestVectorRejectsTrailingBytes(t *testing.T) { + // A prefix that under-describes its buffer leaves bytes whose meaning this + // parser and a middlebox could disagree about. + exts := []byte{0x00, 0x0a, 0x00, 0x05, 0x00, 0x02, 0x00, 0x1d, 0xff} + if _, err := tls.ParseClientExtensions(exts, 0); err == nil { + t.Error("trailing bytes after vector accepted") + } +} + +func TestSubIteratorOffsetsAreBodyRelative(t *testing.T) { + // base threads through every nesting level so a decoder adds no arithmetic. + const base = 100 + exts := []byte{ + 0x00, 0x00, 0x00, 0x0b, // server_name, 11 bytes + 0x00, 0x09, // list length + 0x00, 0x00, 0x06, // host_name, 6 bytes + 'a', '.', 'c', 'o', 'm', '!', + } + list, err := tls.ParseClientExtensions(exts, base) + if err != nil { + t.Fatal(err) + } + for off, ext := range list.All { + if off != base+4 { + t.Errorf("extension data at %d want %d", off, base+4) + } + for noff, name := range ext.ServerNames { + // 4 extension header + 2 list length + 3 entry header + if want := base + 9; noff != want { + t.Errorf("name at %d want %d", noff, want) + } + if string(name.Name) != "a.com!" { + t.Errorf("name %q", name.Name) + } + } + } +} + +func FuzzParseClientExtensions(f *testing.F) { + f.Add([]byte{0x00, 0x2b, 0x00, 0x03, 0x02, 0x03, 0x04}) + f.Add([]byte{0x0a, 0x0a, 0x00, 0x00}) + f.Fuzz(func(t *testing.T, b []byte) { + list, err := tls.ParseClientExtensions(b, 0) + if err != nil { + return + } + total := 0 + for off, ext := range list.All { + total += 4 + len(ext.Data()) + if total > len(b) { + t.Fatalf("walked %d bytes past input length %d", total, len(b)) + } + if off+len(ext.Data()) > len(b) { + t.Fatalf("extension data at %d overruns %d byte input", off, len(b)) + } + // No sub-iterator may escape its slice, whatever the type says. + for o, ks := range ext.KeyShares { + if o+len(ks.Key) > len(b) { + t.Fatalf("key share at %d overruns input", o) + } + } + for o, name := range ext.ServerNames { + if o+len(name.Name) > len(b) { + t.Fatalf("server name at %d overruns input", o) + } + } + for o, p := range ext.ALPNProtos { + if o+len(p) > len(b) { + t.Fatalf("alpn name at %d overruns input", o) + } + } + for o := range ext.SupportedVersions { + if o+2 > len(b) { + t.Fatalf("version at %d overruns input", o) + } + } + for o := range ext.SupportedGroups { + if o+2 > len(b) { + t.Fatalf("group at %d overruns input", o) + } + } + for o := range ext.SignatureSchemes { + if o+2 > len(b) { + t.Fatalf("scheme at %d overruns input", o) + } + } + } + if total != len(b) { + t.Fatalf("clean walk consumed %d of %d bytes", total, len(b)) + } + }) +} diff --git a/x/tls/frame.go b/x/tls/frame.go index f9a7b39..abdce75 100644 --- a/x/tls/frame.go +++ b/x/tls/frame.go @@ -242,244 +242,3 @@ func (hf HandshakeFrame) ValidateSize(v *lneto.Validator) { v.AddError(lneto.ErrInvalidLengthField) } } - -// ExtensionFrame provides access to a single hello extension (RFC 8446 4.2): -// -// struct { -// ExtensionType extension_type; // 2 bytes -// opaque extension_data<0..2^16-1>; -// } Extension; -type ExtensionFrame struct { - buf []byte -} - -// NewExtensionFrame wraps buf as an [ExtensionFrame]. Unlike the record and -// handshake frames, an extension is only ever parsed out of a fully buffered -// hello, so the whole extension must be present. -func NewExtensionFrame(buf []byte) (ExtensionFrame, error) { - if len(buf) < 4 { - return ExtensionFrame{}, lneto.ErrTruncatedFrame - } - n := int(binary.BigEndian.Uint16(buf[2:4])) - if 4+n > len(buf) { - return ExtensionFrame{}, lneto.ErrTruncatedFrame - } - return ExtensionFrame{buf: buf[:4+n]}, nil -} - -// Type returns the extension type. -func (ef ExtensionFrame) Type() ExtensionType { - return ExtensionType(binary.BigEndian.Uint16(ef.buf[0:2])) -} - -// Length returns the declared extension_data length. -func (ef ExtensionFrame) Length() uint16 { - return binary.BigEndian.Uint16(ef.buf[2:4]) -} - -// Data returns the extension_data bytes. -func (ef ExtensionFrame) Data() []byte { return ef.buf[4:] } - -// RawData returns the extension bytes, type and length included. -func (ef ExtensionFrame) RawData() []byte { return ef.buf } - -// ForEachExtension walks an extension list, calling fn for each extension in -// wire order. exts is the contents of the extensions block, with the outer -// two-byte list length already stripped; [ClientHelloFrame.Extensions] returns -// it in that form. -// -// The walker is deliberately permissive about extension types: unknown types, -// including every GREASE value Chrome injects, are passed to fn like any other. -// It is the caller's switch that skips them. The walker is strict about -// framing: a length field that overruns the list aborts with -// [lneto.ErrTruncatedFrame]. -// -// Modelled on tcp.OptionCodec.ForEachOption. -func ForEachExtension(exts []byte, fn func(ExtensionType, []byte) error) error { - for off := 0; off < len(exts); { - ef, err := NewExtensionFrame(exts[off:]) - if err != nil { - return err - } - err = fn(ef.Type(), ef.Data()) - if err != nil { - return err - } - off += len(ef.RawData()) - } - return nil -} - -// ForEachU16 walks a bare list of big-endian uint16 values, such as the -// cipher_suites vector of a ClientHello. b must have even length. -func ForEachU16(b []byte, fn func(uint16) error) error { - if len(b)%2 != 0 { - return lneto.ErrInvalidLengthField - } - for off := 0; off < len(b); off += 2 { - err := fn(binary.BigEndian.Uint16(b[off : off+2])) - if err != nil { - return err - } - } - return nil -} - -// ForEachSupportedGroup walks the contents of a supported_groups extension, -// whose payload is a uint16-length-prefixed list of [NamedGroup] values. -func ForEachSupportedGroup(extData []byte, fn func(NamedGroup) error) error { - body, err := vectorU16(extData) - if err != nil { - return err - } - return ForEachU16(body, func(v uint16) error { return fn(NamedGroup(v)) }) -} - -// ForEachSignatureScheme walks the contents of a signature_algorithms (or -// signature_algorithms_cert) extension. -func ForEachSignatureScheme(extData []byte, fn func(SignatureScheme) error) error { - body, err := vectorU16(extData) - if err != nil { - return err - } - return ForEachU16(body, func(v uint16) error { return fn(SignatureScheme(v)) }) -} - -// ForEachSupportedVersion walks the contents of a supported_versions extension -// as it appears in a ClientHello. Note the prefix here is a single byte, unlike -// every other list in the hello; the ServerHello form carries a bare uint16 -// instead and is not parsed by this function. -func ForEachSupportedVersion(extData []byte, fn func(uint16) error) error { - body, err := vectorU8(extData) - if err != nil { - return err - } - return ForEachU16(body, fn) -} - -// ForEachKeyShare walks the client_shares list of a key_share extension: -// -// struct { -// NamedGroup group; -// opaque key_exchange<1..2^16-1>; -// } KeyShareEntry; -// -// GREASE key shares carry a deliberately absurd body, commonly a single byte, -// and must not be treated as malformed. The walker therefore places no -// constraint on key_exchange length beyond it fitting inside the list; group -// selection is the caller's job. -func ForEachKeyShare(extData []byte, fn func(NamedGroup, []byte) error) error { - body, err := vectorU16(extData) - if err != nil { - return err - } - for off := 0; off < len(body); { - if len(body)-off < 4 { - return lneto.ErrTruncatedFrame - } - group := NamedGroup(binary.BigEndian.Uint16(body[off : off+2])) - n := int(binary.BigEndian.Uint16(body[off+2 : off+4])) - off += 4 - if n > len(body)-off { - return lneto.ErrTruncatedFrame - } - err = fn(group, body[off:off+n]) - if err != nil { - return err - } - off += n - } - return nil -} - -// ForEachALPNProto walks the protocol name list of an ALPN extension. Each -// name is a single-byte-length-prefixed string. Chrome includes a GREASE entry -// here too, so callers must match against their own offer list rather than -// assuming the first entry is meaningful. -// -// A zero-length protocol name is a protocol violation and aborts the walk. -func ForEachALPNProto(extData []byte, fn func([]byte) error) error { - body, err := vectorU16(extData) - if err != nil { - return err - } - for off := 0; off < len(body); { - n := int(body[off]) - off++ - if n == 0 { - return lneto.ErrInvalidLengthField - } else if n > len(body)-off { - return lneto.ErrTruncatedFrame - } - err = fn(body[off : off+n]) - if err != nil { - return err - } - off += n - } - return nil -} - -// ForEachServerName walks the server_name_list of an SNI extension: -// -// struct { -// NameType name_type; // 1 byte, 0 = host_name -// opaque HostName<1..2^16-1>; -// } ServerName; -func ForEachServerName(extData []byte, fn func(nameType uint8, name []byte) error) error { - body, err := vectorU16(extData) - if err != nil { - return err - } - for off := 0; off < len(body); { - if len(body)-off < 3 { - return lneto.ErrTruncatedFrame - } - nameType := body[off] - n := int(binary.BigEndian.Uint16(body[off+1 : off+3])) - off += 3 - if n > len(body)-off { - return lneto.ErrTruncatedFrame - } - err = fn(nameType, body[off:off+n]) - if err != nil { - return err - } - off += n - } - return nil -} - -// vectorU16 strips a two-byte length prefix and returns the vector contents. -// It requires the prefix to describe the buffer exactly: trailing bytes mean -// the sender and this parser disagree on the structure, which is precisely the -// ambiguity parser-differential attacks exploit. -func vectorU16(b []byte) ([]byte, error) { - if len(b) < 2 { - return nil, lneto.ErrTruncatedFrame - } - n := int(binary.BigEndian.Uint16(b[0:2])) - if n != len(b)-2 { - if n > len(b)-2 { - return nil, lneto.ErrTruncatedFrame - } - return nil, errTrailingBytes - } - return b[2:], nil -} - -// vectorU8 strips a one-byte length prefix. See [vectorU16] for why trailing -// bytes are rejected. -func vectorU8(b []byte) ([]byte, error) { - if len(b) < 1 { - return nil, lneto.ErrTruncatedFrame - } - n := int(b[0]) - if n != len(b)-1 { - if n > len(b)-1 { - return nil, lneto.ErrTruncatedFrame - } - return nil, errTrailingBytes - } - return b[1:], nil -} diff --git a/x/tls/frame_test.go b/x/tls/frame_test.go index 67e4d65..51df38d 100644 --- a/x/tls/frame_test.go +++ b/x/tls/frame_test.go @@ -171,148 +171,6 @@ func TestHandshakeFrameRawDataIncludesHeader(t *testing.T) { } } -func TestForEachExtensionWalksAndToleratesGREASE(t *testing.T) { - // Two extensions: a GREASE type with empty data, then supported_versions. - exts := []byte{ - 0x0a, 0x0a, 0x00, 0x00, // GREASE, len 0 - 0x00, 0x2b, 0x00, 0x03, 0x02, 0x03, 0x04, // supported_versions - } - var types []tls.ExtensionType - err := tls.ForEachExtension(exts, func(ext tls.ExtensionType, data []byte) error { - types = append(types, ext) - return nil - }) - if err != nil { - t.Fatal(err) - } - if len(types) != 2 || types[1] != tls.ExtSupportedVersions { - t.Fatalf("got %v", types) - } - if !tls.IsGREASE(uint16(types[0])) { - t.Errorf("first extension %#x not recognized as GREASE", types[0]) - } -} - -func TestForEachExtensionTruncated(t *testing.T) { - for _, tc := range [][]byte{ - {0x00}, // partial type - {0x00, 0x2b, 0x00}, // partial length - {0x00, 0x2b, 0x00, 0x05, 0x02, 0x03}, // length overruns - } { - err := tls.ForEachExtension(tc, func(tls.ExtensionType, []byte) error { return nil }) - if !errors.Is(err, lneto.ErrTruncatedFrame) { - t.Errorf("% x: got %v want ErrTruncatedFrame", tc, err) - } - } -} - -func TestForEachExtensionPropagatesCallbackError(t *testing.T) { - sentinel := errors.New("stop") - exts := []byte{0x00, 0x2b, 0x00, 0x00, 0x00, 0x33, 0x00, 0x00} - n := 0 - err := tls.ForEachExtension(exts, func(tls.ExtensionType, []byte) error { - n++ - return sentinel - }) - if !errors.Is(err, sentinel) { - t.Errorf("got %v want sentinel", err) - } - if n != 1 { - t.Errorf("walk continued after callback error: %d calls", n) - } -} - -func TestForEachKeyShareAcceptsGREASEEntry(t *testing.T) { - // Chrome sends a GREASE key share whose key_exchange is a single byte. - // Rejecting it as malformed breaks Chrome outright. - extData := []byte{ - 0x00, 0x0b, // client_shares length 11 - 0x1a, 0x1a, 0x00, 0x01, 0x00, // GREASE group, 1 byte body - 0x00, 0x1d, 0x00, 0x02, 0xab, 0xcd, // x25519, 2 byte body - } - type share struct { - g tls.NamedGroup - n int - } - var got []share - err := tls.ForEachKeyShare(extData, func(g tls.NamedGroup, key []byte) error { - got = append(got, share{g, len(key)}) - return nil - }) - if err != nil { - t.Fatal(err) - } - if len(got) != 2 { - t.Fatalf("got %d shares want 2", len(got)) - } - if !tls.IsGREASE(uint16(got[0].g)) || got[0].n != 1 { - t.Errorf("GREASE share mishandled: %+v", got[0]) - } - if got[1].g != tls.GroupX25519 || got[1].n != 2 { - t.Errorf("x25519 share mishandled: %+v", got[1]) - } -} - -func TestForEachALPNProtoRejectsEmptyName(t *testing.T) { - // A zero-length protocol name would make the walk unable to advance. - extData := []byte{0x00, 0x01, 0x00} - err := tls.ForEachALPNProto(extData, func([]byte) error { return nil }) - if !errors.Is(err, lneto.ErrInvalidLengthField) { - t.Errorf("got %v want ErrInvalidLengthField", err) - } -} - -func TestForEachALPNProto(t *testing.T) { - extData := []byte{ - 0x00, 0x0c, - 0x02, 'h', '2', - 0x08, 'h', 't', 't', 'p', '/', '1', '.', '1', - } - var names []string - err := tls.ForEachALPNProto(extData, func(b []byte) error { - names = append(names, string(b)) - return nil - }) - if err != nil { - t.Fatal(err) - } - if len(names) != 2 || names[0] != "h2" || names[1] != "http/1.1" { - t.Errorf("got %q", names) - } -} - -func TestForEachSupportedVersionUsesU8Prefix(t *testing.T) { - // supported_versions is the one hello list with a single byte prefix. - extData := []byte{0x04, 0x1a, 0x1a, 0x03, 0x04} - var vers []uint16 - err := tls.ForEachSupportedVersion(extData, func(v uint16) error { - vers = append(vers, v) - return nil - }) - if err != nil { - t.Fatal(err) - } - if len(vers) != 2 || vers[1] != tls.VersionTLS13 { - t.Errorf("got %#x", vers) - } -} - -func TestVectorRejectsTrailingBytes(t *testing.T) { - // A prefix that under-describes its buffer leaves bytes whose meaning this - // parser and a middlebox could disagree about. - err := tls.ForEachSupportedGroup([]byte{0x00, 0x02, 0x00, 0x1d, 0xff}, func(tls.NamedGroup) error { return nil }) - if err == nil { - t.Error("trailing bytes after vector accepted") - } -} - -func TestForEachU16RejectsOddLength(t *testing.T) { - err := tls.ForEachU16([]byte{0x00, 0x1d, 0x00}, func(uint16) error { return nil }) - if !errors.Is(err, lneto.ErrInvalidLengthField) { - t.Errorf("got %v want ErrInvalidLengthField", err) - } -} - func TestIsGREASE(t *testing.T) { // The 16 reserved values of RFC 8701. for i := range 16 { @@ -392,27 +250,3 @@ func FuzzNewInnerPlaintext(f *testing.F) { } }) } - -func FuzzForEachExtension(f *testing.F) { - f.Add([]byte{0x00, 0x2b, 0x00, 0x03, 0x02, 0x03, 0x04}) - f.Add([]byte{0x0a, 0x0a, 0x00, 0x00}) - f.Fuzz(func(t *testing.T, b []byte) { - total := 0 - err := tls.ForEachExtension(b, func(ext tls.ExtensionType, data []byte) error { - total += 4 + len(data) - if total > len(b) { - t.Fatalf("walked %d bytes past input length %d", total, len(b)) - } - // Sub-walkers must also never escape their slice. - _ = tls.ForEachKeyShare(data, func(tls.NamedGroup, []byte) error { return nil }) - _ = tls.ForEachALPNProto(data, func([]byte) error { return nil }) - _ = tls.ForEachSupportedGroup(data, func(tls.NamedGroup) error { return nil }) - _ = tls.ForEachSupportedVersion(data, func(uint16) error { return nil }) - _ = tls.ForEachServerName(data, func(uint8, []byte) error { return nil }) - return nil - }) - if err == nil && total != len(b) { - t.Fatalf("clean walk consumed %d of %d bytes", total, len(b)) - } - }) -} diff --git a/x/tls/hello.go b/x/tls/hello.go index ffc408b..147fd7b 100644 --- a/x/tls/hello.go +++ b/x/tls/hello.go @@ -14,9 +14,26 @@ const SizeRandom = 32 // compatibility mode and the server must echo it verbatim. const MaxSessionIDLen = 32 -// ClientHelloFrame provides zero-copy access to a ClientHello body -// (RFC 8446 4.1.2). The frame wraps the handshake message *body*, that is the -// bytes returned by [HandshakeFrame.Body], not the handshake header. +// Span is the position and length of a field within a message body. +type Span struct { + Off int + Len int +} + +// HelloSpans locates the fields of a ClientHello or ServerHello body. Packet +// decoders need a field's wire position, which the slice returning accessors +// cannot give. +type HelloSpans struct { + Random Span + SessionID Span + CipherSuites Span // ServerHello: the single selected suite. + Compression Span + Extensions Span +} + +// ClientHelloMsg provides zero-copy access to a ClientHello body +// (RFC 8446 4.1.2), wrapping the handshake message body returned by +// [HandshakeFrame.Body], not the handshake header. // // struct { // ProtocolVersion legacy_version = 0x0303; @@ -27,28 +44,30 @@ const MaxSessionIDLen = 32 // Extension extensions<8..2^16-1>; // } ClientHello; // -// Every variable-length field is bounds-checked once by -// [NewClientHelloFrame], so the accessors cannot slice out of range. -type ClientHelloFrame struct { - buf []byte - // Offsets of each variable-length field's contents, resolved once at - // construction so accessors stay branch-free. +// Unlike a [RecordFrame] or [HandshakeFrame], whose fields sit at fixed offsets, +// a hello's fields are length-prefixed and its extensions may arrive in any +// order. [ParseClientHello] resolves every offset once so accessors and +// iterators need no bounds checks and cannot fail. +type ClientHelloMsg struct { + buf []byte + exts ExtensionList + // Offsets of each variable-length field's contents, resolved at parse time. sessionIDOff, sessionIDLen int suitesOff, suitesLen int compOff, compLen int - extsOff, extsLen int } -// NewClientHelloFrame parses the structure of a ClientHello body, validating -// that every length prefix is consistent with the buffer. It performs no -// policy checks: version negotiation, cipher suite selection and extension -// validation are the handshake state machine's job. +// ParseClientHello parses a ClientHello body, validating that every length +// prefix is consistent with the buffer, down to the lists inside the extensions +// this package recognizes. It performs no policy checks: version negotiation, +// cipher suite selection and extension validation are the handshake state +// machine's job. // // Trailing bytes after the extensions block are rejected. A sender and parser // that disagree about where a structure ends is the ambiguity that // parser-differential attacks are built on. -func NewClientHelloFrame(body []byte) (ClientHelloFrame, error) { - var ch ClientHelloFrame +func ParseClientHello(body []byte) (ClientHelloMsg, error) { + var ch ClientHelloMsg // legacy_version(2) + random(32) + session_id length(1) const fixed = 2 + SizeRandom + 1 if len(body) < fixed { @@ -103,20 +122,23 @@ func NewClientHelloFrame(body []byte) (ClientHelloFrame, error) { } else if extsLen != len(body)-off { return ch, errTrailingBytes } - ch.extsOff, ch.extsLen = off, extsLen - + exts, err := ParseClientExtensions(body[off:off+extsLen], off) + if err != nil { + return ClientHelloMsg{}, err + } + ch.exts = exts ch.buf = body return ch, nil } // LegacyVersion returns the legacy_version field, which TLS 1.3 pins to // 0x0303 regardless of the version actually negotiated. -func (ch ClientHelloFrame) LegacyVersion() uint16 { +func (ch ClientHelloMsg) LegacyVersion() uint16 { return binary.BigEndian.Uint16(ch.buf[0:2]) } // Random returns the 32-byte client_random. -func (ch ClientHelloFrame) Random() *[SizeRandom]byte { +func (ch ClientHelloMsg) Random() *[SizeRandom]byte { return (*[SizeRandom]byte)(ch.buf[2 : 2+SizeRandom]) } @@ -124,54 +146,67 @@ func (ch ClientHelloFrame) Random() *[SizeRandom]byte { // server must echo this verbatim in its ServerHello; a non-empty value means // the client is using middlebox compatibility mode and expects a dummy // ChangeCipherSpec record. -func (ch ClientHelloFrame) LegacySessionID() []byte { +func (ch ClientHelloMsg) LegacySessionID() []byte { return ch.buf[ch.sessionIDOff : ch.sessionIDOff+ch.sessionIDLen] } -// CipherSuites returns the cipher_suites vector with its length prefix -// stripped, ready for [ForEachU16]. The list includes GREASE values. -func (ch ClientHelloFrame) CipherSuites() []byte { +// CipherSuites iterates the offered suites in wire order, keyed by each suite's +// offset within the body. The list includes GREASE values. +func (ch ClientHelloMsg) CipherSuites(yield func(off int, suite CipherSuite) bool) { + suites := ch.CipherSuiteBytes() + for off := 0; off+2 <= len(suites); off += 2 { + if !yield(ch.suitesOff+off, CipherSuite(binary.BigEndian.Uint16(suites[off:off+2]))) { + return + } + } +} + +// CipherSuiteBytes returns the cipher_suites vector with its length prefix +// stripped. +func (ch ClientHelloMsg) CipherSuiteBytes() []byte { return ch.buf[ch.suitesOff : ch.suitesOff+ch.suitesLen] } // LegacyCompressionMethods returns the legacy_compression_methods vector // contents. For a TLS 1.3 hello this must be exactly one zero byte; see -// [ClientHelloFrame.ValidateCompression]. -func (ch ClientHelloFrame) LegacyCompressionMethods() []byte { +// [ClientHelloMsg.ValidateCompression]. +func (ch ClientHelloMsg) LegacyCompressionMethods() []byte { return ch.buf[ch.compOff : ch.compOff+ch.compLen] } -// Extensions returns the extensions block contents with the outer length -// prefix stripped, ready for [ForEachExtension]. -func (ch ClientHelloFrame) Extensions() []byte { - return ch.buf[ch.extsOff : ch.extsOff+ch.extsLen] +// Extensions iterates this hello's extensions in wire order, keyed by the +// offset of each extension's data within the body. Duplicates were rejected at +// parse time, so the walk needs no bookkeeping. +func (ch ClientHelloMsg) Extensions(yield func(off int, ext ExtensionFrame) bool) { + ch.exts.All(yield) } -// RawData returns the whole ClientHello body. -func (ch ClientHelloFrame) RawData() []byte { return ch.buf } +// ExtensionList returns the validated extensions block, for passing the block +// itself around rather than ranging over it here. +func (ch ClientHelloMsg) ExtensionList() ExtensionList { return ch.exts } -// ForEachExtension walks this hello's extensions, rejecting a repeated known -// extension type with [lneto.ErrInvalidField]. RFC 8446 4.2 forbids duplicates, -// and tolerating them invites parser-differential attacks in which this parser -// and a middlebox act on different copies of the same extension. -// -// Unknown and GREASE extension types are passed through without duplicate -// checking, since they are skipped rather than acted upon. Prefer this over -// the bare [ForEachExtension] when parsing an untrusted hello. -func (ch ClientHelloFrame) ForEachExtension(fn func(ExtensionType, []byte) error) error { - var seen extSeen - return ForEachExtension(ch.Extensions(), func(ext ExtensionType, data []byte) error { - if seen.mark(ext) { - return lneto.ErrInvalidField - } - return fn(ext, data) - }) +// ExtensionBytes returns the extensions block contents with the outer length +// prefix stripped. +func (ch ClientHelloMsg) ExtensionBytes() []byte { return ch.exts.Bytes() } + +// RawData returns the whole ClientHello body. +func (ch ClientHelloMsg) RawData() []byte { return ch.buf } + +// Spans locates this hello's fields inside [ClientHelloMsg.RawData]. +func (ch ClientHelloMsg) Spans() HelloSpans { + return HelloSpans{ + Random: Span{Off: 2, Len: SizeRandom}, + SessionID: Span{Off: ch.sessionIDOff, Len: ch.sessionIDLen}, + CipherSuites: Span{Off: ch.suitesOff, Len: ch.suitesLen}, + Compression: Span{Off: ch.compOff, Len: ch.compLen}, + Extensions: Span{Off: ch.exts.base, Len: len(ch.exts.buf)}, + } } // ValidateSize adds an error to v if the ClientHello is structurally invalid. -// Since [NewClientHelloFrame] already rejects every inconsistent length, this -// only re-checks that the frame was successfully constructed. -func (ch ClientHelloFrame) ValidateSize(v *lneto.Validator) { +// Since [ParseClientHello] already rejects every inconsistent length, this only +// re-checks that the message was successfully parsed. +func (ch ClientHelloMsg) ValidateSize(v *lneto.Validator) { if ch.buf == nil { v.AddError(lneto.ErrTruncatedFrame) } @@ -181,10 +216,139 @@ func (ch ClientHelloFrame) ValidateSize(v *lneto.Validator) { // the single null method TLS 1.3 mandates (RFC 8446 4.1.2). Anything else must // be rejected with an illegal_parameter alert: a client offering real // compression methods is either pre-1.3 or attempting a downgrade. -func (ch ClientHelloFrame) ValidateCompression() bool { +func (ch ClientHelloMsg) ValidateCompression() bool { return ch.compLen == 1 && ch.buf[ch.compOff] == 0 } +// ServerHelloMsg provides zero-copy access to a ServerHello body +// (RFC 8446 4.1.3). Like [ClientHelloMsg] it wraps the handshake message body. +// +// struct { +// ProtocolVersion legacy_version = 0x0303; +// Random random; // 32 bytes +// opaque legacy_session_id_echo<0..32>; +// CipherSuite cipher_suite; // 2 bytes +// uint8 legacy_compression_method = 0; +// Extension extensions<6..2^16-1>; +// } ServerHello; +// +// A HelloRetryRequest has this same structure; it is told apart by its random +// being the special value of RFC 8446 4.1.3, which is policy, not framing. +type ServerHelloMsg struct { + buf []byte + exts ExtensionList + sessionIDOff, sessionIDLen int + suiteOff int +} + +// ParseServerHello parses a ServerHello body. Like [ParseClientHello] it +// validates framing only, rejects trailing bytes, and validates the lists +// inside recognized extensions, in their server forms. +func ParseServerHello(body []byte) (ServerHelloMsg, error) { + var sh ServerHelloMsg + const fixed = 2 + SizeRandom + 1 + if len(body) < fixed { + return sh, lneto.ErrTruncatedFrame + } + off := 2 + SizeRandom + + sidLen := int(body[off]) + off++ + if sidLen > MaxSessionIDLen { + return sh, lneto.ErrInvalidLengthField + } else if sidLen > len(body)-off { + return sh, lneto.ErrTruncatedFrame + } + sh.sessionIDOff, sh.sessionIDLen = off, sidLen + off += sidLen + + // cipher_suite(2) + legacy_compression_method(1) + if len(body)-off < 3 { + return sh, lneto.ErrTruncatedFrame + } + sh.suiteOff = off + off += 3 + + if len(body)-off < 2 { + return sh, lneto.ErrTruncatedFrame + } + extsLen := int(binary.BigEndian.Uint16(body[off : off+2])) + off += 2 + if extsLen > len(body)-off { + return sh, lneto.ErrTruncatedFrame + } else if extsLen != len(body)-off { + return sh, errTrailingBytes + } + exts, err := ParseServerExtensions(body[off:off+extsLen], off) + if err != nil { + return ServerHelloMsg{}, err + } + sh.exts = exts + sh.buf = body + return sh, nil +} + +// LegacyVersion returns the legacy_version field, pinned to 0x0303 by TLS 1.3. +func (sh ServerHelloMsg) LegacyVersion() uint16 { + return binary.BigEndian.Uint16(sh.buf[0:2]) +} + +// Random returns the 32-byte server_random. +func (sh ServerHelloMsg) Random() *[SizeRandom]byte { + return (*[SizeRandom]byte)(sh.buf[2 : 2+SizeRandom]) +} + +// LegacySessionIDEcho returns the client's legacy_session_id as echoed back. A +// client in middlebox compatibility mode requires it to match what it sent. +func (sh ServerHelloMsg) LegacySessionIDEcho() []byte { + return sh.buf[sh.sessionIDOff : sh.sessionIDOff+sh.sessionIDLen] +} + +// CipherSuite returns the selected cipher suite. +func (sh ServerHelloMsg) CipherSuite() CipherSuite { + return CipherSuite(binary.BigEndian.Uint16(sh.buf[sh.suiteOff : sh.suiteOff+2])) +} + +// LegacyCompressionMethod returns the legacy_compression_method, which TLS 1.3 +// requires to be zero. +func (sh ServerHelloMsg) LegacyCompressionMethod() uint8 { + return sh.buf[sh.suiteOff+2] +} + +// Extensions iterates this hello's extensions in wire order, keyed by the +// offset of each extension's data within the body. +func (sh ServerHelloMsg) Extensions(yield func(off int, ext ExtensionFrame) bool) { + sh.exts.All(yield) +} + +// ExtensionList returns the validated extensions block. +func (sh ServerHelloMsg) ExtensionList() ExtensionList { return sh.exts } + +// ExtensionBytes returns the extensions block contents with the outer length +// prefix stripped. +func (sh ServerHelloMsg) ExtensionBytes() []byte { return sh.exts.Bytes() } + +// RawData returns the whole ServerHello body. +func (sh ServerHelloMsg) RawData() []byte { return sh.buf } + +// Spans locates this hello's fields inside [ServerHelloMsg.RawData]. +func (sh ServerHelloMsg) Spans() HelloSpans { + return HelloSpans{ + Random: Span{Off: 2, Len: SizeRandom}, + SessionID: Span{Off: sh.sessionIDOff, Len: sh.sessionIDLen}, + CipherSuites: Span{Off: sh.suiteOff, Len: 2}, + Compression: Span{Off: sh.suiteOff + 2, Len: 1}, + Extensions: Span{Off: sh.exts.base, Len: len(sh.exts.buf)}, + } +} + +// ValidateSize adds an error to v if the ServerHello was not parsed. +func (sh ServerHelloMsg) ValidateSize(v *lneto.Validator) { + if sh.buf == nil { + v.AddError(lneto.ErrTruncatedFrame) + } +} + // extSeen tracks which known extension types have already been encountered in // a single hello. RFC 8446 4.2 forbids a duplicate extension type, and // tolerating duplicates invites parser-differential attacks where this parser @@ -259,6 +423,8 @@ func extBit(ext ExtensionType) (extSeen, bool) { i = 21 case ExtRenegotiationInfo: i = 22 + case ExtECPointFormats: + i = 23 default: return 0, false } diff --git a/x/tls/hello_test.go b/x/tls/hello_test.go index 8cef47c..fce3418 100644 --- a/x/tls/hello_test.go +++ b/x/tls/hello_test.go @@ -18,7 +18,7 @@ import ( // exercised against genuine extension ordering, a 32-byte compatibility // session ID and GREASE-free but otherwise realistic content. Browser captures // arrive at Stage 6; this covers the structure in the meantime. -func captureClientHello(t *testing.T) ltls.ClientHelloFrame { +func captureClientHello(t *testing.T) ltls.ClientHelloMsg { t.Helper() client, server := net.Pipe() defer client.Close() @@ -61,7 +61,7 @@ func captureClientHello(t *testing.T) ltls.ClientHelloFrame { if !hs.Complete() { t.Fatal("ClientHello spans multiple records") } - ch, err := ltls.NewClientHelloFrame(hs.Body()) + ch, err := ltls.ParseClientHello(hs.Body()) if err != nil { t.Fatalf("client hello: %v", err) } @@ -85,49 +85,29 @@ func TestClientHelloParseRealHello(t *testing.T) { } var sawTLS13, sawX25519, sawSNI, sawALPN bool - var suites []ltls.CipherSuite - err := ch.ForEachExtension(func(ext ltls.ExtensionType, data []byte) error { - switch ext { + for _, ext := range ch.Extensions { + switch ext.Type() { case ltls.ExtSupportedVersions: - return ltls.ForEachSupportedVersion(data, func(v uint16) error { - if v == ltls.VersionTLS13 { - sawTLS13 = true - } - return nil - }) + for _, v := range ext.SupportedVersions { + sawTLS13 = sawTLS13 || v == ltls.VersionTLS13 + } case ltls.ExtKeyShare: - return ltls.ForEachKeyShare(data, func(g ltls.NamedGroup, key []byte) error { - if g == ltls.GroupX25519 && len(key) == 32 { - sawX25519 = true - } - return nil - }) + for _, ks := range ext.KeyShares { + sawX25519 = sawX25519 || ks.Group == ltls.GroupX25519 && len(ks.Key) == 32 + } case ltls.ExtServerName: - return ltls.ForEachServerName(data, func(nameType uint8, name []byte) error { - if nameType == 0 && string(name) == "example.com" { - sawSNI = true - } - return nil - }) + for _, name := range ext.ServerNames { + sawSNI = sawSNI || name.Type == 0 && string(name.Name) == "example.com" + } case ltls.ExtALPN: - return ltls.ForEachALPNProto(data, func(p []byte) error { - if string(p) == "http/1.1" { - sawALPN = true - } - return nil - }) + for _, p := range ext.ALPNProtos { + sawALPN = sawALPN || string(p) == "http/1.1" + } } - return nil - }) - if err != nil { - t.Fatalf("walking extensions: %v", err) } - - if err := ltls.ForEachU16(ch.CipherSuites(), func(v uint16) error { - suites = append(suites, ltls.CipherSuite(v)) - return nil - }); err != nil { - t.Fatalf("walking cipher suites: %v", err) + var suites []ltls.CipherSuite + for _, s := range ch.CipherSuites { + suites = append(suites, s) } if !sawTLS13 { @@ -153,43 +133,9 @@ func TestClientHelloParseRealHello(t *testing.T) { } } -func TestClientHelloRejectsDuplicateExtension(t *testing.T) { - // Duplicating supported_versions must be caught. Tolerating it lets this - // parser and a middlebox act on different copies. - ch := captureClientHello(t) - exts := ch.Extensions() - - // Find the first extension and append a verbatim copy of it. - var first []byte - err := ltls.ForEachExtension(exts, func(ext ltls.ExtensionType, data []byte) error { - if first == nil { - first = make([]byte, 4+len(data)) - copy(first, exts) - } - return nil - }) - if err != nil || first == nil { - t.Fatalf("could not isolate first extension: %v", err) - } - - dup := make([]byte, 0, len(exts)+len(first)) - dup = append(dup, exts...) - dup = append(dup, first...) - - body := rebuildHelloWithExtensions(t, ch, dup) - ch2, err := ltls.NewClientHelloFrame(body) - if err != nil { - t.Fatalf("rebuilt hello did not parse: %v", err) - } - err = ch2.ForEachExtension(func(ltls.ExtensionType, []byte) error { return nil }) - if !errors.Is(err, lneto.ErrInvalidField) { - t.Errorf("duplicate extension got %v, want ErrInvalidField", err) - } -} - // rebuildHelloWithExtensions re-encodes ch with a replacement extensions block, // exercising Builder against a structure produced by a real client. -func rebuildHelloWithExtensions(t *testing.T, ch ltls.ClientHelloFrame, exts []byte) []byte { +func rebuildHelloWithExtensions(t *testing.T, ch ltls.ClientHelloMsg, exts []byte) []byte { t.Helper() var b ltls.Builder b.Reset(make([]byte, 0, len(ch.RawData())+len(exts)+64)) @@ -199,7 +145,7 @@ func rebuildHelloWithExtensions(t *testing.T, ch ltls.ClientHelloFrame, exts []b b.AddBytes(ch.LegacySessionID()) b.Close() b.OpenU16() - b.AddBytes(ch.CipherSuites()) + b.AddBytes(ch.CipherSuiteBytes()) b.Close() b.OpenU8() b.AddBytes(ch.LegacyCompressionMethods()) @@ -225,7 +171,7 @@ func TestClientHelloRejectsOversizeSessionID(t *testing.T) { body = append(body, 0x00, 0x02, 0x13, 0x01) // cipher suites body = append(body, 0x01, 0x00) // compression body = append(body, 0x00, 0x00) // extensions, empty - _, err := ltls.NewClientHelloFrame(body) + _, err := ltls.ParseClientHello(body) if !errors.Is(err, lneto.ErrInvalidLengthField) { t.Errorf("got %v want ErrInvalidLengthField", err) } @@ -234,7 +180,7 @@ func TestClientHelloRejectsOversizeSessionID(t *testing.T) { func TestClientHelloRejectsTrailingBytes(t *testing.T) { ch := captureClientHello(t) body := append(append([]byte{}, ch.RawData()...), 0xff) - if _, err := ltls.NewClientHelloFrame(body); err == nil { + if _, err := ltls.ParseClientHello(body); err == nil { t.Error("trailing byte after extensions block accepted") } } @@ -245,11 +191,11 @@ func TestClientHelloTruncatedAtEveryOffset(t *testing.T) { ch := captureClientHello(t) full := ch.RawData() for n := range len(full) { - frame, err := ltls.NewClientHelloFrame(full[:n]) + msg, err := ltls.ParseClientHello(full[:n]) if err == nil { // A shorter prefix must never parse as a complete hello. t.Errorf("truncation to %d/%d bytes parsed clean", n, len(full)) - _ = frame.Extensions() + _ = msg.ExtensionBytes() } } } @@ -265,7 +211,7 @@ func FuzzNewClientHelloFrame(f *testing.F) { 0x00, 0x00, // no extensions }) f.Fuzz(func(t *testing.T, b []byte) { - ch, err := ltls.NewClientHelloFrame(b) + ch, err := ltls.ParseClientHello(b) if err != nil { return } @@ -273,17 +219,37 @@ func FuzzNewClientHelloFrame(f *testing.F) { if len(ch.LegacySessionID()) > ltls.MaxSessionIDLen { t.Fatalf("session id %d bytes exceeds max", len(ch.LegacySessionID())) } - if len(ch.CipherSuites())%2 != 0 { + if len(ch.CipherSuiteBytes())%2 != 0 { t.Fatal("cipher suites vector has odd length") } total := 2 + ltls.SizeRandom + 1 + len(ch.LegacySessionID()) + - 2 + len(ch.CipherSuites()) + + 2 + len(ch.CipherSuiteBytes()) + 1 + len(ch.LegacyCompressionMethods()) + - 2 + len(ch.Extensions()) + 2 + len(ch.ExtensionBytes()) if total != len(b) { t.Fatalf("fields sum to %d but input is %d bytes", total, len(b)) } _ = ch.ValidateCompression() - _ = ch.ForEachExtension(func(ltls.ExtensionType, []byte) error { return nil }) + // Every nested iterator must stay inside the input too. + for _, ext := range ch.Extensions { + for _, ks := range ext.KeyShares { + _ = ks + } + for _, name := range ext.ServerNames { + _ = name + } + for _, p := range ext.ALPNProtos { + _ = p + } + for _, v := range ext.SupportedVersions { + _ = v + } + for _, g := range ext.SupportedGroups { + _ = g + } + for _, s := range ext.SignatureSchemes { + _ = s + } + } }) } diff --git a/x/tls/msg_test.go b/x/tls/msg_test.go new file mode 100644 index 0000000..6cad37d --- /dev/null +++ b/x/tls/msg_test.go @@ -0,0 +1,314 @@ +package tls_test + +import ( + "bytes" + "errors" + "testing" + + "github.com/soypat/lneto" + ltls "github.com/soypat/lneto/x/tls" +) + +// locates reports whether span points at want inside body. +func locates(body []byte, off int, want []byte) bool { + return off >= 0 && off+len(want) <= len(body) && bytes.Equal(body[off:off+len(want)], want) +} + +// Spans must locate every hello field, so decoders can map a field onto its wire +// position without re-walking the structure. +func TestClientHelloMsgSpans(t *testing.T) { + msg := captureClientHello(t) + body := msg.RawData() + sp := msg.Spans() + for _, tc := range []struct { + name string + span ltls.Span + want []byte + }{ + {"random", sp.Random, msg.Random()[:]}, + {"session id", sp.SessionID, msg.LegacySessionID()}, + {"compression", sp.Compression, msg.LegacyCompressionMethods()}, + {"extensions", sp.Extensions, msg.ExtensionBytes()}, + } { + if tc.span.Len != len(tc.want) { + t.Errorf("%s span len %d want %d", tc.name, tc.span.Len, len(tc.want)) + } else if !locates(body, tc.span.Off, tc.want) { + t.Errorf("%s span %+v does not locate its field", tc.name, tc.span) + } + } +} + +// Iterator keys are offsets into the hello body, at every nesting level, so a +// decoder can point at a cipher suite or an SNI hostname without arithmetic. +func TestClientHelloMsgIterators(t *testing.T) { + msg := captureClientHello(t) + body := msg.RawData() + + nsuites := 0 + for off, suite := range msg.CipherSuites { + want := []byte{byte(suite >> 8), byte(suite)} + if !locates(body, off, want) { + t.Errorf("suite %v at offset %d does not match the wire", suite, off) + } + nsuites++ + } + if nsuites == 0 { + t.Fatal("no cipher suites walked") + } + + var sawSNI, sawALPN, sawTLS13, sawX25519 bool + for off, ext := range msg.Extensions { + if !locates(body, off, ext.Data()) { + t.Errorf("%v data at offset %d does not match the wire", ext.Type(), off) + } + switch ext.Type() { + case ltls.ExtServerName: + for noff, name := range ext.ServerNames { + sawSNI = true + if name.Type != 0 || string(name.Name) != "example.com" { + t.Errorf("server name %d %q", name.Type, name.Name) + } + if !locates(body, noff, name.Name) { + t.Errorf("server name at offset %d does not match the wire", noff) + } + } + case ltls.ExtALPN: + for poff, proto := range ext.ALPNProtos { + sawALPN = true + if !locates(body, poff, proto) { + t.Errorf("alpn %q at offset %d does not match the wire", proto, poff) + } + } + case ltls.ExtSupportedVersions: + for _, v := range ext.SupportedVersions { + sawTLS13 = sawTLS13 || v == ltls.VersionTLS13 + } + case ltls.ExtKeyShare: + for koff, ks := range ext.KeyShares { + if ks.Group == ltls.GroupX25519 { + sawX25519 = true + } + if !locates(body, koff, ks.Key) { + t.Errorf("key share %v at offset %d does not match the wire", ks.Group, koff) + } + } + case ltls.ExtSupportedGroups: + for goff, g := range ext.SupportedGroups { + if !locates(body, goff, []byte{byte(g >> 8), byte(g)}) { + t.Errorf("group %v at offset %d does not match the wire", g, goff) + } + } + case ltls.ExtSignatureAlgorithms: + for soff, s := range ext.SignatureSchemes { + if !locates(body, soff, []byte{byte(s >> 8), byte(s)}) { + t.Errorf("scheme %v at offset %d does not match the wire", s, soff) + } + } + } + } + if !sawSNI || !sawALPN || !sawTLS13 || !sawX25519 { + t.Errorf("sni=%v alpn=%v tls13=%v x25519=%v", sawSNI, sawALPN, sawTLS13, sawX25519) + } +} + +// A sub-iterator reached from the wrong extension yields nothing rather than +// reinterpreting unrelated bytes. +func TestExtensionSubIteratorTypeMismatch(t *testing.T) { + msg := captureClientHello(t) + for _, ext := range msg.Extensions { + if ext.Type() != ltls.ExtServerName { + continue + } + for range ext.KeyShares { + t.Error("KeyShares walked a server_name extension") + } + for range ext.SupportedVersions { + t.Error("SupportedVersions walked a server_name extension") + } + } +} + +// Walking a hello must not allocate: every iterator is a value type over the +// caller's buffer. +func TestClientHelloMsgZeroAlloc(t *testing.T) { + msg := captureClientHello(t) + body := msg.RawData() + n := testing.AllocsPerRun(10, func() { + m, err := ltls.ParseClientHello(body) + if err != nil { + t.Fatal(err) + } + for _, suite := range m.CipherSuites { + _ = suite + } + for _, ext := range m.Extensions { + for _, name := range ext.ServerNames { + _ = name + } + for _, ks := range ext.KeyShares { + _ = ks + } + for _, p := range ext.ALPNProtos { + _ = p + } + for _, v := range ext.SupportedVersions { + _ = v + } + } + }) + if n != 0 { + t.Errorf("parse and walk allocated %v times, want 0", n) + } +} + +// A known extension whose inner framing is broken must fail the parse, since +// every iterator past construction is error-free. +func TestParseClientHelloRejectsMalformedKnownExtension(t *testing.T) { + msg := captureClientHello(t) + // server_name with a host name length one past the extension data. + bad := []byte{ + 0x00, 0x00, 0x00, 0x0b, // server_name, 11 bytes + 0x00, 0x09, // server_name_list length + 0x00, // host_name + 0x00, 0x0a, // name length 10, but only 6 bytes follow + 'e', 'x', 'a', 'm', 'p', 'l', + } + body := rebuildHelloWithExtensions(t, msg, bad) + if _, err := ltls.ParseClientHello(body); err == nil { + t.Error("malformed server_name accepted") + } +} + +func TestParseClientHelloRejectsDuplicateExtension(t *testing.T) { + // Duplicating an extension lets this parser and a middlebox act on + // different copies, so it is rejected at parse time. + msg := captureClientHello(t) + exts := msg.ExtensionBytes() + var first int + for off, ext := range msg.Extensions { + first = off + len(ext.Data()) + break + } + dup := make([]byte, 0, len(exts)+first) + dup = append(dup, exts...) + dup = append(dup, exts[:first-msg.Spans().Extensions.Off]...) + + body := rebuildHelloWithExtensions(t, msg, dup) + _, err := ltls.ParseClientHello(body) + if !errors.Is(err, lneto.ErrInvalidField) { + t.Errorf("duplicate extension got %v want ErrInvalidField", err) + } +} + +// buildServerHello encodes a ServerHello body with supported_versions and a +// key_share, which in the server form is a single entry with no list prefix. +func buildServerHello(t *testing.T, sid []byte) []byte { + t.Helper() + var b ltls.Builder + b.Reset(make([]byte, 0, 256)) + b.AddU16(ltls.VersionTLS12) + for range ltls.SizeRandom { + b.AddU8(0xab) + } + b.OpenU8() + b.AddBytes(sid) + b.Close() + b.AddU16(uint16(ltls.SuiteAES128GCMSHA256)) + b.AddU8(0) // legacy_compression_method + b.OpenU16() + b.AddU16(uint16(ltls.ExtSupportedVersions)) + b.OpenU16() + b.AddU16(ltls.VersionTLS13) + b.Close() + b.AddU16(uint16(ltls.ExtKeyShare)) + b.OpenU16() + b.AddU16(uint16(ltls.GroupX25519)) + b.OpenU16() + for range 32 { + b.AddU8(0xee) + } + b.Close() + b.Close() + b.Close() + body, err := b.Bytes() + if err != nil { + t.Fatal(err) + } + return body +} + +func TestServerHelloMsg(t *testing.T) { + sid := bytes.Repeat([]byte{0xcd}, 32) + body := buildServerHello(t, sid) + msg, err := ltls.ParseServerHello(body) + if err != nil { + t.Fatal(err) + } + if msg.LegacyVersion() != ltls.VersionTLS12 { + t.Errorf("legacy_version %#04x want 0x0303", msg.LegacyVersion()) + } + if !bytes.Equal(msg.LegacySessionIDEcho(), sid) { + t.Errorf("session id echo % x want % x", msg.LegacySessionIDEcho(), sid) + } + if msg.CipherSuite() != ltls.SuiteAES128GCMSHA256 { + t.Errorf("cipher suite %v want TLS_AES_128_GCM_SHA256", msg.CipherSuite()) + } + if msg.LegacyCompressionMethod() != 0 { + t.Errorf("compression method %d want 0", msg.LegacyCompressionMethod()) + } + + var version uint16 + shares := 0 + for _, ext := range msg.Extensions { + switch ext.Type() { + case ltls.ExtSupportedVersions: + // The ServerHello form is a bare uint16, not a list. + if len(ext.Data()) != 2 { + t.Fatalf("supported_versions %d bytes want 2", len(ext.Data())) + } + version = uint16(ext.Data()[0])<<8 | uint16(ext.Data()[1]) + case ltls.ExtKeyShare: + for koff, ks := range ext.KeyShares { + shares++ + if ks.Group != ltls.GroupX25519 || len(ks.Key) != 32 { + t.Errorf("key share %v %d bytes", ks.Group, len(ks.Key)) + } + if !locates(body, koff, ks.Key) { + t.Errorf("key share at offset %d does not match the wire", koff) + } + } + } + } + if version != ltls.VersionTLS13 { + t.Errorf("negotiated version %#04x want 0x0304", version) + } + if shares != 1 { + t.Errorf("walked %d key shares want 1, the server sends a single entry", shares) + } + + sp := msg.Spans() + if sp.CipherSuites.Len != 2 || !locates(body, sp.CipherSuites.Off, body[sp.CipherSuites.Off:sp.CipherSuites.Off+2]) { + t.Errorf("cipher suite span %+v", sp.CipherSuites) + } + if sp.Compression.Len != 1 { + t.Errorf("compression span len %d want 1", sp.Compression.Len) + } + if !locates(body, sp.Extensions.Off, msg.ExtensionBytes()) { + t.Errorf("extensions span %+v does not locate the block", sp.Extensions) + } +} + +func TestParseServerHelloRejectsMalformed(t *testing.T) { + body := buildServerHello(t, bytes.Repeat([]byte{0xcd}, 32)) + if _, err := ltls.ParseServerHello(append(append([]byte{}, body...), 0xff)); err == nil { + t.Error("trailing byte after extensions block accepted") + } + // No truncation may parse clean, panic, or leave an accessor out of range. + for n := range len(body) { + msg, err := ltls.ParseServerHello(body[:n]) + if err == nil { + t.Errorf("truncation to %d/%d bytes parsed clean", n, len(body)) + _ = msg.ExtensionBytes() + } + } +}