From d097148a2c5a1c07a243d868c17892e090885322 Mon Sep 17 00:00:00 2001 From: Wago Networking Agent Date: Sun, 26 Jul 2026 03:34:45 +0000 Subject: [PATCH 1/2] perf: reuse immutable TLS profile snapshots --- agent-todo.md | 25 +++++- benchmarks/README.md | 2 +- docs/release-signoff.md | 9 ++- docs/tls.md | 20 +++-- internal/backend/gotls/benchmark_test.go | 36 +++++++++ internal/backend/gotls/profile.go | 54 ++++++++----- internal/backend/gotls/security_test.go | 97 ++++++++++++++++++++++++ internal/backend/gotls/stream.go | 26 ++++--- 8 files changed, 222 insertions(+), 47 deletions(-) diff --git a/agent-todo.md b/agent-todo.md index 652580b..b21c8e9 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 168 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 168 named tests. ## Bounded TLS host-call hardening — July 26, 2026 @@ -1735,4 +1735,21 @@ 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 168 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. +- 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..965f21a 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 168 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..968c27e 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 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/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..61059bc 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 } From 4c844be90fec441655d29077e0815934943c17f4 Mon Sep 17 00:00:00 2001 From: Wago Networking Agent Date: Sun, 26 Jul 2026 03:39:13 +0000 Subject: [PATCH 2/2] perf: release closed TLS storage --- agent-todo.md | 11 ++++-- docs/release-signoff.md | 2 +- docs/tls.md | 10 +++-- internal/backend/gotls/queue.go | 18 +++++++++ internal/backend/gotls/stream.go | 35 +++++++++++++---- internal/backend/gotls/stream_test.go | 56 +++++++++++++++++++++++++++ 6 files changed, 116 insertions(+), 16 deletions(-) diff --git a/agent-todo.md b/agent-todo.md index b21c8e9..fe687de 100644 --- a/agent-todo.md +++ b/agent-todo.md @@ -1638,7 +1638,7 @@ 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 168 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 @@ -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 168 named tests. + signoff package runs resolving 170 named tests. ## Bounded TLS host-call hardening — July 26, 2026 @@ -1735,7 +1735,7 @@ 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 168 named test targets. + package runs now resolve and pass 170 named test targets. ## TLS immutable-profile allocation hardening — July 26, 2026 @@ -1748,6 +1748,11 @@ No repository-owned workstream or completion criterion from this hardening reque 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 diff --git a/docs/release-signoff.md b/docs/release-signoff.md index 965f21a..f48b710 100644 --- a/docs/release-signoff.md +++ b/docs/release-signoff.md @@ -143,7 +143,7 @@ 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 168 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, diff --git a/docs/tls.md b/docs/tls.md index 968c27e..5dabb60 100644 --- a/docs/tls.md +++ b/docs/tls.md @@ -99,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/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/stream.go b/internal/backend/gotls/stream.go index 61059bc..c978fa3 100644 --- a/internal/backend/gotls/stream.go +++ b/internal/backend/gotls/stream.go @@ -590,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 @@ -617,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)