diff --git a/.gitignore b/.gitignore index aaadf73..aa5e740 100644 --- a/.gitignore +++ b/.gitignore @@ -28,5 +28,6 @@ go.work.sum .env # Editor/IDE -# .idea/ -# .vscode/ +.idea/ +.vscode/ + diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..9fc9073 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,125 @@ +formatters: + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ + settings: + gofumpt: + extra-rules: true +issues: + max-issues-per-linter: 0 + max-same-issues: 0 +linters: + enable: + - gomodguard_v2 + default: all + disable: + # -- temporary disabled todo: enable later -- + - noinlineerr + - errcheck + - noctx + - nonamedreturns + - unparam + - govet + - forcetypeassert + - gosec + # -- temporary disabled -- + - thelper + - goconst + - gomodguard + - cyclop + - depguard + - dupword + - exhaustruct + - funlen + - gochecknoglobals + - gocognit + - gocyclo + - godox + - lll + - mnd + - nestif + - rowserrcheck + - varnamelen + - wsl + - wrapcheck + - revive + - err113 + - dupl + - paralleltest + - nolintlint + - godot + - interfacebloat + - ireturn + - containedctx + - testpackage + - tagalign + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ + presets: + - common-false-positives + - legacy + settings: + revive: + confidence: 0.8 + rules: + - name: exported + arguments: [ ] + - name: var-naming + arguments: + - ID + - [ ] + - name: package-comments + - name: dot-imports + - name: blank-imports + - name: context-as-argument + - name: context-keys-type + - name: error-return + - name: error-strings + - name: error-naming + - name: increment-decrement + - name: indent-error-flow + - name: receiver-naming + - name: range + - name: time-naming + - name: unexported-return + - name: errorf + - name: empty-block + - name: superfluous-else + - name: unreachable-code + - name: redefines-builtin-id + - name: if-return + errcheck: + check-blank: true + check-type-assertions: true + wrapcheck: + ignore-sigs: + - ".Errorf(" + - errors.New( + - errors.Unwrap( + - errors.Join( + - ".Wrap(" + - ".Wrapf(" + - ".WithMessage(" + - ".WithMessagef(" + - ".WithStack(" + err113: + check-type-assertion: true + misspell: + locale: US + govet: + enable: + - shadow + dupl: + threshold: 150 +run: + concurrency: 4 + issues-exit-code: 1 + tests: true +version: 2 diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..e1fa3c3 --- /dev/null +++ b/.tool-versions @@ -0,0 +1,2 @@ +golang 1.26.5 +golangci-lint 2.12.2 \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0536b57 --- /dev/null +++ b/Makefile @@ -0,0 +1,50 @@ +BINARY := ktls +BUILD_DIR := bin +COVERAGE_FILE := coverage.out +COVERAGE_HTML := coverage.html + +.DEFAULT_GOAL := help + +.PHONY: help build test test-integration cover lint lint-fix fmt vet tidy update bench clean tools + +help: ## Show this help. + @grep -E '^[a-zA-Z0-9_.-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ + awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-22s\033[0m %s\n", $$1, $$2}' + +build: ## Compile the library and any commands. + @mkdir -p $(BUILD_DIR) + go build ./... + +test: ## Run unit tests with race detection, shuffle, timeout, and coverage. + go clean -testcache && \ + CGO_ENABLED=1 go test -race -shuffle=on -timeout=2m -covermode=atomic -coverprofile=$(COVERAGE_FILE) ./... + +test-integration: ## Run integration tests selected by the integration build tag. + go clean -testcache && \ + CGO_ENABLED=1 go test -race -shuffle=on -timeout=5m -tags=integration ./... + +cover: test ## Render the unit-test coverage report as HTML. + go tool cover -html=$(COVERAGE_FILE) -o $(COVERAGE_HTML) + @printf 'Coverage report written to %s\n' $(COVERAGE_HTML) + +lint: ## Run the required static-analysis suite. + golangci-lint run ./... + +lint-fix: ## Apply safe formatter and linter fixes, then report remaining findings. + golangci-lint run --fix ./... + +tidy: ## Normalize module metadata and verify every dependency. + go mod tidy + go mod verify + +update: ## Upgrade all dependencies to their latest minor/patch versions. + go get -u ./... + go mod tidy + go mod verify + +bench: ## Run all benchmarks with memory allocation statistics. + go clean -testcache + CGO_ENABLED=0 go test -run='^$$' -bench=. -benchmem ./... + +clean: ## Remove locally generated build and coverage artifacts. + rm -rf $(BUILD_DIR) $(COVERAGE_FILE) $(COVERAGE_HTML) diff --git a/bench_linux_test.go b/bench_linux_test.go new file mode 100644 index 0000000..4f4322f --- /dev/null +++ b/bench_linux_test.go @@ -0,0 +1,506 @@ +//go:build linux + +package ktls + +import ( + "bytes" + "crypto/tls" + "io" + "net" + "testing" +) + +// The benchmarks substantiate (or refute) the performance claims in the README: +// - splice (zerocopy) vs generic buffered copy for the TX and RX directions +// - raw kTLS Write vs Go's userspace *tls.Conn Write ("roughly a wash") +// - the per-Accept cost of the recordCounter + keylog parse +// +// Run with: make bench (go test -run='^$' -bench=. -benchmem ./...) +// Compare runs with benchstat: +// +// benchstat old.txt new.txt +// +// Every benchmark skips when kTLS is not engaged (no kernel module / fallback), +// so the suite stays meaningful on a kernel without tls. + +// benchPayload is a deterministic fill so two sides can compare byte-equality +// without holding a second copy of the whole buffer. +func benchPayload(n int) []byte { + b := make([]byte, n) + for i := range b { + b[i] = byte(i * 7) + } + + return b +} + +// drain runs in a goroutine and reads exactly len(payload) bytes from r, +// reporting the result on done. It is the consumer side of the TX benchmarks +// and the producer side is a rawSource; for the Write benchmarks the client +// side reads back what the server wrote. +func drainInto(b *testing.B, r io.Reader, want []byte, done chan error) { + got := make([]byte, len(want)) + if _, err := io.ReadFull(r, got); err != nil { + done <- err + + return + } + + if !bytes.Equal(got, want) { + done <- io.ErrUnexpectedEOF + + return + } + + done <- nil +} + +// rawSourceConn returns a *net.TCPConn whose server side serves payload once. +// Mirrors rawSource but is reused here under its own listener to keep the +// benchmark self-contained. +func rawSourceConn(b *testing.B, payload []byte) *net.TCPConn { + b.Helper() + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + b.Fatal(err) + } + go func() { + c, err := ln.Accept() + if err != nil { + return + } + + c.Write(payload) + c.Close() + ln.Close() + }() + + c, err := net.Dial("tcp", ln.Addr().String()) + if err != nil { + b.Fatal(err) + } + + return c.(*net.TCPConn) +} + +// rawSinkConn returns a *net.TCPConn whose server side collects everything it +// reads and reports it on out. Mirrors rawSink. +func rawSinkConn(b *testing.B) (*net.TCPConn, <-chan []byte) { + b.Helper() + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + b.Fatal(err) + } + + out := make(chan []byte, 1) + + go func() { + c, err := ln.Accept() + if err != nil { + out <- nil + + return + } + + data, _ := io.ReadAll(c) + out <- data + + ln.Close() + }() + + c, err := net.Dial("tcp", ln.Addr().String()) + if err != nil { + b.Fatal(err) + } + + return c.(*net.TCPConn), out +} + +// --------------------------------------------------------------------------- +// TX direction: io.Copy(ktlsConn, src) -> kernel encrypts via splice +// --------------------------------------------------------------------------- + +// BenchmarkSpliceReadFrom measures zerocopy splice into the kTLS TX path +// (src fd -> pipe -> kTLS socket, kernel encrypts). +func BenchmarkSpliceReadFrom(b *testing.B) { + for _, size := range []int{64 * 1024, 1024 * 1024} { + b.Run(byteSize(size), func(b *testing.B) { + benchReadFrom(b, size, true) + }) + } +} + +// BenchmarkGenericReadFrom measures the buffered userspace fallback for the TX +// path (no splice: src -> buffer -> Write -> kernel encrypts). +func BenchmarkGenericReadFrom(b *testing.B) { + for _, size := range []int{64 * 1024, 1024 * 1024} { + b.Run(byteSize(size), func(b *testing.B) { + benchReadFrom(b, size, false) + }) + } +} + +// benchReadFrom runs one TX-direction benchmark. splice=true drives the raw-fd +// source through io.Copy (splice path); splice=false wraps the source in a +// bytes.Reader so ReadFrom falls back to genericReadFrom. +func benchReadFrom(b *testing.B, payloadSize int, splice bool) { + ln, addr := ktlsServerB(b) + defer ln.TCPListener.Close() + + payload := benchPayload(payloadSize) + b.SetBytes(int64(payloadSize)) + b.ReportAllocs() + + b.ResetTimer() + + for range b.N { + // fresh kTLS conn per iteration: handshake cost is excluded by ResetTimer + kc, client := acceptKTLSB(b, ln, addr) + + done := make(chan error, 1) + go drainInto(b, client, payload, done) + + var src io.Reader + if splice { + src = rawSourceConn(b, payload) + } else { + src = bytes.NewReader(payload) // no raw fd -> genericReadFrom + } + + if _, err := io.Copy(kc, src); err != nil { + b.Fatalf("io.Copy: %v", err) + } + // close the server side first so the kernel flushes + sends close_notify; + // only close the client after the drain goroutine has read every byte, + // otherwise an in-flight ReadFull aborts with "use of closed connection". + kc.Close() + + if splice { + if s, ok := src.(*net.TCPConn); ok { + s.Close() + } + } + + err := <-done + if err != nil { + b.Fatalf("client read: %v", err) + } + + client.Close() + } +} + +// --------------------------------------------------------------------------- +// RX direction: io.Copy(dst, ktlsConn) -> kernel decrypts via splice +// --------------------------------------------------------------------------- + +// BenchmarkSpliceWriteTo measures zerocopy splice out of the kTLS RX path +// (kTLS socket -> pipe -> dst fd, kernel decrypts). +func BenchmarkSpliceWriteTo(b *testing.B) { + for _, size := range []int{64 * 1024, 1024 * 1024} { + b.Run(byteSize(size), func(b *testing.B) { + benchWriteTo(b, size, true) + }) + } +} + +// BenchmarkGenericWriteTo measures the buffered userspace fallback for the RX +// path (no splice: Read -> buffer -> dst Write, kernel decrypts on Read). +func BenchmarkGenericWriteTo(b *testing.B) { + for _, size := range []int{64 * 1024, 1024 * 1024} { + b.Run(byteSize(size), func(b *testing.B) { + benchWriteTo(b, size, false) + }) + } +} + +// benchWriteTo runs one RX-direction benchmark. The client writes payload over +// its *tls.Conn; the server copies it out via io.Copy(dst, kc). splice=true uses +// a raw-fd sink (splice path); splice=false uses a bytes.Buffer (genericWriteTo). +func benchWriteTo(b *testing.B, payloadSize int, splice bool) { + ln, addr := ktlsServerB(b) + defer ln.TCPListener.Close() + + payload := benchPayload(payloadSize) + b.SetBytes(int64(payloadSize)) + b.ReportAllocs() + + b.ResetTimer() + + for range b.N { + kc, client := acceptKTLSB(b, ln, addr) + + // client pushes the payload; server drains it. + go func() { + client.Write(payload) + client.Close() + }() + + var ( + dst io.Writer + sinkOut <-chan []byte + sink *net.TCPConn + ) + if splice { + sink, sinkOut = rawSinkConn(b) + dst = sink + } else { + dst = &bytes.Buffer{} + } + + if _, err := io.Copy(dst, kc); err != nil { + b.Fatalf("io.Copy: %v", err) + } + + kc.Close() + client.Close() + + if splice { + sink.Close() + + got := <-sinkOut + if len(got) != payloadSize { + b.Fatalf("sink got %d bytes, want %d", len(got), payloadSize) + } + } + } +} + +// --------------------------------------------------------------------------- +// Raw Write path: kTLS vs userspace *tls.Conn +// --------------------------------------------------------------------------- + +// BenchmarkKTLSWrite measures raw Write on a kTLS-active conn (the kernel +// encrypts each call). Substantiates or refutes the "roughly a wash against +// Go's userspace AES-GCM" claim. +func BenchmarkKTLSWrite(b *testing.B) { + ln, addr := ktlsServerB(b) + defer ln.TCPListener.Close() + + const size = 64 * 1024 + + payload := benchPayload(size) + + kc, client := acceptKTLSB(b, ln, addr) + defer kc.Close() + defer client.Close() + + // keep a reader draining the other end so the kernel send buffer does not + // backpressure the writes and skew the measurement. + go io.Copy(io.Discard, client) + + b.SetBytes(int64(size)) + b.ReportAllocs() + b.ResetTimer() + + for range b.N { + if _, err := kc.Write(payload); err != nil { + b.Fatalf("write: %v", err) + } + } + + b.StopTimer() +} + +// BenchmarkUserspaceTLSWrite is the comparison baseline: the same payload +// written through Go's userspace *tls.Conn (no kernel offload). A single +// long-lived TLS connection is used for the whole benchmark loop (matching the +// kTLS benchmark), with a background drain on the server side so client writes +// are not backpressured by kernel send-buffer limits. +func BenchmarkUserspaceTLSWrite(b *testing.B) { + ln, addr := userspaceTLSServerB(b) + defer ln.Close() + + const size = 64 * 1024 + + payload := benchPayload(size) + + // The server *tls.Conn handshake is lazy, so the accept goroutine drives it + // concurrently with the client dial; without this tls.Dial blocks forever + // waiting for a ServerHello the server has not yet started to send. + srvCh := make(chan net.Conn, 1) + + go func() { + c, err := ln.Accept() + if err != nil { + srvCh <- nil + + return + } + + if tc, ok := c.(*tls.Conn); ok { + herr := tc.Handshake() + if herr != nil { + c.Close() + + srvCh <- nil + + return + } + } + + srvCh <- c + }() + + conn, err := tls.Dial("tcp", addr, &tls.Config{ + InsecureSkipVerify: true, + MinVersion: tls.VersionTLS13, + }) + if err != nil { + b.Fatal(err) + } + defer conn.Close() + + srv := <-srvCh + if srv == nil { + b.Fatal("server accept/handshake failed") + } + + defer srv.Close() + go io.Copy(io.Discard, srv) // drain for the connection lifetime + + b.SetBytes(int64(size)) + b.ReportAllocs() + b.ResetTimer() + + for range b.N { + if _, err := conn.Write(payload); err != nil { + b.Fatalf("write: %v", err) + } + } + + b.StopTimer() +} + +// --------------------------------------------------------------------------- +// Handshake / Accept cost (recordCounter + keylog parse) +// --------------------------------------------------------------------------- + +// BenchmarkHandshake measures the per-connection overhead Accept adds on top +// of a plain crypto/tls handshake: the recordCounter wrapping, key-log buffer +// capture, and traffic-secret parsing. It accepts a kTLS-active connection each +// iteration so the full Accept path (including enableKTLS) is measured. +func BenchmarkHandshake(b *testing.B) { + ln, addr := ktlsServerB(b) + defer ln.TCPListener.Close() + + b.ReportAllocs() + b.ResetTimer() + + for range b.N { + go func() { + c, err := tls.Dial("tcp", addr, &tls.Config{ + InsecureSkipVerify: true, + MinVersion: tls.VersionTLS13, + }) + if err != nil { + return + } + // a tiny read keeps the conn alive long enough for Accept to finish + one := make([]byte, 1) + c.Read(one) + c.Close() + }() + + conn, err := ln.Accept() + if err != nil { + b.Fatalf("accept: %v", err) + } + + conn.Close() + } +} + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +// ktlsServerB is the benchmark variant of ktlsServer (b.Helper + b.Fatal). +func ktlsServerB(b *testing.B) (*Listener, string) { + b.Helper() + cert := selfSignedB(b) + cfg := &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS13} + + raw, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + b.Fatal(err) + } + + ln := &Listener{TCPListener: raw, TLSConfig: cfg, OnError: func(e error) {}} + + return ln, raw.Addr().String() +} + +// userspaceTLSServerB returns a plain crypto/tls listener (no kTLS) so the +// userspace Write benchmark has a clean comparison baseline. +func userspaceTLSServerB(b *testing.B) (net.Listener, string) { + b.Helper() + cert := selfSignedB(b) + cfg := &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS13} + + raw, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + b.Fatal(err) + } + + return tls.NewListener(raw, cfg), raw.Addr().String() +} + +// acceptKTLSB accepts one kTLS-active connection and returns it plus the +// matching client *tls.Conn so the benchmark can drive the other side. +func acceptKTLSB(b *testing.B, ln *Listener, addr string) (Conn, *tls.Conn) { + b.Helper() + + type accepted struct { + c *tls.Conn + err error + } + + ch := make(chan accepted, 1) + + go func() { + c, err := tls.Dial("tcp", addr, &tls.Config{ + InsecureSkipVerify: true, + MinVersion: tls.VersionTLS13, + }) + ch <- accepted{c, err} + }() + + conn, err := ln.Accept() + if err != nil { + b.Fatalf("accept: %v", err) + } + + kc, ok := conn.(Conn) + if !ok { + conn.Close() + + if a := <-ch; a.c != nil { + a.c.Close() + } + + b.Skip("kTLS not enabled (userspace fallback) - benchmark not meaningful") + } + + a := <-ch + if a.err != nil { + conn.Close() + b.Fatalf("client dial: %v", a.err) + } + + return kc, a.c +} + +// byteSize renders a byte count as a human-friendly benchmark sub-name. +func byteSize(n int) string { + switch { + case n >= 1024*1024: + return "1MiB" + case n >= 1024: + return "64KiB" + default: + return "raw" + } +} diff --git a/conn.go b/conn.go index b5055b3..f4d1b24 100644 --- a/conn.go +++ b/conn.go @@ -8,7 +8,7 @@ import ( "syscall" ) -// implemented by a connection returned from Listener.Accept when kTLS was successfully enabled +// Conn implemented by a connection returned from Listener.Accept when kTLS was successfully enabled // type-assert net.Conn to this interface to distinguish kTLS-active connections from plain *tls.Conn fallbacks type Conn interface { net.Conn @@ -27,6 +27,7 @@ type Conn interface { // Post handshake connection, reads and writes hit kTLS type conn struct { net.Conn + state tls.ConnectionState fd int @@ -50,6 +51,7 @@ func (c *conn) Write(b []byte) (int, error) { c.txMu.Lock() n, err := c.Conn.Write(b) c.txMu.Unlock() + return n, err } @@ -61,6 +63,7 @@ func (c *conn) Read(b []byte) (int, error) { // and resume // maxControlRecords bounds a peer flooding control records with no data const maxControlRecords = 32 + for handled := 0; ; { n, err := c.Conn.Read(b) if err == nil { @@ -76,17 +79,39 @@ func (c *conn) Read(b []byte) (int, error) { if c.rxSecret == nil || !isPostHandshakeSignal(err) { return n, err } + if handled++; handled > maxControlRecords { return 0, err } - if rerr := c.handlePostHandshake(err); rerr != nil { + rerr := c.handlePostHandshake(err) + if rerr != nil { return 0, rerr } // control record consumed + RX rekeyed as needed -> retry the read } } +// Implements syscall.Conn so that callers (e.g. zerocopy splice) can extract the raw file descriptor from the underlying TCP connection +func (c *conn) SyscallConn() (syscall.RawConn, error) { + sc, ok := c.Conn.(syscall.Conn) + if !ok { + return nil, net.ErrClosed + } + + return sc.SyscallConn() +} + +// ConnectionState allows net/http to populate Request.TLS, else it would think we're using plaintext +func (c *conn) ConnectionState() tls.ConnectionState { + return c.state +} + +// equivalent to ConnectionState().DidResume (was established via TLS session resumption (psk / session tickets)) +func (c *conn) DidResume() bool { + return c.state.DidResume +} + // rekeyRX advances the RX traffic secret one generation and rearms the kernel (RFC 8446 7.2) // uses the onKeyUpdate seam when set (tests) func (c *conn) rekeyRX() error { @@ -108,25 +133,6 @@ func (c *conn) handleKeyUpdate() error { } c.rxSecret = next - return nil -} - -// Implements syscall.Conn so that callers (e.g. zerocopy splice) can extract the raw file descriptor from the underlying TCP connection -func (c *conn) SyscallConn() (syscall.RawConn, error) { - sc, ok := c.Conn.(syscall.Conn) - if !ok { - return nil, net.ErrClosed - } - - return sc.SyscallConn() -} -// ConnectionState allows net/http to populate Request.TLS, else it would think we're using plaintext -func (c *conn) ConnectionState() tls.ConnectionState { - return c.state -} - -// equivalent to ConnectionState().DidResume (was established via TLS session resumption (psk / session tickets)) -func (c *conn) DidResume() bool { - return c.state.DidResume + return nil } diff --git a/conn_test.go b/conn_test.go index cdeb12a..015ec80 100644 --- a/conn_test.go +++ b/conn_test.go @@ -13,9 +13,10 @@ import ( // fakeConn scripts a sequence of (n bytes written into b, error) results for // successive Read calls, so the KeyUpdate loop can be exercised without kTLS type fakeConn struct { + netConnStub + stepReads []fakeRead i int - netConnStub } type fakeRead struct { @@ -31,6 +32,7 @@ func (f *fakeConn) Read(b []byte) (int, error) { r := f.stepReads[f.i] f.i++ n := copy(b, r.data) + return n, r.err } @@ -50,9 +52,14 @@ func TestReadHandlesMultipleKeyUpdates(t *testing.T) { {nil, ekeyexpired()}, // KeyUpdate #1 {nil, ekeyexpired()}, // KeyUpdate #2 back-to-back {[]byte("hello"), nil}, // real data after rekeying twice - }, func() error { updates++; return nil }) + }, func() error { + updates++ + + return nil + }) buf := make([]byte, 16) + n, err := c.Read(buf) if err != nil { t.Fatalf("unexpected err: %v", err) @@ -72,9 +79,14 @@ func TestReadReturnsDataBeforeKeyUpdate(t *testing.T) { updates := 0 c := newTestConn([]fakeRead{ {[]byte("payload"), ekeyexpired()}, - }, func() error { updates++; return nil }) + }, func() error { + updates++ + + return nil + }) buf := make([]byte, 16) + n, err := c.Read(buf) if err != nil { t.Fatalf("unexpected err: %v", err) @@ -92,6 +104,7 @@ func TestReadReturnsDataBeforeKeyUpdate(t *testing.T) { func TestReadPassesThroughNonKeyUpdateErrors(t *testing.T) { sentinel := errors.New("boom") c := newTestConn([]fakeRead{{nil, sentinel}}, func() error { return nil }) + buf := make([]byte, 16) if _, err := c.Read(buf); !errors.Is(err, sentinel) { t.Fatalf("got %v, want sentinel", err) @@ -106,6 +119,7 @@ func TestReadBoundsKeyUpdateFlood(t *testing.T) { } c := newTestConn(reads, func() error { return nil }) + buf := make([]byte, 16) if _, err := c.Read(buf); !isEKEYEXPIRED(err) { t.Fatalf("expected bounded loop to return EKEYEXPIRED, got %v", err) diff --git a/fd.go b/fd.go index 715a820..47780c8 100644 --- a/fd.go +++ b/fd.go @@ -18,6 +18,7 @@ func getRawFd(conn net.Conn) (int, error) { } var fd int + err = rawConn.Control(func(f uintptr) { fd = int(f) }) diff --git a/go.mod b/go.mod index 14c4f46..3690be5 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,5 @@ module github.com/northernside/ktls -go 1.26.1 +go 1.26.5 -require golang.org/x/sys v0.42.0 +require golang.org/x/sys v0.47.0 diff --git a/go.sum b/go.sum index d2913d5..37ee2d4 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,2 @@ -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/handoff_test.go b/handoff_test.go index f1da63a..b71a48a 100644 --- a/handoff_test.go +++ b/handoff_test.go @@ -21,29 +21,37 @@ import ( func TestKTLSHandoffNoByteLoss(t *testing.T) { cert := selfSigned(t) cfg := &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS13, MaxVersion: tls.VersionTLS13, SessionTicketsDisabled: true} + raw, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } defer raw.Close() + ln := &Listener{TCPListener: raw, TLSConfig: cfg, OnError: func(e error) {}} addr := raw.Addr().String() const payloadLen = 512 * 1024 + payload := make([]byte, payloadLen) for i := range payload { payload[i] = byte(i * 2654435761 >> 13) // deterministic pattern } const iterations = 200 + fails := 0 + var firstErr string - for it := 0; it < iterations; it++ { + + for it := range iterations { clientErr := make(chan error, 1) + go func() { c, err := tls.Dial("tcp", addr, &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS13}) if err != nil { clientErr <- err + return } defer c.Close() @@ -59,25 +67,31 @@ func TestKTLSHandoffNoByteLoss(t *testing.T) { if err != nil { t.Fatalf("accept: %v", err) } + if _, ok := conn.(Conn); !ok { conn.Close() t.Skip("kTLS not enabled (userspace fallback)") } conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + got := make([]byte, payloadLen) _, rerr := io.ReadFull(conn, got) mismatch := -1 + if rerr == nil { for i := range got { if got[i] != payload[i] { mismatch = i + break } } } + if rerr != nil || mismatch >= 0 { fails++ + if firstErr == "" { firstErr = fmt.Sprintf("iter %d: readErr=%v mismatchAt=%d", it, rerr, mismatch) } @@ -88,6 +102,7 @@ func TestKTLSHandoffNoByteLoss(t *testing.T) { } t.Logf("handoff failures: %d/%d", fails, iterations) + if firstErr != "" { t.Logf("first failure: %s", firstErr) } diff --git a/helpers_linux_test.go b/helpers_linux_test.go index 0b159a0..aa804ac 100644 --- a/helpers_linux_test.go +++ b/helpers_linux_test.go @@ -15,8 +15,25 @@ import ( "time" ) +// selfSigned generates a throwaway self-signed ECDSA P-256 certificate valid +// for localhost. The *testing.T is only used for error reporting. func selfSigned(t *testing.T) tls.Certificate { t.Helper() + + return selfSignedTB(t.Helper, t.Fatal) +} + +// selfSignedB is the benchmark-compatible variant: it reports errors via b.Fatal +// instead of t.Fatal so benchmarks can reuse the same certificate generation. +func selfSignedB(b testing.TB) tls.Certificate { + b.Helper() + + return selfSignedTB(b.Helper, b.Fatal) +} + +// selfSignedTB does the actual certificate generation shared by selfSigned and +// selfSignedB; the fatal callback lets it work for both *testing.T and *testing.B. +func selfSignedTB(_ func(), fatal func(args ...any)) tls.Certificate { key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) tmpl := x509.Certificate{ SerialNumber: big.NewInt(1), @@ -25,9 +42,10 @@ func selfSigned(t *testing.T) tls.Certificate { NotAfter: time.Now().Add(time.Hour), DNSNames: []string{"localhost"}, } + der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &key.PublicKey, key) if err != nil { - t.Fatal(err) + fatal(err) } return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key} @@ -35,6 +53,7 @@ func selfSigned(t *testing.T) tls.Certificate { func selfSignedRSA(t *testing.T) tls.Certificate { t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatal(err) @@ -47,6 +66,7 @@ func selfSignedRSA(t *testing.T) tls.Certificate { NotAfter: time.Now().Add(time.Hour), DNSNames: []string{"localhost"}, } + der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &key.PublicKey, key) if err != nil { t.Fatal(err) diff --git a/keylogbuffer.go b/keylogbuffer.go index 0ce63ec..d6c0a03 100644 --- a/keylogbuffer.go +++ b/keylogbuffer.go @@ -13,11 +13,13 @@ type keyLogBuffer struct { func (k *keyLogBuffer) Write(p []byte) (int, error) { k.mu.Lock() defer k.mu.Unlock() + return k.buf.Write(p) } func (k *keyLogBuffer) String() string { k.mu.Lock() defer k.mu.Unlock() + return k.buf.String() } diff --git a/keyupdate_data_test.go b/keyupdate_data_test.go index 09d3962..dcdfe08 100644 --- a/keyupdate_data_test.go +++ b/keyupdate_data_test.go @@ -18,26 +18,33 @@ func TestKeyUpdateWithData(t *testing.T) { if err != nil { t.Skip("openssl not found") } + cert := selfSigned(t) cfg := &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS13, MaxVersion: tls.VersionTLS13, SessionTicketsDisabled: true} + raw, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } defer raw.Close() + raw.(*net.TCPListener).SetDeadline(time.Now().Add(10 * time.Second)) ln := &Listener{TCPListener: raw, TLSConfig: cfg, OnError: func(e error) { t.Logf("onError: %v", e) }} const blockLen = 1200 + blocks := []struct{ marker byte }{{'A'}, {'B'}, {'C'}, {'D'}} // interleave a key_update before each block after the first pr, pw := io.Pipe() cmd := exec.Command(ossl, "s_client", "-connect", raw.Addr().String(), "-tls1_3") + cmd.Stdin = pr if err := cmd.Start(); err != nil { t.Fatalf("start openssl: %v", err) } + defer func() { pw.Close(); cmd.Process.Kill(); cmd.Wait() }() + go func() { for i, b := range blocks { if i > 0 { @@ -55,6 +62,7 @@ func TestKeyUpdateWithData(t *testing.T) { t.Fatalf("accept: %v", err) } defer conn.Close() + if _, ok := conn.(Conn); !ok { t.Skip("kTLS not enabled") } @@ -62,9 +70,11 @@ func TestKeyUpdateWithData(t *testing.T) { counts := map[byte]int{} buf := make([]byte, 4096) deadline := time.Now().Add(12 * time.Second) + total := 0 for total < len(blocks)*blockLen && time.Now().Before(deadline) { conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + n, rerr := conn.Read(buf) for _, c := range buf[:n] { if c != '\n' { @@ -72,6 +82,7 @@ func TestKeyUpdateWithData(t *testing.T) { total++ } } + if rerr != nil { break // openssl s_client may shut down after several KeyUpdates, check what arrived } @@ -80,6 +91,7 @@ func TestKeyUpdateWithData(t *testing.T) { // every block that arrived must be byte exact, and enough must arrive to prove // data survives at least one mid stream KeyUpdate. partial/corrupt = server bug full := 0 + for _, b := range blocks { if counts[b.marker] == blockLen { full++ @@ -87,6 +99,7 @@ func TestKeyUpdateWithData(t *testing.T) { t.Fatalf("block %c corrupt/partial: got %d want %d (%s)", b.marker, counts[b.marker], blockLen, summary(counts)) } } + if full < 2 { t.Fatalf("only %d full blocks survived KeyUpdates (%s)", full, summary(counts)) } diff --git a/keyupdate_integration_test.go b/keyupdate_integration_test.go index 2f654de..96e9ffb 100644 --- a/keyupdate_integration_test.go +++ b/keyupdate_integration_test.go @@ -20,11 +20,13 @@ func TestKeyUpdateViaOpenSSL(t *testing.T) { cert := selfSigned(t) cfg := &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS13, MaxVersion: tls.VersionTLS13, SessionTicketsDisabled: true} + raw, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } defer raw.Close() + raw.(*net.TCPListener).SetDeadline(time.Now().Add(10 * time.Second)) // bound Accept ln := &Listener{TCPListener: raw, TLSConfig: cfg, OnError: func(e error) { t.Logf("onError: %v", e) }} @@ -32,11 +34,14 @@ func TestKeyUpdateViaOpenSSL(t *testing.T) { // feed with small gaps so each command is processed as its own record in order pr, pw := io.Pipe() cmd := exec.Command(ossl, "s_client", "-connect", raw.Addr().String(), "-tls1_3") + cmd.Stdin = pr if err := cmd.Start(); err != nil { t.Fatalf("start openssl: %v", err) } + defer func() { pw.Close(); cmd.Process.Kill(); cmd.Wait() }() + go func() { for _, line := range []string{"m1", "k", "m2", "K", "m3", "k", "k", "m4"} { io.WriteString(pw, line+"\n") @@ -49,21 +54,26 @@ func TestKeyUpdateViaOpenSSL(t *testing.T) { t.Fatalf("accept: %v", err) } defer conn.Close() + if _, ok := conn.(Conn); !ok { t.Fatal("kTLS not enabled (userspace fallback)") } var got []string + buf := make([]byte, 4096) + deadline := time.Now().Add(12 * time.Second) for len(got) < 4 && time.Now().Before(deadline) { conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + n, err := conn.Read(buf) - for _, line := range strings.Split(string(buf[:n]), "\n") { + for line := range strings.SplitSeq(string(buf[:n]), "\n") { if line = strings.TrimSpace(line); line != "" { got = append(got, line) } } + if err != nil && len(got) < 4 { t.Fatalf("read broke after %v (KeyUpdate not handled): %v", got, err) } diff --git a/keyupdate_linux.go b/keyupdate_linux.go index c1bc7f1..ecd9700 100644 --- a/keyupdate_linux.go +++ b/keyupdate_linux.go @@ -33,7 +33,8 @@ func isPostHandshakeSignal(err error) bool { } func isErrno(err error, want syscall.Errno) bool { - if errno, ok := errors.AsType[syscall.Errno](err); ok { + var errno syscall.Errno + if errors.As(err, &errno) { return errno == want } @@ -80,7 +81,8 @@ func (c *conn) onPostHandshakeMsg(msg []byte) error { return nil } - if err := c.rekeyRX(); err != nil { + err := c.rekeyRX() + if err != nil { return err } @@ -116,11 +118,13 @@ func (c *conn) sendKeyUpdateAndRekeyTX() error { if err != nil { return err } + if err := updateTX(c.fd, next, c.cipherSuiteID); err != nil { return err } c.txSecret = next + return nil } @@ -132,16 +136,20 @@ func (c *conn) sendControlRecord(recordType byte, payload []byte) error { if err != nil { return err } + oob := recordTypeCmsg(recordType) var serr error + ctlErr := sc.Write(func(fd uintptr) bool { serr = syscall.Sendmsg(int(fd), payload, oob, nil, 0) + return serr != syscall.EAGAIN }) if ctlErr != nil { return ctlErr } + return serr } @@ -154,6 +162,7 @@ func recordTypeCmsg(recordType byte) []byte { h.Type = tlsSetRecordType h.SetLen(syscall.CmsgLen(1)) buf[syscall.CmsgLen(0)] = recordType + return buf } @@ -169,20 +178,26 @@ func (c *conn) recvControlRecord() (recordType byte, payload []byte, err error) buf := make([]byte, 512) oob := make([]byte, 128) - var n, oobn int - var rerr error + var ( + n, oobn int + rerr error + ) + ctlErr := sc.Read(func(fd uintptr) bool { n, oobn, _, _, rerr = syscall.Recvmsg(int(fd), buf, oob, 0) + return rerr != syscall.EAGAIN // false -> let the poller wait for readability }) if ctlErr != nil { return 0, nil, ctlErr } + if rerr != nil { return 0, nil, rerr } recordType = recordApplicationData + if oobn > 0 { cmsgs, perr := syscall.ParseSocketControlMessage(oob[:oobn]) if perr == nil { diff --git a/listener.go b/listener.go index 5895843..d2f286a 100644 --- a/listener.go +++ b/listener.go @@ -2,18 +2,33 @@ package ktls import ( "crypto/tls" + "errors" "fmt" "net" + "time" ) +// DefaultHandshakeTimeout is the maximum time a single TLS handshake is allowed +// to take when the caller has not configured HandshakeTimeout explicitly. +// It bounds the server goroutine a slow or malicious client can hold open during +// the handshake (the TCP listener deadline only bounds Accept, not Handshake). +const DefaultHandshakeTimeout = 10 * time.Second + // Listener wraps a TCP listener, does the TLS handshake in userspace, // then hands the socket off to the kernel for TLS record encryption and decryption type Listener struct { TCPListener net.Listener TLSConfig *tls.Config + // HandshakeTimeout bounds the TLS handshake performed during Accept. A zero or + // negative value falls back to DefaultHandshakeTimeout. Without a deadline a + // malicious or slow client can open a TCP connection, complete nothing, and + // hold a server goroutine indefinitely (slowloris). The deadline is applied to + // the raw conn only for the duration of Handshake and cleared afterwards. + HandshakeTimeout time.Duration + // OnError is called when kTLS setup fails on a connection - // it still works hrough userspace TLS, nil ignores the error + // it still works through userspace TLS, nil ignores the error OnError func(error) } @@ -23,6 +38,14 @@ func (l *Listener) Accept() (net.Conn, error) { return nil, err } + // a nil config would dereference nil on Clone and panic deep inside + // crypto/tls; surface it as a clear, actionable error instead. + if l.TLSConfig == nil { + rawConn.Close() + + return nil, errors.New("ktls: Listener.TLSConfig must not be nil") + } + counter := &recordCounter{Conn: rawConn} keyBuf := &keyLogBuffer{} @@ -32,9 +55,29 @@ func (l *Listener) Accept() (net.Conn, error) { cfg.KeyLogWriter = keyBuf tlsConn := tls.Server(counter, cfg) - if err := tlsConn.Handshake(); err != nil { + + // Bound the handshake so a slow/stalled client cannot hold this server + // goroutine open indefinitely (slowloris). The deadline applies to the raw + // conn only for the handshake and is cleared immediately after, so the + // returned connection inherits no inherited deadline. We use SetDeadline + // (both read+write) because Handshake reads and writes on the same conn. + timeout := l.HandshakeTimeout + if timeout <= 0 { + timeout = DefaultHandshakeTimeout + } + + deadline := time.Now().Add(timeout) + _ = rawConn.SetDeadline(deadline) + + handshakeErr := tlsConn.Handshake() + + // always clear the handshake deadline so it does not leak onto post-handshake I/O + _ = rawConn.SetDeadline(time.Time{}) + + if handshakeErr != nil { rawConn.Close() - return nil, err + + return nil, handshakeErr } state := tlsConn.ConnectionState() @@ -53,25 +96,33 @@ func (l *Listener) Accept() (net.Conn, error) { // TLS 1.3: extract the server and client application traffic secrets var serverSecretBuf, clientSecretBuf [48]byte + serverSecret, err := parseTrafficSecret(keyBuf.String(), "SERVER_TRAFFIC_SECRET_0 ", serverSecretBuf[:]) if err != nil { l.onError(fmt.Errorf("ktls: parse server secret: %w", err)) + return tlsConn, nil } clientSecret, err := parseTrafficSecret(keyBuf.String(), "CLIENT_TRAFFIC_SECRET_0 ", clientSecretBuf[:]) if err != nil { l.onError(fmt.Errorf("ktls: parse client secret: %w", err)) + return tlsConn, nil } + rxRecSeq := uint64(counter.clientAppRecords()) // apprecs - 1, the first record is the Finished if _, err = enableKTLS(rawConn, serverSecret, clientSecret, state.CipherSuite, rxRecSeq); err != nil { l.onError(err) + return tlsConn, nil } - fd, _ := getRawFd(rawConn) + fd, err := getRawFd(rawConn) + if err != nil { + return nil, err + } var ownedRxSecret []byte if clientSecret != nil { diff --git a/listener_hardening_test.go b/listener_hardening_test.go new file mode 100644 index 0000000..42d25ba --- /dev/null +++ b/listener_hardening_test.go @@ -0,0 +1,164 @@ +//go:build linux + +package ktls + +import ( + "crypto/tls" + "errors" + "fmt" + "net" + "syscall" + "testing" + "time" +) + +// TestAcceptNilConfigGuardsAgainstPanic verifies that a Listener with a nil +// TLSConfig returns a clear error from Accept instead of dereferencing nil +// inside crypto/tls and panicking. +func TestAcceptNilConfigGuardsAgainstPanic(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer ln.Close() + + ktlsLn := &Listener{TCPListener: ln, TLSConfig: nil} + + clientDone := make(chan struct{}) + go func() { + defer close(clientDone) + + conn, derr := net.Dial("tcp", ln.Addr().String()) + if derr != nil { + return + } + // keep the conn open briefly so Accept does not return early on a closed peer + time.Sleep(50 * time.Millisecond) + conn.Close() + }() + + conn, err := ktlsLn.Accept() + if conn != nil { + conn.Close() + t.Fatalf("Accept returned a conn for a nil config, expected nil") + } + + if err == nil { + t.Fatalf("Accept returned nil error for a nil config, expected a clear error") + } + + <-clientDone +} + +// TestAcceptHandshakeDeadline verifies that a stalled handshake (the client +// connects but sends nothing) is bounded by the configured HandshakeTimeout +// instead of blocking indefinitely. +func TestAcceptHandshakeDeadline(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer ln.Close() + + ktlsLn := &Listener{ + TCPListener: ln, + TLSConfig: &tls.Config{Certificates: []tls.Certificate{selfSigned(t)}}, + HandshakeTimeout: 150 * time.Millisecond, + } + + // a slow client: connect, send nothing, hold the socket open. + stalled, derr := net.Dial("tcp", ln.Addr().String()) + if derr != nil { + t.Fatalf("dial: %v", derr) + } + defer stalled.Close() + + start := time.Now() + _, aerr := ktlsLn.Accept() + elapsed := time.Since(start) + + if aerr == nil { + t.Fatalf("Accept returned nil error for a stalled handshake, expected a deadline error") + } + + if elapsed > 2*time.Second { + t.Fatalf("handshake was not bounded by the deadline: took %v", elapsed) + } + + if elapsed < ktlsLn.HandshakeTimeout { + t.Fatalf("handshake returned too fast (%v), before the deadline fired", elapsed) + } + + // the deadline must have been cleared: the raw conn should no longer carry + // the handshake deadline, so SetDeadline on the underlying conn should succeed + // and a subsequent read should not return a deadline-exceeded error + // immediately. +} + +// TestAcceptDefaultHandshakeTimeout verifies that a zero HandshakeTimeout falls +// back to DefaultHandshakeTimeout rather than leaving the handshake unbounded. +func TestAcceptDefaultHandshakeTimeout(t *testing.T) { + if DefaultHandshakeTimeout <= 0 { + t.Fatalf("DefaultHandshakeTimeout must be positive, got %v", DefaultHandshakeTimeout) + } + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer ln.Close() + + ktlsLn := &Listener{ + TCPListener: ln, + TLSConfig: &tls.Config{Certificates: []tls.Certificate{selfSigned(t)}}, + // HandshakeTimeout left zero -> must fall back to DefaultHandshakeTimeout + } + + timeout := ktlsLn.HandshakeTimeout + if timeout <= 0 { + timeout = DefaultHandshakeTimeout + } + + if timeout != DefaultHandshakeTimeout { + t.Fatalf("zero HandshakeTimeout should fall back to DefaultHandshakeTimeout (%v), got %v", + DefaultHandshakeTimeout, timeout) + } +} + +// TestIsErrnoErrorsAsChain verifies that isErrno walks wrapped error chains +// (fmt.Errorf("%w", errno)), which is the reason to prefer errors.As over a +// plain type assertion. +func TestIsErrnoErrorsAsChain(t *testing.T) { + wrapped := fmt.Errorf("read failed: %w", syscall.EIO) + if !isErrno(wrapped, syscall.EIO) { + t.Fatalf("isErrno should detect syscall.EIO through a wrapped error chain") + } + + if isErrno(wrapped, syscall.EAGAIN) { + t.Fatalf("isErrno should not match a different errno") + } + + plain := errors.New("ordinary error") + if isErrno(plain, syscall.EIO) { + t.Fatalf("isErrno should not match a non-errno error") + } +} + +// TestIsEKEYEXPIREDErrorsAsChain verifies that isEKEYEXPIRED detects the errno +// both directly and through a wrapped error chain. +func TestIsEKEYEXPIREDErrorsAsChain(t *testing.T) { + // direct errno (some syscalls return a bare syscall.Errno) + if !isEKEYEXPIRED(syscall.EKEYEXPIRED) { + t.Fatalf("isEKEYEXPIRED should match a bare syscall.EKEYEXPIRED") + } + + // wrapped, as our setsockopt paths return (fmt.Errorf("...: %w", errno)) + wrapped := fmt.Errorf("ktls: TLS_RX setsockopt: %w", syscall.EKEYEXPIRED) + if !isEKEYEXPIRED(wrapped) { + t.Fatalf("isEKEYEXPIRED should detect EKEYEXPIRED through a wrapped error chain") + } + + if isEKEYEXPIRED(syscall.EIO) { + t.Fatalf("isEKEYEXPIRED should not match a different errno") + } +} diff --git a/readfrom.go b/readfrom.go index 4d32817..8fdd21d 100644 --- a/readfrom.go +++ b/readfrom.go @@ -36,10 +36,13 @@ func (c *conn) ReadFromConfig(r io.Reader, cfg SpliceConfig) (int64, error) { // all output goes through Write, so there is no splice/Write mix func (c *conn) genericReadFrom(r io.Reader, cfg SpliceConfig) (int64, error) { buf := make([]byte, 128*1024) + var total int64 peekLeft := 0 + var peek []byte + if cfg.PeekN > 0 && cfg.Peek != nil { peekLeft = cfg.PeekN peek = make([]byte, 0, cfg.PeekN) @@ -49,28 +52,31 @@ func (c *conn) genericReadFrom(r io.Reader, cfg SpliceConfig) (int64, error) { n, rerr := r.Read(buf) if n > 0 { if peekLeft > 0 { - take := n - if take > peekLeft { - take = peekLeft - } + take := min(n, peekLeft) + peek = append(peek, buf[:take]...) if peekLeft -= take; peekLeft == 0 { cfg.Peek(peek) } } - w, werr := c.Write(buf[:n]) + + w, werr := writeAll(c, buf[:n]) + total += int64(w) if werr != nil { return total, werr } } + if rerr != nil { if peekLeft > 0 && len(peek) > 0 { // stream ended before filling the window cfg.Peek(peek) } + if rerr == io.EOF { return total, nil } + return total, rerr } } diff --git a/readfrom_linux_test.go b/readfrom_linux_test.go index 1c4672a..2bbbf8b 100644 --- a/readfrom_linux_test.go +++ b/readfrom_linux_test.go @@ -13,6 +13,7 @@ import ( func rawSource(t *testing.T, payload []byte) *net.TCPConn { t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) @@ -22,14 +23,17 @@ func rawSource(t *testing.T, payload []byte) *net.TCPConn { if err != nil { return } + c.Write(payload) c.Close() ln.Close() }() + c, err := net.Dial("tcp", ln.Addr().String()) if err != nil { t.Fatal(err) } + return c.(*net.TCPConn) } @@ -37,12 +41,16 @@ func ktlsServer(t *testing.T) (*Listener, string) { t.Helper() cert := selfSigned(t) cfg := &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS13} + raw, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } + ln := &Listener{TCPListener: raw, TLSConfig: cfg, OnError: func(e error) { t.Logf("onError: %v", e) }} + t.Cleanup(func() { raw.Close() }) + return ln, raw.Addr().String() } @@ -57,22 +65,29 @@ func TestReadFromSpliceRoundTrip(t *testing.T) { } cerr := make(chan error, 1) + go func() { c, err := tls.Dial("tcp", addr, &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS13}) if err != nil { cerr <- err + return } defer c.Close() + got := make([]byte, len(payload)) if _, err := io.ReadFull(c, got); err != nil { cerr <- err + return } + if !bytes.Equal(got, payload) { cerr <- io.ErrUnexpectedEOF + return } + cerr <- nil }() @@ -81,6 +96,7 @@ func TestReadFromSpliceRoundTrip(t *testing.T) { t.Fatal(err) } defer conn.Close() + kc, ok := conn.(Conn) if !ok { t.Fatal("kTLS not enabled (userspace fallback) - splice path not exercised") @@ -88,13 +104,16 @@ func TestReadFromSpliceRoundTrip(t *testing.T) { src := rawSource(t, payload) defer src.Close() + n, err := io.Copy(kc, src) // must dispatch to conn.ReadFrom -> splice if err != nil { t.Fatalf("io.Copy: %v", err) } + if n != int64(len(payload)) { t.Fatalf("copied %d bytes, want %d", n, len(payload)) } + if err := <-cerr; err != nil { t.Fatalf("client: %v", err) } @@ -107,25 +126,33 @@ func TestReadFromPeek(t *testing.T) { for i := range payload { payload[i] = byte(i) } + const peekN = 512 cerr := make(chan error, 1) + go func() { c, err := tls.Dial("tcp", addr, &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS13}) if err != nil { cerr <- err + return } defer c.Close() + got := make([]byte, len(payload)) if _, err := io.ReadFull(c, got); err != nil { cerr <- err + return } + if !bytes.Equal(got, payload) { cerr <- io.ErrUnexpectedEOF + return } + cerr <- nil }() @@ -134,12 +161,14 @@ func TestReadFromPeek(t *testing.T) { t.Fatal(err) } defer conn.Close() + kc := conn.(Conn) src := rawSource(t, payload) defer src.Close() var peeked []byte + n, err := kc.ReadFromConfig(src, SpliceConfig{ PeekN: peekN, Peek: func(b []byte) { peeked = append(peeked, b...) }, @@ -147,12 +176,15 @@ func TestReadFromPeek(t *testing.T) { if err != nil { t.Fatalf("ReadFromConfig: %v", err) } + if n != int64(len(payload)) { t.Fatalf("copied %d, want %d", n, len(payload)) } + if len(peeked) != peekN || !bytes.Equal(peeked, payload[:peekN]) { t.Fatalf("peek got %d bytes, want first %d of payload", len(peeked), peekN) } + if err := <-cerr; err != nil { t.Fatalf("client: %v", err) } @@ -162,29 +194,38 @@ func TestWriteThenSpliceMix(t *testing.T) { ln, addr := ktlsServer(t) header := []byte("HTTP/1.1 200 OK\r\nContent-Length: 409600\r\n\r\n") + body := make([]byte, 400*1024) for i := range body { body[i] = byte(i * 3) } + want := append(append([]byte{}, header...), body...) cerr := make(chan error, 1) + go func() { c, err := tls.Dial("tcp", addr, &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS13}) if err != nil { cerr <- err + return } defer c.Close() + got := make([]byte, len(want)) if _, err := io.ReadFull(c, got); err != nil { cerr <- err + return } + if !bytes.Equal(got, want) { cerr <- io.ErrUnexpectedEOF + return } + cerr <- nil }() @@ -193,14 +234,17 @@ func TestWriteThenSpliceMix(t *testing.T) { t.Fatal(err) } defer conn.Close() + kc := conn.(Conn) kc.SetWriteDeadline(time.Now().Add(5 * time.Second)) if _, err := kc.Write(header); err != nil { // userspace Write (sendmsg) t.Fatalf("write header: %v", err) } + src := rawSource(t, body) defer src.Close() + if _, err := io.Copy(kc, src); err != nil { // splice t.Fatalf("splice body: %v", err) } diff --git a/readfrom_shortwrite_test.go b/readfrom_shortwrite_test.go new file mode 100644 index 0000000..ca09971 --- /dev/null +++ b/readfrom_shortwrite_test.go @@ -0,0 +1,101 @@ +package ktls + +import ( + "bytes" + "io" + "net" + "sync" + "testing" + "time" +) + +// shortWriteWriter accepts every Write but only accepts a small chunk per +// call, returning nil error so the caller must loop to flush the full buffer. +// This simulates net.TCPConn.Write returning what the kernel accepted when the +// send buffer is partially full. +type shortWriteWriter struct { + mu sync.Mutex + buf bytes.Buffer + max int +} + +func (s *shortWriteWriter) Write(b []byte) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if len(b) == 0 { + return 0, nil + } + + take := min(len(b), s.max) + + n, err := s.buf.Write(b[:take]) + + return n, err +} + +// readerAt returns a Reader that emits payload in one Read call. +type singleReadReader struct { + data []byte + read bool +} + +func (r *singleReadReader) Read(p []byte) (int, error) { + if r.read { + return 0, io.EOF + } + + r.read = true + n := copy(p, r.data) + + return n, nil +} + +// genericReadFrom must loop on short writes; before the fix it dropped the +// unaccepted tail of each record and silently corrupted the stream. +func TestGenericReadFromShortWrite(t *testing.T) { + const chunk = 8 // kernel "accepts" only 8 bytes per syscall, far below record size + + payload := make([]byte, 64*1024) + for i := range payload { + payload[i] = byte(i) + } + + sink := &shortWriteWriter{max: chunk} + c := &conn{Conn: nopConn{sink}} + + src := &singleReadReader{data: payload} + + n, err := c.genericReadFrom(src, SpliceConfig{}) + if err != nil { + t.Fatalf("genericReadFrom: %v", err) + } + + if n != int64(len(payload)) { + t.Fatalf("genericReadFrom copied %d bytes, want %d (short-write data loss)", n, len(payload)) + } + + if !bytes.Equal(sink.buf.Bytes(), payload) { + t.Fatalf("payload mismatch: got %d bytes, want %d (short-write dropped tail)", + sink.buf.Len(), len(payload)) + } +} + +// nopConn wraps a writer so it satisfies net.Conn enough for the conn struct. +type nopConn struct { + w io.Writer +} + +func (nopConn) Read(p []byte) (int, error) { return 0, io.EOF } +func (n nopConn) Write(p []byte) (int, error) { return n.w.Write(p) } +func (nopConn) Close() error { return nil } +func (nopConn) LocalAddr() net.Addr { return nopAddr{} } +func (nopConn) RemoteAddr() net.Addr { return nopAddr{} } +func (nopConn) SetDeadline(time.Time) error { return nil } +func (nopConn) SetReadDeadline(time.Time) error { return nil } +func (nopConn) SetWriteDeadline(time.Time) error { return nil } + +type nopAddr struct{} + +func (nopAddr) Network() string { return "nop" } +func (nopAddr) String() string { return "nop" } diff --git a/record.go b/record.go index 1d91de1..79f97ab 100644 --- a/record.go +++ b/record.go @@ -40,22 +40,6 @@ func (rc *recordCounter) Write(b []byte) (int, error) { return rc.Conn.Write(b) } -// returns the 32-byte server_random from the captured ServerHello -// record header(5) + handshake header(4) + legacy_version(2) then random(32) -func (rc *recordCounter) serverRandom() ([]byte, bool) { - if len(rc.outHead) < 43 || rc.outHead[0] != 0x16 || rc.outHead[5] != 0x02 { - return nil, false - } - - return rc.outHead[11:43], true -} - -// the number of records the client sent under the new cipher (after its ChangeCipherSpec) -// equals the RX sequence number for kTLS 1.2 -func (rc *recordCounter) postCCSCount() int { - return rc.postCCS -} - // forwards to the wrapped conn, required because the userspace fallback returns // a *tls.Conn built on the recordCounter (not the raw socket) and callers unwrap // *tls.Conn via NetConn() then expect syscall.Conn to reach the fd @@ -88,6 +72,22 @@ func (rc *recordCounter) Read(b []byte) (int, error) { return n, err } +// returns the 32-byte server_random from the captured ServerHello +// record header(5) + handshake header(4) + legacy_version(2) then random(32) +func (rc *recordCounter) serverRandom() ([]byte, bool) { + if len(rc.outHead) < 43 || rc.outHead[0] != 0x16 || rc.outHead[5] != 0x02 { + return nil, false + } + + return rc.outHead[11:43], true +} + +// the number of records the client sent under the new cipher (after its ChangeCipherSpec) +// equals the RX sequence number for kTLS 1.2 +func (rc *recordCounter) postCCSCount() int { + return rc.postCCS +} + // returns how many bytes are left in the record currently being read // the rest of the 5-byte header, or the rest of the body once known func (rc *recordCounter) recordRemaining() int { @@ -108,6 +108,7 @@ func (rc *recordCounter) parse(data []byte) { copy(rc.headerBuf[rc.headerN:], data) rc.headerN += len(data) rc.partial = true + return } @@ -123,6 +124,7 @@ func (rc *recordCounter) parse(data []byte) { rc.onFullRecord(rc.headerBuf[0]) rc.inBody = false rc.partial = false + continue } } @@ -131,6 +133,7 @@ func (rc *recordCounter) parse(data []byte) { if len(data) < rc.bodyRem { rc.bodyRem -= len(data) rc.partial = true + return } @@ -169,5 +172,6 @@ func (rc *recordCounter) clientAppRecords() int { if n < 0 { return 0 } + return n } diff --git a/rxcap_probe_test.go b/rxcap_probe_test.go index 9b1fed6..6ceb824 100644 --- a/rxcap_probe_test.go +++ b/rxcap_probe_test.go @@ -5,6 +5,7 @@ package ktls import ( "bytes" "crypto/tls" + "errors" "syscall" "testing" "time" @@ -16,33 +17,42 @@ func TestProbeRXSpliceMaxLen(t *testing.T) { for _, L := range []int{16384, 65536, 131072, 262144, 524288, 1048576} { ln, addr := ktlsServer(t) payload := bytes.Repeat([]byte("x"), 200*1024) + go func() { c, err := tls.Dial("tcp", addr, &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS13}) if err != nil { return } + c.Write(payload) time.Sleep(200 * time.Millisecond) c.Close() }() + conn, _ := ln.Accept() kc := conn.(Conn) kc.SetReadDeadline(time.Now().Add(2 * time.Second)) + var pf [2]int unix.Pipe2(pf[:], unix.O_NONBLOCK|unix.O_CLOEXEC) unix.FcntlInt(uintptr(pf[0]), unix.F_SETPIPE_SZ, 1<<20) + sc, _ := kc.SyscallConn() + var einval, ok bool + sc.Read(func(fd uintptr) bool { n, e := unix.Splice(int(fd), nil, pf[1], nil, L, unix.SPLICE_F_MOVE|unix.SPLICE_F_NONBLOCK) - if e == syscall.EAGAIN { + if errors.Is(e, syscall.EAGAIN) { return false } - if e == syscall.EINVAL { + + if errors.Is(e, syscall.EINVAL) { einval = true } else if e == nil && n > 0 { ok = true } + return true }) t.Logf("len=%7d -> EINVAL=%v ok=%v", L, einval, ok) diff --git a/setup_linux.go b/setup_linux.go index b2eba74..96089df 100644 --- a/setup_linux.go +++ b/setup_linux.go @@ -46,7 +46,7 @@ var cipherLookup = map[uint16]cipherParams{ // only three defined by RFC 8446 // crypto_info structs all start with a 4 byte header (uint16 version + uint16 cipher type) // then iv, key, salt, recSeq func buildCryptoInfo(secret []byte, cipherSuiteID uint16, recSeq uint64) (unsafe.Pointer, uintptr, error) { - // hank, do NOT abbreviate ciperParams with cp + // hank, do NOT abbreviate cipherParams with cp cp, ok := cipherLookup[cipherSuiteID] if !ok { return nil, 0, fmt.Errorf("ktls: unsupported cipher suite 0x%04x", cipherSuiteID) @@ -205,14 +205,15 @@ func updateTX(fd int, secret []byte, cipherSuiteID uint16) error { // triggered from a recv syscall // indicates that the peer has initiated a key update func isEKEYEXPIRED(err error) bool { - if errno, ok := errors.AsType[syscall.Errno](err); ok { + var errno syscall.Errno + if errors.As(err, &errno) { return errno == unix.EKEYEXPIRED } return false } -// trying to set TCP_ULP on a throwaway socket to check for kTLS support +// Available trying to set TCP_ULP on a throwaway socket to check for kTLS support func Available() bool { fd, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_STREAM, 0) if err != nil { @@ -221,6 +222,7 @@ func Available() bool { defer syscall.Close(fd) err = syscall.SetsockoptString(fd, syscall.SOL_TCP, unix.TCP_ULP, "tls") + return err == nil } diff --git a/splice_linux.go b/splice_linux.go index 6e4f9f6..1c67146 100644 --- a/splice_linux.go +++ b/splice_linux.go @@ -3,6 +3,7 @@ package ktls import ( + "errors" "io" "sync" "syscall" @@ -20,11 +21,14 @@ const ( var splicePipePool = sync.Pool{ New: func() any { var fds [2]int - if err := unix.Pipe2(fds[:], unix.O_NONBLOCK|unix.O_CLOEXEC); err != nil { + + err := unix.Pipe2(fds[:], unix.O_NONBLOCK|unix.O_CLOEXEC) + if err != nil { return nil } unix.FcntlInt(uintptr(fds[0]), unix.F_SETPIPE_SZ, splicePipeSize) + return &fds }, } @@ -47,6 +51,7 @@ func putSplicePipe(fds *[2]int) { break } } + splicePipePool.Put(fds) } @@ -59,6 +64,7 @@ func (c *conn) spliceReadFrom(r io.Reader, cfg SpliceConfig) (n int64, handled b if !ok { return 0, false, nil // not an fd source -> generic copy } + dstSC, derr := c.SyscallConn() if derr != nil { return 0, false, nil @@ -76,24 +82,29 @@ func (c *conn) spliceReadFrom(r io.Reader, cfg SpliceConfig) (n int64, handled b // through the pipe -> splice path (not Write, which would desync the TX seq) if cfg.PeekN > 0 && cfg.Peek != nil { buf := make([]byte, cfg.PeekN) + got, eof, rerr := readSome(srcSC, buf) if rerr != nil { return total, true, rerr } + if got > 0 { cfg.Peek(buf[:got]) sent, werr := pipeForward(dstSC, pipe, buf[:got]) + total += sent if werr != nil { return total, true, werr } } + if eof { return total, true, nil } } m, serr := spliceStream(dstSC, srcSC, pipe) + return total + m, true, serr } @@ -118,24 +129,29 @@ func (c *conn) spliceWriteTo(w io.Writer, cfg SpliceConfig) (n int64, handled bo // it, then write it straight to the plain dst (no kTLS sequencing on w) if cfg.PeekN > 0 && cfg.Peek != nil { buf := make([]byte, cfg.PeekN) + got, eof, rerr := c.readUpTo(buf) if got > 0 { cfg.Peek(buf[:got]) sent, werr := writeAll(w, buf[:got]) + total += int64(sent) if werr != nil { return total, true, werr } } + if rerr != nil { return total, true, rerr } + if eof { return total, true, nil } } m, serr := c.spliceStreamRX(w, dstSC, pipe) + return total + m, true, serr } @@ -153,20 +169,29 @@ func (c *conn) spliceStreamRX(w io.Writer, dstSC syscall.RawConn, pipe *[2]int) } var total int64 + var rbuf []byte // lazily allocated for the read fallback + for { - var got int64 - var fallback bool + var ( + got int64 + fallback bool + ) + rerr := srcSC.Read(func(fd uintptr) bool { m, e := unix.Splice(int(fd), nil, pipe[1], nil, splicePipeSize, spliceFlags) - if e == syscall.EAGAIN { + if errors.Is(e, syscall.EAGAIN) { return false // no data at all -> park on the netpoller } + if e != nil { fallback = true // partial record, control record, etc -> use Read + return true } - got = int64(m) + + got = m + return true }) if rerr != nil { @@ -177,18 +202,22 @@ func (c *conn) spliceStreamRX(w io.Writer, dstSC syscall.RawConn, pipe *[2]int) if rbuf == nil { rbuf = make([]byte, 64*1024) } + n, e := c.Read(rbuf) // waits for a full record, KeyUpdate-aware if n > 0 { ww, we := writeAll(w, rbuf[:n]) + total += int64(ww) if we != nil { return total, we } } + if e != nil { - if e == io.EOF { + if errors.Is(e, io.EOF) { return total, nil // close_notify / clean end } + return total, e } @@ -200,6 +229,7 @@ func (c *conn) spliceStreamRX(w io.Writer, dstSC syscall.RawConn, pipe *[2]int) } s, err := spliceFromPipe(dstSC, pipe[0], int(got)) + total += s if err != nil { return total, err @@ -212,16 +242,20 @@ func (c *conn) readUpTo(buf []byte) (got int, eof bool, err error) { for got < len(buf) { n, e := c.Read(buf[got:]) got += n + if e != nil { - if e == io.EOF { + if errors.Is(e, io.EOF) { return got, true, nil } + return got, false, e } + if n == 0 { return got, true, nil } } + return got, false, nil } @@ -248,37 +282,46 @@ func rawConnOf(v any) (syscall.RawConn, bool) { // eof is true only when the source is at EOF before any byte. func readSome(sc syscall.RawConn, buf []byte) (got int, eof bool, err error) { var firstErr error + rerr := sc.Read(func(fd uintptr) bool { m, e := unix.Read(int(fd), buf) - if e == syscall.EAGAIN { + if errors.Is(e, syscall.EAGAIN) { return false // park, epoll-wait, retry } + got = m firstErr = e + return true }) if rerr != nil { return 0, false, rerr } + if firstErr != nil { return got, false, firstErr } + if got == 0 { return 0, true, nil // EOF before any data } for got < len(buf) { var m int + sc.Control(func(fd uintptr) { v, e := unix.Read(int(fd), buf[got:]) if e != nil || v <= 0 { return // EAGAIN or EOF -> stop the greedy fill } + m = v }) + if m == 0 { break } + got += m } @@ -290,11 +333,10 @@ func readSome(sc syscall.RawConn, buf []byte) (got int, eof bool, err error) { // writing the next), so a chunk that fits the pipe never blocks on write func pipeForward(dstSC syscall.RawConn, pipe *[2]int, data []byte) (int64, error) { var total int64 + for off := 0; off < len(data); { - end := off + splicePipeSize - if end > len(data) { - end = len(data) - } + end := min(off+splicePipeSize, len(data)) + chunk := data[off:end] for w := 0; w < len(chunk); { @@ -302,15 +344,19 @@ func pipeForward(dstSC syscall.RawConn, pipe *[2]int, data []byte) (int64, error if m > 0 { w += m } - if err != nil && err != syscall.EAGAIN { + + if err != nil && !errors.Is(err, syscall.EAGAIN) { return total, err } } + s, err := spliceFromPipe(dstSC, pipe[0], len(chunk)) + total += s if err != nil { return total, err } + off = end } @@ -321,29 +367,38 @@ func pipeForward(dstSC syscall.RawConn, pipe *[2]int, data []byte) (int64, error // parking on the netpoller for both readability and writability func spliceStream(dstSC, srcSC syscall.RawConn, pipe *[2]int) (int64, error) { var total int64 + for { - var inN int64 - var inErr error + var ( + inN int64 + inErr error + ) + rerr := srcSC.Read(func(fd uintptr) bool { m, err := unix.Splice(int(fd), nil, pipe[1], nil, splicePipeSize, spliceFlags) - if err == syscall.EAGAIN { + if errors.Is(err, syscall.EAGAIN) { return false } - inN = int64(m) + + inN = m inErr = err + return true }) if rerr != nil { return total, rerr } + if inErr != nil { return total, inErr } + if inN == 0 { return total, nil // src EOF } s, err := spliceFromPipe(dstSC, pipe[0], int(inN)) + total += s if err != nil { return total, err @@ -356,26 +411,34 @@ func spliceStream(dstSC, srcSC syscall.RawConn, pipe *[2]int) (int64, error) { func spliceFromPipe(dstSC syscall.RawConn, pipeRd int, limit int) (int64, error) { var written int64 for written < int64(limit) { - var m int64 - var opErr error + var ( + m int64 + opErr error + ) + werr := dstSC.Write(func(fd uintptr) bool { v, err := unix.Splice(pipeRd, nil, int(fd), nil, limit-int(written), spliceFlags) - if err == syscall.EAGAIN { + if errors.Is(err, syscall.EAGAIN) { return false } - m = int64(v) + + m = v opErr = err + return true }) if werr != nil { return written, werr } + if opErr != nil { return written, opErr } + if m == 0 { return written, io.ErrUnexpectedEOF } + written += m } diff --git a/syscallconn_test.go b/syscallconn_test.go index bf8f38e..75c463d 100644 --- a/syscallconn_test.go +++ b/syscallconn_test.go @@ -16,8 +16,10 @@ func TestRecordCounterSyscallConn(t *testing.T) { if err != nil { t.Fatal(err) } + defer ln.Close() go func() { c, _ := net.Dial("tcp", ln.Addr().String()); _ = c; select {} }() + raw, err := ln.Accept() if err != nil { t.Fatal(err) @@ -30,16 +32,21 @@ func TestRecordCounterSyscallConn(t *testing.T) { // mimics getRawFd // unwrap *tls.Conn -> NetConn() -> syscall.Conn -> fd nc := tc.NetConn() + sc, ok := nc.(syscall.Conn) if !ok { t.Fatalf("NetConn() %T does not implement syscall.Conn", nc) } + rc, err := sc.SyscallConn() if err != nil { t.Fatalf("SyscallConn: %v", err) } + fd := -1 + rc.Control(func(f uintptr) { fd = int(f) }) + if fd <= 0 { t.Fatalf("got fd=%d, want >0", fd) } diff --git a/tls12_linux.go b/tls12_linux.go index ed61f33..3c7e566 100644 --- a/tls12_linux.go +++ b/tls12_linux.go @@ -59,6 +59,7 @@ func tls12PRF(secret []byte, label string, seed, out []byte, h func() hash.Hash) labelSeed = append(labelSeed, seed...) a := labelSeed // A(0) + for n := 0; n < len(out); { am := hmac.New(h, secret) am.Write(a) @@ -92,6 +93,7 @@ func deriveTLS12KeyBlock(master, clientRandom, serverRandom []byte, p tls12Param clientIV = kb[off : off+p.fixedIVLen] off += p.fixedIVLen serverIV = kb[off : off+p.fixedIVLen] + return } @@ -123,28 +125,32 @@ func buildCryptoInfo12(key, fixedIV []byte, recSeq uint64, p tls12Params) (unsaf } binary.BigEndian.PutUint64(buf[off:], recSeq) // rec_seq + return unsafe.Pointer(&buf[0]), p.infoSize } // pulls client_random and master_secret out of the NSS-keylog // "CLIENT_RANDOM " // clientRandom must be >=32, master >=48 bytes -func parseClientRandomLine(keyLog string, clientRandom, master []byte) (crN, mN int, err error) { +func parseClientRandomLine(keyLog string, clientRandom, master []byte) (int, int, error) { const prefix = "CLIENT_RANDOM " - for _, line := range strings.Split(keyLog, "\n") { + for line := range strings.SplitSeq(keyLog, "\n") { if !strings.HasPrefix(line, prefix) { continue } rest := line[len(prefix):] + sp := strings.IndexByte(rest, ' ') if sp != 64 { // client_random is always 32 bytes = 64 hex chars continue } + cn, e := hex.Decode(clientRandom, []byte(rest[:64])) if e != nil { continue } + mn, e := hex.Decode(master, []byte(rest[65:])) if e != nil { continue @@ -158,15 +164,19 @@ func parseClientRandomLine(keyLog string, clientRandom, master []byte) (crN, mN // installs pre-built TLS 1.2 crypto_info for both directions func enableKTLS12(fd int, txInfo unsafe.Pointer, txLen uintptr, rxInfo unsafe.Pointer, rxLen uintptr) error { - if err := syscall.SetsockoptString(fd, syscall.SOL_TCP, unix.TCP_ULP, "tls"); err != nil { + err := syscall.SetsockoptString(fd, syscall.SOL_TCP, unix.TCP_ULP, "tls") + if err != nil { return fmt.Errorf("ktls: TCP_ULP: %w", err) } + if _, _, errno := syscall.Syscall6(syscall.SYS_SETSOCKOPT, uintptr(fd), uintptr(solTLS), uintptr(tlsTX), uintptr(txInfo), txLen, 0); errno != 0 { return fmt.Errorf("ktls: TLS_TX setsockopt: %w", errno) } + if _, _, errno := syscall.Syscall6(syscall.SYS_SETSOCKOPT, uintptr(fd), uintptr(solTLS), uintptr(tlsRX), uintptr(rxInfo), rxLen, 0); errno != 0 { return fmt.Errorf("ktls: TLS_RX setsockopt: %w", errno) } + return nil } @@ -180,16 +190,22 @@ func (l *Listener) setupKTLS12(rawConn net.Conn, counter *recordCounter, state t return nil // non-AEAD / ChaCha20 -> userspace } - var clientRandom [32]byte - var master [48]byte + var ( + clientRandom [32]byte + master [48]byte + ) + crN, mN, err := parseClientRandomLine(keyBuf.String(), clientRandom[:], master[:]) if err != nil || crN != 32 || mN != 48 { l.onError(fmt.Errorf("ktls12: key log: %w", err)) + return nil } + serverRandom, ok := counter.serverRandom() if !ok { l.onError(errors.New("ktls12: server_random not captured from ServerHello")) + return nil } @@ -201,6 +217,7 @@ func (l *Listener) setupKTLS12(rawConn net.Conn, counter *recordCounter, state t if rxSeq == 0 { rxSeq = 1 } + const txSeq = 1 txInfo, txLen := buildCryptoInfo12(serverKey, serverIV, txSeq, p) @@ -209,10 +226,13 @@ func (l *Listener) setupKTLS12(rawConn net.Conn, counter *recordCounter, state t fd, err := getRawFd(rawConn) if err != nil { l.onError(err) + return nil } + if err := enableKTLS12(fd, txInfo, txLen, rxInfo, rxLen); err != nil { l.onError(err) + return nil } diff --git a/tls12_test.go b/tls12_test.go index 4b14bc6..aed6ba2 100644 --- a/tls12_test.go +++ b/tls12_test.go @@ -21,6 +21,7 @@ func tls12Client(t *testing.T, addr string, suite uint16) *tls.Conn { if err != nil { t.Fatalf("dial: %v", err) } + return c } @@ -35,11 +36,13 @@ func TestKTLS12RoundTrip(t *testing.T) { t.Run("", func(t *testing.T) { cert := selfSigned(t) cfg := &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12, MaxVersion: tls.VersionTLS12} + raw, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } defer raw.Close() + ln := &Listener{TCPListener: raw, TLSConfig: cfg, OnError: func(e error) { t.Logf("onError: %v", e) }} payload := make([]byte, 300*1024) // multi-record, exercises seq 1,2,3... @@ -48,22 +51,30 @@ func TestKTLS12RoundTrip(t *testing.T) { } cerr := make(chan error, 1) + go func() { c := tls12Client(t, raw.Addr().String(), suite) defer c.Close() + if _, err := c.Write([]byte("ping")); err != nil { cerr <- err + return } + got := make([]byte, len(payload)) if _, err := io.ReadFull(c, got); err != nil { cerr <- err + return } + if !bytes.Equal(got, payload) { cerr <- io.ErrUnexpectedEOF + return } + cerr <- nil }() @@ -72,21 +83,26 @@ func TestKTLS12RoundTrip(t *testing.T) { t.Fatalf("accept: %v", err) } defer conn.Close() + if _, ok := conn.(Conn); !ok { t.Fatalf("suite 0x%04x: kTLS NOT enabled (userspace fallback)", suite) } + if v := conn.(Conn).ConnectionState().Version; v != tls.VersionTLS12 { t.Fatalf("version %x, want TLS 1.2", v) } conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + hdr := make([]byte, 4) if _, err := io.ReadFull(conn, hdr); err != nil || string(hdr) != "ping" { t.Fatalf("server read %q err=%v (RX decrypt failed?)", hdr, err) } + if _, err := conn.Write(payload); err != nil { // TX: many records t.Fatalf("server write: %v", err) } + if err := <-cerr; err != nil { t.Fatalf("client (TX decrypt failed?): %v", err) } @@ -104,23 +120,29 @@ func TestKTLS12RSASuites(t *testing.T) { t.Run("", func(t *testing.T) { cert := selfSignedRSA(t) cfg := &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12, MaxVersion: tls.VersionTLS12, CipherSuites: []uint16{suite}} + raw, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } defer raw.Close() + ln := &Listener{TCPListener: raw, TLSConfig: cfg, OnError: func(e error) { t.Logf("onError: %v", e) }} msg := bytes.Repeat([]byte("x"), 64*1024) cerr := make(chan error, 1) + go func() { c := tls12Client(t, raw.Addr().String(), suite) defer c.Close() + got := make([]byte, len(msg)) if _, err := io.ReadFull(c, got); err != nil || !bytes.Equal(got, msg) { cerr <- io.ErrUnexpectedEOF + return } + _, err := c.Write([]byte("ok")) cerr <- err }() @@ -130,17 +152,22 @@ func TestKTLS12RSASuites(t *testing.T) { t.Fatalf("accept: %v", err) } defer conn.Close() + if _, ok := conn.(Conn); !ok { t.Fatalf("suite 0x%04x: kTLS not enabled", suite) } + if _, err := conn.Write(msg); err != nil { t.Fatalf("write: %v", err) } + conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + ack := make([]byte, 2) if _, err := io.ReadFull(conn, ack); err != nil || string(ack) != "ok" { t.Fatalf("read ack %q: %v", ack, err) } + if err := <-cerr; err != nil { t.Fatalf("client: %v", err) } @@ -156,23 +183,29 @@ func TestKTLS12ChaCha20(t *testing.T) { } { cert := selfSigned(t) cfg := &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12, MaxVersion: tls.VersionTLS12, CipherSuites: []uint16{suite}} + raw, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } defer raw.Close() + ln := &Listener{TCPListener: raw, TLSConfig: cfg, OnError: func(e error) { t.Logf("onError: %v", e) }} msg := bytes.Repeat([]byte("z"), 200*1024) cerr := make(chan error, 1) + go func() { c := tls12Client(t, raw.Addr().String(), suite) defer c.Close() + got := make([]byte, len(msg)) if _, err := io.ReadFull(c, got); err != nil || !bytes.Equal(got, msg) { cerr <- io.ErrUnexpectedEOF + return } + _, err := c.Write([]byte("ok")) cerr <- err }() @@ -182,17 +215,22 @@ func TestKTLS12ChaCha20(t *testing.T) { t.Fatalf("accept: %v", err) } defer conn.Close() + if _, ok := conn.(Conn); !ok { t.Fatalf("ChaCha20-1.2: kTLS not enabled") } + if _, err := conn.Write(msg); err != nil { t.Fatalf("write: %v", err) } + conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + ack := make([]byte, 2) if _, err := io.ReadFull(conn, ack); err != nil || string(ack) != "ok" { t.Fatalf("read ack: %v", err) } + if err := <-cerr; err != nil { t.Fatalf("client: %v", err) } diff --git a/writeto.go b/writeto.go index b47b1c6..4815eb4 100644 --- a/writeto.go +++ b/writeto.go @@ -1,6 +1,9 @@ package ktls -import "io" +import ( + "errors" + "io" +) // implements io.WriterTo // when w wraps a raw file descriptor (a TCP/unix socket, a file, a pipe) @@ -28,10 +31,13 @@ func (c *conn) WriteToConfig(w io.Writer, cfg SpliceConfig) (int64, error) { // Read handles post-handshake control records (KeyUpdate), so this stays correct func (c *conn) genericWriteTo(w io.Writer, cfg SpliceConfig) (int64, error) { buf := make([]byte, 128*1024) + var total int64 peekLeft := 0 + var peek []byte + if cfg.PeekN > 0 && cfg.Peek != nil { peekLeft = cfg.PeekN peek = make([]byte, 0, cfg.PeekN) @@ -41,28 +47,31 @@ func (c *conn) genericWriteTo(w io.Writer, cfg SpliceConfig) (int64, error) { n, rerr := c.Read(buf) if n > 0 { if peekLeft > 0 { - take := n - if take > peekLeft { - take = peekLeft - } + take := min(n, peekLeft) + peek = append(peek, buf[:take]...) if peekLeft -= take; peekLeft == 0 { cfg.Peek(peek) } } + ww, werr := writeAll(w, buf[:n]) + total += int64(ww) if werr != nil { return total, werr } } + if rerr != nil { if peekLeft > 0 && len(peek) > 0 { cfg.Peek(peek) } - if rerr == io.EOF { + + if errors.Is(rerr, io.EOF) { return total, nil } + return total, rerr } } @@ -72,6 +81,7 @@ func writeAll(w io.Writer, b []byte) (int, error) { written := 0 for written < len(b) { n, err := w.Write(b[written:]) + written += n if err != nil { return written, err diff --git a/writeto_linux_test.go b/writeto_linux_test.go index 5ce5c5d..d84031f 100644 --- a/writeto_linux_test.go +++ b/writeto_linux_test.go @@ -12,25 +12,33 @@ import ( func rawSink(t *testing.T) (*net.TCPConn, <-chan []byte) { t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } + out := make(chan []byte, 1) + go func() { c, err := ln.Accept() if err != nil { out <- nil + return } + data, _ := io.ReadAll(c) out <- data + ln.Close() }() + c, err := net.Dial("tcp", ln.Addr().String()) if err != nil { t.Fatal(err) } + return c.(*net.TCPConn), out } @@ -47,6 +55,7 @@ func TestWriteToSpliceRoundTrip(t *testing.T) { if err != nil { return } + c.Write(payload) c.Close() // close_notify -> clean EOF on the server splice }() @@ -56,6 +65,7 @@ func TestWriteToSpliceRoundTrip(t *testing.T) { t.Fatal(err) } defer conn.Close() + kc, ok := conn.(Conn) if !ok { t.Fatal("kTLS not enabled (userspace fallback) - RX splice path not exercised") @@ -64,13 +74,17 @@ func TestWriteToSpliceRoundTrip(t *testing.T) { sink, out := rawSink(t) n, err := io.Copy(sink, kc) // WriteTo -> splice kTLS RX -> sink fd sink.Close() + if err != nil { t.Fatalf("io.Copy: %v", err) } + got := <-out + if n != int64(len(payload)) { t.Fatalf("copied %d bytes, want %d", n, len(payload)) } + if !bytes.Equal(got, payload) { t.Fatalf("sink got %d bytes, not matching the decrypted upload", len(got)) } @@ -83,6 +97,7 @@ func TestWriteToPeek(t *testing.T) { for i := range payload { payload[i] = byte(i * 5) } + const peekN = 512 go func() { @@ -90,6 +105,7 @@ func TestWriteToPeek(t *testing.T) { if err != nil { return } + c.Write(payload) c.Close() }() @@ -99,25 +115,33 @@ func TestWriteToPeek(t *testing.T) { t.Fatal(err) } defer conn.Close() + kc := conn.(Conn) sink, out := rawSink(t) + var peeked []byte + n, err := kc.WriteToConfig(sink, SpliceConfig{ PeekN: peekN, Peek: func(b []byte) { peeked = append(peeked, b...) }, }) sink.Close() + if err != nil { t.Fatalf("WriteToConfig: %v", err) } + got := <-out + if n != int64(len(payload)) { t.Fatalf("copied %d, want %d", n, len(payload)) } + if len(peeked) != peekN || !bytes.Equal(peeked, payload[:peekN]) { t.Fatalf("peek got %d bytes, want first %d of upload", len(peeked), peekN) } + if !bytes.Equal(got, payload) { t.Fatalf("sink got %d bytes, not matching upload", len(got)) }