diff --git a/agent-todo.md b/agent-todo.md index 652580b..fe687de 100644 --- a/agent-todo.md +++ b/agent-todo.md @@ -1638,12 +1638,12 @@ No repository-owned workstream or completion criterion from this hardening reque - Current local evidence: `go test ./...`, shuffled tests, full race/shuffle, vet, source boundaries, checkptr, accepted-diagnostic linux/386, all 123 TinyGo-supported packages, all 12 custom CLI bundles, and all 17 TLS signoff - profiles passed. TLS signoff now resolves 164 named tests after bounded + profiles passed. TLS signoff now resolves 170 named tests after bounded resumption, channel-binding, client-certificate, frozen-clock, software-signer, static-SNI, and certificate-rotation coverage. Fuzz smoke passes 47 targets in 33 packages, including seven TLS-owned targets. Benchmark smoke - passes 173 top-level targets; the five-by-200 ms capture expands to 196 result + passes 174 top-level targets; benchmark expansion currently resolves 204 result names and includes separate client/server TLS 1.3 handshakes. Four arm64 test binaries cross-compile, while execution remains `skipped-no-runner`. - Standard-Go TLS client/server streams and listeners are now implemented and @@ -1709,7 +1709,7 @@ No repository-owned workstream or completion criterion from this hardening reque stream module. - Current validation passes `go test ./...`, focused TLS race tests, `go vet ./...`, source-boundary checks, shell syntax, diff checks, and all 17 TLS - signoff package runs resolving 164 named tests. + signoff package runs resolving 170 named tests. ## Bounded TLS host-call hardening — July 26, 2026 @@ -1735,4 +1735,26 @@ No repository-owned workstream or completion criterion from this hardening reque before accepted streams drain remains an explicit abort boundary rather than a zero-downtime handoff claim. - Standard Go passes across the complete repository, and all 17 TLS signoff - package runs now resolve and pass 164 named test targets. + package runs now resolve and pass 170 named test targets. + +## TLS immutable-profile allocation hardening — July 26, 2026 + +- Kept the existing deep clone at the public registration and per-instance + boundary, but removed redundant deep cloning from every stream constructor. + Clients now clone only `crypto/tls.Config`'s shallow per-connection shell to + set an authorized `ServerName`; accepted server streams borrow the immutable + adapter-owned certificate, OCSP/SCT, ALPN, ticket-key, and client-CA snapshot. +- Added client/server snapshot-borrowing assertions and concurrent server + handshake race coverage. The adapter remains the sole profile owner, live + mutation remains unsupported, and profile/certificate rotation semantics are + unchanged. +- Normal and worker-only close now zero and drop plaintext/ciphertext rings, + scratch slices, channel binding, metadata, `crypto/tls.Conn`, transport, and + profile references immediately after workers join. A retained stale resource + object therefore no longer pins the configured TLS buffers or certificate/CA + graph until a later garbage collection of the wrapper itself. +- Controlled TLS 1.3 benchmarks reduce client handshakes from about 391,650 to + 390,050 bytes and from 964 to 952 allocations, and server handshakes from + about 391,220 to 389,650 bytes and from 954 to 948 allocations. Constructing + a stream with 96 KiB of immutable OCSP/SCT metadata falls from about 382 KiB, + 45 allocations, and 40–44 µs to about 282 KiB, 37 allocations, and 30–33 µs. diff --git a/benchmarks/README.md b/benchmarks/README.md index 6bc175c..befc7dc 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -23,7 +23,7 @@ in `docs/architecture.md`: The checked-in capture is in `baseline.txt`; `baseline-summary.md` records the TLS-aware medians and environment. The expanded client/server branch discovers -173 top-level benchmark targets in 50 packages, with 196 distinct result names +174 top-level benchmark targets in 50 packages, with 204 distinct result names after subbenchmark expansion. A full-grid five-by-200 ms local capture includes both handshake roles; the checked-in release-readiness baseline remains the prior outbound-only evidence until this draft branch is adopted. The baseline diff --git a/docs/release-signoff.md b/docs/release-signoff.md index 9399590..f48b710 100644 --- a/docs/release-signoff.md +++ b/docs/release-signoff.md @@ -143,18 +143,19 @@ reviewed standard-Go-only TLS closure is exactly five packages: TinyGo 0.41.1 tests the remaining 123 packages individually and retains one log per package. On the expanded standard-Go client/server stream branch, the explicit TLS signoff still runs 17 package profiles (10 ordinary and seven race) -and now resolves 164 named test targets, including bounded per-instance client +and now resolves 170 named test targets, including bounded per-instance client resumption, explicit server ticket-key rotation, cache isolation, exact cache quota teardown, fixed RFC 9266 channel-binding derivation/output atomicity, -eager client-certificate validation, package-owned frozen validation time, and +eager client-certificate validation, package-owned frozen validation time, rejection of externally delegated signer callbacks, immutable static SNI -selection, and drain-before-listener-replacement certificate rotation. +selection, drain-before-listener-replacement certificate rotation, +immutable-profile borrowing, and concurrent shared-server-profile handshakes. Arm64 signoff cross-compiles four test binaries whose subjects now include the standard-Go server engine, live lneto client/server TLS, explicit listener authority, and eager certificate/key validation; the current local auto profile remains truthfully `skipped-no-runner`, so it is not execution evidence. Benchmark discovery finds -173 top-level targets in 50 packages and 196 expanded result names; a local +174 top-level targets in 50 packages and 204 expanded result names; a local five-by-200 ms capture includes separate TLS 1.3 client and server handshake benchmarks. Fuzz discovery finds 47 targets in 33 packages, including seven TLS-owned targets covering v1/v2 metadata and listener layouts. diff --git a/docs/tls.md b/docs/tls.md index 1a6410e..5dabb60 100644 --- a/docs/tls.md +++ b/docs/tls.md @@ -12,9 +12,13 @@ TCP ownership exactly once. Hosts construct immutable profiles with `NewClientProfile`, exact profile IDs, `AllowServerNames`, optional `RequireALPN`, and an ordinary `*crypto/tls.Config`. The configuration, roots, certificate DER, ALPN list, and name authority are -cloned. Later caller mutation cannot change registration. Client certificate -chains and leaf/key correspondence are parsed eagerly. Private keys stay in host -memory and no certificate chain or private key appears in the guest ABI. +cloned into an immutable registration/instance snapshot. Later caller mutation +cannot change registration. Streams borrow that adapter-owned snapshot rather +than deep-cloning trust pools, authority maps, and certificate data again; a +client creates only the shallow per-connection `crypto/tls.Config` shell needed +to set its authorized `ServerName`. Client certificate chains and leaf/key +correspondence are parsed eagerly. Private keys stay in host memory and no +certificate chain or private key appears in the guest ABI. The first release rejects `InsecureSkipVerify`, `KeyLogWriter`, renegotiation, verification callbacks, certificate-selection callbacks, caller-supplied clock @@ -48,9 +52,13 @@ mentions those endpoint classes. Hosts construct server profiles with `NewServerProfile` and static certificate chains. Every DER certificate is parsed during profile construction, each chain link is signature-checked, and each leaf public key must match its private key. -Certificate DER, OCSP staples, SCTs, ALPN, and CA pools are cloned. Private keys -remain host-owned but are restricted to standard in-memory RSA, NIST ECDSA, and -Ed25519 implementations. Arbitrary `crypto.Signer` wrappers and HSM callbacks are +Certificate DER, OCSP staples, SCTs, ALPN, and CA pools are cloned into the +immutable adapter snapshot. Accepted streams share that read-only snapshot; +they do not recopy certificate chains, OCSP/SCT data, or client CA pools. +Concurrent-handshake race coverage verifies the shared standard-library server +configuration. Private keys remain host-owned but are restricted to standard +in-memory RSA, NIST ECDSA, and Ed25519 implementations. Arbitrary +`crypto.Signer` wrappers and HSM callbacks are rejected because `crypto.Signer.Sign` has no cancellation contract and could otherwise prevent deterministic worker teardown. Dynamic certificate/config selection and verification callbacks are also rejected. Client SNI may select @@ -91,10 +99,12 @@ the graceful TLS stream path: it drains accepted plaintext and emits `close_notify`, while peer `close_notify` becomes stable EOF. Resource `close` remains the bounded abort path: it cancels the handshake, closes the bridge, wakes every condition wait, joins all three workers, clears retained plaintext, -and aborts the private TCP stream without waiting for peer packets or -acknowledgements. Shared namespace teardown joins workers, clears any bounded -client resumption cache, and releases its quota before the private TCP -participant releases transport state. +ciphertext, scratch, channel-binding, connection-metadata, and profile references, +drops their backing slices immediately, and aborts the private TCP stream without +waiting for peer packets or acknowledgements. Worker-only shared-namespace +teardown performs the same memory release before the private TCP participant +releases transport state, then clears any bounded client resumption cache and its +quota. The current bounded bridge is intentionally granular-only and experimental. It has a named standard-Go ordinary/race release check in `scripts/tls-signoff.sh`. diff --git a/internal/backend/gotls/benchmark_test.go b/internal/backend/gotls/benchmark_test.go index c18b90b..2173eb8 100644 --- a/internal/backend/gotls/benchmark_test.go +++ b/internal/backend/gotls/benchmark_test.go @@ -15,6 +15,10 @@ func BenchmarkTLS13Handshake(b *testing.B) { profile := secureTestProfile(roots, "api.example.com") profile.Config.NextProtos = []string{"h2"} profile.RequiredALPN = "h2" + profile, err := profile.Clone() + if err != nil { + b.Fatal(err) + } b.ReportAllocs() for b.Loop() { serverBridge := newBridgeConn(64<<10, 64<<10, 1<<20) @@ -66,6 +70,10 @@ func BenchmarkTLS13ServerHandshake(b *testing.B) { }, RequiredALPN: "h2", MaxCertificateChainBytes: 64 << 10, MaxPeerCertificates: 4, } + profile, err := profile.Clone() + if err != nil { + b.Fatal(err) + } b.ReportAllocs() for b.Loop() { clientBridge := newBridgeConn(64<<10, 64<<10, 1<<20) @@ -98,6 +106,34 @@ func BenchmarkTLS13ServerHandshake(b *testing.B) { } } +func BenchmarkTLS13ServerStreamConstructionWithLargeImmutableProfile(b *testing.B) { + certificate, _ := testCertificate(b, "server.example.com") + certificate.OCSPStaple = make([]byte, 64<<10) + certificate.SignedCertificateTimestamps = [][]byte{make([]byte, 16<<10), make([]byte, 16<<10)} + profile := ServerProfile{ + ID: 10, + Config: &cryptotls.Config{ + Certificates: []cryptotls.Certificate{certificate}, MinVersion: cryptotls.VersionTLS13, + MaxVersion: cryptotls.VersionTLS13, SessionTicketsDisabled: true, + }, + MaxCertificateChainBytes: 256 << 10, MaxPeerCertificates: 4, + } + profile, err := profile.Clone() + if err != nil { + b.Fatal(err) + } + b.ReportAllocs() + for b.Loop() { + server, err := NewServer(&memoryTransport{peer: newBridgeConn(64<<10, 64<<10, 1<<20)}, profile, testLimits()) + if err != nil { + b.Fatal(err) + } + if err := server.Close(); err != nil { + b.Fatal(err) + } + } +} + func BenchmarkByteRingSteadyState(b *testing.B) { ring := newByteRing(32 << 10) input := make([]byte, 4096) diff --git a/internal/backend/gotls/profile.go b/internal/backend/gotls/profile.go index 1ad2191..3eb437c 100644 --- a/internal/backend/gotls/profile.go +++ b/internal/backend/gotls/profile.go @@ -61,28 +61,16 @@ func (profile Profile) Instantiate() (Profile, error) { } func (profile Profile) clone(instantiate bool) (Profile, error) { - if profile.ID == 0 || profile.Config == nil || profile.MaxCertificateChainBytes <= 0 || profile.MaxPeerCertificates == 0 || - (profile.MaxClientSessionEntries == 0) != (profile.MaxClientSessionBytes == 0) { + if !profile.valid(instantiate) { return Profile{}, ErrInvalidConfig } - if profile.Config.ClientSessionCache != nil { - if _, ok := profile.Config.ClientSessionCache.(*boundedClientSessionCache); !ok { - return Profile{}, ErrInvalidConfig - } - } cloned := profile cloned.Config = profile.Config.Clone() cloned.Config.NextProtos = append([]string(nil), profile.Config.NextProtos...) cloned.AllowedNames = make(map[string]tlsns.IdentityType, len(profile.AllowedNames)) for name, identity := range profile.AllowedNames { - if identity != tlsns.IdentityDNS && identity != tlsns.IdentityIP { - return Profile{}, ErrInvalidConfig - } cloned.AllowedNames[name] = identity } - if len(cloned.AllowedNames) == 0 { - return Profile{}, ErrInvalidConfig - } if profile.Config.RootCAs != nil { cloned.Config.RootCAs = profile.Config.RootCAs.Clone() } @@ -102,6 +90,28 @@ func (profile Profile) clone(instantiate bool) (Profile, error) { return cloned, nil } +func (profile Profile) valid(instantiate bool) bool { + if profile.ID == 0 || profile.Config == nil || profile.MaxCertificateChainBytes <= 0 || profile.MaxPeerCertificates == 0 || + (profile.MaxClientSessionEntries == 0) != (profile.MaxClientSessionBytes == 0) || len(profile.AllowedNames) == 0 { + return false + } + for _, identity := range profile.AllowedNames { + if identity != tlsns.IdentityDNS && identity != tlsns.IdentityIP { + return false + } + } + cache := profile.Config.ClientSessionCache + if cache != nil { + if _, ok := cache.(*boundedClientSessionCache); !ok { + return false + } + } + if profile.MaxClientSessionEntries == 0 { + return cache == nil + } + return instantiate || cache != nil +} + // ClearSessionCache removes and zeroes adapter-owned resumable state during // deterministic instance teardown. func (profile Profile) ClearSessionCache() { @@ -117,13 +127,7 @@ func (profile Profile) ClearSessionCache() { // verification, and session callbacks are rejected by the public profile layer // before this internal boundary. func (profile ServerProfile) Clone() (ServerProfile, error) { - if profile.ID == 0 || profile.Config == nil || len(profile.Config.Certificates) == 0 || profile.MaxCertificateChainBytes <= 0 || profile.MaxPeerCertificates == 0 { - return ServerProfile{}, ErrInvalidConfig - } - if profile.Config.ClientAuth != cryptotls.NoClientCert && profile.Config.ClientAuth != cryptotls.RequireAndVerifyClientCert { - return ServerProfile{}, ErrInvalidConfig - } - if profile.Config.ClientAuth == cryptotls.RequireAndVerifyClientCert && profile.Config.ClientCAs == nil { + if !profile.valid() { return ServerProfile{}, ErrInvalidConfig } cloned := profile @@ -136,6 +140,16 @@ func (profile ServerProfile) Clone() (ServerProfile, error) { return cloned, nil } +func (profile ServerProfile) valid() bool { + if profile.ID == 0 || profile.Config == nil || len(profile.Config.Certificates) == 0 || profile.MaxCertificateChainBytes <= 0 || profile.MaxPeerCertificates == 0 { + return false + } + if profile.Config.ClientAuth != cryptotls.NoClientCert && profile.Config.ClientAuth != cryptotls.RequireAndVerifyClientCert { + return false + } + return profile.Config.ClientAuth != cryptotls.RequireAndVerifyClientCert || profile.Config.ClientCAs != nil +} + func cloneTLSCertificates(input []cryptotls.Certificate) []cryptotls.Certificate { output := make([]cryptotls.Certificate, len(input)) for index := range input { diff --git a/internal/backend/gotls/queue.go b/internal/backend/gotls/queue.go index 65230eb..eccc324 100644 --- a/internal/backend/gotls/queue.go +++ b/internal/backend/gotls/queue.go @@ -70,6 +70,11 @@ func (ring *byteRing) clear() { ring.length = 0 } +func (ring *byteRing) release() { + ring.clear() + ring.buffer = nil +} + // bridgeConn is the bounded blocking endpoint used only by crypto/tls workers. // Host calls interact through nonblocking feed/peek methods below. type bridgeConn struct { @@ -150,6 +155,19 @@ type bridgeAddress string func (address bridgeAddress) Network() string { return "wago-tls" } func (address bridgeAddress) String() string { return string(address) } +func (conn *bridgeConn) release() { + if conn == nil { + return + } + conn.mu.Lock() + conn.inbound.release() + conn.outbound.release() + conn.err = nil + conn.handshakeBytes = 0 + conn.maxHandshakeBytes = 0 + conn.mu.Unlock() +} + func (conn *bridgeConn) abort(err error) { if conn == nil { return diff --git a/internal/backend/gotls/security_test.go b/internal/backend/gotls/security_test.go index e03c3f7..046b7a0 100644 --- a/internal/backend/gotls/security_test.go +++ b/internal/backend/gotls/security_test.go @@ -9,6 +9,7 @@ import ( "math/big" "net" "runtime" + "sync" "testing" "time" @@ -35,6 +36,102 @@ func TestServerProfileCloneOwnsSupportedSignatureAlgorithms(t *testing.T) { } } +func TestStreamsBorrowValidatedAdapterOwnedProfileSnapshots(t *testing.T) { + certificate, roots := testCertificate(t, "snapshot.example.com") + clientProfile := secureTestProfile(roots, "snapshot.example.com") + client, err := NewClient(&memoryTransport{peer: newBridgeConn(64<<10, 64<<10, 1<<20)}, clientProfile, "snapshot.example.com", tlsns.IdentityDNS, testLimits()) + if err != nil { + t.Fatal(err) + } + if client.profile.Config != clientProfile.Config || client.profile.Config.RootCAs != clientProfile.Config.RootCAs { + t.Fatal("client stream deep-cloned an already immutable adapter profile") + } + if err := client.Close(); err != nil { + t.Fatal(err) + } + + serverProfile := ServerProfile{ + ID: 2, + Config: &cryptotls.Config{ + Certificates: []cryptotls.Certificate{certificate}, + MinVersion: cryptotls.VersionTLS13, + MaxVersion: cryptotls.VersionTLS13, + SessionTicketsDisabled: true, + }, + MaxCertificateChainBytes: 64 << 10, + MaxPeerCertificates: 4, + } + server, err := NewServer(&memoryTransport{peer: newBridgeConn(64<<10, 64<<10, 1<<20)}, serverProfile, testLimits()) + if err != nil { + t.Fatal(err) + } + if server.serverProfile.Config != serverProfile.Config || &server.serverProfile.Config.Certificates[0].Certificate[0][0] != &serverProfile.Config.Certificates[0].Certificate[0][0] { + t.Fatal("server stream deep-cloned an already immutable adapter profile") + } + if err := server.Close(); err != nil { + t.Fatal(err) + } +} + +func TestConcurrentServersShareImmutableProfile(t *testing.T) { + certificate, roots := testCertificate(t, "shared.example.com") + profile := ServerProfile{ + ID: 3, + Config: &cryptotls.Config{ + Certificates: []cryptotls.Certificate{certificate}, + MinVersion: cryptotls.VersionTLS13, + MaxVersion: cryptotls.VersionTLS13, + SessionTicketsDisabled: true, + }, + MaxCertificateChainBytes: 64 << 10, + MaxPeerCertificates: 4, + } + const connections = 4 + errors := make(chan error, connections) + var workers sync.WaitGroup + workers.Add(connections) + for range connections { + go func() { + defer workers.Done() + clientBridge := newBridgeConn(64<<10, 64<<10, 1<<20) + client := cryptotls.Client(clientBridge, &cryptotls.Config{ + RootCAs: roots, ServerName: "shared.example.com", Time: func() time.Time { return time.Unix(1_800_000_000, 0) }, + MinVersion: cryptotls.VersionTLS13, MaxVersion: cryptotls.VersionTLS13, + }) + clientDone := make(chan error, 1) + go func() { clientDone <- client.Handshake() }() + server, err := NewServer(&memoryTransport{peer: clientBridge}, profile, testLimits()) + if err != nil { + errors <- err + return + } + defer server.Close() + for attempt := 0; attempt < 1_000_000; attempt++ { + progress, err := server.TryFinishConnect() + if err != nil { + errors <- err + return + } + if progress == nscore.ProgressDone { + if err := <-clientDone; err != nil { + errors <- err + } + return + } + runtime.Gosched() + } + errors <- ErrHandshakeLimit + }() + } + workers.Wait() + close(errors) + for err := range errors { + if err != nil { + t.Fatal(err) + } + } +} + func TestRequiredALPNMissingFailsAuthentication(t *testing.T) { certificate, roots := testCertificate(t, "api.example.com") serverBridge := newBridgeConn(64<<10, 64<<10, 1<<20) diff --git a/internal/backend/gotls/stream.go b/internal/backend/gotls/stream.go index c3219e0..c978fa3 100644 --- a/internal/backend/gotls/stream.go +++ b/internal/backend/gotls/stream.go @@ -67,15 +67,17 @@ type Stream struct { identity tlsns.IdentityType } +// NewClient borrows one already validated, adapter-owned immutable profile. +// Only crypto/tls.Config's shallow per-connection shell is cloned so ServerName +// can differ without recopying trust pools, authority maps, or certificate DER. func NewClient(transport Transport, profile Profile, serverName string, identity tlsns.IdentityType, limits Limits) (*Stream, error) { if transport == nil || !ValidLimits(limits) || (identity != tlsns.IdentityDNS && identity != tlsns.IdentityIP) { return nil, ErrInvalidConfig } - cloned, err := profile.Clone() - if err != nil { - return nil, err + if !profile.valid(false) { + return nil, ErrInvalidConfig } - config := cloned.Config.Clone() + config := profile.Config.Clone() config.ServerName = serverName stream, err := newStream(transport, limits, tlsns.RoleClient, func(bridge *bridgeConn) *cryptotls.Conn { return cryptotls.Client(bridge, config) @@ -83,29 +85,29 @@ func NewClient(transport Transport, profile Profile, serverName string, identity if err != nil { return nil, err } - stream.profile = cloned + stream.profile = profile stream.identity = identity return stream, nil } // NewServer starts one bounded server handshake over an already accepted, -// private transport. The accepted TCP stream remains solely owned by the TLS -// stream and never becomes guest-visible. +// private transport and borrows one validated adapter-owned immutable profile. +// The accepted TCP stream remains solely owned by TLS and never becomes +// guest-visible; static certificate and CA material is not recopied per stream. func NewServer(transport Transport, profile ServerProfile, limits Limits) (*Stream, error) { if transport == nil || !ValidLimits(limits) { return nil, ErrInvalidConfig } - cloned, err := profile.Clone() - if err != nil { - return nil, err + if !profile.valid() { + return nil, ErrInvalidConfig } stream, err := newStream(transport, limits, tlsns.RoleServer, func(bridge *bridgeConn) *cryptotls.Conn { - return cryptotls.Server(bridge, cloned.Config) + return cryptotls.Server(bridge, profile.Config) }) if err != nil { return nil, err } - stream.serverProfile = cloned + stream.serverProfile = profile return stream, nil } @@ -588,15 +590,15 @@ func (stream *Stream) Close() error { stream.cancel() stream.bridge.abort(context.Canceled) stream.wg.Wait() + stream.bridge.release() stream.mu.Lock() - stream.rxPlain.clear() - stream.txPlain.clear() - clear(stream.readScratch) - clear(stream.writeScratch) - clear(stream.cipherScratch) - clear(stream.channelBinding[:]) + transport := stream.transport + stream.releaseRetainedLocked() stream.mu.Unlock() - return stream.transport.Close() + if transport == nil { + return nil + } + return transport.Close() } // CloseWorkersLocked is used only by shared-backend teardown while the private @@ -615,11 +617,30 @@ func (stream *Stream) CloseWorkersLocked() { stream.cancel() stream.bridge.abort(context.Canceled) stream.wg.Wait() + stream.bridge.release() stream.mu.Lock() - clear(stream.channelBinding[:]) + stream.releaseRetainedLocked() stream.mu.Unlock() } +func (stream *Stream) releaseRetainedLocked() { + stream.rxPlain.release() + stream.txPlain.release() + clear(stream.readScratch) + clear(stream.writeScratch) + clear(stream.cipherScratch) + stream.readScratch = nil + stream.writeScratch = nil + stream.cipherScratch = nil + clear(stream.channelBinding[:]) + stream.info = tlsns.ConnectionInfo{} + stream.tls = nil + stream.profile = Profile{} + stream.serverProfile = ServerProfile{} + stream.transport = nil + stream.terminal = nil +} + func mapTLSError(err error) error { if err == nil { return nil diff --git a/internal/backend/gotls/stream_test.go b/internal/backend/gotls/stream_test.go index 541e8e8..fe552b6 100644 --- a/internal/backend/gotls/stream_test.go +++ b/internal/backend/gotls/stream_test.go @@ -128,6 +128,62 @@ serverHandshakeComplete: t.Fatal("plaintext did not reach peer") } +func TestCloseReleasesRetainedBuffersAndProfileReferences(t *testing.T) { + _, roots := testCertificate(t, "release.example.com") + clientProfile := secureTestProfile(roots, "release.example.com") + client, err := NewClient(&memoryTransport{peer: newBridgeConn(64<<10, 64<<10, 1<<20)}, clientProfile, "release.example.com", tlsns.IdentityDNS, testLimits()) + if err != nil { + t.Fatal(err) + } + if err := client.Close(); err != nil { + t.Fatal(err) + } + assertTLSStreamReleased(t, client) + + certificate, _ := testCertificate(t, "release.example.com") + transport := &memoryTransport{peer: newBridgeConn(64<<10, 64<<10, 1<<20)} + server, err := NewServer(transport, ServerProfile{ + ID: 2, + Config: &cryptotls.Config{ + Certificates: []cryptotls.Certificate{certificate}, + MinVersion: cryptotls.VersionTLS13, + MaxVersion: cryptotls.VersionTLS13, + SessionTicketsDisabled: true, + }, + MaxCertificateChainBytes: 64 << 10, + MaxPeerCertificates: 4, + }, testLimits()) + if err != nil { + t.Fatal(err) + } + server.CloseWorkersLocked() + assertTLSStreamReleased(t, server) + if transport.closed.Load() { + t.Fatal("worker-only close unexpectedly closed owner-managed transport") + } + if err := transport.Close(); err != nil { + t.Fatal(err) + } +} + +func assertTLSStreamReleased(t testing.TB, stream *Stream) { + t.Helper() + stream.mu.Lock() + defer stream.mu.Unlock() + if stream.tls != nil || stream.transport != nil || stream.profile.Config != nil || stream.serverProfile.Config != nil || + stream.rxPlain.buffer != nil || stream.txPlain.buffer != nil || stream.readScratch != nil || stream.writeScratch != nil || stream.cipherScratch != nil || + stream.info != (tlsns.ConnectionInfo{}) || stream.terminal != nil || stream.channelBinding != ([tlsns.ChannelBindingBytes]byte{}) { + t.Fatalf("closed stream retained state: tls=%p transport=%T profile=%p server-profile=%p rx=%d tx=%d read=%d write=%d cipher=%d info=%+v terminal=%v binding=%x", + stream.tls, stream.transport, stream.profile.Config, stream.serverProfile.Config, cap(stream.rxPlain.buffer), cap(stream.txPlain.buffer), + cap(stream.readScratch), cap(stream.writeScratch), cap(stream.cipherScratch), stream.info, stream.terminal, stream.channelBinding) + } + stream.bridge.mu.Lock() + defer stream.bridge.mu.Unlock() + if stream.bridge.inbound.buffer != nil || stream.bridge.outbound.buffer != nil || stream.bridge.err != nil || stream.bridge.handshakeBytes != 0 || stream.bridge.maxHandshakeBytes != 0 { + t.Fatalf("closed bridge retained state: inbound=%d outbound=%d err=%v handshake=%d max=%d", cap(stream.bridge.inbound.buffer), cap(stream.bridge.outbound.buffer), stream.bridge.err, stream.bridge.handshakeBytes, stream.bridge.maxHandshakeBytes) + } +} + func TestServerHandshakeALPNAndPlaintext(t *testing.T) { certificate, roots := testCertificate(t, "server.example.com") clientBridge := newBridgeConn(64<<10, 64<<10, 1<<20)