3 Commits

Author SHA1 Message Date
soypat ed86ad0d0e use top level method signature for iterators 2026-08-08 23:17:27 -07:00
soypat 91c646c636 tls: add pcap capturing 2026-08-08 21:31:53 -07:00
Patricio Whittingslow 9758d48696 clanker tls 2026-07-24 20:56:52 -03:00
18 changed files with 4470 additions and 9 deletions
+23 -7
View File
@@ -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 != "" {
+36
View File
@@ -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) {
+461
View File
@@ -0,0 +1,461 @@
package pcap
import (
"encoding/binary"
"github.com/soypat/lneto"
"github.com/soypat/lneto/internal"
"github.com/soypat/lneto/x/tls"
)
// maxTLSRecordsPerPacket bounds how many record frames a single packet may
// produce. A TCP segment commonly coalesces several small records, but without
// a bound a segment full of 5-byte empty records would produce thousands of
// frames.
const maxTLSRecordsPerPacket = 8
// payloadIsTLS reports whether payload begins with what could be a TLS record
// header. TLS has no magic number, so this is a heuristic: the content type
// must be one of the four TLS 1.3 defines, the legacy record version must be
// 0x03xx and the declared fragment length must be legal.
//
// It is what lets TLS be captured on any port instead of only on 443, and it
// does not collide with HTTP, whose first byte is a method or version letter
// and never a valid content type.
func payloadIsTLS(payload []byte) bool {
if len(payload) < tls.SizeHeaderRecord {
return false
}
switch tls.ContentType(payload[0]) {
case tls.ContentTypeChangeCipherSpec, tls.ContentTypeAlert,
tls.ContentTypeHandshake, tls.ContentTypeApplicationData:
default:
return false
}
if payload[1] != 0x03 || payload[2] > 0x04 {
// Every record version in use is 3.x: TLS 1.0 through 1.3 inclusive.
return false
}
n := int(binary.BigEndian.Uint16(payload[3:5]))
return n > 0 && n <= tls.MaxCiphertext
}
// CaptureTLS breaks down the TLS records starting at bitOffset. A single entry
// point handles every kind of TLS traffic: a record names its own content type,
// so the caller does not need to know whether it is looking at a handshake, an
// alert or application data, which is what makes capturing a whole port 443
// conversation possible without tracking connection state.
//
// One [Frame] is produced per record, plus one more per cleartext handshake
// message carried inside a handshake record. Everything after the ServerHello
// is encrypted and appears on the wire as application_data; its fragment is
// reported as an opaque payload, since decrypting it needs keys a capture does
// not have.
//
// TLS is a byte stream: a record may span TCP segments and a handshake message
// may span records. Whatever arrived is reported and the affected frame carries
// [tls.ErrNeedMore]; reassembly is out of scope for a stateless breakdown.
func (pc *PacketBreakdown) CaptureTLS(dst []Frame, pkt []byte, bitOffset int) ([]Frame, error) {
debuglog("pcap:tls:start")
if bitOffset%8 != 0 {
return dst, errNotByteAligned
}
off := bitOffset / 8
for nrec := 0; off < len(pkt); nrec++ {
if nrec == maxTLSRecordsPerPacket {
reclaimRemainingFrame(&dst, "TLS records?", FieldClassPayload, off*octet, octet*len(pkt))
break
}
newdst, consumed, err := pc.captureTLSRecord(dst, pkt, off*octet)
dst = newdst
if err != nil {
if nrec == 0 {
// Not TLS after all; let the caller frame the payload.
return dst, err
}
// Bytes trailing the last complete record that are too few or too
// malformed to be a record header of their own.
reclaimRemainingFrame(&dst, unknownPayloadProto, FieldClassPayload, off*octet, octet*len(pkt))
break
}
off += consumed
}
debuglog("pcap:tls:done")
return dst, nil
}
// captureTLSRecord appends the frames of the single record at bitOffset and
// returns how many bytes of pkt the record occupied.
func (pc *PacketBreakdown) captureTLSRecord(dst []Frame, pkt []byte, bitOffset int) ([]Frame, int, error) {
rec := pkt[bitOffset/8:]
rfrm, err := tls.NewRecordFrame(rec)
if err != nil {
return dst, 0, err
}
rfrm.ValidateSize(pc.validator())
if pc.validator().HasError() {
return dst, 0, pc.validator().ErrPop()
}
debuglog("pcap:tls:validated")
const fragOff = tls.SizeHeaderRecord * octet
ctype := rfrm.ContentType()
finfo := reclaimFrame(&dst, "TLS", bitOffset, baseTLSRecordFields[:])
finfo.Fields[0].Name = ctype.StringConst()
frag := rfrm.Payload()
if frag == nil {
// Fragment continues in a later segment. Report what arrived.
avail := len(rec) - tls.SizeHeaderRecord
finfo.Errors = append(finfo.Errors, tls.ErrNeedMore)
if avail > 0 {
var flags Flags
if ctype == tls.ContentTypeApplicationData {
flags = FlagEncrypted
}
finfo.Fields = append(finfo.Fields, FrameField{
Class: FieldClassPayload,
FrameBitOffset: fragOff,
BitLength: avail * octet,
Flags: flags,
})
}
return dst, len(rec), nil
}
switch ctype {
case tls.ContentTypeHandshake:
// Handshake messages get frames of their own. finfo must not be touched
// past this point: appending to dst may move the Frame it points at.
dst = pc.captureTLSHandshake(dst, pkt, bitOffset+fragOff, len(frag))
case tls.ContentTypeAlert:
if len(frag) < 2 {
finfo.Errors = append(finfo.Errors, lneto.ErrTruncatedFrame)
break
}
// The level byte is advisory only: in TLS 1.3 every alert except
// close_notify and user_canceled is fatal whatever it says.
finfo.Fields = append(finfo.Fields, FrameField{
Name: tls.AlertLevel(frag[0]).StringConst(),
Class: FieldClassType,
FrameBitOffset: fragOff,
BitLength: octet,
Flags: FlagLegacy,
}, FrameField{
Name: tls.AlertDescription(frag[1]).StringConst(),
Class: FieldClassType,
FrameBitOffset: fragOff + octet,
BitLength: octet,
})
case tls.ContentTypeApplicationData:
// Either genuine application data or a protected handshake or alert
// record; which of the three is only knowable after decryption.
finfo.Fields = append(finfo.Fields, FrameField{
Class: FieldClassPayload,
FrameBitOffset: fragOff,
BitLength: len(frag) * octet,
Flags: FlagEncrypted,
})
default: // change_cipher_spec and unrecognized content types.
finfo.Fields = append(finfo.Fields, FrameField{
Class: FieldClassPayload,
FrameBitOffset: fragOff,
BitLength: len(frag) * octet,
})
}
return dst, rfrm.RecordLength(), nil
}
// captureTLSHandshake appends one frame per handshake message found in the
// fragLen bytes of handshake record fragment starting at bitOffset.
func (pc *PacketBreakdown) captureTLSHandshake(dst []Frame, pkt []byte, bitOffset, fragLen int) []Frame {
debuglog("pcap:tls:hs-start")
const hdr = tls.SizeHeaderHandshake
frag := pkt[bitOffset/8:][:fragLen]
fragEnd := bitOffset + fragLen*octet
for off := 0; off < fragLen; {
msgBitOff := bitOffset + off*octet
hfrm, err := tls.NewHandshakeFrame(frag[off:])
if err != nil {
// Fewer than 4 bytes left: a message header split across records.
reclaimRemainingFrame(&dst, "TLS Handshake?", FieldClassPayload, msgBitOff, fragEnd)
return dst
}
mtype := hfrm.MsgType()
finfo := reclaimFrame(&dst, tlsHandshakeProto(mtype), msgBitOff, baseTLSHandshakeFields[:])
finfo.Fields[0].Name = mtype.StringConst()
body := hfrm.Body()
if body == nil {
// Message body continues in the next record.
finfo.Errors = append(finfo.Errors, tls.ErrNeedMore)
if avail := fragLen - off - hdr; avail > 0 {
finfo.Fields = append(finfo.Fields, FrameField{
Class: FieldClassPayload,
FrameBitOffset: hdr * octet,
BitLength: avail * octet,
})
}
return dst
}
switch mtype {
case tls.HandshakeTypeClientHello:
pc.captureTLSClientHello(finfo, body)
case tls.HandshakeTypeServerHello:
pc.captureTLSServerHello(finfo, body)
default:
if len(body) > 0 {
finfo.Fields = append(finfo.Fields, FrameField{
Class: FieldClassPayload,
FrameBitOffset: hdr * octet,
BitLength: len(body) * octet,
})
}
}
off += hfrm.MessageLength()
}
debuglog("pcap:tls:hs-done")
return dst
}
// captureTLSClientHello appends the fields of a ClientHello body to finfo.
// Field positions come from the parsed message, so the only arithmetic here is
// shifting past the handshake header.
func (pc *PacketBreakdown) captureTLSClientHello(finfo *Frame, body []byte) {
msg, err := tls.ParseClientHello(body)
if err != nil {
appendTLSHelloError(finfo, body, err)
return
}
sp := msg.Spans()
appendTLSHelloHead(finfo, sp)
pc.appendTLSCipherSuites(finfo, msg, sp.CipherSuites)
finfo.Fields = append(finfo.Fields, FrameField{
Name: "compression methods",
Class: FieldClassOptions,
FrameBitOffset: helloBitOffset(sp.Compression.Off),
BitLength: sp.Compression.Len * octet,
Flags: FlagLegacy,
})
pc.appendTLSExtensions(finfo, msg.ExtensionList(), sp.Extensions)
}
// captureTLSServerHello appends the fields of a ServerHello body to finfo. It
// differs from the client's in naming one suite and one compression method
// where the client offers a list of each.
func (pc *PacketBreakdown) captureTLSServerHello(finfo *Frame, body []byte) {
msg, err := tls.ParseServerHello(body)
if err != nil {
appendTLSHelloError(finfo, body, err)
return
}
sp := msg.Spans()
appendTLSHelloHead(finfo, sp)
finfo.Fields = append(finfo.Fields, FrameField{
Name: msg.CipherSuite().StringConst(),
Class: FieldClassType,
FrameBitOffset: helloBitOffset(sp.CipherSuites.Off),
BitLength: sp.CipherSuites.Len * octet,
}, FrameField{
Name: "compression method",
Class: FieldClassOptions,
FrameBitOffset: helloBitOffset(sp.Compression.Off),
BitLength: sp.Compression.Len * octet,
Flags: FlagLegacy,
})
pc.appendTLSExtensions(finfo, msg.ExtensionList(), sp.Extensions)
}
// helloBitOffset converts an offset within a hello body to one within the
// handshake message frame.
func helloBitOffset(bodyOff int) int {
return (tls.SizeHeaderHandshake + bodyOff) * octet
}
// appendTLSHelloHead appends the fields both hellos begin with.
func appendTLSHelloHead(finfo *Frame, sp tls.HelloSpans) {
finfo.Fields = append(finfo.Fields, FrameField{
// legacy_version. TLS 1.3 pins it to 0x0303 and carries the real version
// in supported_versions.
Class: FieldClassVersion,
FrameBitOffset: helloBitOffset(0),
BitLength: 2 * octet,
Flags: FlagLegacy,
}, FrameField{
Name: "Random",
Class: FieldClassID,
FrameBitOffset: helloBitOffset(sp.Random.Off),
BitLength: sp.Random.Len * octet,
})
if sp.SessionID.Len > 0 {
// TLS 1.3 has no resumption by session ID; a non-empty value means
// middlebox compatibility mode, echoed verbatim by the server.
finfo.Fields = append(finfo.Fields, FrameField{
Name: "Session ID",
Class: FieldClassID,
FrameBitOffset: helloBitOffset(sp.SessionID.Off),
BitLength: sp.SessionID.Len * octet,
})
}
}
// appendTLSHelloError reports a hello that did not parse. The parsers reject a
// whole message on any inconsistent length, which is what makes their iterators
// error-free, so there are no field offsets to show: the error and the raw bytes
// are all a capture can say.
func appendTLSHelloError(finfo *Frame, body []byte, err error) {
finfo.Errors = append(finfo.Errors, err)
finfo.Fields = append(finfo.Fields, FrameField{
Class: FieldClassPayload,
FrameBitOffset: helloBitOffset(0),
BitLength: len(body) * octet,
})
}
// appendTLSCipherSuites appends a cipher_suites container field whose subfields
// name each offered suite. GREASE values show up as such, which is what a
// capture should display: they carry no meaning and are not an error.
func (pc *PacketBreakdown) appendTLSCipherSuites(finfo *Frame, msg tls.ClientHelloMsg, sp tls.Span) {
// Reclaim from the Fields backing array to reuse its SubFields backing array.
sfield := internal.SliceReclaim(&finfo.Fields)
*sfield = FrameField{
Name: "cipher suites",
Class: FieldClassOptions,
SubFields: sfield.SubFields[:0],
FrameBitOffset: helloBitOffset(sp.Off),
BitLength: sp.Len * octet,
}
if pc.SubfieldLimit <= 0 {
return
}
for off, suite := range msg.CipherSuites {
if len(sfield.SubFields) >= pc.SubfieldLimit {
finfo.Errors = append(finfo.Errors, ErrLimitExceeded)
return
}
sfield.SubFields = append(sfield.SubFields, FrameField{
Name: suite.StringConst(),
Class: FieldClassType,
FrameBitOffset: helloBitOffset(off),
BitLength: 2 * octet,
})
}
}
// appendTLSExtensions appends an extensions container field whose subfields are
// the individual extensions.
func (pc *PacketBreakdown) appendTLSExtensions(finfo *Frame, exts tls.ExtensionList, sp tls.Span) {
extfield := internal.SliceReclaim(&finfo.Fields)
*extfield = FrameField{
Name: "extensions",
Class: FieldClassOptions,
SubFields: extfield.SubFields[:0],
FrameBitOffset: helloBitOffset(sp.Off),
BitLength: sp.Len * octet,
}
if pc.SubfieldLimit <= 0 {
return
}
for off, ext := range exts.All {
if len(extfield.SubFields) >= pc.SubfieldLimit {
finfo.Errors = append(finfo.Errors, ErrLimitExceeded)
return
}
extfield.SubFields = append(extfield.SubFields, tlsExtensionField(ext, off))
}
}
// tlsExtensionField describes a single hello extension. Extensions carrying a
// human readable value point at that value, located by the extension's own
// iterators, and the bulky opaque ones are classed as payload so that a
// [Formatter.FilterClasses] can drop them without losing the rest of the hello.
func tlsExtensionField(ext tls.ExtensionFrame, dataOff int) FrameField {
field := FrameField{
Name: ext.Type().StringConst(),
Class: FieldClassOptions,
FrameBitOffset: helloBitOffset(dataOff),
BitLength: len(ext.Data()) * octet,
}
switch ext.Type() {
case tls.ExtServerName:
for off, name := range ext.ServerNames {
if name.Type != 0 {
continue // Only host_name is defined.
}
field.Class = FieldClassText
field.FrameBitOffset = helloBitOffset(off)
field.BitLength = len(name.Name) * octet
break
}
case tls.ExtALPN:
// Span the first name through the last so every offered protocol stays
// visible; the length bytes between them show up as escapes.
first, end := -1, 0
for off, proto := range ext.ALPNProtos {
if first < 0 {
first = off
}
end = off + len(proto)
}
if first >= 0 {
field.Class = FieldClassText
field.FrameBitOffset = helloBitOffset(first)
field.BitLength = (end - first) * octet
}
case tls.ExtKeyShare, tls.ExtPreSharedKey, tls.ExtPadding, tls.ExtSessionTicket,
tls.ExtCookie, tls.ExtEncryptedClientHello, tls.ExtSignedCertificateTimestamp:
// Opaque and large: a post-quantum key share alone runs past 1kB.
field.Class = FieldClassPayload
}
return field
}
// tlsHandshakeProto names the frame of a handshake message. Only the messages a
// capture can see in cleartext get a name of their own; the rest travel inside
// a protected record and never reach here undecrypted.
func tlsHandshakeProto(t tls.HandshakeType) string {
switch t {
case tls.HandshakeTypeClientHello:
return "TLS ClientHello"
case tls.HandshakeTypeServerHello:
return "TLS ServerHello"
}
return "TLS Handshake"
}
var baseTLSRecordFields = [...]FrameField{
{
// Name is filled in with the content type's name by captureTLSRecord.
Class: FieldClassType,
FrameBitOffset: 0,
BitLength: 1 * octet,
},
{
// legacy_record_version, which TLS 1.3 receivers ignore entirely.
Class: FieldClassVersion,
FrameBitOffset: 1 * octet,
BitLength: 2 * octet,
Flags: FlagLegacy,
},
{
Class: FieldClassSize,
FrameBitOffset: 3 * octet,
BitLength: 2 * octet,
},
}
var baseTLSHandshakeFields = [...]FrameField{
{
// Name is filled in with the message type's name by captureTLSHandshake.
Class: FieldClassType,
FrameBitOffset: 0,
BitLength: 1 * octet,
},
{
Class: FieldClassSize,
FrameBitOffset: 1 * octet,
BitLength: 3 * octet,
},
}
+414
View File
@@ -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
}
+7 -2
View File
@@ -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
+214
View File
@@ -0,0 +1,214 @@
package tls
import (
"encoding/binary"
"unsafe"
)
// maxBuilderNest is the deepest length-prefix nesting the [Builder] supports.
// The deepest real TLS 1.3 structure is the ClientHello key_share extension:
// handshake body, extensions block, extension, client_shares list,
// key_exchange. Eight leaves generous headroom.
const maxBuilderNest = 8
// Builder writes TLS wire structures into a caller-supplied buffer that never
// grows. It exists because TLS nests length-prefixed vectors several levels
// deep and the length of each is only known after its contents are written;
// the standard answer, golang.org/x/crypto/cryptobyte, allocates.
//
// Open a length prefix, write the contents, then Close to backpatch the real
// length:
//
// var b tls.Builder
// b.Reset(buf)
// b.AddU16(uint16(tls.ExtSupportedVersions))
// b.OpenU16() // extension_data length
// b.AddU16(tls.VersionTLS13)
// b.Close()
// out, err := b.Bytes()
//
// Errors are sticky: once the destination buffer overflows or the nesting is
// misused, every later call is a no-op and [Builder.Err] reports the first
// failure. Callers therefore need to check only once, at the end.
//
// The zero Builder is not usable; call [Builder.Reset] first.
type Builder struct {
buf []byte
stack [maxBuilderNest]int32 // offset of each open length field
width [maxBuilderNest]int8 // 1, 2 or 3 byte length prefix
n int8 // open prefix count
err error
}
// Reset prepares the Builder to write into dst, discarding any previous state
// and error. The Builder writes into dst[:0] and never reallocates, so dst's
// capacity is a hard ceiling on the structure being built.
func (b *Builder) Reset(dst []byte) {
b.buf = dst[:0]
b.n = 0
b.err = nil
}
// Err returns the first error encountered since [Builder.Reset], if any.
func (b *Builder) Err() error { return b.err }
// Len returns the number of bytes written so far, including the placeholder
// bytes of any currently open length prefix.
func (b *Builder) Len() int { return len(b.buf) }
// Bytes returns the built structure. It reports an error if the Builder failed
// at any point, or if a length prefix was opened and never closed, since the
// resulting bytes would contain an unpatched placeholder.
func (b *Builder) Bytes() ([]byte, error) {
if b.err != nil {
return nil, b.err
}
if b.n != 0 {
return nil, errBuilderUnbal
}
return b.buf, nil
}
// fail records err if no error has been recorded yet.
func (b *Builder) fail(err error) {
if b.err == nil {
b.err = err
}
}
// grow extends the buffer by n bytes and returns the new region, or nil if the
// buffer is full or the Builder has already failed.
func (b *Builder) grow(n int) []byte {
if b.err != nil {
return nil
}
if n > cap(b.buf)-len(b.buf) {
b.fail(errShortBuffer)
return nil
}
start := len(b.buf)
b.buf = b.buf[:start+n]
return b.buf[start:]
}
// AddU8 appends a single byte.
func (b *Builder) AddU8(v uint8) {
if p := b.grow(1); p != nil {
p[0] = v
}
}
// AddU16 appends a big-endian uint16.
func (b *Builder) AddU16(v uint16) {
if p := b.grow(2); p != nil {
binary.BigEndian.PutUint16(p, v)
}
}
// AddU24 appends a big-endian 24-bit value, the length encoding used by
// handshake message headers and certificate entries.
func (b *Builder) AddU24(v uint32) {
if p := b.grow(3); p != nil {
p[0] = byte(v >> 16)
p[1] = byte(v >> 8)
p[2] = byte(v)
}
}
// AddU32 appends a big-endian uint32.
func (b *Builder) AddU32(v uint32) {
if p := b.grow(4); p != nil {
binary.BigEndian.PutUint32(p, v)
}
}
// AddBytes appends raw bytes.
func (b *Builder) AddBytes(v []byte) {
if p := b.grow(len(v)); p != nil {
copy(p, v)
}
}
// AddString appends the bytes of s without allocating. TLS labels and ALPN
// protocol names are naturally string constants, and converting them with
// []byte(s) would copy to the heap on every call.
func (b *Builder) AddString(s string) {
if len(s) == 0 {
return
}
// unsafe.Slice over the string's backing array; the bytes are only read,
// and only for the duration of the copy inside AddBytes.
b.AddBytes(unsafe.Slice(unsafe.StringData(s), len(s)))
}
// openN reserves width bytes for a length prefix whose value is filled in by
// the matching [Builder.Close].
func (b *Builder) openN(width int8) {
if b.err != nil {
return
}
if int(b.n) >= maxBuilderNest {
b.fail(errBuilderNest)
return
}
start := len(b.buf)
if b.grow(int(width)) == nil {
return
}
b.stack[b.n] = int32(start)
b.width[b.n] = width
b.n++
}
// OpenU8 begins a vector with a one-byte length prefix.
func (b *Builder) OpenU8() { b.openN(1) }
// OpenU16 begins a vector with a two-byte length prefix.
func (b *Builder) OpenU16() { b.openN(2) }
// OpenU24 begins a vector with a three-byte length prefix, as used by the
// handshake message header.
func (b *Builder) OpenU24() { b.openN(3) }
// Close ends the innermost open vector and backpatches its length prefix with
// the number of bytes written since the matching Open.
//
// A length that does not fit the reserved prefix width is a failure rather
// than a silent truncation: writing a value modulo 2^16 into a two-byte prefix
// would produce a structurally valid but semantically wrong record.
func (b *Builder) Close() {
if b.err != nil {
return
}
if b.n == 0 {
b.fail(errBuilderUnbal)
return
}
b.n--
start := int(b.stack[b.n])
width := int(b.width[b.n])
n := len(b.buf) - start - width
p := b.buf[start:]
switch width {
case 1:
if n > 0xff {
b.fail(errBadLength)
return
}
p[0] = byte(n)
case 2:
if n > 0xffff {
b.fail(errBadLength)
return
}
binary.BigEndian.PutUint16(p, uint16(n))
case 3:
if n > 0xffffff {
b.fail(errBadLength)
return
}
p[0] = byte(n >> 16)
p[1] = byte(n >> 8)
p[2] = byte(n)
}
}
+240
View File
@@ -0,0 +1,240 @@
package tls_test
import (
"errors"
"testing"
"github.com/soypat/lneto"
"github.com/soypat/lneto/x/tls"
)
// buildSupportedVersions writes the extension a TLS 1.3 ServerHello carries,
// exercising one level of nesting.
func buildSupportedVersions(b *tls.Builder) {
b.AddU16(uint16(tls.ExtSupportedVersions))
b.OpenU16()
b.AddU16(tls.VersionTLS13)
b.Close()
}
func TestBuilderRoundTripThroughParser(t *testing.T) {
// 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)
b.OpenU16() // extensions block length
buildSupportedVersions(&b)
b.AddU16(uint16(tls.ExtKeyShare))
b.OpenU16()
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()
out, err := b.Bytes()
if err != nil {
t.Fatal(err)
}
// Strip the outer block length the way ServerHelloMsg.ExtensionBytes does.
if len(out) < 2 {
t.Fatal("output too short")
}
exts := out[2:]
if int(out[0])<<8|int(out[1]) != len(exts) {
t.Fatalf("outer length %d != %d", int(out[0])<<8|int(out[1]), len(exts))
}
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)
}
}
func TestBuilderStickyShortBuffer(t *testing.T) {
var b tls.Builder
b.Reset(make([]byte, 4))
b.AddU32(0x01020304) // exactly fills
if b.Err() != nil {
t.Fatalf("unexpected error filling buffer: %v", b.Err())
}
b.AddU8(0xff) // overflows
if !errors.Is(b.Err(), lneto.ErrShortBuffer) {
t.Fatalf("got %v want ErrShortBuffer", b.Err())
}
// Once failed, later writes must not panic, must not write, and must not
// replace the original error.
b.AddBytes(make([]byte, 100))
b.OpenU16()
b.Close()
if !errors.Is(b.Err(), lneto.ErrShortBuffer) {
t.Fatalf("error changed to %v", b.Err())
}
if _, err := b.Bytes(); !errors.Is(err, lneto.ErrShortBuffer) {
t.Fatalf("Bytes returned %v", err)
}
}
func TestBuilderUnclosedPrefixIsAnError(t *testing.T) {
// Returning bytes with an unpatched placeholder would emit a structurally
// wrong record that looks valid.
var b tls.Builder
b.Reset(make([]byte, 16))
b.OpenU16()
b.AddU8(1)
if _, err := b.Bytes(); err == nil {
t.Error("Bytes accepted an unclosed length prefix")
}
}
func TestBuilderUnbalancedClose(t *testing.T) {
var b tls.Builder
b.Reset(make([]byte, 16))
b.Close()
if b.Err() == nil {
t.Error("Close without Open accepted")
}
}
func TestBuilderNestingLimit(t *testing.T) {
var b tls.Builder
b.Reset(make([]byte, 64))
for range 8 {
b.OpenU8()
}
if b.Err() != nil {
t.Fatalf("8 levels rejected: %v", b.Err())
}
b.OpenU8()
if b.Err() == nil {
t.Error("9th nesting level accepted")
}
}
func TestBuilderLengthOverflowRejected(t *testing.T) {
// 300 bytes cannot be described by a one-byte prefix. Truncating modulo 256
// would produce a valid-looking but wrong structure, so this must fail.
var b tls.Builder
b.Reset(make([]byte, 512))
b.OpenU8()
b.AddBytes(make([]byte, 300))
b.Close()
if !errors.Is(b.Err(), lneto.ErrInvalidLengthField) {
t.Errorf("got %v want ErrInvalidLengthField", b.Err())
}
}
func TestBuilderU24RoundTrip(t *testing.T) {
var b tls.Builder
b.Reset(make([]byte, 8))
b.AddU8(byte(tls.HandshakeTypeServerHello))
b.OpenU24()
b.AddBytes([]byte{1, 2, 3})
b.Close()
out, err := b.Bytes()
if err != nil {
t.Fatal(err)
}
hf, err := tls.NewHandshakeFrame(out)
if err != nil {
t.Fatal(err)
}
if hf.MsgType() != tls.HandshakeTypeServerHello {
t.Errorf("type got %v", hf.MsgType())
}
if hf.Length() != 3 {
t.Errorf("length got %d want 3", hf.Length())
}
if !hf.Complete() || len(hf.Body()) != 3 {
t.Errorf("body got % x", hf.Body())
}
}
func TestBuilderAddStringMatchesAddBytes(t *testing.T) {
const label = "tls13 derived"
var b1, b2 tls.Builder
buf1, buf2 := make([]byte, 32), make([]byte, 32)
b1.Reset(buf1)
b1.AddString(label)
b2.Reset(buf2)
b2.AddBytes([]byte(label))
out1, err1 := b1.Bytes()
out2, err2 := b2.Bytes()
if err1 != nil || err2 != nil {
t.Fatalf("%v %v", err1, err2)
}
if string(out1) != string(out2) {
t.Errorf("AddString %q != AddBytes %q", out1, out2)
}
}
func TestBuilderZeroAlloc(t *testing.T) {
// The whole point of not using cryptobyte. A regression here means the
// outbound handshake path started allocating per connection.
buf := make([]byte, 256)
var b tls.Builder
n := testing.AllocsPerRun(100, func() {
b.Reset(buf)
b.AddU8(byte(tls.HandshakeTypeServerHello))
b.OpenU24()
b.AddU16(tls.VersionTLS12)
b.AddBytes(make([]byte, 0, 0)) // no-op, must not allocate
b.OpenU16()
buildSupportedVersions(&b)
b.Close()
b.AddString("x")
b.Close()
_, _ = b.Bytes()
})
if n != 0 {
t.Errorf("Builder allocated %v times per run, want 0", n)
}
}
func FuzzBuilderNeverEscapesBuffer(f *testing.F) {
f.Add([]byte{1, 2, 3}, uint8(16))
f.Fuzz(func(t *testing.T, payload []byte, size uint8) {
buf := make([]byte, size)
canary := make([]byte, len(buf))
var b tls.Builder
b.Reset(buf)
b.OpenU16()
b.AddBytes(payload)
b.OpenU8()
b.AddBytes(payload)
b.Close()
b.Close()
out, err := b.Bytes()
if err != nil {
return
}
if len(out) > len(buf) {
t.Fatalf("wrote %d bytes into a %d byte buffer", len(out), len(buf))
}
// Whatever was produced must parse back as a well-formed vector.
if len(out) < 2 {
t.Fatalf("output %d bytes is too short to hold its own prefix", len(out))
}
if declared := int(out[0])<<8 | int(out[1]); declared != len(out)-2 {
t.Fatalf("declared %d != actual %d", declared, len(out)-2)
}
_ = canary
})
}
+276
View File
@@ -0,0 +1,276 @@
// Package tls implements a heapless, preallocated TLS 1.3 server (RFC 8446).
//
// All buffers are supplied by the caller and never grow. There is no
// allocation in the steady-state record datapath, no goroutines, and no
// dependency on an operating system. A [Conn] wraps any [io.ReadWriter], so it
// works over a lneto tcp.Conn, over net.Pipe, or over a bytes.Buffer in tests.
//
// # Scope
//
// TLS 1.3 only. There is deliberately no TLS 1.2 fallback, no renegotiation,
// no 0-RTT/early data, and no client certificate support. Each omission
// removes a whole class of attack surface.
//
// # Cryptographic primitives
//
// This package implements the record layer, the key schedule and the handshake
// state machine, but ships no cryptographic primitives of its own. Concrete
// hashes, AEADs, key agreements and signers cross the [Hasher], [AEADSuite],
// [KeyAgreement] and [Signer] interfaces. Standard library implementations
// live in the tlsstd subpackage; a hardware AEAD or a secure-element signer
// slots in at the same seam without protocol code changing.
//
// Note that on embedded targets the Signer is expected to be supplied by the
// integrator: Go's crypto/ecdsa carries an 88kB precomputed P-256 basepoint
// table that lands in RAM, which is a third of an RP2040's SRAM.
//
// # Randomness
//
// Config.Rand is required and has no default. Embedded targets frequently have
// a weak or stubbed entropy source, and a biased ECDSA nonce recovers the
// signing key from two signatures, so the choice is made explicit rather than
// silently defaulted. Never seed anything here from internal.Prand32/64; those
// are for TCP initial sequence numbers and DNS IDs, not for key material.
package tls
import (
"errors"
"github.com/soypat/lneto"
)
// Sizes of fixed length headers and the record size limits of RFC 8446 5.2.
const (
// SizeHeaderRecord is the size of a TLSPlaintext/TLSCiphertext header:
// content type(1) + legacy record version(2) + length(2).
SizeHeaderRecord = 5
// SizeHeaderHandshake is the size of a Handshake header:
// message type(1) + 24-bit length(3).
SizeHeaderHandshake = 4
// MaxPlaintext is the largest legal TLSPlaintext.fragment.
MaxPlaintext = 1 << 14 // 16384
// MaxCiphertext is the largest legal TLSCiphertext.encrypted_record.
// The extra 256 bytes cover the content type byte, padding and the AEAD tag.
MaxCiphertext = 1<<14 + 256 // 16640
// MaxRecord is the largest legal record as it appears on the wire.
MaxRecord = SizeHeaderRecord + MaxCiphertext // 16645
// MinRecordSizeLimit is the smallest value a peer may advertise in the
// record_size_limit extension of RFC 8449 4.
MinRecordSizeLimit = 64
)
//go:generate stringer -type=ContentType,HandshakeType,ExtensionType,AlertDescription,AlertLevel,NamedGroup,SignatureScheme,CipherSuite -linecomment -output stringers.go .
// ContentType is the outermost record demultiplexing tag of RFC 8446 5.1.
type ContentType uint8
// Record content types. Values below are the only ones TLS 1.3 defines;
// everything else must be rejected with an unexpected_message alert.
const (
ContentTypeInvalid ContentType = 0 // invalid
ContentTypeChangeCipherSpec ContentType = 20 // change_cipher_spec
ContentTypeAlert ContentType = 21 // alert
ContentTypeHandshake ContentType = 22 // handshake
ContentTypeApplicationData ContentType = 23 // application_data
)
// HandshakeType identifies a handshake message, RFC 8446 4.
type HandshakeType uint8
// Handshake message types. Types this server never sends or accepts are still
// listed so that a peer sending one can be logged and rejected precisely.
const (
HandshakeTypeClientHello HandshakeType = 1 // client_hello
HandshakeTypeServerHello HandshakeType = 2 // server_hello
HandshakeTypeNewSessionTicket HandshakeType = 4 // new_session_ticket
HandshakeTypeEndOfEarlyData HandshakeType = 5 // end_of_early_data
HandshakeTypeEncryptedExtensions HandshakeType = 8 // encrypted_extensions
HandshakeTypeCertificate HandshakeType = 11 // certificate
HandshakeTypeCertificateRequest HandshakeType = 13 // certificate_request
HandshakeTypeCertificateVerify HandshakeType = 15 // certificate_verify
HandshakeTypeFinished HandshakeType = 20 // finished
HandshakeTypeKeyUpdate HandshakeType = 24 // key_update
HandshakeTypeMessageHash HandshakeType = 254 // message_hash
)
// ExtensionType identifies a hello extension, RFC 8446 4.2.
type ExtensionType uint16
// Extension types. Those marked "ignored" are ones a browser sends and this
// server parses past without erroring; being strict about unknown extensions
// breaks real clients.
const (
ExtServerName ExtensionType = 0 // server_name
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
ExtPadding ExtensionType = 21 // padding
ExtExtendedMasterSecret ExtensionType = 23 // extended_master_secret
ExtCompressCertificate ExtensionType = 27 // compress_certificate
ExtRecordSizeLimit ExtensionType = 28 // record_size_limit
ExtSessionTicket ExtensionType = 35 // session_ticket
ExtPreSharedKey ExtensionType = 41 // pre_shared_key
ExtEarlyData ExtensionType = 42 // early_data
ExtSupportedVersions ExtensionType = 43 // supported_versions
ExtCookie ExtensionType = 44 // cookie
ExtPSKKeyExchangeModes ExtensionType = 45 // psk_key_exchange_modes
ExtCertificateAuthorities ExtensionType = 47 // certificate_authorities
ExtSignatureAlgorithmsCert ExtensionType = 50 // signature_algorithms_cert
ExtKeyShare ExtensionType = 51 // key_share
ExtApplicationSettings ExtensionType = 17513 // application_settings
ExtEncryptedClientHello ExtensionType = 65037 // encrypted_client_hello
ExtRenegotiationInfo ExtensionType = 65281 // renegotiation_info
)
// AlertLevel is the legacy severity byte of an alert. In TLS 1.3 every alert
// except close_notify and user_canceled is fatal regardless of this field
// (RFC 8446 6.1), so it is carried for wire compatibility only and must never
// be used to decide whether to continue.
type AlertLevel uint8
// Alert levels.
const (
AlertLevelWarning AlertLevel = 1 // warning
AlertLevelFatal AlertLevel = 2 // fatal
)
// AlertDescription is the alert code of RFC 8446 6.
type AlertDescription uint8
// Alert descriptions. Only the subset a TLS 1.3 server can legitimately send
// or receive is listed.
const (
AlertCloseNotify AlertDescription = 0 // close_notify
AlertUnexpectedMessage AlertDescription = 10 // unexpected_message
AlertBadRecordMAC AlertDescription = 20 // bad_record_mac
AlertRecordOverflow AlertDescription = 22 // record_overflow
AlertHandshakeFailure AlertDescription = 40 // handshake_failure
AlertBadCertificate AlertDescription = 42 // bad_certificate
AlertUnsupportedCertificate AlertDescription = 43 // unsupported_certificate
AlertCertificateRevoked AlertDescription = 44 // certificate_revoked
AlertCertificateExpired AlertDescription = 45 // certificate_expired
AlertCertificateUnknown AlertDescription = 46 // certificate_unknown
AlertIllegalParameter AlertDescription = 47 // illegal_parameter
AlertUnknownCA AlertDescription = 48 // unknown_ca
AlertAccessDenied AlertDescription = 49 // access_denied
AlertDecodeError AlertDescription = 50 // decode_error
AlertDecryptError AlertDescription = 51 // decrypt_error
AlertProtocolVersion AlertDescription = 70 // protocol_version
AlertInsufficientSecurity AlertDescription = 71 // insufficient_security
AlertInternalError AlertDescription = 80 // internal_error
AlertInappropriateFallback AlertDescription = 86 // inappropriate_fallback
AlertUserCanceled AlertDescription = 90 // user_canceled
AlertMissingExtension AlertDescription = 109 // missing_extension
AlertUnsupportedExtension AlertDescription = 110 // unsupported_extension
AlertUnrecognizedName AlertDescription = 112 // unrecognized_name
AlertBadCertificateStatusResponse AlertDescription = 113 // bad_certificate_status_response
AlertUnknownPSKIdentity AlertDescription = 115 // unknown_psk_identity
AlertCertificateRequired AlertDescription = 116 // certificate_required
AlertNoApplicationProtocol AlertDescription = 120 // no_application_protocol
)
// Protocol versions as they appear on the wire.
const (
// VersionTLS12 is the value TLS 1.3 requires in ClientHello.legacy_version
// and in ServerHello.legacy_version for middlebox compatibility.
VersionTLS12 uint16 = 0x0303
// VersionTLS13 is the real negotiated version, carried only in the
// supported_versions extension.
VersionTLS13 uint16 = 0x0304
// VersionTLS10 appears in the legacy_record_version of an initial
// ClientHello record. The field is ignored entirely on receipt.
VersionTLS10 uint16 = 0x0301
)
// NamedGroup identifies a key exchange group, RFC 8446 4.2.7.
type NamedGroup uint16
// Named groups. Only X25519 is implemented; the rest are recognized so that
// group selection and HelloRetryRequest can report precisely what was offered.
const (
GroupSECP256R1 NamedGroup = 0x0017 // secp256r1
GroupSECP384R1 NamedGroup = 0x0018 // secp384r1
GroupSECP521R1 NamedGroup = 0x0019 // secp521r1
GroupX25519 NamedGroup = 0x001d // x25519
GroupX448 NamedGroup = 0x001e // x448
GroupX25519MLKEM768 NamedGroup = 0x11ec // x25519mlkem768
)
// SignatureScheme identifies a signature algorithm, RFC 8446 4.2.3.
type SignatureScheme uint16
// Signature schemes. Browsers do not accept Ed25519 certificates, so
// [SigECDSAP256SHA256] is the practical minimum for a public-facing server.
const (
SigRSAPKCS1SHA256 SignatureScheme = 0x0401 // rsa_pkcs1_sha256
SigRSAPKCS1SHA384 SignatureScheme = 0x0501 // rsa_pkcs1_sha384
SigRSAPKCS1SHA512 SignatureScheme = 0x0601 // rsa_pkcs1_sha512
SigECDSAP256SHA256 SignatureScheme = 0x0403 // ecdsa_secp256r1_sha256
SigECDSAP384SHA384 SignatureScheme = 0x0503 // ecdsa_secp384r1_sha384
SigECDSAP521SHA512 SignatureScheme = 0x0603 // ecdsa_secp521r1_sha512
SigRSAPSSRSAESHA256 SignatureScheme = 0x0804 // rsa_pss_rsae_sha256
SigRSAPSSRSAESHA384 SignatureScheme = 0x0805 // rsa_pss_rsae_sha384
SigRSAPSSRSAESHA512 SignatureScheme = 0x0806 // rsa_pss_rsae_sha512
SigEd25519 SignatureScheme = 0x0807 // ed25519
SigRSAPSSPSSSHA256 SignatureScheme = 0x0809 // rsa_pss_pss_sha256
)
// CipherSuite identifies an AEAD plus hash pair, RFC 8446 B.4.
type CipherSuite uint16
// TLS 1.3 cipher suites. Only [SuiteAES128GCMSHA256] is implemented; it is the
// one suite RFC 8446 9.1 makes mandatory to implement.
const (
SuiteAES128GCMSHA256 CipherSuite = 0x1301 // TLS_AES_128_GCM_SHA256
SuiteAES256GCMSHA384 CipherSuite = 0x1302 // TLS_AES_256_GCM_SHA384
SuiteChaCha20Poly1305SHA256 CipherSuite = 0x1303 // TLS_CHACHA20_POLY1305_SHA256
SuiteAES128CCMSHA256 CipherSuite = 0x1304 // TLS_AES_128_CCM_SHA256
SuiteAES128CCM8SHA256 CipherSuite = 0x1305 // TLS_AES_128_CCM_8_SHA256
)
// IsGREASE reports whether v is one of the 16 reserved GREASE values of
// RFC 8701. Chrome injects GREASE values into its offered cipher suites,
// supported groups, extensions, ALPN protocol list and key shares. They carry
// no meaning and must be skipped wherever they appear; rejecting them breaks
// Chrome outright.
//
// GREASE values have both bytes equal and of the form 0x?a.
func IsGREASE(v uint16) bool {
return v&0x0f0f == 0x0a0a && v>>8 == v&0xff
}
// ErrNeedMore is returned by incremental parsers and by the record layer when
// the input available so far is a valid prefix but not yet a complete unit. It
// is not a failure: the caller should retry once more data has arrived.
//
// It is deliberately distinct from [lneto.ErrTruncatedFrame], which means the
// data is complete but malformed.
var ErrNeedMore = errors.New("tls: need more data")
// ErrUnexpectedClose is returned by Conn.Read when the underlying transport
// reached EOF before a close_notify alert was received. Returning io.EOF here
// instead would let an attacker who can inject a TCP FIN or RST silently
// truncate a response or a request body. Callers must treat this as a failure
// unless the application layer has independently confirmed the message was
// complete, for example by satisfying a Content-Length.
var ErrUnexpectedClose = errors.New("tls: connection closed without close_notify")
// Errors reported by frame constructors and walkers. These reuse the generic
// lneto error set so that callers can compare against a single vocabulary.
var (
errTruncated error = lneto.ErrTruncatedFrame
errShortBuffer error = lneto.ErrShortBuffer
errBadLength error = lneto.ErrInvalidLengthField
errBadField error = lneto.ErrInvalidField
errUnsupported error = lneto.ErrUnsupported
errTrailingBytes = errors.New("tls: trailing bytes after structure")
errBuilderNest = errors.New("tls: builder nesting depth exceeded")
errBuilderUnbal = errors.New("tls: builder length prefix unbalanced")
errAllZeroPlaintext = errors.New("tls: inner plaintext is all padding")
)
+458
View File
@@ -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
}
+283
View File
@@ -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))
}
})
}
+244
View File
@@ -0,0 +1,244 @@
package tls
import (
"encoding/binary"
"github.com/soypat/lneto"
)
// RecordFrame provides zero-copy access to a TLS record (RFC 8446 5.1).
//
// struct {
// ContentType type; // 1 byte
// ProtocolVersion legacy_record_version; // 2 bytes
// uint16 length; // 2 bytes
// opaque fragment[length];
// } TLSPlaintext;
//
// A RecordFrame may be constructed over a buffer holding only the header, so
// that [RecordFrame.Length] can be consulted to decide how many more bytes to
// read. Accessors that reach into the fragment return nil until the whole
// record is present; call [RecordFrame.Complete] to test for that explicitly.
type RecordFrame struct {
buf []byte
}
// NewRecordFrame wraps buf as a [RecordFrame]. It validates only that the
// 5-byte header is present, since the fragment commonly arrives later.
func NewRecordFrame(buf []byte) (RecordFrame, error) {
if len(buf) < SizeHeaderRecord {
return RecordFrame{}, lneto.ErrTruncatedFrame
}
return RecordFrame{buf: buf}, nil
}
// ContentType returns the record's outer content type. For a protected record
// this is always [ContentTypeApplicationData]; the real type lives in the
// encrypted [InnerPlaintext].
func (rf RecordFrame) ContentType() ContentType { return ContentType(rf.buf[0]) }
// SetContentType sets the outer content type.
func (rf RecordFrame) SetContentType(ct ContentType) { rf.buf[0] = byte(ct) }
// LegacyVersion returns the legacy_record_version field. TLS 1.3 requires
// receivers to ignore this field entirely; it is exposed for logging only.
func (rf RecordFrame) LegacyVersion() uint16 {
return binary.BigEndian.Uint16(rf.buf[1:3])
}
// SetLegacyVersion sets the legacy_record_version field.
func (rf RecordFrame) SetLegacyVersion(v uint16) {
binary.BigEndian.PutUint16(rf.buf[1:3], v)
}
// Length returns the declared fragment length. It is attacker controlled and
// must be checked against [MaxCiphertext] before being used to size a read;
// [RecordFrame.ValidateSize] does this.
func (rf RecordFrame) Length() uint16 {
return binary.BigEndian.Uint16(rf.buf[3:5])
}
// SetLength sets the declared fragment length.
func (rf RecordFrame) SetLength(n uint16) {
binary.BigEndian.PutUint16(rf.buf[3:5], n)
}
// RecordLength returns the total wire size of this record, header included.
func (rf RecordFrame) RecordLength() int {
return SizeHeaderRecord + int(rf.Length())
}
// Complete reports whether the whole record, header and fragment, is present
// in the underlying buffer.
func (rf RecordFrame) Complete() bool {
return len(rf.buf) >= rf.RecordLength()
}
// Payload returns the record fragment, or nil if the whole record has not
// arrived yet. The result aliases the underlying buffer.
func (rf RecordFrame) Payload() []byte {
if !rf.Complete() {
return nil
}
return rf.buf[SizeHeaderRecord:rf.RecordLength()]
}
// RawData returns the record bytes, header included, truncated to the declared
// length when the full record is present.
func (rf RecordFrame) RawData() []byte {
if !rf.Complete() {
return rf.buf
}
return rf.buf[:rf.RecordLength()]
}
// ValidateSize adds an error to v if the record is structurally invalid.
// It does not require the fragment to have arrived; it only rejects a declared
// length that could never be legal.
func (rf RecordFrame) ValidateSize(v *lneto.Validator) {
if len(rf.buf) < SizeHeaderRecord {
v.AddError(lneto.ErrTruncatedFrame)
return
}
if rf.Length() > MaxCiphertext {
// Checked before the length is ever used to size a read.
v.AddError(lneto.ErrInvalidLengthField)
}
}
// InnerPlaintext provides access to a decrypted TLSInnerPlaintext
// (RFC 8446 5.2):
//
// struct {
// opaque content[length];
// ContentType type;
// uint8 zeros[length_of_padding];
// } TLSInnerPlaintext;
//
// The real content type is the last non-zero byte, and everything after the
// content is padding that must be stripped before use.
type InnerPlaintext struct {
buf []byte // content only, padding and type byte already stripped
ctype ContentType
padding int
}
// NewInnerPlaintext scans decrypted for its trailing content type byte,
// stripping any zero padding that follows it.
//
// A record whose plaintext is entirely zeros carries no content type and is a
// protocol violation; it is reported so the caller can send an
// unexpected_message alert rather than silently treating it as empty.
func NewInnerPlaintext(decrypted []byte) (InnerPlaintext, error) {
i := len(decrypted) - 1
for i >= 0 && decrypted[i] == 0 {
i--
}
if i < 0 {
return InnerPlaintext{}, errAllZeroPlaintext
}
return InnerPlaintext{
buf: decrypted[:i],
ctype: ContentType(decrypted[i]),
padding: len(decrypted) - i - 1,
}, nil
}
// ContentType returns the true content type recovered from the inner plaintext.
func (ip InnerPlaintext) ContentType() ContentType { return ip.ctype }
// Content returns the plaintext with the content type byte and padding removed.
// The result aliases the buffer passed to [NewInnerPlaintext].
func (ip InnerPlaintext) Content() []byte { return ip.buf }
// PaddingLen returns how many padding bytes followed the content type byte.
func (ip InnerPlaintext) PaddingLen() int { return ip.padding }
// HandshakeFrame provides zero-copy access to a handshake message
// (RFC 8446 4):
//
// struct {
// HandshakeType msg_type; // 1 byte
// uint24 length; // 3 bytes
// opaque body[length];
// } Handshake;
//
// As with [RecordFrame], a HandshakeFrame may be constructed over a buffer
// holding only the 4-byte header so that the body length can be consulted
// before the rest has arrived.
type HandshakeFrame struct {
buf []byte
}
// NewHandshakeFrame wraps buf as a [HandshakeFrame], validating that the
// 4-byte header is present.
func NewHandshakeFrame(buf []byte) (HandshakeFrame, error) {
if len(buf) < SizeHeaderHandshake {
return HandshakeFrame{}, lneto.ErrTruncatedFrame
}
return HandshakeFrame{buf: buf}, nil
}
// MsgType returns the handshake message type.
func (hf HandshakeFrame) MsgType() HandshakeType { return HandshakeType(hf.buf[0]) }
// SetMsgType sets the handshake message type.
func (hf HandshakeFrame) SetMsgType(t HandshakeType) { hf.buf[0] = byte(t) }
// Length returns the declared 24-bit body length. It is attacker controlled;
// the value is returned as an int32 rather than an int so that behaviour is
// identical on 32- and 64-bit targets.
func (hf HandshakeFrame) Length() int32 {
return int32(hf.buf[1])<<16 | int32(hf.buf[2])<<8 | int32(hf.buf[3])
}
// SetLength sets the declared 24-bit body length. Values outside the 24-bit
// range are silently masked; callers building messages should ensure the body
// fits first.
func (hf HandshakeFrame) SetLength(n int32) {
hf.buf[1] = byte(n >> 16)
hf.buf[2] = byte(n >> 8)
hf.buf[3] = byte(n)
}
// MessageLength returns the total size of this handshake message, header
// included.
func (hf HandshakeFrame) MessageLength() int {
return SizeHeaderHandshake + int(hf.Length())
}
// Complete reports whether the entire handshake message is present.
func (hf HandshakeFrame) Complete() bool {
return len(hf.buf) >= hf.MessageLength()
}
// Body returns the handshake message body, or nil if the whole message has not
// arrived. The result aliases the underlying buffer.
func (hf HandshakeFrame) Body() []byte {
if !hf.Complete() {
return nil
}
return hf.buf[SizeHeaderHandshake:hf.MessageLength()]
}
// RawData returns the handshake message bytes, header included. This is what
// must be fed to the transcript hash: the header is hashed along with the body,
// exactly once per message, even when the message spanned several records.
func (hf HandshakeFrame) RawData() []byte {
if !hf.Complete() {
return hf.buf
}
return hf.buf[:hf.MessageLength()]
}
// ValidateSize adds an error to v if the handshake header is malformed.
func (hf HandshakeFrame) ValidateSize(v *lneto.Validator) {
if len(hf.buf) < SizeHeaderHandshake {
v.AddError(lneto.ErrTruncatedFrame)
return
}
if hf.Length() > MaxPlaintext {
// No handshake message this server accepts approaches 2^14 bytes.
v.AddError(lneto.ErrInvalidLengthField)
}
}
+252
View File
@@ -0,0 +1,252 @@
package tls_test
import (
"errors"
"testing"
"github.com/soypat/lneto"
"github.com/soypat/lneto/x/tls"
)
func TestRecordFrameHeaderBeforeBody(t *testing.T) {
// A record layer sees the 5 byte header first and must be able to consult
// the length to know how much more to read.
hdr := []byte{22, 0x03, 0x01, 0x00, 0x10}
rf, err := tls.NewRecordFrame(hdr)
if err != nil {
t.Fatal(err)
}
if rf.ContentType() != tls.ContentTypeHandshake {
t.Errorf("content type got %v want handshake", rf.ContentType())
}
if rf.Length() != 16 {
t.Errorf("length got %d want 16", rf.Length())
}
if rf.RecordLength() != 21 {
t.Errorf("record length got %d want 21", rf.RecordLength())
}
if rf.Complete() {
t.Error("record with header only reported complete")
}
if rf.Payload() != nil {
t.Error("payload of incomplete record must be nil, not a short slice")
}
full := append(hdr, make([]byte, 16)...)
rf, err = tls.NewRecordFrame(full)
if err != nil {
t.Fatal(err)
}
if !rf.Complete() {
t.Fatal("full record reported incomplete")
}
if len(rf.Payload()) != 16 {
t.Errorf("payload len got %d want 16", len(rf.Payload()))
}
}
func TestRecordFrameShortHeader(t *testing.T) {
for n := range tls.SizeHeaderRecord {
_, err := tls.NewRecordFrame(make([]byte, n))
if !errors.Is(err, lneto.ErrTruncatedFrame) {
t.Errorf("len=%d got %v want ErrTruncatedFrame", n, err)
}
}
}
func TestRecordFrameRejectsOversizeLength(t *testing.T) {
// The length field is attacker controlled and must be refused before it is
// ever used to size a read.
hdr := []byte{23, 0x03, 0x03, 0xff, 0xff} // 65535 > MaxCiphertext
rf, err := tls.NewRecordFrame(hdr)
if err != nil {
t.Fatal(err)
}
var v lneto.Validator
rf.ValidateSize(&v)
if err := v.ErrPop(); !errors.Is(err, lneto.ErrInvalidLengthField) {
t.Errorf("got %v want ErrInvalidLengthField", err)
}
// Exactly at the limit is legal.
maxCT := uint16(tls.MaxCiphertext)
hdr[3], hdr[4] = byte(maxCT>>8), byte(maxCT)
rf, _ = tls.NewRecordFrame(hdr)
rf.ValidateSize(&v)
if err := v.ErrPop(); err != nil {
t.Errorf("MaxCiphertext rejected: %v", err)
}
}
func TestInnerPlaintextStripsPadding(t *testing.T) {
for _, tc := range []struct {
name string
in []byte
content string
ctype tls.ContentType
padding int
}{
{"no padding", []byte{'h', 'i', 23}, "hi", tls.ContentTypeApplicationData, 0},
{"padded", []byte{'h', 'i', 23, 0, 0, 0}, "hi", tls.ContentTypeApplicationData, 3},
{"empty content", []byte{22}, "", tls.ContentTypeHandshake, 0},
{"content ends in zero", []byte{'a', 0, 'b', 21, 0}, "a\x00b", tls.ContentTypeAlert, 1},
} {
t.Run(tc.name, func(t *testing.T) {
ip, err := tls.NewInnerPlaintext(tc.in)
if err != nil {
t.Fatal(err)
}
if string(ip.Content()) != tc.content {
t.Errorf("content got %q want %q", ip.Content(), tc.content)
}
if ip.ContentType() != tc.ctype {
t.Errorf("type got %v want %v", ip.ContentType(), tc.ctype)
}
if ip.PaddingLen() != tc.padding {
t.Errorf("padding got %d want %d", ip.PaddingLen(), tc.padding)
}
})
}
}
func TestInnerPlaintextAllZeroRejected(t *testing.T) {
// An all-zero inner plaintext carries no content type. Silently treating it
// as empty would let a peer inject records the state machine cannot
// classify; RFC 8446 5.4 requires unexpected_message.
for _, n := range []int{0, 1, 8} {
_, err := tls.NewInnerPlaintext(make([]byte, n))
if err == nil {
t.Errorf("len=%d: all-zero plaintext accepted", n)
}
}
}
func TestHandshakeFrame24BitLength(t *testing.T) {
hdr := []byte{1, 0x01, 0x02, 0x03} // ClientHello, length 0x010203
hf, err := tls.NewHandshakeFrame(hdr)
if err != nil {
t.Fatal(err)
}
if hf.MsgType() != tls.HandshakeTypeClientHello {
t.Errorf("type got %v", hf.MsgType())
}
if hf.Length() != 0x010203 {
t.Errorf("length got %#x want 0x010203", hf.Length())
}
if hf.Complete() {
t.Error("header-only message reported complete")
}
if hf.Body() != nil {
t.Error("body of incomplete message must be nil")
}
}
func TestHandshakeFrameRoundTripLength(t *testing.T) {
buf := make([]byte, tls.SizeHeaderHandshake)
hf, err := tls.NewHandshakeFrame(buf)
if err != nil {
t.Fatal(err)
}
for _, n := range []int32{0, 1, 255, 256, 65535, 65536, 0xffffff} {
hf.SetLength(n)
if got := hf.Length(); got != n {
t.Errorf("SetLength(%d) round-tripped to %d", n, got)
}
}
}
func TestHandshakeFrameRawDataIncludesHeader(t *testing.T) {
// The transcript hash covers the header plus body, exactly once per
// message. Getting this wrong breaks Finished verification.
msg := []byte{20, 0, 0, 2, 0xaa, 0xbb}
hf, err := tls.NewHandshakeFrame(msg)
if err != nil {
t.Fatal(err)
}
if got := hf.RawData(); len(got) != 6 {
t.Errorf("RawData len got %d want 6", len(got))
}
if got := hf.Body(); len(got) != 2 || got[0] != 0xaa {
t.Errorf("Body got % x", got)
}
}
func TestIsGREASE(t *testing.T) {
// The 16 reserved values of RFC 8701.
for i := range 16 {
v := uint16(i)<<12 | 0x0a00 | uint16(i)<<4 | 0x0a
if !tls.IsGREASE(v) {
t.Errorf("%#04x not detected as GREASE", v)
}
}
for _, v := range []uint16{
0x0000, 0x1301, 0x001d, 0x0403, 0x0a0b, 0x0b0a, 0x1a2a, 0xffff,
} {
if tls.IsGREASE(v) {
t.Errorf("%#04x falsely detected as GREASE", v)
}
}
}
func FuzzNewRecordFrame(f *testing.F) {
f.Add([]byte{22, 3, 1, 0, 5, 1, 2, 3, 4, 5})
f.Add([]byte{23, 3, 3, 0xff, 0xff})
f.Fuzz(func(t *testing.T, b []byte) {
rf, err := tls.NewRecordFrame(b)
if err != nil {
return
}
var v lneto.Validator
rf.ValidateSize(&v)
// Accessors must stay in bounds regardless of validation outcome.
_ = rf.ContentType()
_ = rf.LegacyVersion()
_ = rf.Length()
if p := rf.Payload(); p != nil && len(p) != int(rf.Length()) {
t.Fatalf("payload len %d != declared %d", len(p), rf.Length())
}
if raw := rf.RawData(); len(raw) > len(b) {
t.Fatalf("RawData %d longer than input %d", len(raw), len(b))
}
})
}
func FuzzNewHandshakeFrame(f *testing.F) {
f.Add([]byte{1, 0, 0, 2, 3, 4})
f.Add([]byte{20, 0xff, 0xff, 0xff})
f.Fuzz(func(t *testing.T, b []byte) {
hf, err := tls.NewHandshakeFrame(b)
if err != nil {
return
}
var v lneto.Validator
hf.ValidateSize(&v)
if hf.Length() < 0 {
t.Fatalf("negative 24-bit length %d", hf.Length())
}
if body := hf.Body(); body != nil && len(body) != int(hf.Length()) {
t.Fatalf("body len %d != declared %d", len(body), hf.Length())
}
if raw := hf.RawData(); len(raw) > len(b) {
t.Fatalf("RawData %d longer than input %d", len(raw), len(b))
}
})
}
func FuzzNewInnerPlaintext(f *testing.F) {
f.Add([]byte{1, 2, 23, 0, 0})
f.Add([]byte{0, 0, 0})
f.Fuzz(func(t *testing.T, b []byte) {
ip, err := tls.NewInnerPlaintext(b)
if err != nil {
return
}
if ip.ContentType() == 0 {
t.Fatal("accepted a zero content type")
}
if len(ip.Content())+ip.PaddingLen()+1 != len(b) {
t.Fatalf("content %d + padding %d + 1 != input %d",
len(ip.Content()), ip.PaddingLen(), len(b))
}
})
}
+432
View File
@@ -0,0 +1,432 @@
package tls
import (
"encoding/binary"
"github.com/soypat/lneto"
)
// SizeRandom is the length of the client_random and server_random fields.
const SizeRandom = 32
// MaxSessionIDLen is the largest legal legacy_session_id. TLS 1.3 does not use
// session IDs, but a client sends a 32-byte one to trigger middlebox
// compatibility mode and the server must echo it verbatim.
const MaxSessionIDLen = 32
// 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;
// Random random; // 32 bytes
// opaque legacy_session_id<0..32>;
// CipherSuite cipher_suites<2..2^16-2>;
// opaque legacy_compression_methods<1..2^8-1>;
// Extension extensions<8..2^16-1>;
// } ClientHello;
//
// 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
}
// 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 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 {
return ch, lneto.ErrTruncatedFrame
}
off := 2 + SizeRandom
sidLen := int(body[off])
off++
if sidLen > MaxSessionIDLen {
// Bounds-checked before it can reach a fixed [32]byte echo buffer.
return ch, lneto.ErrInvalidLengthField
} else if sidLen > len(body)-off {
return ch, lneto.ErrTruncatedFrame
}
ch.sessionIDOff, ch.sessionIDLen = off, sidLen
off += sidLen
if len(body)-off < 2 {
return ch, lneto.ErrTruncatedFrame
}
suitesLen := int(binary.BigEndian.Uint16(body[off : off+2]))
off += 2
if suitesLen > len(body)-off {
return ch, lneto.ErrTruncatedFrame
} else if suitesLen%2 != 0 || suitesLen == 0 {
return ch, lneto.ErrInvalidLengthField
}
ch.suitesOff, ch.suitesLen = off, suitesLen
off += suitesLen
if len(body)-off < 1 {
return ch, lneto.ErrTruncatedFrame
}
compLen := int(body[off])
off++
if compLen > len(body)-off {
return ch, lneto.ErrTruncatedFrame
}
ch.compOff, ch.compLen = off, compLen
off += compLen
// TLS 1.3 requires extensions; a ClientHello without them cannot possibly
// carry supported_versions and so cannot be a 1.3 hello.
if len(body)-off < 2 {
return ch, lneto.ErrTruncatedFrame
}
extsLen := int(binary.BigEndian.Uint16(body[off : off+2]))
off += 2
if extsLen > len(body)-off {
return ch, lneto.ErrTruncatedFrame
} else if extsLen != len(body)-off {
return ch, errTrailingBytes
}
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 ClientHelloMsg) LegacyVersion() uint16 {
return binary.BigEndian.Uint16(ch.buf[0:2])
}
// Random returns the 32-byte client_random.
func (ch ClientHelloMsg) Random() *[SizeRandom]byte {
return (*[SizeRandom]byte)(ch.buf[2 : 2+SizeRandom])
}
// LegacySessionID returns the legacy_session_id, at most 32 bytes. A TLS 1.3
// 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 ClientHelloMsg) LegacySessionID() []byte {
return ch.buf[ch.sessionIDOff : ch.sessionIDOff+ch.sessionIDLen]
}
// 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
// [ClientHelloMsg.ValidateCompression].
func (ch ClientHelloMsg) LegacyCompressionMethods() []byte {
return ch.buf[ch.compOff : ch.compOff+ch.compLen]
}
// 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)
}
// 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 }
// 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 [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)
}
}
// ValidateCompression reports whether legacy_compression_methods is exactly
// 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 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
// and a middlebox act on different copies.
//
// Only known types are tracked; unknown and GREASE types are exempt because
// they are skipped without being acted upon.
type extSeen uint64
// mark records ext and reports whether it had already been seen. Extension
// types with no assigned bit are never reported as duplicates.
func (s *extSeen) mark(ext ExtensionType) (duplicate bool) {
bit, ok := extBit(ext)
if !ok {
return false
}
if *s&bit != 0 {
return true
}
*s |= bit
return false
}
// extBit maps an extension type to its bit in [extSeen]. The mapping covers
// every extension this server reads or must reject a duplicate of.
func extBit(ext ExtensionType) (extSeen, bool) {
var i uint
switch ext {
case ExtServerName:
i = 0
case ExtMaxFragmentLength:
i = 1
case ExtStatusRequest:
i = 2
case ExtSupportedGroups:
i = 3
case ExtSignatureAlgorithms:
i = 4
case ExtALPN:
i = 5
case ExtSignedCertificateTimestamp:
i = 6
case ExtPadding:
i = 7
case ExtExtendedMasterSecret:
i = 8
case ExtCompressCertificate:
i = 9
case ExtRecordSizeLimit:
i = 10
case ExtSessionTicket:
i = 11
case ExtPreSharedKey:
i = 12
case ExtEarlyData:
i = 13
case ExtSupportedVersions:
i = 14
case ExtCookie:
i = 15
case ExtPSKKeyExchangeModes:
i = 16
case ExtCertificateAuthorities:
i = 17
case ExtSignatureAlgorithmsCert:
i = 18
case ExtKeyShare:
i = 19
case ExtApplicationSettings:
i = 20
case ExtEncryptedClientHello:
i = 21
case ExtRenegotiationInfo:
i = 22
case ExtECPointFormats:
i = 23
default:
return 0, false
}
return 1 << i, true
}
+255
View File
@@ -0,0 +1,255 @@
package tls_test
import (
"crypto/tls"
"errors"
"net"
"testing"
"time"
"github.com/soypat/lneto"
ltls "github.com/soypat/lneto/x/tls"
)
// captureClientHello drives a standard library TLS client far enough to emit
// its first flight and returns the ClientHello handshake message body.
//
// Using a real client rather than a hand-written fixture means the parser is
// 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.ClientHelloMsg {
t.Helper()
client, server := net.Pipe()
defer client.Close()
defer server.Close()
go func() {
c := tls.Client(client, &tls.Config{
ServerName: "example.com",
MinVersion: tls.VersionTLS13,
MaxVersion: tls.VersionTLS13,
NextProtos: []string{"h2", "http/1.1"},
})
_ = c.Handshake() // will fail; we only need the first flight
}()
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 := ltls.NewRecordFrame(buf[:n])
if err != nil {
t.Fatalf("record: %v", err)
}
if rec.ContentType() != ltls.ContentTypeHandshake {
t.Fatalf("first record is %v, want handshake", rec.ContentType())
}
if !rec.Complete() {
t.Fatalf("ClientHello record split across reads: have %d want %d",
n, rec.RecordLength())
}
hs, err := ltls.NewHandshakeFrame(rec.Payload())
if err != nil {
t.Fatalf("handshake: %v", err)
}
if hs.MsgType() != ltls.HandshakeTypeClientHello {
t.Fatalf("first message is %v, want client_hello", hs.MsgType())
}
if !hs.Complete() {
t.Fatal("ClientHello spans multiple records")
}
ch, err := ltls.ParseClientHello(hs.Body())
if err != nil {
t.Fatalf("client hello: %v", err)
}
return ch
}
func TestClientHelloParseRealHello(t *testing.T) {
ch := captureClientHello(t)
if ch.LegacyVersion() != ltls.VersionTLS12 {
t.Errorf("legacy_version %#04x want 0x0303", ch.LegacyVersion())
}
if !ch.ValidateCompression() {
t.Errorf("legacy_compression_methods % x, want exactly {0}",
ch.LegacyCompressionMethods())
}
if n := len(ch.LegacySessionID()); n != 32 {
// A TLS 1.3 client sends a fake 32-byte session ID to trigger
// middlebox compatibility mode. The server must echo it.
t.Errorf("session id len %d, want 32 for middlebox compat", n)
}
var sawTLS13, sawX25519, sawSNI, sawALPN bool
for _, ext := range ch.Extensions {
switch ext.Type() {
case ltls.ExtSupportedVersions:
for _, v := range ext.SupportedVersions {
sawTLS13 = sawTLS13 || v == ltls.VersionTLS13
}
case ltls.ExtKeyShare:
for _, ks := range ext.KeyShares {
sawX25519 = sawX25519 || ks.Group == ltls.GroupX25519 && len(ks.Key) == 32
}
case ltls.ExtServerName:
for _, name := range ext.ServerNames {
sawSNI = sawSNI || name.Type == 0 && string(name.Name) == "example.com"
}
case ltls.ExtALPN:
for _, p := range ext.ALPNProtos {
sawALPN = sawALPN || string(p) == "http/1.1"
}
}
}
var suites []ltls.CipherSuite
for _, s := range ch.CipherSuites {
suites = append(suites, s)
}
if !sawTLS13 {
t.Error("supported_versions did not offer TLS 1.3")
}
if !sawX25519 {
t.Error("no 32-byte x25519 key share found")
}
if !sawSNI {
t.Error("SNI host not recovered")
}
if !sawALPN {
t.Error("ALPN http/1.1 not recovered")
}
var mandatory bool
for _, s := range suites {
if s == ltls.SuiteAES128GCMSHA256 {
mandatory = true
}
}
if !mandatory {
t.Errorf("TLS_AES_128_GCM_SHA256 not offered; got %v", suites)
}
}
// 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.ClientHelloMsg, exts []byte) []byte {
t.Helper()
var b ltls.Builder
b.Reset(make([]byte, 0, len(ch.RawData())+len(exts)+64))
b.AddU16(ch.LegacyVersion())
b.AddBytes(ch.Random()[:])
b.OpenU8()
b.AddBytes(ch.LegacySessionID())
b.Close()
b.OpenU16()
b.AddBytes(ch.CipherSuiteBytes())
b.Close()
b.OpenU8()
b.AddBytes(ch.LegacyCompressionMethods())
b.Close()
b.OpenU16()
b.AddBytes(exts)
b.Close()
out, err := b.Bytes()
if err != nil {
t.Fatalf("rebuilding hello: %v", err)
}
return out
}
func TestClientHelloRejectsOversizeSessionID(t *testing.T) {
// legacy_session_id feeds a fixed [32]byte echo buffer in the server, so
// the bound must be enforced at parse time.
body := make([]byte, 0, 128)
body = append(body, 0x03, 0x03)
body = append(body, make([]byte, ltls.SizeRandom)...)
body = append(body, 33) // session id length, one over
body = append(body, make([]byte, 33)...) //
body = append(body, 0x00, 0x02, 0x13, 0x01) // cipher suites
body = append(body, 0x01, 0x00) // compression
body = append(body, 0x00, 0x00) // extensions, empty
_, err := ltls.ParseClientHello(body)
if !errors.Is(err, lneto.ErrInvalidLengthField) {
t.Errorf("got %v want ErrInvalidLengthField", err)
}
}
func TestClientHelloRejectsTrailingBytes(t *testing.T) {
ch := captureClientHello(t)
body := append(append([]byte{}, ch.RawData()...), 0xff)
if _, err := ltls.ParseClientHello(body); err == nil {
t.Error("trailing byte after extensions block accepted")
}
}
func TestClientHelloTruncatedAtEveryOffset(t *testing.T) {
// Truncating a valid hello anywhere must produce an error, never a panic
// and never a frame whose accessors read out of bounds.
ch := captureClientHello(t)
full := ch.RawData()
for n := range len(full) {
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))
_ = msg.ExtensionBytes()
}
}
}
func FuzzNewClientHelloFrame(f *testing.F) {
f.Add([]byte{
0x03, 0x03,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0x00, // no session id
0x00, 0x02, 0x13, 0x01, // one cipher suite
0x01, 0x00, // null compression
0x00, 0x00, // no extensions
})
f.Fuzz(func(t *testing.T, b []byte) {
ch, err := ltls.ParseClientHello(b)
if err != nil {
return
}
// Every accessor must stay inside the input.
if len(ch.LegacySessionID()) > ltls.MaxSessionIDLen {
t.Fatalf("session id %d bytes exceeds max", len(ch.LegacySessionID()))
}
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.CipherSuiteBytes()) +
1 + len(ch.LegacyCompressionMethods()) +
2 + len(ch.ExtensionBytes())
if total != len(b) {
t.Fatalf("fields sum to %d but input is %d bytes", total, len(b))
}
_ = ch.ValidateCompression()
// 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
}
}
})
}
+314
View File
@@ -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()
}
}
}
+366
View File
@@ -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]]
}
+122
View File
@@ -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
}
+73
View File
@@ -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)
}
})
}
}