Files
lneto/x/tls/hello_test.go
T
2026-08-08 23:17:27 -07:00

256 lines
7.2 KiB
Go

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