Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 26 additions & 4 deletions agent-todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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.
2 changes: 1 addition & 1 deletion benchmarks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 5 additions & 4 deletions docs/release-signoff.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
30 changes: 20 additions & 10 deletions docs/tls.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`.
Expand Down
36 changes: 36 additions & 0 deletions internal/backend/gotls/benchmark_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
54 changes: 34 additions & 20 deletions internal/backend/gotls/profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand All @@ -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() {
Expand All @@ -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
Expand All @@ -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 {
Expand Down
18 changes: 18 additions & 0 deletions internal/backend/gotls/queue.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading