mirror of
https://github.com/soypat/lneto.git
synced 2026-08-18 05:34:02 +00:00
256 lines
7.2 KiB
Go
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
|
|
}
|
|
}
|
|
})
|
|
}
|