diff --git a/x/tls/builder.go b/x/tls/builder.go new file mode 100644 index 0000000..d501565 --- /dev/null +++ b/x/tls/builder.go @@ -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) + } +} diff --git a/x/tls/builder_test.go b/x/tls/builder_test.go new file mode 100644 index 0000000..942d295 --- /dev/null +++ b/x/tls/builder_test.go @@ -0,0 +1,242 @@ +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 an 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.OpenU16() // client_shares + b.AddU16(uint16(tls.GroupX25519)) + b.OpenU16() + b.AddBytes(make([]byte, 8)) + b.Close() + b.Close() + b.Close() + b.Close() + + out, err := b.Bytes() + if err != nil { + t.Fatal(err) + } + // Strip the outer block length the way ClientHelloFrame.Extensions does. + 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)) + } + + var seen []tls.ExtensionType + err = tls.ForEachExtension(exts, func(ext tls.ExtensionType, data []byte) error { + seen = append(seen, ext) + if ext == tls.ExtKeyShare { + return tls.ForEachKeyShare(data, func(g tls.NamedGroup, key []byte) error { + if g != tls.GroupX25519 || len(key) != 8 { + t.Errorf("key share got %v len %d", g, len(key)) + } + return nil + }) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + 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 + }) +} diff --git a/x/tls/definitions.go b/x/tls/definitions.go new file mode 100644 index 0000000..eaf7d13 --- /dev/null +++ b/x/tls/definitions.go @@ -0,0 +1,275 @@ +// 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 + 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") +) diff --git a/x/tls/frame.go b/x/tls/frame.go new file mode 100644 index 0000000..f9a7b39 --- /dev/null +++ b/x/tls/frame.go @@ -0,0 +1,485 @@ +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) + } +} + +// ExtensionFrame provides access to a single hello extension (RFC 8446 4.2): +// +// struct { +// ExtensionType extension_type; // 2 bytes +// opaque extension_data<0..2^16-1>; +// } Extension; +type ExtensionFrame struct { + buf []byte +} + +// NewExtensionFrame wraps buf as an [ExtensionFrame]. Unlike the record and +// handshake frames, an extension is only ever parsed out of a fully buffered +// hello, so the whole extension must be present. +func NewExtensionFrame(buf []byte) (ExtensionFrame, error) { + if len(buf) < 4 { + return ExtensionFrame{}, lneto.ErrTruncatedFrame + } + n := int(binary.BigEndian.Uint16(buf[2:4])) + if 4+n > len(buf) { + return ExtensionFrame{}, lneto.ErrTruncatedFrame + } + return ExtensionFrame{buf: buf[:4+n]}, nil +} + +// Type returns the extension type. +func (ef ExtensionFrame) Type() ExtensionType { + return ExtensionType(binary.BigEndian.Uint16(ef.buf[0:2])) +} + +// Length returns the declared extension_data length. +func (ef ExtensionFrame) Length() uint16 { + return binary.BigEndian.Uint16(ef.buf[2:4]) +} + +// Data returns the extension_data bytes. +func (ef ExtensionFrame) Data() []byte { return ef.buf[4:] } + +// RawData returns the extension bytes, type and length included. +func (ef ExtensionFrame) RawData() []byte { return ef.buf } + +// ForEachExtension walks an extension list, calling fn for each extension in +// wire order. exts is the contents of the extensions block, with the outer +// two-byte list length already stripped; [ClientHelloFrame.Extensions] returns +// it in that form. +// +// The walker is deliberately permissive about extension types: unknown types, +// including every GREASE value Chrome injects, are passed to fn like any other. +// It is the caller's switch that skips them. The walker is strict about +// framing: a length field that overruns the list aborts with +// [lneto.ErrTruncatedFrame]. +// +// Modelled on tcp.OptionCodec.ForEachOption. +func ForEachExtension(exts []byte, fn func(ExtensionType, []byte) error) error { + for off := 0; off < len(exts); { + ef, err := NewExtensionFrame(exts[off:]) + if err != nil { + return err + } + err = fn(ef.Type(), ef.Data()) + if err != nil { + return err + } + off += len(ef.RawData()) + } + return nil +} + +// ForEachU16 walks a bare list of big-endian uint16 values, such as the +// cipher_suites vector of a ClientHello. b must have even length. +func ForEachU16(b []byte, fn func(uint16) error) error { + if len(b)%2 != 0 { + return lneto.ErrInvalidLengthField + } + for off := 0; off < len(b); off += 2 { + err := fn(binary.BigEndian.Uint16(b[off : off+2])) + if err != nil { + return err + } + } + return nil +} + +// ForEachSupportedGroup walks the contents of a supported_groups extension, +// whose payload is a uint16-length-prefixed list of [NamedGroup] values. +func ForEachSupportedGroup(extData []byte, fn func(NamedGroup) error) error { + body, err := vectorU16(extData) + if err != nil { + return err + } + return ForEachU16(body, func(v uint16) error { return fn(NamedGroup(v)) }) +} + +// ForEachSignatureScheme walks the contents of a signature_algorithms (or +// signature_algorithms_cert) extension. +func ForEachSignatureScheme(extData []byte, fn func(SignatureScheme) error) error { + body, err := vectorU16(extData) + if err != nil { + return err + } + return ForEachU16(body, func(v uint16) error { return fn(SignatureScheme(v)) }) +} + +// ForEachSupportedVersion walks the contents of a supported_versions extension +// as it appears in a ClientHello. Note the prefix here is a single byte, unlike +// every other list in the hello; the ServerHello form carries a bare uint16 +// instead and is not parsed by this function. +func ForEachSupportedVersion(extData []byte, fn func(uint16) error) error { + body, err := vectorU8(extData) + if err != nil { + return err + } + return ForEachU16(body, fn) +} + +// ForEachKeyShare walks the client_shares list of a key_share extension: +// +// struct { +// NamedGroup group; +// opaque key_exchange<1..2^16-1>; +// } KeyShareEntry; +// +// GREASE key shares carry a deliberately absurd body, commonly a single byte, +// and must not be treated as malformed. The walker therefore places no +// constraint on key_exchange length beyond it fitting inside the list; group +// selection is the caller's job. +func ForEachKeyShare(extData []byte, fn func(NamedGroup, []byte) error) error { + body, err := vectorU16(extData) + if err != nil { + return err + } + for off := 0; off < len(body); { + if len(body)-off < 4 { + return lneto.ErrTruncatedFrame + } + group := NamedGroup(binary.BigEndian.Uint16(body[off : off+2])) + n := int(binary.BigEndian.Uint16(body[off+2 : off+4])) + off += 4 + if n > len(body)-off { + return lneto.ErrTruncatedFrame + } + err = fn(group, body[off:off+n]) + if err != nil { + return err + } + off += n + } + return nil +} + +// ForEachALPNProto walks the protocol name list of an ALPN extension. Each +// name is a single-byte-length-prefixed string. Chrome includes a GREASE entry +// here too, so callers must match against their own offer list rather than +// assuming the first entry is meaningful. +// +// A zero-length protocol name is a protocol violation and aborts the walk. +func ForEachALPNProto(extData []byte, fn func([]byte) error) error { + body, err := vectorU16(extData) + if err != nil { + return err + } + for off := 0; off < len(body); { + n := int(body[off]) + off++ + if n == 0 { + return lneto.ErrInvalidLengthField + } else if n > len(body)-off { + return lneto.ErrTruncatedFrame + } + err = fn(body[off : off+n]) + if err != nil { + return err + } + off += n + } + return nil +} + +// ForEachServerName walks the server_name_list of an SNI extension: +// +// struct { +// NameType name_type; // 1 byte, 0 = host_name +// opaque HostName<1..2^16-1>; +// } ServerName; +func ForEachServerName(extData []byte, fn func(nameType uint8, name []byte) error) error { + body, err := vectorU16(extData) + if err != nil { + return err + } + for off := 0; off < len(body); { + if len(body)-off < 3 { + return lneto.ErrTruncatedFrame + } + nameType := body[off] + n := int(binary.BigEndian.Uint16(body[off+1 : off+3])) + off += 3 + if n > len(body)-off { + return lneto.ErrTruncatedFrame + } + err = fn(nameType, body[off:off+n]) + if err != nil { + return err + } + off += n + } + return nil +} + +// vectorU16 strips a two-byte length prefix and returns the vector contents. +// It requires the prefix to describe the buffer exactly: trailing bytes mean +// the sender and this parser disagree on the structure, which is precisely the +// ambiguity parser-differential attacks exploit. +func vectorU16(b []byte) ([]byte, error) { + if len(b) < 2 { + return nil, lneto.ErrTruncatedFrame + } + n := int(binary.BigEndian.Uint16(b[0:2])) + if n != len(b)-2 { + if n > len(b)-2 { + return nil, lneto.ErrTruncatedFrame + } + return nil, errTrailingBytes + } + return b[2:], nil +} + +// vectorU8 strips a one-byte length prefix. See [vectorU16] for why trailing +// bytes are rejected. +func vectorU8(b []byte) ([]byte, error) { + if len(b) < 1 { + return nil, lneto.ErrTruncatedFrame + } + n := int(b[0]) + if n != len(b)-1 { + if n > len(b)-1 { + return nil, lneto.ErrTruncatedFrame + } + return nil, errTrailingBytes + } + return b[1:], nil +} diff --git a/x/tls/frame_test.go b/x/tls/frame_test.go new file mode 100644 index 0000000..67e4d65 --- /dev/null +++ b/x/tls/frame_test.go @@ -0,0 +1,418 @@ +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 TestForEachExtensionWalksAndToleratesGREASE(t *testing.T) { + // Two extensions: a GREASE type with empty data, then supported_versions. + exts := []byte{ + 0x0a, 0x0a, 0x00, 0x00, // GREASE, len 0 + 0x00, 0x2b, 0x00, 0x03, 0x02, 0x03, 0x04, // supported_versions + } + var types []tls.ExtensionType + err := tls.ForEachExtension(exts, func(ext tls.ExtensionType, data []byte) error { + types = append(types, ext) + return nil + }) + if err != nil { + t.Fatal(err) + } + if len(types) != 2 || types[1] != tls.ExtSupportedVersions { + t.Fatalf("got %v", types) + } + if !tls.IsGREASE(uint16(types[0])) { + t.Errorf("first extension %#x not recognized as GREASE", types[0]) + } +} + +func TestForEachExtensionTruncated(t *testing.T) { + for _, tc := range [][]byte{ + {0x00}, // partial type + {0x00, 0x2b, 0x00}, // partial length + {0x00, 0x2b, 0x00, 0x05, 0x02, 0x03}, // length overruns + } { + err := tls.ForEachExtension(tc, func(tls.ExtensionType, []byte) error { return nil }) + if !errors.Is(err, lneto.ErrTruncatedFrame) { + t.Errorf("% x: got %v want ErrTruncatedFrame", tc, err) + } + } +} + +func TestForEachExtensionPropagatesCallbackError(t *testing.T) { + sentinel := errors.New("stop") + exts := []byte{0x00, 0x2b, 0x00, 0x00, 0x00, 0x33, 0x00, 0x00} + n := 0 + err := tls.ForEachExtension(exts, func(tls.ExtensionType, []byte) error { + n++ + return sentinel + }) + if !errors.Is(err, sentinel) { + t.Errorf("got %v want sentinel", err) + } + if n != 1 { + t.Errorf("walk continued after callback error: %d calls", n) + } +} + +func TestForEachKeyShareAcceptsGREASEEntry(t *testing.T) { + // Chrome sends a GREASE key share whose key_exchange is a single byte. + // Rejecting it as malformed breaks Chrome outright. + extData := []byte{ + 0x00, 0x0b, // client_shares length 11 + 0x1a, 0x1a, 0x00, 0x01, 0x00, // GREASE group, 1 byte body + 0x00, 0x1d, 0x00, 0x02, 0xab, 0xcd, // x25519, 2 byte body + } + type share struct { + g tls.NamedGroup + n int + } + var got []share + err := tls.ForEachKeyShare(extData, func(g tls.NamedGroup, key []byte) error { + got = append(got, share{g, len(key)}) + return nil + }) + if err != nil { + t.Fatal(err) + } + if len(got) != 2 { + t.Fatalf("got %d shares want 2", len(got)) + } + if !tls.IsGREASE(uint16(got[0].g)) || got[0].n != 1 { + t.Errorf("GREASE share mishandled: %+v", got[0]) + } + if got[1].g != tls.GroupX25519 || got[1].n != 2 { + t.Errorf("x25519 share mishandled: %+v", got[1]) + } +} + +func TestForEachALPNProtoRejectsEmptyName(t *testing.T) { + // A zero-length protocol name would make the walk unable to advance. + extData := []byte{0x00, 0x01, 0x00} + err := tls.ForEachALPNProto(extData, func([]byte) error { return nil }) + if !errors.Is(err, lneto.ErrInvalidLengthField) { + t.Errorf("got %v want ErrInvalidLengthField", err) + } +} + +func TestForEachALPNProto(t *testing.T) { + extData := []byte{ + 0x00, 0x0c, + 0x02, 'h', '2', + 0x08, 'h', 't', 't', 'p', '/', '1', '.', '1', + } + var names []string + err := tls.ForEachALPNProto(extData, func(b []byte) error { + names = append(names, string(b)) + return nil + }) + if err != nil { + t.Fatal(err) + } + if len(names) != 2 || names[0] != "h2" || names[1] != "http/1.1" { + t.Errorf("got %q", names) + } +} + +func TestForEachSupportedVersionUsesU8Prefix(t *testing.T) { + // supported_versions is the one hello list with a single byte prefix. + extData := []byte{0x04, 0x1a, 0x1a, 0x03, 0x04} + var vers []uint16 + err := tls.ForEachSupportedVersion(extData, func(v uint16) error { + vers = append(vers, v) + return nil + }) + if err != nil { + t.Fatal(err) + } + if len(vers) != 2 || vers[1] != tls.VersionTLS13 { + t.Errorf("got %#x", vers) + } +} + +func TestVectorRejectsTrailingBytes(t *testing.T) { + // A prefix that under-describes its buffer leaves bytes whose meaning this + // parser and a middlebox could disagree about. + err := tls.ForEachSupportedGroup([]byte{0x00, 0x02, 0x00, 0x1d, 0xff}, func(tls.NamedGroup) error { return nil }) + if err == nil { + t.Error("trailing bytes after vector accepted") + } +} + +func TestForEachU16RejectsOddLength(t *testing.T) { + err := tls.ForEachU16([]byte{0x00, 0x1d, 0x00}, func(uint16) error { return nil }) + if !errors.Is(err, lneto.ErrInvalidLengthField) { + t.Errorf("got %v want ErrInvalidLengthField", err) + } +} + +func TestIsGREASE(t *testing.T) { + // The 16 reserved values of RFC 8701. + for i := range 16 { + 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)) + } + }) +} + +func FuzzForEachExtension(f *testing.F) { + f.Add([]byte{0x00, 0x2b, 0x00, 0x03, 0x02, 0x03, 0x04}) + f.Add([]byte{0x0a, 0x0a, 0x00, 0x00}) + f.Fuzz(func(t *testing.T, b []byte) { + total := 0 + err := tls.ForEachExtension(b, func(ext tls.ExtensionType, data []byte) error { + total += 4 + len(data) + if total > len(b) { + t.Fatalf("walked %d bytes past input length %d", total, len(b)) + } + // Sub-walkers must also never escape their slice. + _ = tls.ForEachKeyShare(data, func(tls.NamedGroup, []byte) error { return nil }) + _ = tls.ForEachALPNProto(data, func([]byte) error { return nil }) + _ = tls.ForEachSupportedGroup(data, func(tls.NamedGroup) error { return nil }) + _ = tls.ForEachSupportedVersion(data, func(uint16) error { return nil }) + _ = tls.ForEachServerName(data, func(uint8, []byte) error { return nil }) + return nil + }) + if err == nil && total != len(b) { + t.Fatalf("clean walk consumed %d of %d bytes", total, len(b)) + } + }) +} diff --git a/x/tls/hello.go b/x/tls/hello.go new file mode 100644 index 0000000..ffc408b --- /dev/null +++ b/x/tls/hello.go @@ -0,0 +1,266 @@ +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 + +// ClientHelloFrame provides zero-copy access to a ClientHello body +// (RFC 8446 4.1.2). The frame wraps the handshake message *body*, that is the +// bytes returned by [HandshakeFrame.Body], not the handshake header. +// +// 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; +// +// Every variable-length field is bounds-checked once by +// [NewClientHelloFrame], so the accessors cannot slice out of range. +type ClientHelloFrame struct { + buf []byte + // Offsets of each variable-length field's contents, resolved once at + // construction so accessors stay branch-free. + sessionIDOff, sessionIDLen int + suitesOff, suitesLen int + compOff, compLen int + extsOff, extsLen int +} + +// NewClientHelloFrame parses the structure of a ClientHello body, validating +// that every length prefix is consistent with the buffer. It performs no +// policy checks: version negotiation, cipher suite selection and extension +// validation are the handshake state machine's job. +// +// Trailing bytes after the extensions block are rejected. A sender and parser +// that disagree about where a structure ends is the ambiguity that +// parser-differential attacks are built on. +func NewClientHelloFrame(body []byte) (ClientHelloFrame, error) { + var ch ClientHelloFrame + // 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 + } + ch.extsOff, ch.extsLen = off, extsLen + + ch.buf = body + return ch, nil +} + +// LegacyVersion returns the legacy_version field, which TLS 1.3 pins to +// 0x0303 regardless of the version actually negotiated. +func (ch ClientHelloFrame) LegacyVersion() uint16 { + return binary.BigEndian.Uint16(ch.buf[0:2]) +} + +// Random returns the 32-byte client_random. +func (ch ClientHelloFrame) 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 ClientHelloFrame) LegacySessionID() []byte { + return ch.buf[ch.sessionIDOff : ch.sessionIDOff+ch.sessionIDLen] +} + +// CipherSuites returns the cipher_suites vector with its length prefix +// stripped, ready for [ForEachU16]. The list includes GREASE values. +func (ch ClientHelloFrame) CipherSuites() []byte { + return ch.buf[ch.suitesOff : ch.suitesOff+ch.suitesLen] +} + +// LegacyCompressionMethods returns the legacy_compression_methods vector +// contents. For a TLS 1.3 hello this must be exactly one zero byte; see +// [ClientHelloFrame.ValidateCompression]. +func (ch ClientHelloFrame) LegacyCompressionMethods() []byte { + return ch.buf[ch.compOff : ch.compOff+ch.compLen] +} + +// Extensions returns the extensions block contents with the outer length +// prefix stripped, ready for [ForEachExtension]. +func (ch ClientHelloFrame) Extensions() []byte { + return ch.buf[ch.extsOff : ch.extsOff+ch.extsLen] +} + +// RawData returns the whole ClientHello body. +func (ch ClientHelloFrame) RawData() []byte { return ch.buf } + +// ForEachExtension walks this hello's extensions, rejecting a repeated known +// extension type with [lneto.ErrInvalidField]. RFC 8446 4.2 forbids duplicates, +// and tolerating them invites parser-differential attacks in which this parser +// and a middlebox act on different copies of the same extension. +// +// Unknown and GREASE extension types are passed through without duplicate +// checking, since they are skipped rather than acted upon. Prefer this over +// the bare [ForEachExtension] when parsing an untrusted hello. +func (ch ClientHelloFrame) ForEachExtension(fn func(ExtensionType, []byte) error) error { + var seen extSeen + return ForEachExtension(ch.Extensions(), func(ext ExtensionType, data []byte) error { + if seen.mark(ext) { + return lneto.ErrInvalidField + } + return fn(ext, data) + }) +} + +// ValidateSize adds an error to v if the ClientHello is structurally invalid. +// Since [NewClientHelloFrame] already rejects every inconsistent length, this +// only re-checks that the frame was successfully constructed. +func (ch ClientHelloFrame) ValidateSize(v *lneto.Validator) { + 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 ClientHelloFrame) ValidateCompression() bool { + return ch.compLen == 1 && ch.buf[ch.compOff] == 0 +} + +// 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 + default: + return 0, false + } + return 1 << i, true +} diff --git a/x/tls/hello_test.go b/x/tls/hello_test.go new file mode 100644 index 0000000..8cef47c --- /dev/null +++ b/x/tls/hello_test.go @@ -0,0 +1,289 @@ +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.ClientHelloFrame { + 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.NewClientHelloFrame(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 + var suites []ltls.CipherSuite + err := ch.ForEachExtension(func(ext ltls.ExtensionType, data []byte) error { + switch ext { + case ltls.ExtSupportedVersions: + return ltls.ForEachSupportedVersion(data, func(v uint16) error { + if v == ltls.VersionTLS13 { + sawTLS13 = true + } + return nil + }) + case ltls.ExtKeyShare: + return ltls.ForEachKeyShare(data, func(g ltls.NamedGroup, key []byte) error { + if g == ltls.GroupX25519 && len(key) == 32 { + sawX25519 = true + } + return nil + }) + case ltls.ExtServerName: + return ltls.ForEachServerName(data, func(nameType uint8, name []byte) error { + if nameType == 0 && string(name) == "example.com" { + sawSNI = true + } + return nil + }) + case ltls.ExtALPN: + return ltls.ForEachALPNProto(data, func(p []byte) error { + if string(p) == "http/1.1" { + sawALPN = true + } + return nil + }) + } + return nil + }) + if err != nil { + t.Fatalf("walking extensions: %v", err) + } + + if err := ltls.ForEachU16(ch.CipherSuites(), func(v uint16) error { + suites = append(suites, ltls.CipherSuite(v)) + return nil + }); err != nil { + t.Fatalf("walking cipher suites: %v", err) + } + + 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) + } +} + +func TestClientHelloRejectsDuplicateExtension(t *testing.T) { + // Duplicating supported_versions must be caught. Tolerating it lets this + // parser and a middlebox act on different copies. + ch := captureClientHello(t) + exts := ch.Extensions() + + // Find the first extension and append a verbatim copy of it. + var first []byte + err := ltls.ForEachExtension(exts, func(ext ltls.ExtensionType, data []byte) error { + if first == nil { + first = make([]byte, 4+len(data)) + copy(first, exts) + } + return nil + }) + if err != nil || first == nil { + t.Fatalf("could not isolate first extension: %v", err) + } + + dup := make([]byte, 0, len(exts)+len(first)) + dup = append(dup, exts...) + dup = append(dup, first...) + + body := rebuildHelloWithExtensions(t, ch, dup) + ch2, err := ltls.NewClientHelloFrame(body) + if err != nil { + t.Fatalf("rebuilt hello did not parse: %v", err) + } + err = ch2.ForEachExtension(func(ltls.ExtensionType, []byte) error { return nil }) + if !errors.Is(err, lneto.ErrInvalidField) { + t.Errorf("duplicate extension got %v, want ErrInvalidField", err) + } +} + +// rebuildHelloWithExtensions re-encodes ch with a replacement extensions block, +// exercising Builder against a structure produced by a real client. +func rebuildHelloWithExtensions(t *testing.T, ch ltls.ClientHelloFrame, exts []byte) []byte { + 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.CipherSuites()) + 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.NewClientHelloFrame(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.NewClientHelloFrame(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) { + frame, err := ltls.NewClientHelloFrame(full[:n]) + if err == nil { + // A shorter prefix must never parse as a complete hello. + t.Errorf("truncation to %d/%d bytes parsed clean", n, len(full)) + _ = frame.Extensions() + } + } +} + +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.NewClientHelloFrame(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.CipherSuites())%2 != 0 { + t.Fatal("cipher suites vector has odd length") + } + total := 2 + ltls.SizeRandom + 1 + len(ch.LegacySessionID()) + + 2 + len(ch.CipherSuites()) + + 1 + len(ch.LegacyCompressionMethods()) + + 2 + len(ch.Extensions()) + if total != len(b) { + t.Fatalf("fields sum to %d but input is %d bytes", total, len(b)) + } + _ = ch.ValidateCompression() + _ = ch.ForEachExtension(func(ltls.ExtensionType, []byte) error { return nil }) + }) +}