From 35b2fcf52a4c3c18de5332b098ca4bdff10bff6b Mon Sep 17 00:00:00 2001 From: Wago Networking Agent Date: Mon, 20 Jul 2026 13:09:11 +0000 Subject: [PATCH 01/17] feat: establish bounded TLS server foundations --- internal/abi/tls/tls.go | 10 +- internal/abi/tls/tls_test.go | 7 +- internal/backend/gotls/profile.go | 51 ++++++++ internal/backend/gotls/stream.go | 110 +++++++++++++--- internal/backend/gotls/stream_test.go | 94 +++++++++++++- internal/backend/lneto/tcp/tcp.go | 24 +++- internal/backend/lneto/tcp/tcp_test.go | 28 +++++ internal/instance/tls/tls_test.go | 4 +- internal/namespace/tls/tls.go | 53 +++++--- internal/namespace/tls/tls_test.go | 14 ++- internal/policy/policy.go | 3 + internal/policy/tls_test.go | 21 ++++ tls/profile.go | 166 ++++++++++++++++++++++++- tls/profile_test.go | 69 ++++++++++ 14 files changed, 605 insertions(+), 49 deletions(-) diff --git a/internal/abi/tls/tls.go b/internal/abi/tls/tls.go index 60e9b9a..a325be3 100644 --- a/internal/abi/tls/tls.go +++ b/internal/abi/tls/tls.go @@ -77,9 +77,17 @@ func EncodeConnectionInfoV1(memory []byte, ptr uint32, info tlsns.ConnectionInfo } binary.LittleEndian.PutUint16(encoded[64:66], info.TLSVersion) binary.LittleEndian.PutUint16(encoded[66:68], info.CipherSuite) + var flags uint32 if info.Resumed { - binary.LittleEndian.PutUint32(encoded[68:72], 1) + flags |= 1 << 0 } + if info.Role == tlsns.RoleServer { + flags |= 1 << 1 + } + if info.PeerAuthenticated { + flags |= 1 << 2 + } + binary.LittleEndian.PutUint32(encoded[68:72], flags) binary.LittleEndian.PutUint32(encoded[72:76], uint32(info.VerifiedIdentity)) binary.LittleEndian.PutUint32(encoded[76:80], uint32(len(info.NegotiatedALPN))) copy(encoded[80:112], info.NegotiatedALPN) diff --git a/internal/abi/tls/tls_test.go b/internal/abi/tls/tls_test.go index aaffcf7..1688948 100644 --- a/internal/abi/tls/tls_test.go +++ b/internal/abi/tls/tls_test.go @@ -30,8 +30,8 @@ func TestEncodeConnectionInfoAtomicAndBounded(t *testing.T) { info := tlsns.ConnectionInfo{ LocalEndpoint: nscore.Endpoint{Address: netip.MustParseAddr("192.0.2.1"), Port: 1234}, RemoteEndpoint: nscore.Endpoint{Address: netip.MustParseAddr("192.0.2.2"), Port: 443}, - TLSVersion: 0x304, CipherSuite: 0x1301, NegotiatedALPN: string(make([]byte, MaxALPNV1Bytes+1)), - VerifiedIdentity: tlsns.IdentityDNS, + TLSVersion: 0x304, CipherSuite: 0x1301, NegotiatedALPN: string(make([]byte, MaxALPNV1Bytes+1)), Role: tlsns.RoleClient, + PeerAuthenticated: true, PeerLeafSPKI256: [32]byte{1}, VerifiedIdentity: tlsns.IdentityDNS, } if EncodeConnectionInfoV1(memory, 0, info) || !bytes.Equal(memory, before) { t.Fatal("oversized ALPN mutated output") @@ -62,7 +62,8 @@ func FuzzEncodeConnectionInfoV1(f *testing.F) { info := tlsns.ConnectionInfo{ LocalEndpoint: nscore.Endpoint{Address: netip.MustParseAddr("192.0.2.1"), Port: 1234}, RemoteEndpoint: nscore.Endpoint{Address: netip.MustParseAddr("192.0.2.2"), Port: 443}, - TLSVersion: version, CipherSuite: cipher, NegotiatedALPN: alpn, VerifiedIdentity: tlsns.IdentityDNS, + TLSVersion: version, CipherSuite: cipher, NegotiatedALPN: alpn, Role: tlsns.RoleClient, + PeerAuthenticated: true, PeerLeafSPKI256: [32]byte{1}, VerifiedIdentity: tlsns.IdentityDNS, } _ = EncodeConnectionInfoV1(memory, 0, info) }) diff --git a/internal/backend/gotls/profile.go b/internal/backend/gotls/profile.go index 74ad36a..b171449 100644 --- a/internal/backend/gotls/profile.go +++ b/internal/backend/gotls/profile.go @@ -36,6 +36,17 @@ type Profile struct { AllowedNames map[string]tlsns.IdentityType } +// ServerProfile is an internal immutable crypto/tls server profile. It owns +// host-selected certificate material, ALPN policy, and optional verified client +// authentication policy; guests select only its numeric ID. +type ServerProfile struct { + ID uint32 + Config *cryptotls.Config + RequiredALPN string + MaxCertificateChainBytes int + MaxPeerCertificates uint16 +} + func (profile Profile) Clone() (Profile, error) { if profile.ID == 0 || profile.Config == nil || profile.MaxCertificateChainBytes <= 0 || profile.MaxPeerCertificates == 0 { return Profile{}, ErrInvalidConfig @@ -59,6 +70,46 @@ func (profile Profile) Clone() (Profile, error) { return cloned, nil } +// Clone validates and deeply clones one server profile. Dynamic certificate, +// 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 { + return ServerProfile{}, ErrInvalidConfig + } + cloned := profile + cloned.Config = profile.Config.Clone() + cloned.Config.NextProtos = append([]string(nil), profile.Config.NextProtos...) + cloned.Config.Certificates = cloneTLSCertificates(profile.Config.Certificates) + if profile.Config.ClientCAs != nil { + cloned.Config.ClientCAs = profile.Config.ClientCAs.Clone() + } + return cloned, nil +} + +func cloneTLSCertificates(input []cryptotls.Certificate) []cryptotls.Certificate { + output := make([]cryptotls.Certificate, len(input)) + for index := range input { + output[index] = input[index] + output[index].Certificate = make([][]byte, len(input[index].Certificate)) + for certificateIndex := range input[index].Certificate { + output[index].Certificate[certificateIndex] = append([]byte(nil), input[index].Certificate[certificateIndex]...) + } + output[index].OCSPStaple = append([]byte(nil), input[index].OCSPStaple...) + output[index].SignedCertificateTimestamps = make([][]byte, len(input[index].SignedCertificateTimestamps)) + for timestampIndex := range input[index].SignedCertificateTimestamps { + output[index].SignedCertificateTimestamps[timestampIndex] = append([]byte(nil), input[index].SignedCertificateTimestamps[timestampIndex]...) + } + } + return output +} + // AuthorizeServerName normalizes one guest-selected verification identity and // checks exact host authority before any transport is created. func (profile Profile) AuthorizeServerName(name string) (string, tlsns.IdentityType, bool) { diff --git a/internal/backend/gotls/stream.go b/internal/backend/gotls/stream.go index d197b91..7be0ccd 100644 --- a/internal/backend/gotls/stream.go +++ b/internal/backend/gotls/stream.go @@ -59,7 +59,9 @@ type Stream struct { closed bool terminal error info tlsns.ConnectionInfo + role tlsns.Role profile Profile + serverProfile ServerProfile identity tlsns.IdentityType } @@ -73,18 +75,54 @@ func NewClient(transport Transport, profile Profile, serverName string, identity } config := cloned.Config.Clone() config.ServerName = serverName + stream, err := newStream(transport, limits, tlsns.RoleClient, func(bridge *bridgeConn) *cryptotls.Conn { + return cryptotls.Client(bridge, config) + }) + if err != nil { + return nil, err + } + stream.profile = cloned + 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. +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 + } + stream, err := newStream(transport, limits, tlsns.RoleServer, func(bridge *bridgeConn) *cryptotls.Conn { + return cryptotls.Server(bridge, cloned.Config) + }) + if err != nil { + return nil, err + } + stream.serverProfile = cloned + return stream, nil +} + +func newStream(transport Transport, limits Limits, role tlsns.Role, makeTLS func(*bridgeConn) *cryptotls.Conn) (*Stream, error) { local, remote := transport.LocalEndpoint(), transport.RemoteEndpoint() - if !local.Valid() || !remote.Valid() { + if !local.Valid() || !remote.Valid() || (role != tlsns.RoleClient && role != tlsns.RoleServer) || makeTLS == nil { return nil, ErrInvalidConfig } bridge := newBridgeConn(limits.CiphertextReceiveBytes, limits.CiphertextTransmitBytes, limits.MaxHandshakeBytes) ctx, cancel := context.WithCancel(context.Background()) stream := &Stream{ - transport: transport, local: local, remote: remote, bridge: bridge, tls: cryptotls.Client(bridge, config), limits: limits, + transport: transport, local: local, remote: remote, bridge: bridge, tls: makeTLS(bridge), limits: limits, cancel: cancel, ready: make(chan struct{}), rxPlain: newByteRing(limits.PlaintextReceiveBytes), txPlain: newByteRing(limits.PlaintextTransmitBytes), readScratch: make([]byte, 16<<10), - writeScratch: make([]byte, 16<<10), cipherScratch: make([]byte, CiphertextScratchBytes), - profile: cloned, identity: identity, + writeScratch: make([]byte, 16<<10), cipherScratch: make([]byte, CiphertextScratchBytes), role: role, + } + if stream.tls == nil { + cancel() + return nil, ErrInvalidConfig } stream.cond = sync.NewCond(&stream.mu) stream.wg.Add(3) @@ -114,28 +152,66 @@ func (stream *Stream) handshakeWorker(ctx context.Context) { func (stream *Stream) validateConnection() error { state := stream.tls.ConnectionState() - if len(state.PeerCertificates) == 0 || len(state.PeerCertificates) > int(stream.profile.MaxPeerCertificates) { + info := tlsns.ConnectionInfo{ + LocalEndpoint: stream.local, RemoteEndpoint: stream.remote, + TLSVersion: state.Version, CipherSuite: state.CipherSuite, NegotiatedALPN: state.NegotiatedProtocol, + Resumed: state.DidResume, Role: stream.role, + } + switch stream.role { + case tlsns.RoleClient: + if err := validatePeerCertificates(state.PeerCertificates, state.VerifiedChains, stream.profile.MaxCertificateChainBytes, stream.profile.MaxPeerCertificates, true); err != nil { + return err + } + if stream.profile.RequiredALPN != "" && state.NegotiatedProtocol != stream.profile.RequiredALPN { + return ErrALPN + } + info.PeerAuthenticated = true + info.PeerLeafSPKI256 = sha256.Sum256(state.PeerCertificates[0].RawSubjectPublicKeyInfo) + info.VerifiedIdentity = stream.identity + case tlsns.RoleServer: + requirePeer := stream.serverProfile.Config.ClientAuth == cryptotls.RequireAndVerifyClientCert + if err := validatePeerCertificates(state.PeerCertificates, state.VerifiedChains, stream.serverProfile.MaxCertificateChainBytes, stream.serverProfile.MaxPeerCertificates, requirePeer); err != nil { + return err + } + if stream.serverProfile.RequiredALPN != "" && state.NegotiatedProtocol != stream.serverProfile.RequiredALPN { + return ErrALPN + } + if len(state.PeerCertificates) != 0 { + info.PeerAuthenticated = len(state.VerifiedChains) != 0 + if info.PeerAuthenticated { + info.PeerLeafSPKI256 = sha256.Sum256(state.PeerCertificates[0].RawSubjectPublicKeyInfo) + } + } + default: + return ErrInvalidConfig + } + if !info.Valid(255) { + return ErrInvalidConfig + } + stream.info = info + return nil +} + +func validatePeerCertificates(peer []*x509.Certificate, verified [][]*x509.Certificate, maxBytes int, maxCertificates uint16, required bool) error { + if len(peer) == 0 { + if required { + return x509.UnknownAuthorityError{} + } + return nil + } + if len(peer) > int(maxCertificates) { return ErrCertificateLimit } total := 0 - for _, certificate := range state.PeerCertificates { + for _, certificate := range peer { total += len(certificate.Raw) - if total > stream.profile.MaxCertificateChainBytes { + if total > maxBytes { return ErrCertificateLimit } } - if len(state.VerifiedChains) == 0 { + if len(verified) == 0 { return x509.UnknownAuthorityError{} } - if stream.profile.RequiredALPN != "" && state.NegotiatedProtocol != stream.profile.RequiredALPN { - return ErrALPN - } - stream.info = tlsns.ConnectionInfo{ - LocalEndpoint: stream.local, RemoteEndpoint: stream.remote, - TLSVersion: state.Version, CipherSuite: state.CipherSuite, NegotiatedALPN: state.NegotiatedProtocol, - Resumed: state.DidResume, PeerLeafSPKI256: sha256.Sum256(state.PeerCertificates[0].RawSubjectPublicKeyInfo), - VerifiedIdentity: stream.identity, - } return nil } diff --git a/internal/backend/gotls/stream_test.go b/internal/backend/gotls/stream_test.go index c111407..0d0de74 100644 --- a/internal/backend/gotls/stream_test.go +++ b/internal/backend/gotls/stream_test.go @@ -96,6 +96,83 @@ func TestClientHandshakeVerificationALPNAndPlaintext(t *testing.T) { t.Fatal("plaintext did not reach peer") } +func TestServerHandshakeALPNAndPlaintext(t *testing.T) { + certificate, roots := testCertificate(t, "server.example.com") + clientBridge := newBridgeConn(64<<10, 64<<10, 1<<20) + client := cryptotls.Client(clientBridge, &cryptotls.Config{ + RootCAs: roots, ServerName: "server.example.com", Time: func() time.Time { return time.Unix(1_800_000_000, 0) }, MinVersion: cryptotls.VersionTLS13, + MaxVersion: cryptotls.VersionTLS13, NextProtos: []string{"h2"}, + }) + clientDone := make(chan error, 1) + go func() { + err := client.Handshake() + clientBridge.finishHandshake() + clientDone <- err + }() + + local := nscore.Endpoint{Address: netip.MustParseAddr("192.0.2.2"), Port: 443} + remote := nscore.Endpoint{Address: netip.MustParseAddr("192.0.2.1"), Port: 49152} + profile := ServerProfile{ + ID: 9, + Config: &cryptotls.Config{ + Certificates: []cryptotls.Certificate{certificate}, MinVersion: cryptotls.VersionTLS13, + MaxVersion: cryptotls.VersionTLS13, NextProtos: []string{"h2"}, + }, + RequiredALPN: "h2", MaxCertificateChainBytes: 64 << 10, MaxPeerCertificates: 4, + } + server, err := NewServer(&memoryTransport{peer: clientBridge, local: local, remote: remote, readLimit: 13, writeLimit: 11}, profile, testLimits()) + if err != nil { + t.Fatal(err) + } + defer server.Close() + + for attempt := 0; attempt < 1000000; attempt++ { + progress, err := server.TryFinishConnect() + if err != nil { + t.Fatal(err) + } + if progress == nscore.ProgressDone { + break + } + runtime.Gosched() + if attempt == 999999 { + t.Fatal("server handshake did not complete") + } + } + if err := <-clientDone; err != nil { + t.Fatal(err) + } + info, ok := server.ConnectionInfo() + if !ok || info.Role != tlsns.RoleServer || info.PeerAuthenticated || info.NegotiatedALPN != "h2" || info.LocalEndpoint != local || info.RemoteEndpoint != remote { + t.Fatalf("server connection info = %+v, %v", info, ok) + } + + clientWrite := make(chan error, 1) + go func() { + _, err := client.Write([]byte("hello")) + clientWrite <- err + }() + buffer := make([]byte, 5) + for attempt := 0; attempt < 100000; attempt++ { + _, _, _ = server.TryService(nscore.ServiceBudget{Packets: 8, Bytes: 64 << 10, Operations: 8}) + result, err := server.TryRead(buffer) + if err != nil { + t.Fatal(err) + } + if result.State == nscore.IOReady && result.Bytes != 0 { + if string(buffer[:result.Bytes]) != "hello" { + t.Fatalf("server plaintext = %q", buffer[:result.Bytes]) + } + if err := <-clientWrite; err != nil { + t.Fatal(err) + } + return + } + runtime.Gosched() + } + t.Fatal("client plaintext did not reach bounded TLS server") +} + func TestTransportEOFConsumesExactlyOneServiceOperation(t *testing.T) { peer := newBridgeConn(32, 32, 64) transport := &memoryTransport{peer: peer} @@ -159,17 +236,24 @@ func testLimits() Limits { } type memoryTransport struct { - peer *bridgeConn - closed atomic.Bool - eof atomic.Bool - readLimit int - writeLimit int + peer *bridgeConn + closed atomic.Bool + eof atomic.Bool + readLimit int + writeLimit int + local, remote nscore.Endpoint } func (transport *memoryTransport) LocalEndpoint() nscore.Endpoint { + if transport.local.Valid() { + return transport.local + } return nscore.Endpoint{Address: netip.MustParseAddr("192.0.2.1"), Port: 49152} } func (transport *memoryTransport) RemoteEndpoint() nscore.Endpoint { + if transport.remote.Valid() { + return transport.remote + } return nscore.Endpoint{Address: netip.MustParseAddr("192.0.2.2"), Port: 443} } func (transport *memoryTransport) Readiness() nscore.Readiness { diff --git a/internal/backend/lneto/tcp/tcp.go b/internal/backend/lneto/tcp/tcp.go index dfc53e7..84ab8e0 100644 --- a/internal/backend/lneto/tcp/tcp.go +++ b/internal/backend/lneto/tcp/tcp.go @@ -508,12 +508,31 @@ func (n *Adapter) ingressTCPPayloadLocked(payload []byte, checksum *lneto.CRC791 return false, nil } +// ListenAuthorizer decides whether one structurally valid local endpoint may +// consume a private TCP listener and returns a stable classified failure when +// denied or unsupported. It runs while the shared core lock is held and must +// not block, retain either argument, or call back into the adapter. +type ListenAuthorizer func(*policy.Policy, nscore.Endpoint) error + // TryListenTCP implements the narrow TCP namespace facet. func (n *Adapter) TryListenTCP(local nscore.Endpoint) (nscore.Resource, nscore.Progress, error) { return n.TryListen(local) } +// TryListen preserves the public raw-TCP policy behavior. func (n *Adapter) TryListen(local nscore.Endpoint) (nscore.Resource, nscore.Progress, error) { + return n.TryListenAuthorized(local, func(compiled *policy.Policy, endpoint nscore.Endpoint) error { + if !compiled.CheckEndpoint(policy.OperationTCPListen, endpoint.Address, endpoint.Port) { + return nscore.Fail(nscore.FailureAccessDenied, ErrPolicyDenied) + } + return nil + }) +} + +// TryListenAuthorized creates a private TCP listener only when the selecting +// protocol's authorizer permits it. The returned listener remains owned by the +// caller and is never published as a raw-TCP guest handle. +func (n *Adapter) TryListenAuthorized(local nscore.Endpoint, authorize ListenAuthorizer) (nscore.Resource, nscore.Progress, error) { if n == nil { return nil, 0, nscore.Fail(nscore.FailureClosed, net.ErrClosed) } @@ -538,9 +557,12 @@ func (n *Adapter) TryListen(local nscore.Endpoint) (nscore.Resource, nscore.Prog } else if local.FlowInfo != 0 || (!local.Address.IsUnspecified() && (local.Address != n.core.IPv6AddressLocked() || !n.ipv6ScopeMatchesLocked(local))) { return nil, 0, nscore.Fail(nscore.FailureAddressUnavailable, lneto.ErrInvalidAddr) } - if !n.policy.CheckEndpoint(policy.OperationTCPListen, local.Address, local.Port) { + if authorize == nil { return nil, 0, nscore.Fail(nscore.FailureAccessDenied, ErrPolicyDenied) } + if err := authorize(n.policy, local); err != nil { + return nil, 0, err + } if len(n.listeners) == int(n.config.MaxListeners) { return nil, 0, nscore.Fail(nscore.FailureResourceLimit, lneto.ErrExhausted) } diff --git a/internal/backend/lneto/tcp/tcp_test.go b/internal/backend/lneto/tcp/tcp_test.go index 0eff264..da29b57 100644 --- a/internal/backend/lneto/tcp/tcp_test.go +++ b/internal/backend/lneto/tcp/tcp_test.go @@ -3,6 +3,7 @@ package tcp import ( "bytes" "encoding/binary" + "errors" "net/netip" "sync" "testing" @@ -101,6 +102,33 @@ func TestValidConfigRejectsOverflowAndKeepsAdapterCreationBounded(t *testing.T) } } +func TestTryListenAuthorizedUsesProtocolSpecificAuthority(t *testing.T) { + _, adapter := newTestAdapter(t, 2, 1, 0) + endpoint := nscore.Endpoint{Address: netip.MustParseAddr("192.0.2.2"), Port: 4202} + called := false + denied := nscore.Fail(nscore.FailureAccessDenied, ErrPolicyDenied) + resourceValue, progress, err := adapter.TryListenAuthorized(endpoint, func(compiled *policy.Policy, got nscore.Endpoint) error { + called = true + if compiled == nil || got != endpoint { + t.Fatalf("authorizer input = %p, %+v", compiled, got) + } + return denied + }) + if !called || resourceValue != nil || progress != 0 || !errors.Is(err, denied) { + t.Fatalf("denied private listen = called=%v resource=%T progress=%v err=%v", called, resourceValue, progress, err) + } + resourceValue, progress, err = adapter.TryListenAuthorized(endpoint, func(*policy.Policy, nscore.Endpoint) error { return nil }) + if err != nil || progress != nscore.ProgressDone || resourceValue == nil { + t.Fatalf("authorized private listen = %T, %v, %v", resourceValue, progress, err) + } + if err := resourceValue.Close(); err != nil { + t.Fatal(err) + } + if value, progress, err := adapter.TryListenAuthorized(endpoint, nil); value != nil || progress != 0 || err == nil { + t.Fatalf("nil-authorizer listen = %T, %v, %v", value, progress, err) + } +} + func TestListenerAndConnectReuseReduceSteadyStateAllocations(t *testing.T) { _, adapter := newTestAdapter(t, 3, 1, 1) listen := nscore.Endpoint{Address: netip.MustParseAddr("192.0.2.3"), Port: 4203} diff --git a/internal/instance/tls/tls_test.go b/internal/instance/tls/tls_test.go index bb6f3a2..e94ab63 100644 --- a/internal/instance/tls/tls_test.go +++ b/internal/instance/tls/tls_test.go @@ -65,7 +65,7 @@ func (stream *fakeStream) ConnectionInfo() (tlsns.ConnectionInfo, bool) { return func TestTLSOperationsKeepHandlesKindSpecificAndPartial(t *testing.T) { local := nscore.Endpoint{Address: netip.MustParseAddr("192.0.2.1"), Port: 49152} remote := nscore.Endpoint{Address: netip.MustParseAddr("192.0.2.2"), Port: 443} - info := tlsns.ConnectionInfo{LocalEndpoint: local, RemoteEndpoint: remote, TLSVersion: 0x304, CipherSuite: 0x1301, NegotiatedALPN: "h2", VerifiedIdentity: tlsns.IdentityDNS} + info := tlsns.ConnectionInfo{LocalEndpoint: local, RemoteEndpoint: remote, TLSVersion: 0x304, CipherSuite: 0x1301, NegotiatedALPN: "h2", Role: tlsns.RoleClient, PeerAuthenticated: true, PeerLeafSPKI256: [32]byte{1}, VerifiedIdentity: tlsns.IdentityDNS} stream := &fakeStream{local: local, remote: remote, input: []byte("reply"), info: info} namespace := &fakeNamespace{stream: stream} state, manager, instance := attachState(t, namespace) @@ -103,7 +103,7 @@ func TestTLSOperationsKeepHandlesKindSpecificAndPartial(t *testing.T) { func TestTLSHandleIsCrossInstanceAndWrongKindSafe(t *testing.T) { endpoint := nscore.Endpoint{Address: netip.MustParseAddr("192.0.2.2"), Port: 443} - firstStream := &fakeStream{local: endpoint, remote: endpoint, info: tlsns.ConnectionInfo{LocalEndpoint: endpoint, RemoteEndpoint: endpoint, TLSVersion: 0x304, CipherSuite: 0x1301, VerifiedIdentity: tlsns.IdentityIP}} + firstStream := &fakeStream{local: endpoint, remote: endpoint, info: tlsns.ConnectionInfo{LocalEndpoint: endpoint, RemoteEndpoint: endpoint, TLSVersion: 0x304, CipherSuite: 0x1301, Role: tlsns.RoleClient, PeerAuthenticated: true, PeerLeafSPKI256: [32]byte{1}, VerifiedIdentity: tlsns.IdentityIP}} first, firstManager, firstInstance := attachState(t, &fakeNamespace{stream: firstStream}) defer firstManager.Detach(firstInstance) handle, _, err := Connect(first, first.NamespaceHandle(), endpoint, 1, "192.0.2.2") diff --git a/internal/namespace/tls/tls.go b/internal/namespace/tls/tls.go index 092e54e..d9ae39b 100644 --- a/internal/namespace/tls/tls.go +++ b/internal/namespace/tls/tls.go @@ -13,29 +13,54 @@ const MaxReadBytes = 64 << 10 type IdentityType uint8 const ( - IdentityDNS IdentityType = iota + 1 + IdentityNone IdentityType = iota + IdentityDNS IdentityIP ) +// Role records which side of the authenticated TLS channel is locally owned. +type Role uint8 + +const ( + RoleClient Role = iota + 1 + RoleServer +) + // ConnectionInfo is bounded post-handshake metadata. Certificate chains and -// private key material are deliberately absent. +// private key material are deliberately absent. Client streams always +// authenticate their server peer. A server stream may omit peer authentication +// when its immutable profile does not require a client certificate. type ConnectionInfo struct { - LocalEndpoint nscore.Endpoint - RemoteEndpoint nscore.Endpoint - TLSVersion uint16 - CipherSuite uint16 - NegotiatedALPN string - Resumed bool - PeerLeafSPKI256 [32]byte - VerifiedIdentity IdentityType + LocalEndpoint nscore.Endpoint + RemoteEndpoint nscore.Endpoint + TLSVersion uint16 + CipherSuite uint16 + NegotiatedALPN string + Resumed bool + PeerAuthenticated bool + PeerLeafSPKI256 [32]byte + VerifiedIdentity IdentityType + Role Role } // Valid reports whether metadata can be represented without truncation. func (info ConnectionInfo) Valid(maxALPN int) bool { - return info.LocalEndpoint.Valid() && info.RemoteEndpoint.Valid() && - info.TLSVersion != 0 && info.CipherSuite != 0 && - (info.VerifiedIdentity == IdentityDNS || info.VerifiedIdentity == IdentityIP) && - len(info.NegotiatedALPN) <= maxALPN + if !info.LocalEndpoint.Valid() || !info.RemoteEndpoint.Valid() || info.TLSVersion == 0 || info.CipherSuite == 0 || len(info.NegotiatedALPN) > maxALPN { + return false + } + switch info.Role { + case RoleClient: + return info.PeerAuthenticated && + (info.VerifiedIdentity == IdentityDNS || info.VerifiedIdentity == IdentityIP) && + info.PeerLeafSPKI256 != ([32]byte{}) + case RoleServer: + if !info.PeerAuthenticated { + return info.VerifiedIdentity == IdentityNone && info.PeerLeafSPKI256 == ([32]byte{}) + } + return info.PeerLeafSPKI256 != ([32]byte{}) + default: + return false + } } // Namespace creates only outbound secure streams from finite host profiles. diff --git a/internal/namespace/tls/tls_test.go b/internal/namespace/tls/tls_test.go index 8a6867a..4f2e7be 100644 --- a/internal/namespace/tls/tls_test.go +++ b/internal/namespace/tls/tls_test.go @@ -11,7 +11,8 @@ func TestConnectionInfoValidation(t *testing.T) { info := ConnectionInfo{ LocalEndpoint: nscore.Endpoint{Address: netip.MustParseAddr("192.0.2.1"), Port: 49152}, RemoteEndpoint: nscore.Endpoint{Address: netip.MustParseAddr("192.0.2.2"), Port: 443}, - TLSVersion: 0x304, CipherSuite: 0x1301, NegotiatedALPN: "h2", VerifiedIdentity: IdentityDNS, + TLSVersion: 0x304, CipherSuite: 0x1301, NegotiatedALPN: "h2", Role: RoleClient, + PeerAuthenticated: true, PeerLeafSPKI256: [32]byte{1}, VerifiedIdentity: IdentityDNS, } if !info.Valid(32) { t.Fatal("valid info rejected") @@ -20,4 +21,15 @@ func TestConnectionInfoValidation(t *testing.T) { if info.Valid(2) { t.Fatal("oversized ALPN accepted") } + server := ConnectionInfo{ + LocalEndpoint: info.LocalEndpoint, RemoteEndpoint: info.RemoteEndpoint, + TLSVersion: 0x304, CipherSuite: 0x1301, Role: RoleServer, + } + if !server.Valid(32) { + t.Fatal("valid unauthenticated-peer server info rejected") + } + server.PeerAuthenticated = true + if server.Valid(32) { + t.Fatal("server peer authentication without a peer key accepted") + } } diff --git a/internal/policy/policy.go b/internal/policy/policy.go index 1c20035..a958160 100644 --- a/internal/policy/policy.go +++ b/internal/policy/policy.go @@ -84,6 +84,7 @@ const ( OperationDHCPv6ClientSend OperationDHCPv6ClientReceive OperationTLSConnect + OperationTLSListen ) // PortRange is an inclusive port selector. @@ -518,6 +519,8 @@ func operationEndpoint(operation Operation) (Transport, Direction, bool) { return TransportTCP, DirectionOutbound, true case OperationTLSConnect: return TransportTLS, DirectionOutbound, true + case OperationTLSListen: + return TransportTLS, DirectionInbound, true case OperationNTPSync: return TransportNTP, DirectionOutbound, true case OperationMDNSSend: diff --git a/internal/policy/tls_test.go b/internal/policy/tls_test.go index 009ffcc..cf2ed62 100644 --- a/internal/policy/tls_test.go +++ b/internal/policy/tls_test.go @@ -26,6 +26,27 @@ func TestTLSAuthorityIsDistinctAndHonorsRawTCPDeny(t *testing.T) { } } +func TestTLSServerAuthorityIsDistinctAndHonorsRawTCPDeny(t *testing.T) { + denied := netip.MustParsePrefix("192.0.2.9/32") + compiled, err := Compile(Config{Rules: []Rule{ + {Action: ActionAllow, Transports: []Transport{TransportTLS}, Directions: []Direction{DirectionInbound}}, + {Action: ActionDeny, Transports: []Transport{TransportTCP}, Directions: []Direction{DirectionInbound}, Prefixes: []netip.Prefix{denied}}, + }}) + if err != nil { + t.Fatal(err) + } + allowed := netip.MustParseAddr("192.0.2.8") + if !compiled.CheckEndpoint(OperationTLSListen, allowed, 8443) { + t.Fatal("TLS server authority denied ordinary endpoint") + } + if compiled.CheckEndpoint(OperationTCPListen, allowed, 8443) { + t.Fatal("TLS server authority implied raw TCP listen") + } + if compiled.CheckEndpoint(OperationTLSListen, denied.Addr(), 8443) { + t.Fatal("raw TCP inbound deny failed to constrain private TLS listener") + } +} + func TestTLSSpecialClassesRemainTLSScoped(t *testing.T) { compiled, err := Compile(Config{ Rules: []Rule{{Action: ActionAllow, Transports: []Transport{TransportTLS}, Directions: []Direction{DirectionOutbound}}}, diff --git a/tls/profile.go b/tls/profile.go index 15a4e05..6459102 100644 --- a/tls/profile.go +++ b/tls/profile.go @@ -1,6 +1,7 @@ package tls import ( + "crypto" cryptotls "crypto/tls" "crypto/x509" "errors" @@ -13,10 +14,11 @@ import ( ) var ( - ErrInvalidProfile = errors.New("wagonet/tls: invalid client profile") - ErrUnsafeTLSConfig = errors.New("wagonet/tls: unsafe TLS configuration") - ErrUnauthorizedName = errors.New("wagonet/tls: server name is not authorized") - ErrTLS12RequiresOptIn = errors.New("wagonet/tls: TLS 1.2 requires explicit opt-in") + ErrInvalidProfile = errors.New("wagonet/tls: invalid client profile") + ErrInvalidServerProfile = errors.New("wagonet/tls: invalid server profile") + ErrUnsafeTLSConfig = errors.New("wagonet/tls: unsafe TLS configuration") + ErrUnauthorizedName = errors.New("wagonet/tls: server name is not authorized") + ErrTLS12RequiresOptIn = errors.New("wagonet/tls: TLS 1.2 requires explicit opt-in") ) // ClientProfile is an effectively immutable host-defined TLS client profile. @@ -29,6 +31,16 @@ type ClientProfile struct { allowTLS12 bool } +// ServerProfile is an effectively immutable host-defined TLS server profile. +// Certificate chains and private keys remain host-owned and never enter guest +// memory; guests can select only the numeric profile ID while listening. +type ServerProfile struct { + id uint32 + config *cryptotls.Config + requiredALPN string + allowTLS12 bool +} + type identityKind uint8 const ( @@ -51,6 +63,22 @@ type profileBuilder struct { allowTLS12 bool } +// ServerProfileOption constrains one host-owned server profile. +type ServerProfileOption interface { + applyServerProfile(*serverProfileBuilder) error +} + +type serverProfileOptionFunc func(*serverProfileBuilder) error + +func (option serverProfileOptionFunc) applyServerProfile(builder *serverProfileBuilder) error { + return option(builder) +} + +type serverProfileBuilder struct { + requiredALPN string + allowTLS12 bool +} + // AllowServerNames authorizes exact normalized DNS names or canonical IP // literals. The guest must select one of these identities before any network // activity begins. @@ -95,6 +123,27 @@ func EnableTLS12() ClientProfileOption { }) } +// RequireServerALPN requires an accepted client to negotiate exactly protocol. +// The offered protocol list remains immutable host configuration. +func RequireServerALPN(protocol string) ServerProfileOption { + return serverProfileOptionFunc(func(builder *serverProfileBuilder) error { + if !validALPN(protocol) || builder.requiredALPN != "" { + return ErrInvalidServerProfile + } + builder.requiredALPN = protocol + return nil + }) +} + +// EnableServerTLS12 is the conspicuous opt-in required before a server profile +// may lower MinVersion to TLS 1.2. +func EnableServerTLS12() ServerProfileOption { + return serverProfileOptionFunc(func(builder *serverProfileBuilder) error { + builder.allowTLS12 = true + return nil + }) +} + // NewClientProfile validates and deeply clones a caller-owned crypto/tls // configuration. Later mutation of the supplied config, trust pool, certificate // slices, or ALPN slice cannot change the profile. @@ -128,7 +177,38 @@ func NewClientProfile(id uint32, config *cryptotls.Config, options ...ClientProf return &ClientProfile{id: id, config: cloned, allowedNames: builder.allowedNames, requiredALPN: builder.requiredALPN, allowTLS12: builder.allowTLS12}, nil } -// ID returns the finite guest-selectable profile identifier. +// NewServerProfile validates and deeply clones a caller-owned crypto/tls +// server configuration. Static certificates are mandatory. Dynamic +// certificate, verification, session, entropy, and key-log callbacks are +// rejected so guest traffic cannot mutate host policy. +func NewServerProfile(id uint32, config *cryptotls.Config, options ...ServerProfileOption) (*ServerProfile, error) { + if id == 0 || config == nil { + return nil, ErrInvalidServerProfile + } + builder := serverProfileBuilder{} + for _, option := range options { + if option == nil { + return nil, ErrInvalidServerProfile + } + if err := option.applyServerProfile(&builder); err != nil { + return nil, err + } + } + cloned, err := cloneSafeServerConfig(config, builder.allowTLS12) + if err != nil { + return nil, err + } + if builder.requiredALPN != "" { + if len(cloned.NextProtos) == 0 { + cloned.NextProtos = []string{builder.requiredALPN} + } else if !slices.Contains(cloned.NextProtos, builder.requiredALPN) { + return nil, ErrInvalidServerProfile + } + } + return &ServerProfile{id: id, config: cloned, requiredALPN: builder.requiredALPN, allowTLS12: builder.allowTLS12}, nil +} + +// ID returns the finite guest-selectable client profile identifier. func (profile *ClientProfile) ID() uint32 { if profile == nil { return 0 @@ -136,6 +216,14 @@ func (profile *ClientProfile) ID() uint32 { return profile.id } +// ID returns the finite guest-selectable server profile identifier. +func (profile *ServerProfile) ID() uint32 { + if profile == nil { + return 0 + } + return profile.id +} + func (profile *ClientProfile) authorizeServerName(name string) (string, identityKind, error) { if profile == nil { return "", 0, ErrInvalidProfile @@ -197,6 +285,74 @@ func cloneSafeConfig(input *cryptotls.Config, allowTLS12 bool) (*cryptotls.Confi return cloned, nil } +func cloneSafeServerConfig(input *cryptotls.Config, allowTLS12 bool) (*cryptotls.Config, error) { + if len(input.Certificates) == 0 { + return nil, ErrInvalidServerProfile + } + if input.InsecureSkipVerify || input.KeyLogWriter != nil || input.Renegotiation != cryptotls.RenegotiateNever || + input.VerifyPeerCertificate != nil || input.VerifyConnection != nil || input.GetClientCertificate != nil || + input.GetCertificate != nil || input.GetConfigForClient != nil || input.ClientSessionCache != nil || + input.UnwrapSession != nil || input.WrapSession != nil || input.Rand != nil || input.NameToCertificate != nil || + input.RootCAs != nil || input.ServerName != "" || input.SessionTicketKey != ([32]byte{}) || + len(input.CipherSuites) != 0 || len(input.CurvePreferences) != 0 || + len(input.EncryptedClientHelloConfigList) != 0 || input.EncryptedClientHelloRejectionVerify != nil || + len(input.EncryptedClientHelloKeys) != 0 { + return nil, ErrUnsafeTLSConfig + } + if input.ClientAuth != cryptotls.NoClientCert && input.ClientAuth != cryptotls.RequireAndVerifyClientCert { + return nil, ErrUnsafeTLSConfig + } + if input.ClientAuth == cryptotls.RequireAndVerifyClientCert && input.ClientCAs == nil { + return nil, ErrInvalidServerProfile + } + for _, certificate := range input.Certificates { + signer, signerOK := certificate.PrivateKey.(crypto.Signer) + if len(certificate.Certificate) == 0 || !signerOK || signer.Public() == nil { + return nil, ErrInvalidServerProfile + } + for _, der := range certificate.Certificate { + if len(der) == 0 { + return nil, ErrInvalidServerProfile + } + } + } + cloned := input.Clone() + cloned.NextProtos = append([]string(nil), input.NextProtos...) + for _, protocol := range cloned.NextProtos { + if !validALPN(protocol) { + return nil, ErrInvalidServerProfile + } + } + cloned.Certificates = cloneCertificates(input.Certificates) + if input.ClientCAs != nil { + cloned.ClientCAs = input.ClientCAs.Clone() + } + // Session resumption requires additional key-rotation and retained-state + // policy. Disable it until that authority is represented explicitly. + cloned.SessionTicketsDisabled = true + minVersion := cloned.MinVersion + if minVersion == 0 { + minVersion = cryptotls.VersionTLS13 + } + if minVersion < cryptotls.VersionTLS12 { + return nil, ErrUnsafeTLSConfig + } + if minVersion == cryptotls.VersionTLS12 && !allowTLS12 { + return nil, ErrTLS12RequiresOptIn + } + if minVersion > cryptotls.VersionTLS13 { + return nil, ErrInvalidServerProfile + } + cloned.MinVersion = minVersion + if cloned.MaxVersion == 0 { + cloned.MaxVersion = cryptotls.VersionTLS13 + } + if cloned.MaxVersion < cloned.MinVersion || cloned.MaxVersion > cryptotls.VersionTLS13 { + return nil, ErrInvalidServerProfile + } + return cloned, nil +} + func cloneCertificates(input []cryptotls.Certificate) []cryptotls.Certificate { out := make([]cryptotls.Certificate, len(input)) for i := range input { diff --git a/tls/profile_test.go b/tls/profile_test.go index 49208f2..46b5edb 100644 --- a/tls/profile_test.go +++ b/tls/profile_test.go @@ -1,9 +1,14 @@ package tls import ( + "crypto/ed25519" + "crypto/rand" cryptotls "crypto/tls" + "crypto/x509" + "math/big" "net/netip" "testing" + "time" "github.com/wago-org/net/internal/policy" ) @@ -61,6 +66,70 @@ func TestClientProfileRequiresTLS12OptInAndExactIdentity(t *testing.T) { } } +func TestServerProfileDefaultsTLS13ClonesAndRequiresStaticCertificate(t *testing.T) { + config := testServerConfig(t) + profile, err := NewServerProfile(7, config, RequireServerALPN("h2")) + if err != nil { + t.Fatal(err) + } + originalDER := append([]byte(nil), profile.config.Certificates[0].Certificate[0]...) + config.NextProtos[0] = "mutated" + config.Certificates[0].Certificate[0][0] ^= 0xff + config.SessionTicketsDisabled = false + if profile.ID() != 7 || profile.config.NextProtos[0] != "h2" || string(profile.config.Certificates[0].Certificate[0]) != string(originalDER) { + t.Fatal("server profile retained caller mutation") + } + if profile.config.MinVersion != cryptotls.VersionTLS13 || profile.config.MaxVersion != cryptotls.VersionTLS13 || !profile.config.SessionTicketsDisabled { + t.Fatalf("server profile defaults = %x..%x tickets-disabled=%v", profile.config.MinVersion, profile.config.MaxVersion, profile.config.SessionTicketsDisabled) + } + if _, err := NewServerProfile(8, &cryptotls.Config{}); err != ErrInvalidServerProfile { + t.Fatalf("missing certificate = %v", err) + } +} + +func TestServerProfileRejectsUnsafeConfigurationAndRequiresTLS12OptIn(t *testing.T) { + unsafe := testServerConfig(t) + unsafe.GetCertificate = func(*cryptotls.ClientHelloInfo) (*cryptotls.Certificate, error) { return nil, nil } + if _, err := NewServerProfile(1, unsafe); err != ErrUnsafeTLSConfig { + t.Fatalf("dynamic certificate callback = %v", err) + } + invalidClientAuth := testServerConfig(t) + invalidClientAuth.ClientAuth = cryptotls.RequireAndVerifyClientCert + if _, err := NewServerProfile(1, invalidClientAuth); err != ErrInvalidServerProfile { + t.Fatalf("client auth without roots = %v", err) + } + tls12 := testServerConfig(t) + tls12.MinVersion = cryptotls.VersionTLS12 + if _, err := NewServerProfile(1, tls12); err != ErrTLS12RequiresOptIn { + t.Fatalf("TLS 1.2 without opt-in = %v", err) + } + if _, err := NewServerProfile(1, tls12, EnableServerTLS12()); err != nil { + t.Fatalf("TLS 1.2 with opt-in = %v", err) + } +} + +func testServerConfig(t testing.TB) *cryptotls.Config { + t.Helper() + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + now := time.Unix(1_800_000_000, 0) + der, err := x509.CreateCertificate(rand.Reader, &x509.Certificate{ + SerialNumber: big.NewInt(1), DNSNames: []string{"server.example.com"}, + NotBefore: now.Add(-time.Hour), NotAfter: now.Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + }, &x509.Certificate{ + SerialNumber: big.NewInt(1), DNSNames: []string{"server.example.com"}, + NotBefore: now.Add(-time.Hour), NotAfter: now.Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + }, publicKey, privateKey) + if err != nil { + t.Fatal(err) + } + return &cryptotls.Config{Certificates: []cryptotls.Certificate{{Certificate: [][]byte{der}, PrivateKey: privateKey}}, NextProtos: []string{"h2"}} +} + func TestAllowLoopbackRegistrationAuthorityIsTLSScoped(t *testing.T) { configuration := registration{config: DefaultConfig(), defaultAuthority: true} if err := AllowLoopback().applyTLS(&configuration); err != nil { From 52c9d8da0bf6dde93fee82afa39a4b290068fc3a Mon Sep 17 00:00:00 2001 From: Wago Networking Agent Date: Mon, 20 Jul 2026 15:48:29 +0000 Subject: [PATCH 02/17] feat: expose host-profiled TLS server listeners --- internal/abi/tls/tls.go | 25 ++ internal/backend/lneto/tls/tls.go | 255 +++++++++++++++++- internal/backend/lneto/tls/tls_test.go | 53 ++++ internal/binding/tls/descriptor_test.go | 6 +- internal/binding/tls/tls.go | 114 +++++++- .../dependencytest/inspection_tls_test.go | 4 +- internal/instance/tls/tls.go | 136 +++++++++- internal/instance/tls/tls_test.go | 68 ++++- internal/namespace/tls/tls.go | 12 +- internal/resource/table.go | 3 + internal/tlslimits/limits.go | 32 ++- tls/config.go | 8 +- tls/config_test.go | 5 +- tls/profile_test.go | 25 +- tls/register_test.go | 4 +- tls/tls.go | 90 ++++++- 16 files changed, 789 insertions(+), 51 deletions(-) diff --git a/internal/abi/tls/tls.go b/internal/abi/tls/tls.go index a325be3..9608436 100644 --- a/internal/abi/tls/tls.go +++ b/internal/abi/tls/tls.go @@ -12,6 +12,7 @@ import ( const ( StreamV1Size uint32 = 72 + ListenerV1Size uint32 = 40 IOResultV1Size uint32 = 8 ConnectionInfoV1Size uint32 = 144 MaxALPNV1Bytes uint32 = 32 @@ -25,6 +26,13 @@ func CheckCreateV1(memory []byte, endpointPtr, serverNamePtr, serverNameLength, ) } +func CheckListenV1(memory []byte, endpointPtr, listenerPtr uint32) bool { + return abicore.CheckRanges(memory, true, + abicore.Range{Ptr: endpointPtr, Length: abicore.AddressV1Size}, + abicore.Range{Ptr: listenerPtr, Length: ListenerV1Size}, + ) +} + func CheckIOV1(memory []byte, payloadPtr, payloadLength, resultPtr uint32) bool { return abicore.CheckRanges(memory, true, abicore.Range{Ptr: payloadPtr, Length: payloadLength}, @@ -32,6 +40,23 @@ func CheckIOV1(memory []byte, payloadPtr, payloadLength, resultPtr uint32) bool ) } +func EncodeListenerV1(memory []byte, ptr uint32, handle resource.Handle, local nscore.Endpoint) bool { + if handle == 0 || !local.Valid() { + return false + } + output, ok := abicore.Slice(memory, ptr, ListenerV1Size) + if !ok { + return false + } + var encoded [ListenerV1Size]byte + binary.LittleEndian.PutUint64(encoded[0:8], uint64(handle)) + if !abicore.EncodeEndpointV1(encoded[:], 8, local) { + return false + } + copy(output, encoded[:]) + return true +} + func EncodeStreamV1(memory []byte, ptr uint32, handle resource.Handle, local, remote nscore.Endpoint) bool { if handle == 0 || !local.Valid() || !remote.Valid() { return false diff --git a/internal/backend/lneto/tls/tls.go b/internal/backend/lneto/tls/tls.go index 5e4d24e..5bd9d11 100644 --- a/internal/backend/lneto/tls/tls.go +++ b/internal/backend/lneto/tls/tls.go @@ -26,6 +26,7 @@ const tlsCloseOrder = 19 var ( ErrInvalidConfig = errors.New("net/tls: invalid lneto TLS configuration") ErrUnknownProfile = errors.New("net/tls: unknown client profile") + ErrUnknownServerProfile = errors.New("net/tls: unknown server profile") ErrUnauthorizedName = errors.New("net/tls: unauthorized server name") limitedBroadcastAddress = netip.AddrFrom4([4]byte{255, 255, 255, 255}) ) @@ -33,24 +34,29 @@ var ( // Config fixes private TCP storage, TLS queues, and immutable client profiles. type Config struct { MaxStreams uint16 + MaxListeners uint16 + AcceptBacklog uint16 MaxConcurrentHandshakes uint16 MaxServerNameBytes uint16 MaxServiceAttemptsPerHandshake uint32 TCP tcpbackend.Config Engine gotls.Limits Profiles []gotls.Profile + ServerProfiles []gotls.ServerProfile } // Adapter owns TLS streams and one private raw-byte TCP adapter. type Adapter struct { - core *lnetocore.Namespace - tcp *tcpbackend.Adapter - quotas *quota.Account - config Config - storage tlslimits.Plan - profiles map[uint32]gotls.Profile + core *lnetocore.Namespace + tcp *tcpbackend.Adapter + quotas *quota.Account + config Config + storage tlslimits.Plan + profiles map[uint32]gotls.Profile + serverProfiles map[uint32]gotls.ServerProfile mu sync.Mutex + listeners []*listener streams []*stream handshakes int closed bool @@ -71,8 +77,10 @@ func New(common *lnetocore.Namespace, config Config) (*Adapter, error) { common.Unlock() adapter := &Adapter{ core: common, quotas: quotas, config: config, storage: storage, - profiles: make(map[uint32]gotls.Profile, len(config.Profiles)), - streams: make([]*stream, 0, config.MaxStreams), + profiles: make(map[uint32]gotls.Profile, len(config.Profiles)), + serverProfiles: make(map[uint32]gotls.ServerProfile, len(config.ServerProfiles)), + listeners: make([]*listener, 0, config.MaxListeners), + streams: make([]*stream, 0, config.MaxStreams), } for _, input := range config.Profiles { profile, err := input.Clone() @@ -84,6 +92,16 @@ func New(common *lnetocore.Namespace, config Config) (*Adapter, error) { } adapter.profiles[profile.ID] = profile } + for _, input := range config.ServerProfiles { + profile, err := input.Clone() + if err != nil { + return nil, nscore.Fail(nscore.FailureUnsupportedConfiguration, err) + } + if _, exists := adapter.serverProfiles[profile.ID]; exists { + return nil, nscore.Fail(nscore.FailureInvalidArgument, ErrInvalidConfig) + } + adapter.serverProfiles[profile.ID] = profile + } privateTCP, err := tcpbackend.New(common, config.TCP) if err != nil { return nil, err @@ -105,8 +123,8 @@ func validConfig(config Config) bool { func validateConfig(config Config, maxIntValue uint64) (tlslimits.Plan, bool) { if config.MaxServerNameBytes == 0 || config.MaxServerNameBytes > 253 || config.MaxServiceAttemptsPerHandshake == 0 || config.MaxServiceAttemptsPerHandshake > tlslimits.MaxServiceAttempts || - config.TCP.MaxListeners != 0 || config.TCP.MaxOutboundStreams < config.MaxStreams || config.TCP.TransmitPackets <= 0 || config.TCP.TransmitPackets > tlslimits.MaxTransportPackets || - config.TCP.TransmitPackets > config.TCP.TransmitBytes || len(config.Profiles) == 0 || len(config.Profiles) > tlslimits.MaxProfiles { + config.TCP.MaxListeners < config.MaxListeners || config.TCP.MaxOutboundStreams < config.MaxStreams || config.TCP.AcceptBacklog != config.AcceptBacklog || config.TCP.TransmitPackets <= 0 || config.TCP.TransmitPackets > tlslimits.MaxTransportPackets || + config.TCP.TransmitPackets > config.TCP.TransmitBytes || (len(config.Profiles) == 0 && len(config.ServerProfiles) == 0) || len(config.Profiles) > tlslimits.MaxProfiles || len(config.ServerProfiles) > tlslimits.MaxProfiles { return tlslimits.Plan{}, false } for _, profile := range config.Profiles { @@ -120,8 +138,16 @@ func validateConfig(config Config, maxIntValue uint64) (tlslimits.Plan, bool) { maxCertificateBytes = profile.MaxCertificateChainBytes } } + for _, profile := range config.ServerProfiles { + if profile.MaxPeerCertificates == 0 || profile.MaxPeerCertificates > tlslimits.MaxPeerCertificates { + return tlslimits.Plan{}, false + } + if profile.MaxCertificateChainBytes > maxCertificateBytes { + maxCertificateBytes = profile.MaxCertificateChainBytes + } + } plan, ok := tlslimits.Validate(tlslimits.Config{ - MaxStreams: config.MaxStreams, MaxConcurrentHandshakes: config.MaxConcurrentHandshakes, + MaxStreams: config.MaxStreams, MaxListeners: config.MaxListeners, AcceptBacklog: config.AcceptBacklog, MaxConcurrentHandshakes: config.MaxConcurrentHandshakes, PlaintextReceiveBytes: config.Engine.PlaintextReceiveBytes, PlaintextTransmitBytes: config.Engine.PlaintextTransmitBytes, CiphertextReceiveBytes: config.Engine.CiphertextReceiveBytes, CiphertextTransmitBytes: config.Engine.CiphertextTransmitBytes, TransportReceiveBytes: config.TCP.ReceiveBytes, TransportTransmitBytes: config.TCP.TransmitBytes, @@ -133,6 +159,61 @@ func validateConfig(config Config, maxIntValue uint64) (tlslimits.Plan, bool) { return plan, true } +func (adapter *Adapter) TryListenTLS(local nscore.Endpoint, profileID uint32) (nscore.Resource, nscore.Progress, error) { + if adapter == nil { + return nil, 0, nscore.Fail(nscore.FailureClosed, net.ErrClosed) + } + profile, exists := adapter.serverProfiles[profileID] + if !exists { + return nil, 0, nscore.Fail(nscore.FailureInvalidArgument, ErrUnknownServerProfile) + } + adapter.mu.Lock() + if adapter.closed { + adapter.mu.Unlock() + return nil, 0, nscore.Fail(nscore.FailureClosed, net.ErrClosed) + } + if len(adapter.listeners) >= int(adapter.config.MaxListeners) { + adapter.mu.Unlock() + return nil, 0, nscore.Fail(nscore.FailureResourceLimit, quota.ErrLimit) + } + adapter.mu.Unlock() + + private, progress, err := adapter.tcp.TryListenAuthorized(local, func(compiled *policy.Policy, endpoint nscore.Endpoint) error { + if !compiled.CheckEndpoint(policy.OperationTLSListen, endpoint.Address, endpoint.Port) { + return nscore.Fail(nscore.FailureAccessDenied, tcpbackend.ErrPolicyDenied) + } + return nil + }) + if err != nil { + return nil, 0, err + } + transport, ok := private.(tcpns.Listener) + if !ok || resource.IsNil(transport) { + if !resource.IsNil(private) { + _ = private.Close() + } + return nil, 0, nscore.Fail(nscore.FailureIO, ErrInvalidConfig) + } + created := &listener{owner: adapter, transport: transport, profile: profile} + if err := adapter.quotas.AcquireResource(&created.retained, quota.ResourceTLS, 1); err != nil { + _ = transport.Close() + return nil, 0, mapQuotaError(err) + } + adapter.mu.Lock() + if adapter.closed || len(adapter.listeners) >= int(adapter.config.MaxListeners) { + adapter.mu.Unlock() + _ = transport.Close() + created.retained.Release() + if adapter.closed { + return nil, 0, nscore.Fail(nscore.FailureClosed, net.ErrClosed) + } + return nil, 0, nscore.Fail(nscore.FailureResourceLimit, quota.ErrLimit) + } + adapter.listeners = append(adapter.listeners, created) + adapter.mu.Unlock() + return created, progress, nil +} + func (adapter *Adapter) TryConnectTLS(remote nscore.Endpoint, profileID uint32, serverName string) (nscore.Resource, nscore.Progress, error) { if adapter == nil { return nil, 0, nscore.Fail(nscore.FailureClosed, net.ErrClosed) @@ -227,6 +308,136 @@ func mapQuotaError(err error) error { return nscore.Fail(nscore.FailureInvalidArgument, err) } +type listener struct { + owner *Adapter + transport tcpns.Listener + profile gotls.ServerProfile + retained quota.Charge + closed bool + mu sync.Mutex +} + +func (listener *listener) LocalEndpoint() nscore.Endpoint { + if listener == nil || resource.IsNil(listener.transport) { + return nscore.Endpoint{} + } + return listener.transport.LocalEndpoint() +} + +func (listener *listener) Readiness() nscore.Readiness { + if listener == nil || resource.IsNil(listener.transport) { + return nscore.ReadyClosed + } + listener.mu.Lock() + closed := listener.closed + listener.mu.Unlock() + if closed { + return nscore.ReadyClosed + } + return listener.transport.Readiness() +} + +func (listener *listener) TryAcceptTLS() (nscore.Resource, nscore.Progress, error) { + if listener == nil || listener.owner == nil || resource.IsNil(listener.transport) { + return nil, 0, nscore.Fail(nscore.FailureClosed, net.ErrClosed) + } + listener.mu.Lock() + if listener.closed { + listener.mu.Unlock() + return nil, 0, nscore.Fail(nscore.FailureClosed, net.ErrClosed) + } + listener.mu.Unlock() + owner := listener.owner + owner.mu.Lock() + if owner.closed { + owner.mu.Unlock() + return nil, 0, nscore.Fail(nscore.FailureClosed, net.ErrClosed) + } + if len(owner.streams) >= int(owner.config.MaxStreams) || owner.handshakes >= int(owner.config.MaxConcurrentHandshakes) { + owner.mu.Unlock() + return nil, 0, nscore.Fail(nscore.FailureResourceLimit, quota.ErrLimit) + } + owner.handshakes++ + owner.mu.Unlock() + + private, progress, err := listener.transport.TryAccept() + if err != nil { + owner.releaseHandshakeSlot(nil) + return nil, 0, err + } + if progress == nscore.ProgressWouldBlock { + owner.releaseHandshakeSlot(nil) + if !resource.IsNil(private) { + _ = private.Close() + return nil, 0, nscore.Fail(nscore.FailureIO, ErrInvalidConfig) + } + return nil, progress, nil + } + transport, ok := private.(tcpns.Stream) + if progress != nscore.ProgressDone || !ok || resource.IsNil(transport) { + owner.releaseHandshakeSlot(nil) + if !resource.IsNil(private) { + _ = private.Close() + } + return nil, 0, nscore.Fail(nscore.FailureIO, ErrInvalidConfig) + } + created := &stream{owner: owner, handshakeLive: true} + if err := owner.quotas.AcquireTLSStream(&created.retained, owner.storage.PlaintextBytes, owner.storage.CiphertextBytes); err != nil { + _ = transport.Close() + owner.releaseHandshakeSlot(created) + return nil, 0, mapQuotaError(err) + } + if err := owner.quotas.AcquireTLSHandshake(&created.handshake, 1); err != nil { + _ = transport.Close() + created.retained.Release() + owner.releaseHandshakeSlot(created) + return nil, 0, mapQuotaError(err) + } + engine, err := gotls.NewServer(transport, listener.profile, owner.config.Engine) + if err != nil { + _ = transport.Close() + created.release() + return nil, 0, nscore.Fail(nscore.FailureUnsupportedConfiguration, err) + } + created.engine = engine + owner.mu.Lock() + if owner.closed || len(owner.streams) >= int(owner.config.MaxStreams) { + owner.mu.Unlock() + _ = engine.Close() + created.release() + if owner.closed { + return nil, 0, nscore.Fail(nscore.FailureClosed, net.ErrClosed) + } + return nil, 0, nscore.Fail(nscore.FailureResourceLimit, quota.ErrLimit) + } + owner.streams = append(owner.streams, created) + owner.mu.Unlock() + return created, nscore.ProgressInProgress, nil +} + +func (listener *listener) Close() error { + if listener == nil { + return nil + } + listener.mu.Lock() + if listener.closed { + listener.mu.Unlock() + return nil + } + listener.closed = true + transport := listener.transport + listener.mu.Unlock() + var err error + if !resource.IsNil(transport) { + err = transport.Close() + } + listener.retained.Release() + if listener.owner != nil { + listener.owner.removeListener(listener) + } + return err +} + type stream struct { owner *Adapter engine *gotls.Stream @@ -359,6 +570,20 @@ func (adapter *Adapter) releaseHandshakeSlot(created *stream) { adapter.mu.Unlock() } +func (adapter *Adapter) removeListener(target *listener) { + adapter.mu.Lock() + defer adapter.mu.Unlock() + for index, candidate := range adapter.listeners { + if candidate != target { + continue + } + copy(adapter.listeners[index:], adapter.listeners[index+1:]) + adapter.listeners[len(adapter.listeners)-1] = nil + adapter.listeners = adapter.listeners[:len(adapter.listeners)-1] + return + } +} + func (adapter *Adapter) remove(target *stream) { adapter.mu.Lock() defer adapter.mu.Unlock() @@ -386,9 +611,17 @@ func (adapter *Adapter) CloseLocked() { return } adapter.closed = true + listeners := append([]*listener(nil), adapter.listeners...) streams := append([]*stream(nil), adapter.streams...) + adapter.listeners = nil adapter.streams = nil adapter.mu.Unlock() + for _, listener := range listeners { + listener.mu.Lock() + listener.closed = true + listener.mu.Unlock() + listener.retained.Release() + } for _, stream := range streams { stream.mu.Lock() stream.closed = true diff --git a/internal/backend/lneto/tls/tls_test.go b/internal/backend/lneto/tls/tls_test.go index 2326f0d..4db0f65 100644 --- a/internal/backend/lneto/tls/tls_test.go +++ b/internal/backend/lneto/tls/tls_test.go @@ -93,6 +93,59 @@ func TestTLSUsesPrivateTCPWithoutRawTCPAuthorityAndRollsBack(t *testing.T) { } } +func TestTLSServerListenerUsesInboundTLSAuthorityWithoutRawTCPGrant(t *testing.T) { + compiled, err := policy.Compile(policy.Config{Rules: []policy.Rule{{ + Action: policy.ActionAllow, Transports: []policy.Transport{policy.TransportTLS}, Directions: []policy.Direction{policy.DirectionInbound}, Prefixes: []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24")}, + }}}) + if err != nil { + t.Fatal(err) + } + account := quota.NewAccount(quota.Limits{Resources: 8, TCPResources: 4, TLSResources: 4, TLSHandshakes: 2, QueuedBytes: 1 << 20, TLSPlaintextBytes: 128 << 10, TLSCiphertextBytes: 128 << 10}) + mtu := uint16(ethernet.MaxMTU) + common, err := lnetocore.New(lnetocore.Config{ + Hostname: "tls-server", RandSeed: 3, HardwareAddress: [6]byte{0x02, 0, 0, 0, 0, 3}, + GatewayHardwareAddress: [6]byte{0x02, 0, 0, 0, 0, 4}, IPv4Address: netip.MustParseAddr("192.0.2.3"), MTU: mtu, + Link: packetlink.Config{MaxFrameBytes: int(mtu) + 14, IngressFrames: 4, EgressFrames: 4}, MaxActiveTCPPorts: 2, Policy: compiled, Quotas: account, + }) + if err != nil { + t.Fatal(err) + } + defer common.Close() + tcpConfig := tcpbackend.Config{MaxListeners: 1, MaxOutboundStreams: 1, AcceptBacklog: 1, ReceiveBytes: 512, TransmitBytes: 512, TransmitPackets: 4} + adapter, err := New(common, Config{ + MaxStreams: 1, MaxListeners: 1, AcceptBacklog: 1, MaxConcurrentHandshakes: 1, MaxServerNameBytes: 253, MaxServiceAttemptsPerHandshake: 64, + TCP: tcpConfig, Engine: engineLimitsForTest(), + ServerProfiles: []gotls.ServerProfile{{ + ID: 2, Config: &cryptotls.Config{Certificates: []cryptotls.Certificate{{Certificate: [][]byte{{1}}, PrivateKey: struct{}{}}}, MinVersion: cryptotls.VersionTLS13, MaxVersion: cryptotls.VersionTLS13}, + MaxCertificateChainBytes: 64 << 10, MaxPeerCertificates: 4, + }}, + }) + if err != nil { + t.Fatal(err) + } + local := nscore.Endpoint{Address: netip.MustParseAddr("192.0.2.3"), Port: 8443} + if compiled.CheckEndpoint(policy.OperationTCPListen, local.Address, local.Port) { + t.Fatal("fixture accidentally grants raw TCP listen") + } + value, progress, err := adapter.TryListenTLS(local, 2) + if err != nil || progress != nscore.ProgressDone || value == nil { + t.Fatalf("TLS listen = %T, %v, %v", value, progress, err) + } + listener := value.(tlsns.Listener) + if listener.LocalEndpoint() != local { + t.Fatalf("listener endpoint = %+v", listener.LocalEndpoint()) + } + if accepted, progress, err := listener.TryAcceptTLS(); accepted != nil || progress != nscore.ProgressWouldBlock || err != nil { + t.Fatalf("empty accept = %T, %v, %v", accepted, progress, err) + } + if err := listener.Close(); err != nil { + t.Fatal(err) + } + if usage, _ := account.Snapshot(); usage != (quota.Usage{}) { + t.Fatalf("server listener leaked quota = %+v", usage) + } +} + func TestTLSLoopbackAuthorityIsScopedAndFunctional(t *testing.T) { for _, test := range []struct { name string diff --git a/internal/binding/tls/descriptor_test.go b/internal/binding/tls/descriptor_test.go index 8120a60..6ad2822 100644 --- a/internal/binding/tls/descriptor_test.go +++ b/internal/binding/tls/descriptor_test.go @@ -35,8 +35,8 @@ func TestDescriptorInstallsExactTLSBindingsAndPreservesBackend(t *testing.T) { t.Fatalf("incompatible backend = %v", err) } bindings := Bindings(plugin.Host{}) - if len(bindings) != 9 { - t.Fatalf("bindings = %d, want 9", len(bindings)) + if len(bindings) != 12 { + t.Fatalf("bindings = %d, want 12", len(bindings)) } seen := make(map[string]struct{}, len(bindings)) for _, binding := range bindings { @@ -48,7 +48,7 @@ func TestDescriptorInstallsExactTLSBindingsAndPreservesBackend(t *testing.T) { } seen[binding.Name] = struct{}{} } - for _, required := range []string{"namespace_default", "connect", "finish_connect", "read", "write", "shutdown_write", "connection_info", "close", "poll"} { + for _, required := range []string{"namespace_default", "listen", "accept", "connect", "finish_connect", "read", "write", "shutdown_write", "connection_info", "close", "close_listener", "poll"} { if _, ok := seen[required]; !ok { t.Fatalf("binding %q missing", required) } diff --git a/internal/binding/tls/tls.go b/internal/binding/tls/tls.go index 2116002..8e6de10 100644 --- a/internal/binding/tls/tls.go +++ b/internal/binding/tls/tls.go @@ -23,7 +23,7 @@ const ( func Descriptor(backend ...plugin.Backend) plugin.Module { return plugin.NewModule(plugin.ModuleTLS, func(registry *wago.Registry, host plugin.Host) { - registry.Capability(Capability, wago.CapabilityDocs("use checked outbound verified TLS client streams for the exact calling instance")) + registry.Capability(Capability, wago.CapabilityDocs("use checked verified TLS client streams and host-profiled TLS server listeners for the exact calling instance")) plugin.RegisterBindings(registry.ImportModule(Module), Bindings(host)) }, backend...) } @@ -33,13 +33,16 @@ func Bindings(host plugin.Host) []plugin.Binding { {Name: "namespace_default", Func: func(module wago.HostModule, params, results []uint64) { namespaceDefault(host, module, params, results) }, Params: []wago.ValType{wago.ValI32}, Results: []wago.ValType{wago.ValI32}, Capability: Capability, Docs: "discover the calling instance's TLS namespace"}, + {Name: "listen", Func: func(module wago.HostModule, params, results []uint64) { listen(host, module, params, results) }, Params: []wago.ValType{wago.ValI64, wago.ValI32, wago.ValI32, wago.ValI32}, Results: []wago.ValType{wago.ValI32}, Capability: Capability, Docs: "create one host-profiled TLS server listener"}, + {Name: "accept", Func: func(module wago.HostModule, params, results []uint64) { accept(host, module, params, results) }, Params: []wago.ValType{wago.ValI64, wago.ValI32}, Results: []wago.ValType{wago.ValI32}, Capability: Capability, Docs: "accept private TCP and begin one bounded TLS server handshake"}, {Name: "connect", Func: func(module wago.HostModule, params, results []uint64) { connect(host, module, params, results) }, Params: []wago.ValType{wago.ValI64, wago.ValI32, wago.ValI32, wago.ValI32, wago.ValI32, wago.ValI32}, Results: []wago.ValType{wago.ValI32}, Capability: Capability, Docs: "start one host-profiled verified TLS client connection"}, - {Name: "finish_connect", Func: func(module wago.HostModule, params, results []uint64) { finishConnect(host, module, params, results) }, Params: []wago.ValType{wago.ValI64}, Results: []wago.ValType{wago.ValI32}, Capability: Capability, Docs: "advance TCP, TLS handshake, verification, and required ALPN"}, + {Name: "finish_connect", Func: func(module wago.HostModule, params, results []uint64) { finishConnect(host, module, params, results) }, Params: []wago.ValType{wago.ValI64}, Results: []wago.ValType{wago.ValI32}, Capability: Capability, Docs: "advance client connection or accepted server handshake and required ALPN"}, {Name: "read", Func: func(module wago.HostModule, params, results []uint64) { read(host, module, params, results) }, Params: []wago.ValType{wago.ValI64, wago.ValI32, wago.ValI32, wago.ValI32}, Results: []wago.ValType{wago.ValI32}, Capability: Capability, Docs: "perform one checked partial decrypted read"}, {Name: "write", Func: func(module wago.HostModule, params, results []uint64) { write(host, module, params, results) }, Params: []wago.ValType{wago.ValI64, wago.ValI32, wago.ValI32, wago.ValI32}, Results: []wago.ValType{wago.ValI32}, Capability: Capability, Docs: "perform one checked partial plaintext write"}, {Name: "shutdown_write", Func: func(module wago.HostModule, params, results []uint64) { shutdownWrite(host, module, params, results) }, Params: []wago.ValType{wago.ValI64}, Results: []wago.ValType{wago.ValI32}, Capability: Capability, Docs: "queue TLS close_notify and reject later plaintext writes"}, {Name: "connection_info", Func: func(module wago.HostModule, params, results []uint64) { connectionInfo(host, module, params, results) }, Params: []wago.ValType{wago.ValI64, wago.ValI32}, Results: []wago.ValType{wago.ValI32}, Capability: Capability, Docs: "return bounded verified TLS connection metadata"}, {Name: "close", Func: func(module wago.HostModule, params, results []uint64) { closeStream(host, module, params, results) }, Params: []wago.ValType{wago.ValI64}, Results: []wago.ValType{wago.ValI32}, Capability: Capability, Docs: "abort and close one exact TLS stream without waiting for the peer"}, + {Name: "close_listener", Func: func(module wago.HostModule, params, results []uint64) { closeListener(host, module, params, results) }, Params: []wago.ValType{wago.ValI64}, Results: []wago.ValType{wago.ValI32}, Capability: Capability, Docs: "close one exact TLS server listener"}, {Name: "poll", Func: func(module wago.HostModule, params, results []uint64) { guest.Poll(host, module, params, results) }, Params: []wago.ValType{wago.ValI32, wago.ValI32, wago.ValI32, wago.ValI32}, Results: []wago.ValType{wago.ValI32}, Capability: Capability, Docs: "perform one bounded TLS readiness and transport-service pass"}, } } @@ -72,6 +75,100 @@ func namespaceDefault(host plugin.Host, module wago.HostModule, params, results guest.SetStatus(results, guest.StatusOK) } +func listen(host plugin.Host, module wago.HostModule, params, results []uint64) { + if len(params) != 4 || len(results) != 1 { + guest.SetStatus(results, guest.StatusInvalidArgument) + return + } + memory := guest.Memory(module) + profileID, profileOK := abicore.NarrowUint32(params[1]) + endpointPtr, endpointOK := abicore.NarrowUint32(params[2]) + out, outOK := abicore.NarrowUint32(params[3]) + if !profileOK || !endpointOK || !outOK || profileID == 0 || !tlsabi.CheckListenV1(memory, endpointPtr, out) { + guest.SetStatus(results, guest.StatusInvalidArgument) + return + } + local, ok := abicore.DecodeEndpointV1(memory, endpointPtr) + if !ok { + guest.SetStatus(results, guest.StatusInvalidArgument) + return + } + state, status := instanceState(host, module) + if status != guest.StatusOK { + guest.SetStatus(results, status) + return + } + handle, progress, err := tlsinstance.Listen(state, resource.Handle(params[0]), local, profileID) + if err != nil { + guest.SetStatus(results, guest.FromError(err)) + return + } + if progress != nscore.ProgressDone { + if handle != 0 { + _ = state.CloseHandle(handle, resource.KindTLSListener) + } + guest.SetStatus(results, guest.StatusOther) + return + } + actual, err := tlsinstance.ListenerEndpoint(state, handle) + if err != nil || !tlsabi.EncodeListenerV1(memory, out, handle, actual) { + _ = state.CloseHandle(handle, resource.KindTLSListener) + if err != nil { + guest.SetStatus(results, guest.FromError(err)) + } else { + guest.SetStatus(results, guest.StatusOther) + } + return + } + guest.SetStatus(results, guest.StatusOK) +} + +func accept(host plugin.Host, module wago.HostModule, params, results []uint64) { + if len(params) != 2 || len(results) != 1 { + guest.SetStatus(results, guest.StatusInvalidArgument) + return + } + memory := guest.Memory(module) + out, ok := abicore.NarrowUint32(params[1]) + if !ok || !abicore.CheckRanges(memory, false, abicore.Range{Ptr: out, Length: tlsabi.StreamV1Size}) { + guest.SetStatus(results, guest.StatusInvalidArgument) + return + } + state, status := instanceState(host, module) + if status != guest.StatusOK { + guest.SetStatus(results, status) + return + } + handle, progress, err := tlsinstance.Accept(state, resource.Handle(params[0])) + if err != nil { + guest.SetStatus(results, guest.FromError(err)) + return + } + status = guest.FromProgress(progress) + if progress == nscore.ProgressWouldBlock { + guest.SetStatus(results, status) + return + } + if status != guest.StatusOK && status != guest.StatusInProgress { + if handle != 0 { + _ = state.CloseHandle(handle, resource.KindTLSStream) + } + guest.SetStatus(results, guest.StatusOther) + return + } + local, remote, err := tlsinstance.Endpoints(state, handle) + if err != nil || !tlsabi.EncodeStreamV1(memory, out, handle, local, remote) { + _ = state.CloseHandle(handle, resource.KindTLSStream) + if err != nil { + guest.SetStatus(results, guest.FromError(err)) + } else { + guest.SetStatus(results, guest.StatusOther) + } + return + } + guest.SetStatus(results, status) +} + func connect(host plugin.Host, module wago.HostModule, params, results []uint64) { if len(params) != 6 || len(results) != 1 { guest.SetStatus(results, guest.StatusInvalidArgument) @@ -242,6 +339,19 @@ func connectionInfo(host plugin.Host, module wago.HostModule, params, results [] guest.SetStatus(results, guest.StatusOK) } +func closeListener(host plugin.Host, module wago.HostModule, params, results []uint64) { + if len(params) != 1 || len(results) != 1 { + guest.SetStatus(results, guest.StatusInvalidArgument) + return + } + state, status := instanceState(host, module) + if status != guest.StatusOK { + guest.SetStatus(results, status) + return + } + guest.SetStatus(results, guest.FromError(state.CloseHandle(resource.Handle(params[0]), resource.KindTLSListener))) +} + func closeStream(host plugin.Host, module wago.HostModule, params, results []uint64) { if len(params) != 1 || len(results) != 1 { guest.SetStatus(results, guest.StatusInvalidArgument) diff --git a/internal/dependencytest/inspection_tls_test.go b/internal/dependencytest/inspection_tls_test.go index 9019558..4278148 100644 --- a/internal/dependencytest/inspection_tls_test.go +++ b/internal/dependencytest/inspection_tls_test.go @@ -19,8 +19,8 @@ func TestTLSFixtureRuntimeInspection(t *testing.T) { capabilities []wago.Capability imports map[string]int }{ - {name: "tls", newNetwork: tlsfixture.Network, capabilities: []wago.Capability{wagonet.CapInfo, wagonet.CapTLS}, imports: map[string]int{wagonet.Module: 1, wagonet.TLSModule: 9}}, - {name: "tcp_tls", newNetwork: tcptlsfixture.Network, capabilities: []wago.Capability{wagonet.CapInfo, wagonet.CapTCP, wagonet.CapTLS}, imports: map[string]int{wagonet.Module: 1, wagonet.TCPModule: 11, wagonet.TLSModule: 9}}, + {name: "tls", newNetwork: tlsfixture.Network, capabilities: []wago.Capability{wagonet.CapInfo, wagonet.CapTLS}, imports: map[string]int{wagonet.Module: 1, wagonet.TLSModule: 12}}, + {name: "tcp_tls", newNetwork: tcptlsfixture.Network, capabilities: []wago.Capability{wagonet.CapInfo, wagonet.CapTCP, wagonet.CapTLS}, imports: map[string]int{wagonet.Module: 1, wagonet.TCPModule: 11, wagonet.TLSModule: 12}}, } { t.Run(test.name, func(t *testing.T) { network, err := test.newNetwork() diff --git a/internal/instance/tls/tls.go b/internal/instance/tls/tls.go index 9a78bae..5e2faf6 100644 --- a/internal/instance/tls/tls.go +++ b/internal/instance/tls/tls.go @@ -8,7 +8,8 @@ import ( "github.com/wago-org/net/internal/resource" ) -func Connect(state *core.State, namespaceHandle resource.Handle, remote nscore.Endpoint, profileID uint32, serverName string) (handle resource.Handle, progress nscore.Progress, err error) { +// Listen transactionally creates and poll-registers one TLS server listener. +func Listen(state *core.State, namespaceHandle resource.Handle, local nscore.Endpoint, profileID uint32) (handle resource.Handle, progress nscore.Progress, err error) { err = state.WithLock(func(locked core.LockedState) error { value, lookupErr := locked.Resources.Lookup(namespaceHandle, resource.KindNamespace) if lookupErr != nil { @@ -18,7 +19,7 @@ func Connect(state *core.State, namespaceHandle resource.Handle, remote nscore.E if !ok || resource.IsNil(backend) { return nscore.Fail(nscore.FailureIO, core.ErrInvalidBackendResult) } - created, backendProgress, backendErr := backend.TryConnectTLS(remote, profileID, serverName) + created, backendProgress, backendErr := backend.TryListenTLS(local, profileID) progress = backendProgress if backendErr != nil { if !resource.IsNil(created) { @@ -26,22 +27,21 @@ func Connect(state *core.State, namespaceHandle resource.Handle, remote nscore.E } return backendErr } - stream, ok := created.(tlsns.Stream) - if (progress != nscore.ProgressDone && progress != nscore.ProgressInProgress) || !ok || resource.IsNil(stream) { + listener, ok := created.(tlsns.Listener) + if progress != nscore.ProgressDone || !ok || resource.IsNil(listener) { if !resource.IsNil(created) { _ = created.Close() } progress = 0 return nscore.Fail(nscore.FailureIO, core.ErrInvalidBackendResult) } - handle, err = locked.Resources.Add(resource.KindTLSStream, stream) + handle, err = locked.Resources.Add(resource.KindTLSListener, listener) if err != nil { - _ = stream.Close() - progress = 0 + _ = listener.Close() return err } - if err = locked.Readiness.Register(handle, resource.KindTLSStream); err != nil { - _ = locked.Resources.CloseHandle(handle, resource.KindTLSStream) + if err = locked.Readiness.Register(handle, resource.KindTLSListener); err != nil { + _ = locked.Resources.CloseHandle(handle, resource.KindTLSListener) handle, progress = 0, 0 return err } @@ -53,6 +53,124 @@ func Connect(state *core.State, namespaceHandle resource.Handle, remote nscore.E return } +// Accept owns one accepted stream while its TLS server handshake progresses. +func Accept(state *core.State, listenerHandle resource.Handle) (handle resource.Handle, progress nscore.Progress, err error) { + err = state.WithLock(func(locked core.LockedState) error { + value, lookupErr := locked.Resources.Lookup(listenerHandle, resource.KindTLSListener) + if lookupErr != nil { + return lookupErr + } + listener, ok := value.(tlsns.Listener) + if !ok || resource.IsNil(listener) { + return nscore.Fail(nscore.FailureIO, core.ErrInvalidBackendResult) + } + created, backendProgress, backendErr := listener.TryAcceptTLS() + progress = backendProgress + if backendErr != nil { + if !resource.IsNil(created) { + _ = created.Close() + } + return backendErr + } + if progress == nscore.ProgressWouldBlock { + if !resource.IsNil(created) { + _ = created.Close() + progress = 0 + return nscore.Fail(nscore.FailureIO, core.ErrInvalidBackendResult) + } + return nil + } + stream, ok := created.(tlsns.Stream) + if (progress != nscore.ProgressDone && progress != nscore.ProgressInProgress) || !ok || resource.IsNil(stream) { + if !resource.IsNil(created) { + _ = created.Close() + } + progress = 0 + return nscore.Fail(nscore.FailureIO, core.ErrInvalidBackendResult) + } + handle, err = ownStream(locked, stream) + if err != nil { + progress = 0 + } + return err + }) + if err != nil { + handle, progress = 0, 0 + } + return +} + +func Connect(state *core.State, namespaceHandle resource.Handle, remote nscore.Endpoint, profileID uint32, serverName string) (handle resource.Handle, progress nscore.Progress, err error) { + err = state.WithLock(func(locked core.LockedState) error { + value, lookupErr := locked.Resources.Lookup(namespaceHandle, resource.KindNamespace) + if lookupErr != nil { + return lookupErr + } + backend, ok := nscore.ResolveNamespaceService(value, tlsns.ServiceKey).(tlsns.Namespace) + if !ok || resource.IsNil(backend) { + return nscore.Fail(nscore.FailureIO, core.ErrInvalidBackendResult) + } + created, backendProgress, backendErr := backend.TryConnectTLS(remote, profileID, serverName) + progress = backendProgress + if backendErr != nil { + if !resource.IsNil(created) { + _ = created.Close() + } + return backendErr + } + stream, ok := created.(tlsns.Stream) + if (progress != nscore.ProgressDone && progress != nscore.ProgressInProgress) || !ok || resource.IsNil(stream) { + if !resource.IsNil(created) { + _ = created.Close() + } + progress = 0 + return nscore.Fail(nscore.FailureIO, core.ErrInvalidBackendResult) + } + handle, err = ownStream(locked, stream) + if err != nil { + progress = 0 + } + return err + }) + if err != nil { + handle, progress = 0, 0 + } + return +} + +func ownStream(locked core.LockedState, stream tlsns.Stream) (resource.Handle, error) { + handle, err := locked.Resources.Add(resource.KindTLSStream, stream) + if err != nil { + _ = stream.Close() + return 0, err + } + if err := locked.Readiness.Register(handle, resource.KindTLSStream); err != nil { + _ = locked.Resources.CloseHandle(handle, resource.KindTLSStream) + return 0, err + } + return handle, nil +} + +func ListenerEndpoint(state *core.State, handle resource.Handle) (local nscore.Endpoint, err error) { + err = state.WithLock(func(locked core.LockedState) error { + value, lookupErr := locked.Resources.Lookup(handle, resource.KindTLSListener) + if lookupErr != nil { + return lookupErr + } + listener, ok := value.(tlsns.Listener) + if !ok || resource.IsNil(listener) { + return nscore.Fail(nscore.FailureIO, core.ErrInvalidBackendResult) + } + local = listener.LocalEndpoint() + if !local.Valid() { + local = nscore.Endpoint{} + return nscore.Fail(nscore.FailureIO, core.ErrInvalidBackendResult) + } + return nil + }) + return +} + func Endpoints(state *core.State, handle resource.Handle) (local, remote nscore.Endpoint, err error) { err = state.WithLock(func(locked core.LockedState) error { stream, lookupErr := lookupStream(locked, handle) diff --git a/internal/instance/tls/tls_test.go b/internal/instance/tls/tls_test.go index e94ab63..3c51fa0 100644 --- a/internal/instance/tls/tls_test.go +++ b/internal/instance/tls/tls_test.go @@ -16,9 +16,12 @@ import ( ) type fakeNamespace struct { - stream nscore.Resource - profile uint32 - name string + stream nscore.Resource + listener nscore.Resource + profile uint32 + serverProfile uint32 + name string + local nscore.Endpoint } func (*fakeNamespace) Close() error { return nil } @@ -30,6 +33,28 @@ func (namespace *fakeNamespace) TryConnectTLS(_ nscore.Endpoint, profile uint32, namespace.profile, namespace.name = profile, name return namespace.stream, nscore.ProgressInProgress, nil } +func (namespace *fakeNamespace) TryListenTLS(local nscore.Endpoint, profile uint32) (nscore.Resource, nscore.Progress, error) { + namespace.local, namespace.serverProfile = local, profile + return namespace.listener, nscore.ProgressDone, nil +} + +type fakeListener struct { + local nscore.Endpoint + stream nscore.Resource + closed int +} + +func (listener *fakeListener) Close() error { listener.closed++; return nil } +func (*fakeListener) Readiness() nscore.Readiness { return nscore.ReadyAccept } +func (listener *fakeListener) LocalEndpoint() nscore.Endpoint { return listener.local } +func (listener *fakeListener) TryAcceptTLS() (nscore.Resource, nscore.Progress, error) { + if listener.stream == nil { + return nil, nscore.ProgressWouldBlock, nil + } + stream := listener.stream + listener.stream = nil + return stream, nscore.ProgressInProgress, nil +} type fakeStream struct { local, remote nscore.Endpoint @@ -101,6 +126,43 @@ func TestTLSOperationsKeepHandlesKindSpecificAndPartial(t *testing.T) { } } +func TestTLSServerListenAcceptAndHandleKinds(t *testing.T) { + local := nscore.Endpoint{Address: netip.MustParseAddr("192.0.2.10"), Port: 8443} + remote := nscore.Endpoint{Address: netip.MustParseAddr("192.0.2.11"), Port: 49152} + info := tlsns.ConnectionInfo{LocalEndpoint: local, RemoteEndpoint: remote, TLSVersion: 0x304, CipherSuite: 0x1301, NegotiatedALPN: "h2", Role: tlsns.RoleServer} + stream := &fakeStream{local: local, remote: remote, info: info} + listener := &fakeListener{local: local, stream: stream} + namespace := &fakeNamespace{listener: listener} + state, manager, instance := attachState(t, namespace) + defer manager.Detach(instance) + listenerHandle, progress, err := Listen(state, state.NamespaceHandle(), local, 9) + if err != nil || progress != nscore.ProgressDone || listenerHandle == 0 { + t.Fatalf("Listen = %v, %v, %v", listenerHandle, progress, err) + } + if namespace.local != local || namespace.serverProfile != 9 { + t.Fatalf("listen selection = %+v, %d", namespace.local, namespace.serverProfile) + } + if endpoint, err := ListenerEndpoint(state, listenerHandle); err != nil || endpoint != local { + t.Fatalf("ListenerEndpoint = %+v, %v", endpoint, err) + } + streamHandle, progress, err := Accept(state, listenerHandle) + if err != nil || progress != nscore.ProgressInProgress || streamHandle == 0 { + t.Fatalf("Accept = %v, %v, %v", streamHandle, progress, err) + } + if got, progress, err := ConnectionInfo(state, streamHandle); err != nil || progress != nscore.ProgressDone || got.Role != tlsns.RoleServer { + t.Fatalf("server info = %+v, %v, %v", got, progress, err) + } + if _, _, err := Accept(state, listenerHandle); err != nil { + t.Fatalf("empty accept = %v", err) + } + if err := state.CloseHandle(listenerHandle, resource.KindTLSListener); err != nil || listener.closed != 1 { + t.Fatalf("listener close = %v count=%d", err, listener.closed) + } + if _, err := Read(state, listenerHandle, make([]byte, 1)); !errors.Is(err, resource.ErrBadHandle) { + t.Fatalf("listener used as stream = %v", err) + } +} + func TestTLSHandleIsCrossInstanceAndWrongKindSafe(t *testing.T) { endpoint := nscore.Endpoint{Address: netip.MustParseAddr("192.0.2.2"), Port: 443} firstStream := &fakeStream{local: endpoint, remote: endpoint, info: tlsns.ConnectionInfo{LocalEndpoint: endpoint, RemoteEndpoint: endpoint, TLSVersion: 0x304, CipherSuite: 0x1301, Role: tlsns.RoleClient, PeerAuthenticated: true, PeerLeafSPKI256: [32]byte{1}, VerifiedIdentity: tlsns.IdentityIP}} diff --git a/internal/namespace/tls/tls.go b/internal/namespace/tls/tls.go index d9ae39b..7e58bf6 100644 --- a/internal/namespace/tls/tls.go +++ b/internal/namespace/tls/tls.go @@ -63,9 +63,19 @@ func (info ConnectionInfo) Valid(maxALPN int) bool { } } -// Namespace creates only outbound secure streams from finite host profiles. +// Namespace creates outbound streams and inbound listeners from finite, +// immutable host profiles. type Namespace interface { TryConnectTLS(remote nscore.Endpoint, profileID uint32, serverName string) (nscore.Resource, nscore.Progress, error) + TryListenTLS(local nscore.Endpoint, profileID uint32) (nscore.Resource, nscore.Progress, error) +} + +// Listener accepts private TCP streams and begins one bounded TLS server +// handshake before returning a secure stream resource to the caller. +type Listener interface { + nscore.Resource + LocalEndpoint() nscore.Endpoint + TryAcceptTLS() (nscore.Resource, nscore.Progress, error) } // Stream exposes plaintext only after TCP completion, TLS handshake, diff --git a/internal/resource/table.go b/internal/resource/table.go index 3252563..90aae1e 100644 --- a/internal/resource/table.go +++ b/internal/resource/table.go @@ -34,6 +34,7 @@ const ( KindDHCPv6Lease KindTLSStream KindPollable + KindTLSListener ) var ( @@ -339,6 +340,8 @@ func (k Kind) String() string { return "tls_stream" case KindPollable: return "pollable" + case KindTLSListener: + return "tls_listener" default: return fmt.Sprintf("kind(%d)", uint8(k)) } diff --git a/internal/tlslimits/limits.go b/internal/tlslimits/limits.go index 61bd1b0..2b4953f 100644 --- a/internal/tlslimits/limits.go +++ b/internal/tlslimits/limits.go @@ -6,6 +6,8 @@ import "github.com/wago-org/net/internal/checked" const ( MaxStreams uint16 = 64 + MaxListeners uint16 = 64 + MaxAcceptBacklog uint16 = 64 MaxConcurrentHandshakes uint16 = 64 MaxProfiles = 256 MaxServerNamesPerProfile = 256 @@ -32,6 +34,8 @@ const ( // Config contains every TLS-owned or private-transport storage dimension. type Config struct { MaxStreams uint16 + MaxListeners uint16 + AcceptBacklog uint16 MaxConcurrentHandshakes uint16 PlaintextReceiveBytes int PlaintextTransmitBytes int @@ -47,17 +51,19 @@ type Config struct { // configuration. TransportBytes is charged by the private TCP adapter, not by // TLS plaintext/ciphertext quota, while TotalBytes includes it exactly once. type Plan struct { - PlaintextBytes uint64 - CiphertextBytes uint64 - TransportBytes uint64 - PerStreamBytes uint64 - TotalBytes uint64 + PlaintextBytes uint64 + CiphertextBytes uint64 + TransportBytes uint64 + ListenerTransportBytes uint64 + PerStreamBytes uint64 + TotalBytes uint64 } // Validate proves target-int representability, every sum, stream // multiplication, and the repository aggregate retained-storage ceiling. func Validate(config Config, maxIntValue uint64) (Plan, bool) { - if config.MaxStreams == 0 || config.MaxStreams > MaxStreams || + if config.MaxStreams == 0 || config.MaxStreams > MaxStreams || config.MaxListeners > MaxListeners || + (config.MaxListeners == 0 && config.AcceptBacklog != 0) || (config.MaxListeners != 0 && (config.AcceptBacklog == 0 || config.AcceptBacklog > MaxAcceptBacklog)) || config.MaxConcurrentHandshakes == 0 || config.MaxConcurrentHandshakes > MaxConcurrentHandshakes || config.MaxConcurrentHandshakes > config.MaxStreams { return Plan{}, false @@ -123,10 +129,22 @@ func Validate(config Config, maxIntValue uint64) (Plan, bool) { return Plan{}, false } total, ok := checked.MultiplyUint64(perStream, uint64(config.MaxStreams)) + if !ok { + return Plan{}, false + } + listenerSlots, ok := checked.MultiplyUint64(uint64(config.MaxListeners), uint64(config.AcceptBacklog)) + if !ok { + return Plan{}, false + } + listenerTransport, ok := checked.MultiplyUint64(listenerSlots, transport) + if !ok { + return Plan{}, false + } + total, ok = checked.AddUint64(total, listenerTransport) if !ok || total > MaxAggregateRetainedBytes { return Plan{}, false } - return Plan{PlaintextBytes: plaintext, CiphertextBytes: ciphertext, TransportBytes: transport, PerStreamBytes: perStream, TotalBytes: total}, true + return Plan{PlaintextBytes: plaintext, CiphertextBytes: ciphertext, TransportBytes: transport, ListenerTransportBytes: listenerTransport, PerStreamBytes: perStream, TotalBytes: total}, true } func storageValue(value int, minimum int, maximum uint64, maxIntValue uint64) (uint64, bool) { diff --git a/tls/config.go b/tls/config.go index 50908fa..1cb858d 100644 --- a/tls/config.go +++ b/tls/config.go @@ -13,6 +13,8 @@ const ( // MaximumStreams and MaximumConcurrentHandshakes bound worker and handshake // concurrency for one instance. MaximumStreams = tlslimits.MaxStreams + MaximumListeners = tlslimits.MaxListeners + MaximumAcceptBacklog = tlslimits.MaxAcceptBacklog MaximumConcurrentHandshakes = tlslimits.MaxConcurrentHandshakes MaximumClientProfiles = tlslimits.MaxProfiles MaximumServerNamesPerProfile = tlslimits.MaxServerNamesPerProfile @@ -42,6 +44,8 @@ const ( // sentinel. type Config struct { MaxStreams uint16 + MaxListeners uint16 + AcceptBacklog uint16 MaxConcurrentHandshakes uint16 PlaintextReceiveBytes int PlaintextTransmitBytes int @@ -65,6 +69,8 @@ type Config struct { func DefaultConfig() Config { return Config{ MaxStreams: 8, + MaxListeners: 4, + AcceptBacklog: 4, MaxConcurrentHandshakes: 4, PlaintextReceiveBytes: 16 << 10, PlaintextTransmitBytes: 16 << 10, @@ -91,7 +97,7 @@ func validConfig(config Config) bool { func validateConfig(config Config, maxIntValue uint64) (tlslimits.Plan, bool) { plan, ok := tlslimits.Validate(tlslimits.Config{ - MaxStreams: config.MaxStreams, MaxConcurrentHandshakes: config.MaxConcurrentHandshakes, + MaxStreams: config.MaxStreams, MaxListeners: config.MaxListeners, AcceptBacklog: config.AcceptBacklog, MaxConcurrentHandshakes: config.MaxConcurrentHandshakes, PlaintextReceiveBytes: config.PlaintextReceiveBytes, PlaintextTransmitBytes: config.PlaintextTransmitBytes, CiphertextReceiveBytes: config.CiphertextReceiveBytes, CiphertextTransmitBytes: config.CiphertextTransmitBytes, TransportReceiveBytes: config.TransportReceiveBytes, TransportTransmitBytes: config.TransportTransmitBytes, diff --git a/tls/config_test.go b/tls/config_test.go index 76537b9..b909d36 100644 --- a/tls/config_test.go +++ b/tls/config_test.go @@ -48,9 +48,10 @@ func TestDefaultTLSStorageClassificationMatchesQuotaOwnership(t *testing.T) { wantPlaintext := uint64(64 << 10) wantCiphertext := uint64(80 << 10) wantTransport := uint64(64 << 10) + wantListenerTransport := uint64(4 * 4 * (64 << 10)) wantPerStream := uint64(208 << 10) - wantTotal := uint64(8 * (208 << 10)) - if plan.PlaintextBytes != wantPlaintext || plan.CiphertextBytes != wantCiphertext || plan.TransportBytes != wantTransport || plan.PerStreamBytes != wantPerStream || plan.TotalBytes != wantTotal { + wantTotal := uint64(8*(208<<10)) + wantListenerTransport + if plan.PlaintextBytes != wantPlaintext || plan.CiphertextBytes != wantCiphertext || plan.TransportBytes != wantTransport || plan.ListenerTransportBytes != wantListenerTransport || plan.PerStreamBytes != wantPerStream || plan.TotalBytes != wantTotal { t.Fatalf("default storage plan = %+v", plan) } } diff --git a/tls/profile_test.go b/tls/profile_test.go index 46b5edb..42a410a 100644 --- a/tls/profile_test.go +++ b/tls/profile_test.go @@ -87,6 +87,29 @@ func TestServerProfileDefaultsTLS13ClonesAndRequiresStaticCertificate(t *testing } } +func TestServerOnlyRegistrationAuthorityIsInboundAndProfileCompiles(t *testing.T) { + profile, err := NewServerProfile(7, testServerConfig(t), RequireServerALPN("h2")) + if err != nil { + t.Fatal(err) + } + configuration := registration{config: DefaultConfig(), serverProfiles: []*ServerProfile{profile}, defaultAuthority: true} + compiled, err := policy.Compile(configuration.authority()) + if err != nil { + t.Fatal(err) + } + address := netip.MustParseAddr("192.0.2.20") + if !compiled.CheckEndpoint(policy.OperationTLSListen, address, 8443) { + t.Fatal("server profile did not grant inbound TLS authority") + } + if compiled.CheckEndpoint(policy.OperationTLSConnect, address, 8443) { + t.Fatal("server-only profile granted outbound TLS authority") + } + profiles, err := compileServerProfiles(configuration.serverProfiles, configuration.config) + if err != nil || len(profiles) != 1 || profiles[0].ID != 7 || profiles[0].RequiredALPN != "h2" { + t.Fatalf("compiled server profiles = %+v, %v", profiles, err) + } +} + func TestServerProfileRejectsUnsafeConfigurationAndRequiresTLS12OptIn(t *testing.T) { unsafe := testServerConfig(t) unsafe.GetCertificate = func(*cryptotls.ClientHelloInfo) (*cryptotls.Certificate, error) { return nil, nil } @@ -131,7 +154,7 @@ func testServerConfig(t testing.TB) *cryptotls.Config { } func TestAllowLoopbackRegistrationAuthorityIsTLSScoped(t *testing.T) { - configuration := registration{config: DefaultConfig(), defaultAuthority: true} + configuration := registration{config: DefaultConfig(), profiles: []*ClientProfile{{id: 1}}, defaultAuthority: true} if err := AllowLoopback().applyTLS(&configuration); err != nil { t.Fatal(err) } diff --git a/tls/register_test.go b/tls/register_test.go index 7c09464..de4d873 100644 --- a/tls/register_test.go +++ b/tls/register_test.go @@ -34,7 +34,7 @@ func TestRegisterExposesOnlyTLSAndSharedCore(t *testing.T) { for _, spec := range runtime.ProvidedImports() { imports[spec.Module]++ } - want := map[string]int{wagonet.Module: 1, wagonet.TLSModule: 9} + want := map[string]int{wagonet.Module: 1, wagonet.TLSModule: 12} if !reflect.DeepEqual(imports, want) { t.Fatalf("imports = %v, want %v", imports, want) } @@ -63,7 +63,7 @@ func TestTCPAndTLSComposeWithoutCapabilityWidening(t *testing.T) { for _, spec := range runtime.ProvidedImports() { imports[spec.Module]++ } - wantImports := map[string]int{wagonet.Module: 1, wagonet.TCPModule: 11, wagonet.TLSModule: 9} + wantImports := map[string]int{wagonet.Module: 1, wagonet.TCPModule: 11, wagonet.TLSModule: 12} if !reflect.DeepEqual(imports, wantImports) { t.Fatalf("imports = %v, want %v", imports, wantImports) } diff --git a/tls/tls.go b/tls/tls.go index 27bdf8a..1b45d20 100644 --- a/tls/tls.go +++ b/tls/tls.go @@ -28,6 +28,7 @@ func (option optionFunc) applyTLS(target *registration) error { return option(ta type registration struct { config Config profiles []*ClientProfile + serverProfiles []*ServerProfile defaultAuthority bool authorityAdditions policy.Config } @@ -49,6 +50,18 @@ func WithClientProfile(profile *ClientProfile) Option { }) } +// WithServerProfile adds one immutable host-defined server identity profile. +// Duplicate IDs are rejected independently from client profile IDs. +func WithServerProfile(profile *ServerProfile) Option { + return optionFunc(func(target *registration) error { + if profile == nil || profile.id == 0 { + return ErrInvalidServerProfile + } + target.serverProfiles = append(target.serverProfiles, profile) + return nil + }) +} + // WithPolicy adds advanced TLS authority. Deny rules from any composition // layer retain precedence, including applicable raw-TCP denies. func WithPolicy(config wagonet.PolicyConfig) Option { @@ -69,9 +82,19 @@ func AllowLoopback() Option { return WithPolicy(wagonet.PolicyConfig{LoopbackTransports: []wagonet.PolicyTransport{wagonet.PolicyTransportTLS}}) } -func defaultAuthority() policy.Config { +func defaultAuthority(client, server bool) policy.Config { + directions := make([]policy.Direction, 0, 2) + if client { + directions = append(directions, policy.DirectionOutbound) + } + if server { + directions = append(directions, policy.DirectionInbound) + } + if len(directions) == 0 { + return policy.Config{} + } return policy.Config{Rules: []policy.Rule{{ - Action: policy.ActionAllow, Transports: []policy.Transport{policy.TransportTLS}, Directions: []policy.Direction{policy.DirectionOutbound}, + Action: policy.ActionAllow, Transports: []policy.Transport{policy.TransportTLS}, Directions: directions, }}} } @@ -79,7 +102,7 @@ func (registration registration) authority() policy.Config { if !registration.defaultAuthority { return policy.Merge(registration.authorityAdditions) } - return policy.Merge(defaultAuthority(), registration.authorityAdditions) + return policy.Merge(defaultAuthority(len(registration.profiles) != 0, len(registration.serverProfiles) != 0), registration.authorityAdditions) } // Register selects only net.tls, wago_net_tls, and the private TLS transport. @@ -94,20 +117,32 @@ func Register(network *wagonet.Network, options ...Option) error { return err } } - if network == nil || !validConfig(config.config) || len(config.profiles) == 0 || len(config.profiles) > MaximumClientProfiles { + if network == nil || !validConfig(config.config) || (len(config.profiles) == 0 && len(config.serverProfiles) == 0) || len(config.profiles) > MaximumClientProfiles || len(config.serverProfiles) > MaximumClientProfiles { return ErrInvalidConfig } profiles, err := compileProfiles(config.profiles, config.config) if err != nil { return err } + serverProfiles, err := compileServerProfiles(config.serverProfiles, config.config) + if err != nil { + return err + } + maxListeners, acceptBacklog := uint16(0), uint16(0) + if len(serverProfiles) != 0 { + maxListeners, acceptBacklog = config.config.MaxListeners, config.config.AcceptBacklog + } backendConfig := tlsbackend.Config{ MaxStreams: config.config.MaxStreams, + MaxListeners: maxListeners, + AcceptBacklog: acceptBacklog, MaxConcurrentHandshakes: config.config.MaxConcurrentHandshakes, MaxServerNameBytes: config.config.MaxServerNameBytes, MaxServiceAttemptsPerHandshake: config.config.MaxServiceAttemptsPerHandshake, TCP: tcpbackend.Config{ + MaxListeners: maxListeners, MaxOutboundStreams: config.config.MaxStreams, + AcceptBacklog: acceptBacklog, ReceiveBytes: config.config.TransportReceiveBytes, TransmitBytes: config.config.TransportTransmitBytes, TransmitPackets: config.config.TransportTransmitPackets, @@ -121,7 +156,7 @@ func Register(network *wagonet.Network, options ...Option) error { MaxServiceAttemptsPerHandshake: config.config.MaxServiceAttemptsPerHandshake, MaxRecordsPerService: int(config.config.MaxRecordsPerService), }, - Profiles: profiles, + Profiles: profiles, ServerProfiles: serverProfiles, } backend := plugin.NewBackend(plugin.BackendLnetoV1, func(target any) error { @@ -129,10 +164,10 @@ func Register(network *wagonet.Network, options ...Option) error { if !ok { return plugin.ErrInvalidBackend } - if uint32(common.MaxActiveTCPPorts)+uint32(config.config.MaxStreams) > uint32(^uint16(0)) { + if uint32(common.MaxActiveTCPPorts)+uint32(config.config.MaxStreams)+uint32(maxListeners) > uint32(^uint16(0)) { return plugin.ErrInvalidBackend } - common.MaxActiveTCPPorts += config.config.MaxStreams + common.MaxActiveTCPPorts += config.config.MaxStreams + maxListeners return nil }, func(base any) (nscore.Service, error) { @@ -151,6 +186,47 @@ func Register(network *wagonet.Network, options ...Option) error { return network.RegisterModule(module) } +func compileServerProfiles(input []*ServerProfile, config Config) ([]gotls.ServerProfile, error) { + profiles := make([]gotls.ServerProfile, 0, len(input)) + seen := make(map[uint32]struct{}, len(input)) + for _, profile := range input { + if profile == nil || profile.id == 0 || profile.config == nil { + return nil, ErrInvalidServerProfile + } + if _, exists := seen[profile.id]; exists { + return nil, ErrInvalidServerProfile + } + seen[profile.id] = struct{}{} + if len(profile.config.NextProtos) > int(config.MaxALPNProtocols) { + return nil, ErrInvalidServerProfile + } + aggregate := 0 + for _, protocol := range profile.config.NextProtos { + if len(protocol) > 32 { + return nil, ErrInvalidServerProfile + } + aggregate += len(protocol) + } + if aggregate > int(config.MaxALPNAggregateBytes) { + return nil, ErrInvalidServerProfile + } + chainBytes := 0 + for _, certificate := range profile.config.Certificates { + for _, der := range certificate.Certificate { + chainBytes += len(der) + if chainBytes > config.MaxCertificateChainBytes { + return nil, ErrInvalidServerProfile + } + } + } + profiles = append(profiles, gotls.ServerProfile{ + ID: profile.id, Config: profile.config.Clone(), RequiredALPN: profile.requiredALPN, + MaxCertificateChainBytes: config.MaxCertificateChainBytes, MaxPeerCertificates: config.MaxPeerCertificates, + }) + } + return profiles, nil +} + func compileProfiles(input []*ClientProfile, config Config) ([]gotls.Profile, error) { profiles := make([]gotls.Profile, 0, len(input)) seen := make(map[uint32]struct{}, len(input)) From ba8b247a8fb8971f10e87c27fe752c4c6e529bbf Mon Sep 17 00:00:00 2001 From: Wago Networking Agent Date: Mon, 20 Jul 2026 17:26:06 +0000 Subject: [PATCH 03/17] fix: flush the final TLS client handshake flight --- internal/backend/gotls/stream_test.go | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/internal/backend/gotls/stream_test.go b/internal/backend/gotls/stream_test.go index 0d0de74..e1a3cce 100644 --- a/internal/backend/gotls/stream_test.go +++ b/internal/backend/gotls/stream_test.go @@ -63,9 +63,26 @@ func TestClientHandshakeVerificationALPNAndPlaintext(t *testing.T) { t.Fatalf("handshake did not complete: ready=%v terminal=%v verified=%v client-out=%d server-out=%d", client.Readiness(), terminal, verified, client.bridge.cipherPending(), serverBridge.cipherPending()) } } - if err := <-serverDone; err != nil { - t.Fatal(err) + serverDeadline := time.NewTimer(2 * time.Second) + defer serverDeadline.Stop() + for { + if _, _, err := client.TryService(nscore.ServiceBudget{Packets: 8, Bytes: 64 << 10, Operations: 8}); err != nil { + t.Fatal(err) + } + select { + case err := <-serverDone: + if err != nil { + t.Fatal(err) + } + goto serverHandshakeComplete + case <-serverDeadline.C: + t.Fatalf("server handshake did not receive final client flight: client-out=%d server-out=%d", client.bridge.cipherPending(), serverBridge.cipherPending()) + default: + runtime.Gosched() + } } + +serverHandshakeComplete: info, ok := client.ConnectionInfo() if !ok || info.NegotiatedALPN != "h2" || info.TLSVersion != cryptotls.VersionTLS13 || info.PeerLeafSPKI256 == ([32]byte{}) { t.Fatalf("connection info = %+v, %v", info, ok) From 0a2962482aaa8ce6285ac2e9e1c8e4950139dd31 Mon Sep 17 00:00:00 2001 From: Wago Networking Agent Date: Mon, 20 Jul 2026 17:45:10 +0000 Subject: [PATCH 04/17] fix: preserve TLS connection info v1 ABI --- README.md | 5 +- abi.go | 63 ++++++----- docs/abi-v1.md | 38 +++++++ docs/tls.md | 27 +++-- internal/abi/tls/tls.go | 55 ++++++++- internal/abi/tls/tls_test.go | 105 ++++++++++++++++++ internal/binding/tls/descriptor_test.go | 6 +- internal/binding/tls/tls.go | 25 ++++- internal/binding/tls/tls_test.go | 25 +++-- .../dependencytest/inspection_tls_test.go | 4 +- tls/register_test.go | 4 +- 11 files changed, 297 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index 5463373..a36e932 100644 --- a/README.md +++ b/README.md @@ -206,8 +206,9 @@ operation bitset and work operations return `NOT_SUPPORTED` without output mutation. Registering only DHCPv6 exposes `net.info`, `net.dhcpv6`, the shared ABI import, and seven `wago_net_dhcpv6` imports; it becomes operational only with a separately configured scoped link-local IPv6 identity. Registering only TLS -exposes exactly `net.info` and `net.tls`, `wago_net.abi_version`, and nine -`wago_net_tls` imports; it does not expose `net.tcp` or `wago_net_tcp`. +exposes exactly `net.info` and `net.tls`, `wago_net.abi_version`, and thirteen +`wago_net_tls` imports on the server-foundation branch; it does not expose +`net.tcp` or `wago_net_tcp`. This exact TLS surface is inspected through explicit composition fixtures rather than a self-registering extension. Unregistered protocol imports are absent and fail normal WebAssembly import resolution. The public TCP, UDP, DNS, ICMPv4, NTP, mDNS, DHCPv4, link-local, IPv6, ICMPv6, DHCPv6, and TLS facades each construct diff --git a/abi.go b/abi.go index e7b0b76..3eca32e 100644 --- a/abi.go +++ b/abi.go @@ -6,35 +6,40 @@ package net // These public compatibility constants intentionally remain literal values so // the protocol-neutral root package does not import protocol ABI packages. const ( - AddressV1Size uint32 = 32 - HandleV1Size uint32 = 8 - UDPReceiveResultV1Size uint32 = 48 - TCPStreamV1Size uint32 = 72 - TCPIOResultV1Size uint32 = 8 - TLSStreamV1Size uint32 = 72 - TLSIOResultV1Size uint32 = 8 - TLSConnectionInfoV1Size uint32 = 144 - TLSMaxALPNV1Bytes uint32 = 32 - DNSNameV1Size uint32 = 260 - DNSQueryV1Size uint32 = 268 - DNSRecordV1Size uint32 = 560 - ICMPv4EchoRequestV1Size uint32 = 48 - ICMPv4EchoResultV1Size uint32 = 48 - ICMPv6EchoRequestV1Size uint32 = 48 - ICMPv6EchoResultV1Size uint32 = 48 - ICMPv6NeighborKeyV1Size uint32 = 32 - ICMPv6NeighborV1Size uint32 = 40 - ICMPv6OperationsV1Size uint32 = 4 - DHCPv6OperationsV1Size uint32 = 4 - DHCPv6ConfigurationV1Size uint32 = 3368 - NTPSampleV1Size uint32 = 72 - MDNSNameV1Size uint32 = 260 - MDNSQueryV1Size uint32 = 268 - MDNSRecordV1Size uint32 = 832 - MDNSAnnouncementV1Size uint32 = 8 - PollBudgetV1Size uint32 = 24 - PollEventV1Size uint32 = 16 - PollResultV1Size uint32 = 24 + AddressV1Size uint32 = 32 + HandleV1Size uint32 = 8 + UDPReceiveResultV1Size uint32 = 48 + TCPStreamV1Size uint32 = 72 + TCPIOResultV1Size uint32 = 8 + TLSStreamV1Size uint32 = 72 + TLSIOResultV1Size uint32 = 8 + TLSConnectionInfoV1Size uint32 = 144 + TLSConnectionInfoV2Size uint32 = 144 + TLSMaxALPNV1Bytes uint32 = 32 + + TLSConnectionInfoV2FlagResumed uint32 = 1 << 0 + TLSConnectionInfoV2FlagServerRole uint32 = 1 << 1 + TLSConnectionInfoV2FlagPeerAuthenticated uint32 = 1 << 2 + DNSNameV1Size uint32 = 260 + DNSQueryV1Size uint32 = 268 + DNSRecordV1Size uint32 = 560 + ICMPv4EchoRequestV1Size uint32 = 48 + ICMPv4EchoResultV1Size uint32 = 48 + ICMPv6EchoRequestV1Size uint32 = 48 + ICMPv6EchoResultV1Size uint32 = 48 + ICMPv6NeighborKeyV1Size uint32 = 32 + ICMPv6NeighborV1Size uint32 = 40 + ICMPv6OperationsV1Size uint32 = 4 + DHCPv6OperationsV1Size uint32 = 4 + DHCPv6ConfigurationV1Size uint32 = 3368 + NTPSampleV1Size uint32 = 72 + MDNSNameV1Size uint32 = 260 + MDNSQueryV1Size uint32 = 268 + MDNSRecordV1Size uint32 = 832 + MDNSAnnouncementV1Size uint32 = 8 + PollBudgetV1Size uint32 = 24 + PollEventV1Size uint32 = 16 + PollResultV1Size uint32 = 24 UDPReceiveFlagTruncated uint32 = 1 diff --git a/docs/abi-v1.md b/docs/abi-v1.md index 9619224..d3c0e46 100644 --- a/docs/abi-v1.md +++ b/docs/abi-v1.md @@ -683,6 +683,8 @@ provide `net.tcp`. All functions return one `i32` status: ```text namespace_default(out_namespace_ptr: i32) -> i32 +listen(namespace: i64, local_ptr: i32, profile_id: i32, out_listener_ptr: i32) -> i32 +accept(listener: i64, out_stream_ptr: i32) -> i32 connect(namespace: i64, remote_ptr: i32, profile_id: i32, server_name_ptr: i32, server_name_len: i32, out_stream_ptr: i32) -> i32 finish_connect(stream: i64) -> i32 @@ -690,7 +692,9 @@ read(stream: i64, dst_ptr: i32, dst_len: i32, out_result_ptr: i32) -> i32 write(stream: i64, src_ptr: i32, src_len: i32, out_result_ptr: i32) -> i32 shutdown_write(stream: i64) -> i32 connection_info(stream: i64, out_info_ptr: i32) -> i32 +connection_info_v2(stream: i64, out_info_ptr: i32) -> i32 close(stream: i64) -> i32 +close_listener(listener: i64) -> i32 poll(events_ptr: i32, events_capacity: i32, budget_ptr: i32, result_ptr: i32) -> i32 ``` @@ -712,6 +716,40 @@ struct wago_net_tls_connection_info_v1 { }; ``` +At offset 68, v1 `resumed` remains a little-endian boolean whose only emitted +values are exactly 0 and 1. Server role and peer-authentication state are not +encoded into that field. This byte-for-byte contract predates server support and +is retained unchanged. + +The additive `connection_info_v2` import writes a distinct 144-byte +`wago_net_tls_connection_info_v2` layout. Its physical offsets match v1 except +that offset 68 is a flags word: + +```c +struct wago_net_tls_connection_info_v2 { + struct wago_net_addr_v1 local; // offset 0 + struct wago_net_addr_v1 remote; // offset 32 + uint16_t tls_version; // offset 64 + uint16_t cipher_suite; // offset 66 + uint32_t flags; // offset 68 + uint32_t identity_type; // offset 72 + uint32_t alpn_length; // offset 76, maximum 32 + uint8_t alpn[32]; // offset 80 + uint8_t peer_leaf_spki_sha256[32]; // offset 112 +}; +``` + +Defined v2 flags are bit 0 `RESUMED`, bit 1 `SERVER_ROLE`, and bit 2 +`PEER_AUTHENTICATED`; all other bits are reserved and must be zero. For a server +stream without required client authentication, `PEER_AUTHENTICATED` is clear, +`identity_type` is 0, and the peer SPKI digest is all zero. For a mutually +authenticated server stream, the flag and digest are present while +`identity_type` remains 0 because no DNS/IP client-name assertion is made. + +The shared `wago_net.abi_version` remains 1.0: the existing v1 import and bytes +are unchanged, while role-aware metadata is feature-detected through the +separately named additive `connection_info_v2` import. + Connection metadata is available only after verified completion. Certificate DER, chains, private keys, and error strings are never guest output. Clean `close_notify` returns `EOF`; raw transport EOF and corrupted records return diff --git a/docs/tls.md b/docs/tls.md index d443751..5b5b84f 100644 --- a/docs/tls.md +++ b/docs/tls.md @@ -70,24 +70,32 @@ executed arm64 evidence are still required before production readiness. ## ABI -`wago_net_tls` exports nine operations: +`wago_net_tls` exports thirteen operations on the server-foundation branch: - `namespace_default` +- `listen` +- `accept` - `connect` - `finish_connect` - `read` - `write` - `shutdown_write` - `connection_info` +- `connection_info_v2` - `close` +- `close_listener` - `poll` `finish_connect` reports success only after TCP establishment, TLS handshake, certificate-chain validation, DNS/IP identity validation, and required ALPN. No plaintext is readable or writable before that point. `connection_info` -returns only local/remote endpoints, TLS version, cipher-suite number, -negotiated ALPN (maximum 32 bytes in ABI v1), resumption flag, peer leaf SPKI -SHA-256, and verified identity type. Arbitrary certificate DER is not exported. +retains the exact client-era v1 byte contract: offset 68 is only the resumed +boolean 0 or 1. `connection_info_v2` additively reports resumed, local server +role, and peer-authenticated flags without reinterpreting v1. Both versions +return only bounded local/remote endpoints, TLS version, cipher-suite number, +negotiated ALPN (maximum 32 bytes), optional peer leaf SPKI SHA-256, and the +client-side verified server identity type. Arbitrary certificate DER is not +exported. All input/output ranges are checked before backend work. Server-name bytes are copied during the host call. Outputs remain unchanged on errors, would-block, @@ -125,8 +133,9 @@ close and failed verification release each charge exactly once. ## Unsupported scope -There are no listeners, server handshakes, incoming client authentication, -DTLS, QUIC TLS, STARTTLS upgrades, guest-handle wrapping, arbitrary guest TLS -configuration, session-ticket key rotation, or inbound handshake queues. The -certificate-validation clock is the cloned host `tls.Config.Time` function when -provided, otherwise Go's standard clock. +There is no HTTP/HTTPS request API, DTLS, QUIC TLS, STARTTLS upgrade, +guest-handle wrapping, arbitrary guest TLS configuration, or session-ticket key +rotation. Server listeners and bounded inbound handshakes are available only +through explicit granular TLS registration and authority; they do not place TLS +in aggregate `register`. The certificate-validation clock is the cloned host +`tls.Config.Time` function when provided, otherwise Go's standard clock. diff --git a/internal/abi/tls/tls.go b/internal/abi/tls/tls.go index 9608436..2e4f6a2 100644 --- a/internal/abi/tls/tls.go +++ b/internal/abi/tls/tls.go @@ -15,7 +15,13 @@ const ( ListenerV1Size uint32 = 40 IOResultV1Size uint32 = 8 ConnectionInfoV1Size uint32 = 144 + ConnectionInfoV2Size uint32 = 144 MaxALPNV1Bytes uint32 = 32 + + ConnectionInfoV2FlagResumed uint32 = 1 << 0 + ConnectionInfoV2FlagServerRole uint32 = 1 << 1 + ConnectionInfoV2FlagPeerAuthenticated uint32 = 1 << 2 + ConnectionInfoV2KnownFlags = ConnectionInfoV2FlagResumed | ConnectionInfoV2FlagServerRole | ConnectionInfoV2FlagPeerAuthenticated ) func CheckCreateV1(memory []byte, endpointPtr, serverNamePtr, serverNameLength, streamPtr uint32) bool { @@ -102,16 +108,59 @@ func EncodeConnectionInfoV1(memory []byte, ptr uint32, info tlsns.ConnectionInfo } binary.LittleEndian.PutUint16(encoded[64:66], info.TLSVersion) binary.LittleEndian.PutUint16(encoded[66:68], info.CipherSuite) + if info.Resumed { + binary.LittleEndian.PutUint32(encoded[68:72], 1) + } + binary.LittleEndian.PutUint32(encoded[72:76], uint32(info.VerifiedIdentity)) + binary.LittleEndian.PutUint32(encoded[76:80], uint32(len(info.NegotiatedALPN))) + copy(encoded[80:112], info.NegotiatedALPN) + copy(encoded[112:144], info.PeerLeafSPKI256[:]) + copy(output, encoded[:]) + return true +} + +// ValidConnectionInfoV2Flags reports whether no unknown v2 metadata bits are +// set. It is shared by fixtures and future decoders so additive metadata cannot +// be confused with an unversioned v1 boolean. +func ValidConnectionInfoV2Flags(flags uint32) bool { + return flags&^ConnectionInfoV2KnownFlags == 0 +} + +func connectionInfoV2Flags(info tlsns.ConnectionInfo) (uint32, bool) { var flags uint32 if info.Resumed { - flags |= 1 << 0 + flags |= ConnectionInfoV2FlagResumed } if info.Role == tlsns.RoleServer { - flags |= 1 << 1 + flags |= ConnectionInfoV2FlagServerRole } if info.PeerAuthenticated { - flags |= 1 << 2 + flags |= ConnectionInfoV2FlagPeerAuthenticated } + return flags, ValidConnectionInfoV2Flags(flags) +} + +// EncodeConnectionInfoV2 writes role-aware additive metadata into the distinct +// connection-info v2 contract. The physical size intentionally matches v1, but +// bytes 68..71 are flags rather than the v1 resumed boolean. +func EncodeConnectionInfoV2(memory []byte, ptr uint32, info tlsns.ConnectionInfo) bool { + if !info.Valid(int(MaxALPNV1Bytes)) { + return false + } + flags, ok := connectionInfoV2Flags(info) + if !ok { + return false + } + output, ok := abicore.Slice(memory, ptr, ConnectionInfoV2Size) + if !ok { + return false + } + var encoded [ConnectionInfoV2Size]byte + if !abicore.EncodeEndpointV1(encoded[:], 0, info.LocalEndpoint) || !abicore.EncodeEndpointV1(encoded[:], 32, info.RemoteEndpoint) { + return false + } + binary.LittleEndian.PutUint16(encoded[64:66], info.TLSVersion) + binary.LittleEndian.PutUint16(encoded[66:68], info.CipherSuite) binary.LittleEndian.PutUint32(encoded[68:72], flags) binary.LittleEndian.PutUint32(encoded[72:76], uint32(info.VerifiedIdentity)) binary.LittleEndian.PutUint32(encoded[76:80], uint32(len(info.NegotiatedALPN))) diff --git a/internal/abi/tls/tls_test.go b/internal/abi/tls/tls_test.go index 1688948..dc108a3 100644 --- a/internal/abi/tls/tls_test.go +++ b/internal/abi/tls/tls_test.go @@ -2,6 +2,8 @@ package tls import ( "bytes" + "encoding/binary" + "encoding/hex" "net/netip" "testing" @@ -42,6 +44,87 @@ func TestEncodeConnectionInfoAtomicAndBounded(t *testing.T) { } } +func TestEncodeConnectionInfoV1CompatibilityFixture(t *testing.T) { + memory := make([]byte, ConnectionInfoV1Size) + var peerSPKI [32]byte + for index := range peerSPKI { + peerSPKI[index] = byte(index + 1) + } + info := tlsns.ConnectionInfo{ + LocalEndpoint: nscore.Endpoint{Address: netip.MustParseAddr("192.0.2.1"), Port: 49152}, + RemoteEndpoint: nscore.Endpoint{Address: netip.MustParseAddr("192.0.2.2"), Port: 443}, + TLSVersion: 0x0304, + CipherSuite: 0x1301, + NegotiatedALPN: "h2", + PeerAuthenticated: true, + PeerLeafSPKI256: peerSPKI, + VerifiedIdentity: tlsns.IdentityDNS, + Role: tlsns.RoleClient, + } + if !EncodeConnectionInfoV1(memory, 0, info) { + t.Fatal("fixture encode failed") + } + const preServerFixture = "010000c000000000c000020100000000000000000000000000000000000000000100bb0100000000c000020200000000000000000000000000000000000000000403011300000000010000000200000068320000000000000000000000000000000000000000000000000000000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20" + want, err := hex.DecodeString(preServerFixture) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(memory, want) { + t.Fatalf("v1 bytes changed:\n got %x\nwant %x", memory, want) + } + if resumed := binary.LittleEndian.Uint32(memory[68:72]); resumed != 0 { + t.Fatalf("v1 resumed = %d, want 0", resumed) + } + + info.Role = tlsns.RoleServer + info.PeerAuthenticated = false + info.PeerLeafSPKI256 = [32]byte{} + info.VerifiedIdentity = tlsns.IdentityNone + if !EncodeConnectionInfoV1(memory, 0, info) { + t.Fatal("server v1 encode failed") + } + if resumed := binary.LittleEndian.Uint32(memory[68:72]); resumed != 0 { + t.Fatalf("server role leaked into v1 resumed = %d", resumed) + } + info.Resumed = true + if !EncodeConnectionInfoV1(memory, 0, info) { + t.Fatal("resumed server v1 encode failed") + } + if resumed := binary.LittleEndian.Uint32(memory[68:72]); resumed != 1 { + t.Fatalf("v1 resumed = %d, want 1", resumed) + } +} + +func TestEncodeConnectionInfoV2FlagsAndBounds(t *testing.T) { + memory := bytes.Repeat([]byte{0xa5}, int(ConnectionInfoV2Size)+8) + info := tlsns.ConnectionInfo{ + LocalEndpoint: nscore.Endpoint{Address: netip.MustParseAddr("192.0.2.1"), Port: 443}, + RemoteEndpoint: nscore.Endpoint{Address: netip.MustParseAddr("192.0.2.2"), Port: 49152}, + TLSVersion: 0x0304, + CipherSuite: 0x1301, + NegotiatedALPN: "h2", + Resumed: true, + PeerAuthenticated: true, + PeerLeafSPKI256: [32]byte{1}, + VerifiedIdentity: tlsns.IdentityDNS, + Role: tlsns.RoleServer, + } + if !EncodeConnectionInfoV2(memory, 4, info) { + t.Fatal("v2 encode failed") + } + flags := binary.LittleEndian.Uint32(memory[4+68 : 4+72]) + want := ConnectionInfoV2FlagResumed | ConnectionInfoV2FlagServerRole | ConnectionInfoV2FlagPeerAuthenticated + if flags != want { + t.Fatalf("v2 flags = %#x, want %#x", flags, want) + } + if !bytes.Equal(memory[:4], bytes.Repeat([]byte{0xa5}, 4)) || !bytes.Equal(memory[4+ConnectionInfoV2Size:], bytes.Repeat([]byte{0xa5}, 4)) { + t.Fatal("v2 encode wrote outside output range") + } + if ValidConnectionInfoV2Flags(ConnectionInfoV2KnownFlags | 1<<31) { + t.Fatal("unknown v2 flag accepted") + } +} + func FuzzCheckCreateV1(f *testing.F) { f.Add(uint32(0), uint32(32), uint32(4), uint32(64), uint32(256)) f.Fuzz(func(t *testing.T, endpointPtr, namePtr, nameLength, streamPtr, memoryLength uint32) { @@ -69,6 +152,28 @@ func FuzzEncodeConnectionInfoV1(f *testing.F) { }) } +func FuzzEncodeConnectionInfoV2(f *testing.F) { + f.Add("h2", uint16(0x304), uint16(0x1301), uint8(tlsns.RoleServer), true, true) + f.Fuzz(func(t *testing.T, alpn string, version, cipher uint16, role uint8, resumed, authenticated bool) { + if len(alpn) > 128 { + alpn = alpn[:128] + } + memory := make([]byte, ConnectionInfoV2Size) + identity := tlsns.IdentityNone + hash := [32]byte{} + if authenticated { + identity = tlsns.IdentityDNS + hash[0] = 1 + } + info := tlsns.ConnectionInfo{ + LocalEndpoint: nscore.Endpoint{Address: netip.MustParseAddr("192.0.2.1"), Port: 1234}, RemoteEndpoint: nscore.Endpoint{Address: netip.MustParseAddr("192.0.2.2"), Port: 443}, + TLSVersion: version, CipherSuite: cipher, NegotiatedALPN: alpn, Resumed: resumed, Role: tlsns.Role(role), + PeerAuthenticated: authenticated, PeerLeafSPKI256: hash, VerifiedIdentity: identity, + } + _ = EncodeConnectionInfoV2(memory, 0, info) + }) +} + func TestEncodeStreamRejectsZeroHandle(t *testing.T) { memory := make([]byte, StreamV1Size) endpoint := nscore.Endpoint{Address: netip.MustParseAddr("192.0.2.1"), Port: 443} diff --git a/internal/binding/tls/descriptor_test.go b/internal/binding/tls/descriptor_test.go index 6ad2822..53e0789 100644 --- a/internal/binding/tls/descriptor_test.go +++ b/internal/binding/tls/descriptor_test.go @@ -35,8 +35,8 @@ func TestDescriptorInstallsExactTLSBindingsAndPreservesBackend(t *testing.T) { t.Fatalf("incompatible backend = %v", err) } bindings := Bindings(plugin.Host{}) - if len(bindings) != 12 { - t.Fatalf("bindings = %d, want 12", len(bindings)) + if len(bindings) != 13 { + t.Fatalf("bindings = %d, want 13", len(bindings)) } seen := make(map[string]struct{}, len(bindings)) for _, binding := range bindings { @@ -48,7 +48,7 @@ func TestDescriptorInstallsExactTLSBindingsAndPreservesBackend(t *testing.T) { } seen[binding.Name] = struct{}{} } - for _, required := range []string{"namespace_default", "listen", "accept", "connect", "finish_connect", "read", "write", "shutdown_write", "connection_info", "close", "close_listener", "poll"} { + for _, required := range []string{"namespace_default", "listen", "accept", "connect", "finish_connect", "read", "write", "shutdown_write", "connection_info", "connection_info_v2", "close", "close_listener", "poll"} { if _, ok := seen[required]; !ok { t.Fatalf("binding %q missing", required) } diff --git a/internal/binding/tls/tls.go b/internal/binding/tls/tls.go index 8e6de10..f677086 100644 --- a/internal/binding/tls/tls.go +++ b/internal/binding/tls/tls.go @@ -40,7 +40,10 @@ func Bindings(host plugin.Host) []plugin.Binding { {Name: "read", Func: func(module wago.HostModule, params, results []uint64) { read(host, module, params, results) }, Params: []wago.ValType{wago.ValI64, wago.ValI32, wago.ValI32, wago.ValI32}, Results: []wago.ValType{wago.ValI32}, Capability: Capability, Docs: "perform one checked partial decrypted read"}, {Name: "write", Func: func(module wago.HostModule, params, results []uint64) { write(host, module, params, results) }, Params: []wago.ValType{wago.ValI64, wago.ValI32, wago.ValI32, wago.ValI32}, Results: []wago.ValType{wago.ValI32}, Capability: Capability, Docs: "perform one checked partial plaintext write"}, {Name: "shutdown_write", Func: func(module wago.HostModule, params, results []uint64) { shutdownWrite(host, module, params, results) }, Params: []wago.ValType{wago.ValI64}, Results: []wago.ValType{wago.ValI32}, Capability: Capability, Docs: "queue TLS close_notify and reject later plaintext writes"}, - {Name: "connection_info", Func: func(module wago.HostModule, params, results []uint64) { connectionInfo(host, module, params, results) }, Params: []wago.ValType{wago.ValI64, wago.ValI32}, Results: []wago.ValType{wago.ValI32}, Capability: Capability, Docs: "return bounded verified TLS connection metadata"}, + {Name: "connection_info", Func: func(module wago.HostModule, params, results []uint64) { connectionInfo(host, module, params, results) }, Params: []wago.ValType{wago.ValI64, wago.ValI32}, Results: []wago.ValType{wago.ValI32}, Capability: Capability, Docs: "return backward-compatible TLS connection-info v1 metadata"}, + {Name: "connection_info_v2", Func: func(module wago.HostModule, params, results []uint64) { + connectionInfoV2(host, module, params, results) + }, Params: []wago.ValType{wago.ValI64, wago.ValI32}, Results: []wago.ValType{wago.ValI32}, Capability: Capability, Docs: "return role-aware TLS connection-info v2 metadata"}, {Name: "close", Func: func(module wago.HostModule, params, results []uint64) { closeStream(host, module, params, results) }, Params: []wago.ValType{wago.ValI64}, Results: []wago.ValType{wago.ValI32}, Capability: Capability, Docs: "abort and close one exact TLS stream without waiting for the peer"}, {Name: "close_listener", Func: func(module wago.HostModule, params, results []uint64) { closeListener(host, module, params, results) }, Params: []wago.ValType{wago.ValI64}, Results: []wago.ValType{wago.ValI32}, Capability: Capability, Docs: "close one exact TLS server listener"}, {Name: "poll", Func: func(module wago.HostModule, params, results []uint64) { guest.Poll(host, module, params, results) }, Params: []wago.ValType{wago.ValI32, wago.ValI32, wago.ValI32, wago.ValI32}, Results: []wago.ValType{wago.ValI32}, Capability: Capability, Docs: "perform one bounded TLS readiness and transport-service pass"}, @@ -307,13 +310,25 @@ func shutdownWrite(host plugin.Host, module wago.HostModule, params, results []u } func connectionInfo(host plugin.Host, module wago.HostModule, params, results []uint64) { + connectionInfoCall(host, module, params, results, false) +} + +func connectionInfoV2(host plugin.Host, module wago.HostModule, params, results []uint64) { + connectionInfoCall(host, module, params, results, true) +} + +func connectionInfoCall(host plugin.Host, module wago.HostModule, params, results []uint64, version2 bool) { if len(params) != 2 || len(results) != 1 { guest.SetStatus(results, guest.StatusInvalidArgument) return } memory := guest.Memory(module) out, ok := abicore.NarrowUint32(params[1]) - if !ok || !abicore.CheckRanges(memory, false, abicore.Range{Ptr: out, Length: tlsabi.ConnectionInfoV1Size}) { + size := tlsabi.ConnectionInfoV1Size + if version2 { + size = tlsabi.ConnectionInfoV2Size + } + if !ok || !abicore.CheckRanges(memory, false, abicore.Range{Ptr: out, Length: size}) { guest.SetStatus(results, guest.StatusInvalidArgument) return } @@ -332,7 +347,11 @@ func connectionInfo(host plugin.Host, module wago.HostModule, params, results [] guest.SetStatus(results, status) return } - if !tlsabi.EncodeConnectionInfoV1(memory, out, info) { + encoded := tlsabi.EncodeConnectionInfoV1(memory, out, info) + if version2 { + encoded = tlsabi.EncodeConnectionInfoV2(memory, out, info) + } + if !encoded { guest.SetStatus(results, guest.StatusIO) return } diff --git a/internal/binding/tls/tls_test.go b/internal/binding/tls/tls_test.go index 778a1a3..2a1e236 100644 --- a/internal/binding/tls/tls_test.go +++ b/internal/binding/tls/tls_test.go @@ -42,13 +42,24 @@ func TestBindingsRejectMalformedAndOverlappingRangesWithoutMutation(t *testing.T t.Fatal("malformed read mutated memory") } - results[0] = 0 - byName["connection_info"].Func(memoryModule{memory}, []uint64{1, ^uint64(0)}, results) - if got := guest.Status(wago.AsI32(results[0])); got != guest.StatusInvalidArgument { - t.Fatalf("info status = %v", got) - } - if !bytes.Equal(memory, before) { - t.Fatal("malformed info mutated memory") + for _, name := range []string{"connection_info", "connection_info_v2"} { + results[0] = 0 + byName[name].Func(memoryModule{memory}, []uint64{1, ^uint64(0)}, results) + if got := guest.Status(wago.AsI32(results[0])); got != guest.StatusInvalidArgument { + t.Fatalf("%s malformed status = %v", name, got) + } + if !bytes.Equal(memory, before) { + t.Fatalf("malformed %s mutated memory", name) + } + + results[0] = 0 + byName[name].Func(memoryModule{memory}, []uint64{1, 64}, results) + if got := guest.Status(wago.AsI32(results[0])); got == guest.StatusOK { + t.Fatalf("%s unexpectedly succeeded without an instance", name) + } + if !bytes.Equal(memory, before) { + t.Fatalf("failed %s mutated output", name) + } } } diff --git a/internal/dependencytest/inspection_tls_test.go b/internal/dependencytest/inspection_tls_test.go index 4278148..31505d3 100644 --- a/internal/dependencytest/inspection_tls_test.go +++ b/internal/dependencytest/inspection_tls_test.go @@ -19,8 +19,8 @@ func TestTLSFixtureRuntimeInspection(t *testing.T) { capabilities []wago.Capability imports map[string]int }{ - {name: "tls", newNetwork: tlsfixture.Network, capabilities: []wago.Capability{wagonet.CapInfo, wagonet.CapTLS}, imports: map[string]int{wagonet.Module: 1, wagonet.TLSModule: 12}}, - {name: "tcp_tls", newNetwork: tcptlsfixture.Network, capabilities: []wago.Capability{wagonet.CapInfo, wagonet.CapTCP, wagonet.CapTLS}, imports: map[string]int{wagonet.Module: 1, wagonet.TCPModule: 11, wagonet.TLSModule: 12}}, + {name: "tls", newNetwork: tlsfixture.Network, capabilities: []wago.Capability{wagonet.CapInfo, wagonet.CapTLS}, imports: map[string]int{wagonet.Module: 1, wagonet.TLSModule: 13}}, + {name: "tcp_tls", newNetwork: tcptlsfixture.Network, capabilities: []wago.Capability{wagonet.CapInfo, wagonet.CapTCP, wagonet.CapTLS}, imports: map[string]int{wagonet.Module: 1, wagonet.TCPModule: 11, wagonet.TLSModule: 13}}, } { t.Run(test.name, func(t *testing.T) { network, err := test.newNetwork() diff --git a/tls/register_test.go b/tls/register_test.go index de4d873..7bfcda5 100644 --- a/tls/register_test.go +++ b/tls/register_test.go @@ -34,7 +34,7 @@ func TestRegisterExposesOnlyTLSAndSharedCore(t *testing.T) { for _, spec := range runtime.ProvidedImports() { imports[spec.Module]++ } - want := map[string]int{wagonet.Module: 1, wagonet.TLSModule: 12} + want := map[string]int{wagonet.Module: 1, wagonet.TLSModule: 13} if !reflect.DeepEqual(imports, want) { t.Fatalf("imports = %v, want %v", imports, want) } @@ -63,7 +63,7 @@ func TestTCPAndTLSComposeWithoutCapabilityWidening(t *testing.T) { for _, spec := range runtime.ProvidedImports() { imports[spec.Module]++ } - wantImports := map[string]int{wagonet.Module: 1, wagonet.TCPModule: 11, wagonet.TLSModule: 12} + wantImports := map[string]int{wagonet.Module: 1, wagonet.TCPModule: 11, wagonet.TLSModule: 13} if !reflect.DeepEqual(imports, wantImports) { t.Fatalf("imports = %v, want %v", imports, wantImports) } From f5184e62b441c06e10d5f7390dd1b51732764552 Mon Sep 17 00:00:00 2001 From: Wago Networking Agent Date: Mon, 20 Jul 2026 17:47:41 +0000 Subject: [PATCH 05/17] feat: require explicit TLS listener authority --- docs/tls.md | 35 ++++++++++++++++----- tls/profile.go | 74 ++++++++++++++++++++++++++------------------ tls/profile_test.go | 42 +++++++++++++++++++++++-- tls/register_test.go | 3 ++ tls/tls.go | 17 +++++++--- 5 files changed, 127 insertions(+), 44 deletions(-) diff --git a/docs/tls.md b/docs/tls.md index 5b5b84f..d4b6cde 100644 --- a/docs/tls.md +++ b/docs/tls.md @@ -1,10 +1,11 @@ -# Outbound TLS client capability +# Bounded TLS client and server capability -`github.com/wago-org/net/tls` is a separately selectable, client-only secure -stream protocol. It declares `net.tls` and `wago_net_tls`; it does not declare -`net.tcp` or install `wago_net_tcp`. The lneto implementation privately owns an -internal TCP stream, never publishes that stream in the guest resource table, -and closes both TLS and TCP ownership exactly once. +`github.com/wago-org/net/tls` is a separately selectable secure stream protocol +with outbound clients and explicitly authorized inbound listeners. It declares +`net.tls` and `wago_net_tls`; it does not declare `net.tcp` or install +`wago_net_tcp`. The lneto implementation privately owns internal TCP streams and +listeners, never publishes them in the guest resource table, and closes TLS and +TCP ownership exactly once. ## Public API and authority @@ -36,10 +37,28 @@ loopback gate; raw TCP still requires its own TCP-scoped grant. Multicast and limited broadcast remain unsupported TLS destinations even if advanced policy 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 +`crypto.Signer`. Certificate DER, OCSP staples, SCTs, ALPN, and CA pools are +cloned. The signer itself remains a host-owned interface value and must remain +available, immutable, and concurrency-safe for the profile lifetime; it never +enters guest memory. Dynamic certificate/config selection and verification +callbacks are rejected. Client SNI may select only among the immutable static +certificates supplied by the host; it cannot select a new configuration or +credential source. Server session tickets remain disabled. + +A stored server profile grants no endpoint authority. Hosts must separately opt +in with `tls.AllowListeners()` or supply explicit advanced inbound TLS policy. +That authority does not grant raw-TCP listen, and applicable raw-TCP inbound deny +rules continue to constrain the private listener. Listener handles and accepted +TLS streams remain kind-separated and finite. + TLS intentionally has no `tls/register` package or zero-configuration extension. A self-registering package cannot safely invent trust roots, profile IDs, -verification identities, ALPN, or client credentials. Hosts must call -`tls.NewClientProfile` and `tls.Register` explicitly in Go composition. +verification identities, ALPN, server certificates, private keys, or listen +policy. Hosts must call profile constructors and `tls.Register` explicitly in Go +composition. ## Nonblocking engine diff --git a/tls/profile.go b/tls/profile.go index 6459102..3b82fca 100644 --- a/tls/profile.go +++ b/tls/profile.go @@ -1,6 +1,7 @@ package tls import ( + "bytes" "crypto" cryptotls "crypto/tls" "crypto/x509" @@ -177,10 +178,13 @@ func NewClientProfile(id uint32, config *cryptotls.Config, options ...ClientProf return &ClientProfile{id: id, config: cloned, allowedNames: builder.allowedNames, requiredALPN: builder.requiredALPN, allowTLS12: builder.allowTLS12}, nil } -// NewServerProfile validates and deeply clones a caller-owned crypto/tls -// server configuration. Static certificates are mandatory. Dynamic -// certificate, verification, session, entropy, and key-log callbacks are -// rejected so guest traffic cannot mutate host policy. +// NewServerProfile validates and clones a caller-owned crypto/tls server +// configuration. Static certificate DER and metadata are deeply cloned. Each +// crypto.Signer remains a host-owned interface value because private keys are +// intentionally never copied or exposed; the caller must keep that signer +// available, concurrency-safe, and immutable for the profile lifetime. +// Dynamic certificate, verification, session, entropy, and key-log callbacks +// are rejected so guest traffic cannot mutate host policy. func NewServerProfile(id uint32, config *cryptotls.Config, options ...ServerProfileOption) (*ServerProfile, error) { if id == 0 || config == nil { return nil, ErrInvalidServerProfile @@ -306,14 +310,8 @@ func cloneSafeServerConfig(input *cryptotls.Config, allowTLS12 bool) (*cryptotls return nil, ErrInvalidServerProfile } for _, certificate := range input.Certificates { - signer, signerOK := certificate.PrivateKey.(crypto.Signer) - if len(certificate.Certificate) == 0 || !signerOK || signer.Public() == nil { - return nil, ErrInvalidServerProfile - } - for _, der := range certificate.Certificate { - if len(der) == 0 { - return nil, ErrInvalidServerProfile - } + if err := validateStaticServerCertificate(certificate); err != nil { + return nil, err } } cloned := input.Clone() @@ -353,6 +351,38 @@ func cloneSafeServerConfig(input *cryptotls.Config, allowTLS12 bool) (*cryptotls return cloned, nil } +func validateStaticServerCertificate(certificate cryptotls.Certificate) error { + signer, signerOK := certificate.PrivateKey.(crypto.Signer) + if len(certificate.Certificate) == 0 || !signerOK || signer.Public() == nil { + return ErrInvalidServerProfile + } + parsed := make([]*x509.Certificate, len(certificate.Certificate)) + for index, der := range certificate.Certificate { + if len(der) == 0 { + return ErrInvalidServerProfile + } + value, err := x509.ParseCertificate(der) + if err != nil { + return ErrInvalidServerProfile + } + parsed[index] = value + } + for index := 0; index+1 < len(parsed); index++ { + if err := parsed[index].CheckSignatureFrom(parsed[index+1]); err != nil { + return ErrInvalidServerProfile + } + } + leafPublic, err := x509.MarshalPKIXPublicKey(parsed[0].PublicKey) + if err != nil { + return ErrInvalidServerProfile + } + signerPublic, err := x509.MarshalPKIXPublicKey(signer.Public()) + if err != nil || !bytes.Equal(leafPublic, signerPublic) { + return ErrInvalidServerProfile + } + return nil +} + func cloneCertificates(input []cryptotls.Certificate) []cryptotls.Certificate { out := make([]cryptotls.Certificate, len(input)) for i := range input { @@ -366,29 +396,13 @@ func cloneCertificates(input []cryptotls.Certificate) []cryptotls.Certificate { for j := range input[i].SignedCertificateTimestamps { out[i].SignedCertificateTimestamps[j] = append([]byte(nil), input[i].SignedCertificateTimestamps[j]...) } - if input[i].Leaf != nil { - out[i].Leaf = cloneCertificate(input[i].Leaf) + if len(out[i].Certificate) != 0 { + out[i].Leaf, _ = x509.ParseCertificate(out[i].Certificate[0]) } } return out } -func cloneCertificate(input *x509.Certificate) *x509.Certificate { - if input == nil { - return nil - } - // Parsing Raw creates an independent standard-library representation while - // avoiding a fragile hand-maintained copy of x509.Certificate's slices. - if len(input.Raw) != 0 { - if parsed, err := x509.ParseCertificate(append([]byte(nil), input.Raw...)); err == nil { - return parsed - } - } - // A malformed or synthetic Leaf is not retained: crypto/tls can safely parse - // the independently cloned certificate DER when it needs a leaf. - return nil -} - func normalizeIdentity(name string) (string, identityKind, bool) { if name == "" || !utf8.ValidString(name) || strings.TrimSpace(name) != name { return "", 0, false diff --git a/tls/profile_test.go b/tls/profile_test.go index 42a410a..f1bdd93 100644 --- a/tls/profile_test.go +++ b/tls/profile_test.go @@ -87,7 +87,7 @@ func TestServerProfileDefaultsTLS13ClonesAndRequiresStaticCertificate(t *testing } } -func TestServerOnlyRegistrationAuthorityIsInboundAndProfileCompiles(t *testing.T) { +func TestServerProfileStorageRequiresExplicitListenerAuthority(t *testing.T) { profile, err := NewServerProfile(7, testServerConfig(t), RequireServerALPN("h2")) if err != nil { t.Fatal(err) @@ -98,8 +98,21 @@ func TestServerOnlyRegistrationAuthorityIsInboundAndProfileCompiles(t *testing.T t.Fatal(err) } address := netip.MustParseAddr("192.0.2.20") + if compiled.CheckEndpoint(policy.OperationTLSListen, address, 8443) { + t.Fatal("server profile storage implicitly granted inbound TLS authority") + } + if err := AllowListeners().applyTLS(&configuration); err != nil { + t.Fatal(err) + } + compiled, err = policy.Compile(configuration.authority()) + if err != nil { + t.Fatal(err) + } if !compiled.CheckEndpoint(policy.OperationTLSListen, address, 8443) { - t.Fatal("server profile did not grant inbound TLS authority") + t.Fatal("explicit listener authority did not grant inbound TLS") + } + if compiled.CheckEndpoint(policy.OperationTCPListen, address, 8443) { + t.Fatal("explicit TLS listener authority widened raw TCP listen") } if compiled.CheckEndpoint(policy.OperationTLSConnect, address, 8443) { t.Fatal("server-only profile granted outbound TLS authority") @@ -110,6 +123,31 @@ func TestServerOnlyRegistrationAuthorityIsInboundAndProfileCompiles(t *testing.T } } +func TestServerProfileRejectsMalformedChainAndMismatchedSigner(t *testing.T) { + malformed := testServerConfig(t) + malformed.Certificates[0].Certificate[0] = []byte{1, 2, 3} + if _, err := NewServerProfile(1, malformed); err != ErrInvalidServerProfile { + t.Fatalf("malformed leaf = %v", err) + } + + mismatched := testServerConfig(t) + _, otherSigner, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + mismatched.Certificates[0].PrivateKey = otherSigner + if _, err := NewServerProfile(1, mismatched); err != ErrInvalidServerProfile { + t.Fatalf("mismatched signer = %v", err) + } + + brokenChain := testServerConfig(t) + other := testServerConfig(t) + brokenChain.Certificates[0].Certificate = append(brokenChain.Certificates[0].Certificate, other.Certificates[0].Certificate[0]) + if _, err := NewServerProfile(1, brokenChain); err != ErrInvalidServerProfile { + t.Fatalf("broken chain = %v", err) + } +} + func TestServerProfileRejectsUnsafeConfigurationAndRequiresTLS12OptIn(t *testing.T) { unsafe := testServerConfig(t) unsafe.GetCertificate = func(*cryptotls.ClientHelloInfo) (*cryptotls.Certificate, error) { return nil, nil } diff --git a/tls/register_test.go b/tls/register_test.go index 7bfcda5..31ef8cb 100644 --- a/tls/register_test.go +++ b/tls/register_test.go @@ -133,6 +133,9 @@ func TestRegisterRejectsMissingProfileDuplicateAndFrozen(t *testing.T) { if err := wagonettls.Register(wagonet.New()); !errors.Is(err, wagonettls.ErrInvalidConfig) { t.Fatalf("missing profile = %v", err) } + if err := wagonettls.Register(wagonet.New(), wagonettls.WithClientProfile(testProfile(t)), wagonettls.AllowListeners()); !errors.Is(err, wagonettls.ErrInvalidConfig) { + t.Fatalf("listener authority without server profile = %v", err) + } network := wagonet.New() profile := testProfile(t) if err := wagonettls.Register(network, nil); !errors.Is(err, wagonettls.ErrInvalidOption) { diff --git a/tls/tls.go b/tls/tls.go index 1b45d20..94b1f4d 100644 --- a/tls/tls.go +++ b/tls/tls.go @@ -1,5 +1,6 @@ -// Package tls selectively registers Wago's outbound, verified, nonblocking TLS -// client capability. TLS is independent from the public raw-TCP capability. +// Package tls selectively registers Wago's verified, nonblocking TLS client +// and explicitly authorized server-listener capability. TLS is independent +// from the public raw-TCP capability. package tls import ( @@ -30,6 +31,7 @@ type registration struct { profiles []*ClientProfile serverProfiles []*ServerProfile defaultAuthority bool + listenerAuthority bool authorityAdditions policy.Config } @@ -62,6 +64,13 @@ func WithServerProfile(profile *ServerProfile) Option { }) } +// AllowListeners explicitly grants ordinary inbound TLS listen authority when +// at least one server profile is registered. Storing server credentials alone +// never grants endpoint authority or raw-TCP listen capability. +func AllowListeners() Option { + return optionFunc(func(target *registration) error { target.listenerAuthority = true; return nil }) +} + // WithPolicy adds advanced TLS authority. Deny rules from any composition // layer retain precedence, including applicable raw-TCP denies. func WithPolicy(config wagonet.PolicyConfig) Option { @@ -102,7 +111,7 @@ func (registration registration) authority() policy.Config { if !registration.defaultAuthority { return policy.Merge(registration.authorityAdditions) } - return policy.Merge(defaultAuthority(len(registration.profiles) != 0, len(registration.serverProfiles) != 0), registration.authorityAdditions) + return policy.Merge(defaultAuthority(len(registration.profiles) != 0, registration.listenerAuthority && len(registration.serverProfiles) != 0), registration.authorityAdditions) } // Register selects only net.tls, wago_net_tls, and the private TLS transport. @@ -117,7 +126,7 @@ func Register(network *wagonet.Network, options ...Option) error { return err } } - if network == nil || !validConfig(config.config) || (len(config.profiles) == 0 && len(config.serverProfiles) == 0) || len(config.profiles) > MaximumClientProfiles || len(config.serverProfiles) > MaximumClientProfiles { + if network == nil || !validConfig(config.config) || (len(config.profiles) == 0 && len(config.serverProfiles) == 0) || len(config.profiles) > MaximumClientProfiles || len(config.serverProfiles) > MaximumClientProfiles || (config.listenerAuthority && len(config.serverProfiles) == 0) { return ErrInvalidConfig } profiles, err := compileProfiles(config.profiles, config.config) From beac180d2e32057f8b314c7865f9baf758257a8d Mon Sep 17 00:00:00 2001 From: Wago Networking Agent Date: Mon, 20 Jul 2026 17:55:32 +0000 Subject: [PATCH 06/17] test: exercise live TLS client server lifecycle --- internal/abi/tls/tls_test.go | 20 + internal/backend/gotls/benchmark_test.go | 59 +- .../backend/lneto/tls/integration_test.go | 615 ++++++++++++++++++ internal/backend/lneto/tls/tls.go | 5 + 4 files changed, 697 insertions(+), 2 deletions(-) create mode 100644 internal/backend/lneto/tls/integration_test.go diff --git a/internal/abi/tls/tls_test.go b/internal/abi/tls/tls_test.go index dc108a3..2a13549 100644 --- a/internal/abi/tls/tls_test.go +++ b/internal/abi/tls/tls_test.go @@ -125,6 +125,26 @@ func TestEncodeConnectionInfoV2FlagsAndBounds(t *testing.T) { } } +func FuzzCheckListenV1(f *testing.F) { + f.Add(uint32(0), uint32(32), uint32(128)) + f.Fuzz(func(t *testing.T, endpointPtr, listenerPtr, memoryLength uint32) { + if memoryLength > 4096 { + memoryLength = 4096 + } + _ = CheckListenV1(make([]byte, memoryLength), endpointPtr, listenerPtr) + }) +} + +func FuzzEncodeListenerV1(f *testing.F) { + f.Add(uint32(0), uint64(1), uint16(443), uint32(128)) + f.Fuzz(func(t *testing.T, ptr uint32, handle uint64, port uint16, memoryLength uint32) { + if memoryLength > 4096 { + memoryLength = 4096 + } + _ = EncodeListenerV1(make([]byte, memoryLength), ptr, resource.Handle(handle), nscore.Endpoint{Address: netip.MustParseAddr("192.0.2.1"), Port: port}) + }) +} + func FuzzCheckCreateV1(f *testing.F) { f.Add(uint32(0), uint32(32), uint32(4), uint32(64), uint32(256)) f.Fuzz(func(t *testing.T, endpointPtr, namePtr, nameLength, streamPtr, memoryLength uint32) { diff --git a/internal/backend/gotls/benchmark_test.go b/internal/backend/gotls/benchmark_test.go index 4dd95f2..c18b90b 100644 --- a/internal/backend/gotls/benchmark_test.go +++ b/internal/backend/gotls/benchmark_test.go @@ -4,6 +4,7 @@ import ( cryptotls "crypto/tls" "runtime" "testing" + "time" nscore "github.com/wago-org/net/internal/namespace/core" tlsns "github.com/wago-org/net/internal/namespace/tls" @@ -34,15 +35,69 @@ func BenchmarkTLS13Handshake(b *testing.B) { } runtime.Gosched() } - if err := <-serverDone; err != nil { - b.Fatal(err) + for { + if _, _, err := client.TryService(nscore.ServiceBudget{Packets: 8, Bytes: 64 << 10, Operations: 8}); err != nil { + b.Fatal(err) + } + select { + case err := <-serverDone: + if err != nil { + b.Fatal(err) + } + goto serverHandshakeComplete + default: + runtime.Gosched() + } } + serverHandshakeComplete: if err := client.Close(); err != nil { b.Fatal(err) } } } +func BenchmarkTLS13ServerHandshake(b *testing.B) { + certificate, roots := testCertificate(b, "server.example.com") + profile := ServerProfile{ + ID: 9, + Config: &cryptotls.Config{ + Certificates: []cryptotls.Certificate{certificate}, MinVersion: cryptotls.VersionTLS13, + MaxVersion: cryptotls.VersionTLS13, NextProtos: []string{"h2"}, SessionTicketsDisabled: true, + }, + RequiredALPN: "h2", MaxCertificateChainBytes: 64 << 10, MaxPeerCertificates: 4, + } + b.ReportAllocs() + for b.Loop() { + clientBridge := newBridgeConn(64<<10, 64<<10, 1<<20) + client := cryptotls.Client(clientBridge, &cryptotls.Config{ + RootCAs: roots, ServerName: "server.example.com", Time: func() time.Time { return time.Unix(1_800_000_000, 0) }, MinVersion: cryptotls.VersionTLS13, + MaxVersion: cryptotls.VersionTLS13, NextProtos: []string{"h2"}, + }) + clientDone := make(chan error, 1) + go func() { clientDone <- client.Handshake() }() + server, err := NewServer(&memoryTransport{peer: clientBridge}, profile, testLimits()) + if err != nil { + b.Fatal(err) + } + for { + progress, err := server.TryFinishConnect() + if err != nil { + b.Fatal(err) + } + if progress == nscore.ProgressDone { + break + } + runtime.Gosched() + } + if err := <-clientDone; 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/lneto/tls/integration_test.go b/internal/backend/lneto/tls/integration_test.go new file mode 100644 index 0000000..6ddd3d1 --- /dev/null +++ b/internal/backend/lneto/tls/integration_test.go @@ -0,0 +1,615 @@ +package tls + +import ( + "bytes" + "crypto/ed25519" + "crypto/rand" + cryptotls "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "math/big" + "net/netip" + "runtime" + "sync" + "testing" + "time" + + "github.com/soypat/lneto/ethernet" + gotls "github.com/wago-org/net/internal/backend/gotls" + lnetocore "github.com/wago-org/net/internal/backend/lneto/core" + tcpbackend "github.com/wago-org/net/internal/backend/lneto/tcp" + nscore "github.com/wago-org/net/internal/namespace/core" + tlsns "github.com/wago-org/net/internal/namespace/tls" + "github.com/wago-org/net/internal/packetlink" + "github.com/wago-org/net/internal/policy" + "github.com/wago-org/net/internal/quota" +) + +func TestLiveLnetoTLSClientServerHandshakeDataShutdownAndReuse(t *testing.T) { + for _, mutual := range []bool{false, true} { + name := "server-auth" + if mutual { + name = "mutual-auth" + } + t.Run(name, func(t *testing.T) { + pair := newLiveTLSPair(t, mutual) + listenerValue, progress, err := pair.server.TryListenTLS(pair.endpoint, 2) + if err != nil || progress != nscore.ProgressDone { + t.Fatalf("listen = %T, %v, %v", listenerValue, progress, err) + } + listener := listenerValue.(*listener) + pair.assertLiveCounts(t, 0, 1, 0, 1) + + client, server := pair.establish(t, listener) + pair.assertLiveCounts(t, 1, 2, 1, 1) + assertLiveConnectionInfo(t, client, server, mutual) + pair.exchange(t, client, server, bytes.Repeat([]byte("client-to-server/"), 256)) + pair.exchange(t, server, client, bytes.Repeat([]byte("server-to-client/"), 256)) + pair.cleanShutdown(t, client, server) + if err := client.Close(); err != nil { + t.Fatal(err) + } + if err := server.Close(); err != nil { + t.Fatal(err) + } + pair.assertLiveCounts(t, 0, 1, 0, 1) + + // Reuse the same live TLS listener and port for a second connection, + // then abort it without close_notify to prove truncation classification. + secondClient, secondServer := pair.establish(t, listener) + pair.finishRawTransportShutdown(t, secondClient) + pair.expectTruncation(t, secondServer) + if err := secondClient.Close(); err != nil { + t.Fatal(err) + } + if err := secondServer.Close(); err != nil { + t.Fatal(err) + } + if err := listener.Close(); err != nil { + t.Fatal(err) + } + pair.assertReleased(t) + }) + } +} + +func TestLiveLnetoTLSListenerCloseAcceptRaceReleasesOwnership(t *testing.T) { + pair := newLiveTLSPair(t, false) + for iteration := 0; iteration < 32; iteration++ { + listenerValue, progress, err := pair.server.TryListenTLS(pair.endpoint, 2) + if err != nil || progress != nscore.ProgressDone { + t.Fatalf("iteration %d listen = %T, %v, %v", iteration, listenerValue, progress, err) + } + listener := listenerValue.(*listener) + clientValue, progress, err := pair.client.TryConnectTLS(pair.endpoint, 1, "server.example.com") + if err != nil || progress != nscore.ProgressInProgress { + t.Fatalf("iteration %d connect = %T, %v, %v", iteration, clientValue, progress, err) + } + client := clientValue.(*stream) + for attempt := 0; attempt < 100000 && listener.Readiness()&nscore.ReadyAccept == 0; attempt++ { + pair.serviceStream(t, client, false) + pair.relay(t, pair.clientCore, pair.serverCore) + pair.relay(t, pair.serverCore, pair.clientCore) + runtime.Gosched() + } + if listener.Readiness()&nscore.ReadyAccept == 0 { + t.Fatalf("iteration %d connection did not reach accept backlog", iteration) + } + + start := make(chan struct{}) + var accepted nscore.Resource + var acceptProgress nscore.Progress + var acceptErr, closeErr error + var workers sync.WaitGroup + workers.Add(2) + go func() { + defer workers.Done() + <-start + accepted, acceptProgress, acceptErr = listener.TryAcceptTLS() + }() + go func() { + defer workers.Done() + <-start + closeErr = listener.Close() + }() + close(start) + workers.Wait() + if closeErr != nil { + t.Fatalf("iteration %d listener close: %v", iteration, closeErr) + } + if accepted != nil { + if acceptErr != nil || acceptProgress != nscore.ProgressInProgress { + t.Fatalf("iteration %d accepted result = %T, %v, %v", iteration, accepted, acceptProgress, acceptErr) + } + if err := accepted.Close(); err != nil { + t.Fatalf("iteration %d accepted close: %v", iteration, err) + } + } else if acceptErr == nil && acceptProgress != nscore.ProgressWouldBlock { + t.Fatalf("iteration %d empty accept result = %v, %v", iteration, acceptProgress, acceptErr) + } + if err := client.Close(); err != nil { + t.Fatalf("iteration %d client close: %v", iteration, err) + } + } + pair.assertReleased(t) +} + +func TestLiveLnetoTLSConcurrentResourceAndNamespaceCloseIsExactlyOnce(t *testing.T) { + pair := newLiveTLSPair(t, true) + listenerValue, progress, err := pair.server.TryListenTLS(pair.endpoint, 2) + if err != nil || progress != nscore.ProgressDone { + t.Fatalf("listen = %T, %v, %v", listenerValue, progress, err) + } + listener := listenerValue.(*listener) + client, server := pair.establish(t, listener) + closers := []func() error{client.Close, server.Close, listener.Close, pair.clientCore.Close, pair.serverCore.Close} + start := make(chan struct{}) + errors := make(chan error, len(closers)) + var workers sync.WaitGroup + for _, closeResource := range closers { + workers.Add(1) + go func(closeResource func() error) { + defer workers.Done() + <-start + errors <- closeResource() + }(closeResource) + } + close(start) + workers.Wait() + close(errors) + for err := range errors { + if err != nil { + t.Fatal(err) + } + } + pair.assertReleased(t) +} + +func TestLiveLnetoTLSNamespaceTeardownReleasesListenerStreamAndHandshake(t *testing.T) { + pair := newLiveTLSPair(t, false) + listenerValue, progress, err := pair.server.TryListenTLS(pair.endpoint, 2) + if err != nil || progress != nscore.ProgressDone { + t.Fatalf("listen = %T, %v, %v", listenerValue, progress, err) + } + listener := listenerValue.(*listener) + client, server := pair.establish(t, listener) + if client == nil || server == nil { + t.Fatal("live streams missing") + } + if err := pair.clientCore.Close(); err != nil { + t.Fatal(err) + } + if err := pair.serverCore.Close(); err != nil { + t.Fatal(err) + } + pair.assertReleased(t) +} + +type liveTLSPair struct { + clientCore *lnetocore.Namespace + serverCore *lnetocore.Namespace + client *Adapter + server *Adapter + clientAccount *quota.Account + serverAccount *quota.Account + endpoint nscore.Endpoint +} + +func newLiveTLSPair(t testing.TB, mutual bool) *liveTLSPair { + t.Helper() + certificate, clientCertificate, roots, now := liveTLSCertificates(t) + clientMAC := [6]byte{0x02, 0, 0, 0, 0, 41} + serverMAC := [6]byte{0x02, 0, 0, 0, 0, 42} + clientAddress := netip.MustParseAddr("192.0.2.41") + serverAddress := netip.MustParseAddr("192.0.2.42") + endpoint := nscore.Endpoint{Address: serverAddress, Port: 8443} + + clientPolicy, err := policy.Compile(policy.Config{Rules: []policy.Rule{{ + Action: policy.ActionAllow, Transports: []policy.Transport{policy.TransportTLS}, + Directions: []policy.Direction{policy.DirectionOutbound}, Prefixes: []netip.Prefix{netip.PrefixFrom(serverAddress, 32)}, + }}}) + if err != nil { + t.Fatal(err) + } + serverPolicy, err := policy.Compile(policy.Config{Rules: []policy.Rule{{ + Action: policy.ActionAllow, Transports: []policy.Transport{policy.TransportTLS}, + Directions: []policy.Direction{policy.DirectionInbound}, Prefixes: []netip.Prefix{netip.PrefixFrom(serverAddress, 32)}, + }}}) + if err != nil { + t.Fatal(err) + } + limits := quota.Limits{ + Resources: 16, TCPResources: 8, TLSResources: 8, TLSHandshakes: 4, + QueuedBytes: 2 << 20, TLSPlaintextBytes: 512 << 10, TLSCiphertextBytes: 512 << 10, + } + clientAccount := quota.NewAccount(limits) + serverAccount := quota.NewAccount(limits) + mtu := uint16(ethernet.MaxMTU) + newCore := func(hostname string, seed int64, address netip.Addr, hardware, gateway [6]byte, compiled *policy.Policy, account *quota.Account) *lnetocore.Namespace { + core, err := lnetocore.New(lnetocore.Config{ + Hostname: hostname, RandSeed: seed, HardwareAddress: hardware, GatewayHardwareAddress: gateway, + IPv4Address: address, MTU: mtu, MaxActiveTCPPorts: 4, Policy: compiled, Quotas: account, + Link: packetlink.Config{MaxFrameBytes: int(mtu) + 14, IngressFrames: 64, EgressFrames: 64}, + }) + if err != nil { + t.Fatal(err) + } + return core + } + clientCore := newCore("tls-client", 41, clientAddress, clientMAC, serverMAC, clientPolicy, clientAccount) + serverCore := newCore("tls-server", 42, serverAddress, serverMAC, clientMAC, serverPolicy, serverAccount) + t.Cleanup(func() { + _ = clientCore.Close() + _ = serverCore.Close() + }) + + clientTLSConfig := &cryptotls.Config{ + RootCAs: roots, Time: func() time.Time { return now }, MinVersion: cryptotls.VersionTLS13, + MaxVersion: cryptotls.VersionTLS13, NextProtos: []string{"h2"}, + } + if mutual { + clientTLSConfig.Certificates = []cryptotls.Certificate{clientCertificate} + } + serverTLSConfig := &cryptotls.Config{ + Certificates: []cryptotls.Certificate{certificate}, Time: func() time.Time { return now }, + MinVersion: cryptotls.VersionTLS13, MaxVersion: cryptotls.VersionTLS13, + NextProtos: []string{"h2"}, SessionTicketsDisabled: true, + } + if mutual { + serverTLSConfig.ClientAuth = cryptotls.RequireAndVerifyClientCert + serverTLSConfig.ClientCAs = roots + } + engine := engineLimitsForTest() + engine.MaxServiceAttemptsPerHandshake = 100000 + client, err := New(clientCore, Config{ + MaxStreams: 2, MaxConcurrentHandshakes: 2, MaxServerNameBytes: 253, MaxServiceAttemptsPerHandshake: 100000, + TCP: tcpbackend.Config{MaxOutboundStreams: 2, ReceiveBytes: 8 << 10, TransmitBytes: 8 << 10, TransmitPackets: 32}, + Engine: engine, + Profiles: []gotls.Profile{{ + ID: 1, Config: clientTLSConfig, RequiredALPN: "h2", MaxCertificateChainBytes: 64 << 10, + MaxPeerCertificates: 4, AllowedNames: map[string]tlsns.IdentityType{"server.example.com": tlsns.IdentityDNS}, + }}, + }) + if err != nil { + t.Fatal(err) + } + server, err := New(serverCore, Config{ + MaxStreams: 2, MaxListeners: 1, AcceptBacklog: 2, MaxConcurrentHandshakes: 2, + MaxServerNameBytes: 253, MaxServiceAttemptsPerHandshake: 100000, + TCP: tcpbackend.Config{MaxListeners: 1, MaxOutboundStreams: 2, AcceptBacklog: 2, ReceiveBytes: 8 << 10, TransmitBytes: 8 << 10, TransmitPackets: 32}, + Engine: engine, + ServerProfiles: []gotls.ServerProfile{{ + ID: 2, Config: serverTLSConfig, RequiredALPN: "h2", MaxCertificateChainBytes: 64 << 10, MaxPeerCertificates: 4, + }}, + }) + if err != nil { + t.Fatal(err) + } + return &liveTLSPair{ + clientCore: clientCore, serverCore: serverCore, client: client, server: server, + clientAccount: clientAccount, serverAccount: serverAccount, endpoint: endpoint, + } +} + +func (pair *liveTLSPair) establish(t testing.TB, listener *listener) (*stream, *stream) { + t.Helper() + clientValue, progress, err := pair.client.TryConnectTLS(pair.endpoint, 1, "server.example.com") + if err != nil || progress != nscore.ProgressInProgress { + t.Fatalf("connect = %T, %v, %v", clientValue, progress, err) + } + client := clientValue.(*stream) + var server *stream + for attempt := 0; attempt < 100000; attempt++ { + pair.serviceStream(t, client, false) + if server != nil { + pair.serviceStream(t, server, false) + } + pair.relay(t, pair.clientCore, pair.serverCore) + pair.relay(t, pair.serverCore, pair.clientCore) + if server == nil && listener.Readiness()&nscore.ReadyAccept != 0 { + serverValue, acceptProgress, acceptErr := listener.TryAcceptTLS() + if acceptErr != nil || acceptProgress != nscore.ProgressInProgress { + t.Fatalf("accept = %T, %v, %v", serverValue, acceptProgress, acceptErr) + } + server = serverValue.(*stream) + } + clientProgress, clientErr := client.TryFinishConnect() + if clientErr != nil { + t.Fatalf("client handshake: %v", clientErr) + } + serverProgress := nscore.ProgressInProgress + if server != nil { + serverProgress, err = server.TryFinishConnect() + if err != nil { + t.Fatalf("server handshake: %v", err) + } + } + if clientProgress == nscore.ProgressDone && serverProgress == nscore.ProgressDone { + return client, server + } + runtime.Gosched() + } + t.Fatalf("live TLS handshake did not complete: client=%v server=%v listener=%v", client.Readiness(), readinessOf(server), listener.Readiness()) + return nil, nil +} + +func (pair *liveTLSPair) exchange(t testing.TB, from, to *stream, payload []byte) { + t.Helper() + sent := 0 + backpressured := false + received := make([]byte, 0, len(payload)) + buffer := make([]byte, 257) + for attempt := 0; attempt < 200000 && len(received) < len(payload); attempt++ { + if sent < len(payload) { + remaining := len(payload) - sent + result, err := from.TryWrite(payload[sent:]) + if err != nil { + t.Fatalf("write after %d bytes: %+v, %v", sent, result, err) + } + if result.Bytes < remaining { + backpressured = true + } + sent += result.Bytes + } + pair.serviceStream(t, from, false) + pair.serviceStream(t, to, false) + pair.relay(t, pair.clientCore, pair.serverCore) + pair.relay(t, pair.serverCore, pair.clientCore) + result, err := to.TryRead(buffer) + if err != nil { + t.Fatalf("read after %d bytes: %+v, %v", len(received), result, err) + } + if result.Bytes != 0 { + received = append(received, buffer[:result.Bytes]...) + } + runtime.Gosched() + } + if sent != len(payload) || !bytes.Equal(received, payload) { + t.Fatalf("exchange sent=%d received=%d want=%d", sent, len(received), len(payload)) + } + if len(payload) > 1024 && !backpressured { + t.Fatal("payload larger than the plaintext queue did not exercise backpressure") + } +} + +func (pair *liveTLSPair) cleanShutdown(t testing.TB, client, server *stream) { + t.Helper() + pair.finishShutdownWrite(t, client, server, "client") + pair.awaitEOF(t, server, client, "server") + pair.finishShutdownWrite(t, server, client, "server") + pair.awaitEOF(t, client, server, "client") +} + +func (pair *liveTLSPair) finishShutdownWrite(t testing.TB, writer, peer *stream, side string) { + t.Helper() + for attempt := 0; attempt < 100000; attempt++ { + progress, err := writer.TryShutdownWrite() + if err != nil { + t.Fatalf("%s shutdown = %v, %v", side, progress, err) + } + if progress == nscore.ProgressDone { + return + } + pair.serviceStream(t, writer, false) + pair.serviceStream(t, peer, false) + pair.relay(t, pair.clientCore, pair.serverCore) + pair.relay(t, pair.serverCore, pair.clientCore) + runtime.Gosched() + } + t.Fatalf("%s shutdown did not complete", side) +} + +func (pair *liveTLSPair) awaitEOF(t testing.TB, reader, writer *stream, side string) { + t.Helper() + buffer := make([]byte, 1) + for attempt := 0; attempt < 100000; attempt++ { + pair.serviceStream(t, writer, false) + pair.serviceStream(t, reader, false) + pair.relay(t, pair.clientCore, pair.serverCore) + pair.relay(t, pair.serverCore, pair.clientCore) + result, err := reader.TryRead(buffer) + if err != nil { + t.Fatalf("%s EOF read = %+v, %v", side, result, err) + } + if result.State == nscore.IOEOF { + return + } + runtime.Gosched() + } + t.Fatalf("%s did not observe close_notify", side) +} + +func (pair *liveTLSPair) finishRawTransportShutdown(t testing.TB, client *stream) { + t.Helper() + if client == nil || client.transport == nil { + t.Fatal("client private transport missing") + } + for attempt := 0; attempt < 100000; attempt++ { + progress, err := client.transport.TryShutdownWrite() + if err != nil { + t.Fatalf("raw transport shutdown = %v, %v", progress, err) + } + pair.relay(t, pair.clientCore, pair.serverCore) + pair.relay(t, pair.serverCore, pair.clientCore) + if progress == nscore.ProgressDone { + return + } + } + t.Fatal("raw transport shutdown did not complete") +} + +func (pair *liveTLSPair) expectTruncation(t testing.TB, server *stream) { + t.Helper() + for attempt := 0; attempt < 100000; attempt++ { + _, _, serviceErr := server.TryService(nscore.ServiceBudget{Packets: 8, Bytes: 64 << 10, Operations: 32}) + pair.relay(t, pair.clientCore, pair.serverCore) + pair.relay(t, pair.serverCore, pair.clientCore) + _, readErr := server.TryRead(make([]byte, 1)) + for _, candidate := range []error{serviceErr, readErr} { + if candidate == nil { + continue + } + failure, ok := nscore.FailureOf(candidate) + if !ok || failure != nscore.FailureTLSProtocol { + t.Fatalf("truncation = %v (%v)", candidate, failure) + } + return + } + runtime.Gosched() + } + t.Fatal("abrupt TLS close did not become TLS_PROTOCOL") +} + +func (pair *liveTLSPair) serviceStream(t testing.TB, value *stream, allowTerminal bool) { + t.Helper() + if value == nil { + return + } + _, _, err := value.TryService(nscore.ServiceBudget{Packets: 8, Bytes: 64 << 10, Operations: 32}) + if err != nil && !allowTerminal { + t.Fatal(err) + } +} + +func (pair *liveTLSPair) relay(t testing.TB, from, to *lnetocore.Namespace) bool { + t.Helper() + from.Lock() + from.SetNextIngressLocked(false) + required := from.RequiredFrameBytesLocked() + from.Unlock() + report, progress, err := from.TryService(nscore.ServiceBudget{Packets: 1, Bytes: uint32(required), Operations: 8}) + if err != nil { + t.Fatalf("egress service = %+v, %v, %v", report, progress, err) + } + if report.Packets == 0 { + return false + } + frame := make([]byte, from.Link().MaxFrameBytes()) + result, err := from.Link().TryDequeue(packetlink.Egress, frame) + if err != nil || !result.Ready || result.Truncated || result.FrameBytes == 0 { + t.Fatalf("egress dequeue = %+v, %v", result, err) + } + if err := to.Link().TryEnqueue(packetlink.Ingress, frame[:result.FrameBytes]); err != nil { + t.Fatal(err) + } + to.Lock() + to.SetNextIngressLocked(true) + required = to.RequiredFrameBytesLocked() + to.Unlock() + report, progress, err = to.TryService(nscore.ServiceBudget{Packets: 1, Bytes: uint32(required), Operations: 8}) + if err != nil || report.Packets != 1 || progress != nscore.ProgressDone { + t.Fatalf("ingress service = %+v, %v, %v", report, progress, err) + } + return true +} + +func (pair *liveTLSPair) assertLiveCounts(t testing.TB, clientTLS, serverTLS, clientPorts, serverPorts uint64) { + t.Helper() + clientUsage, _ := pair.clientAccount.Snapshot() + serverUsage, _ := pair.serverAccount.Snapshot() + if clientUsage.TLSResources != clientTLS || clientUsage.TLSHandshakes != 0 { + t.Fatalf("client live quota = %+v, want TLS=%d handshakes=0", clientUsage, clientTLS) + } + if serverUsage.TLSResources != serverTLS || serverUsage.TLSHandshakes != 0 { + t.Fatalf("server live quota = %+v, want TLS=%d handshakes=0", serverUsage, serverTLS) + } + for _, item := range []struct { + name string + core *lnetocore.Namespace + want uint64 + }{{"client", pair.clientCore, clientPorts}, {"server", pair.serverCore, serverPorts}} { + item.core.Lock() + got := uint64(item.core.TCPPortLeaseCountLocked()) + item.core.Unlock() + if got != item.want { + t.Fatalf("%s live TCP leases = %d, want %d", item.name, got, item.want) + } + } +} + +func (pair *liveTLSPair) assertReleased(t testing.TB) { + t.Helper() + for name, core := range map[string]*lnetocore.Namespace{"client": pair.clientCore, "server": pair.serverCore} { + core.Lock() + leases := core.TCPPortLeaseCountLocked() + core.Unlock() + if leases != 0 { + t.Fatalf("%s retained %d TCP port leases", name, leases) + } + } + for name, account := range map[string]*quota.Account{"client": pair.clientAccount, "server": pair.serverAccount} { + usage, _ := account.Snapshot() + if usage != (quota.Usage{}) { + t.Fatalf("%s retained quota: %+v", name, usage) + } + } +} + +func assertLiveConnectionInfo(t testing.TB, client, server *stream, mutual bool) { + t.Helper() + clientInfo, ok := client.ConnectionInfo() + if !ok || clientInfo.Role != tlsns.RoleClient || !clientInfo.PeerAuthenticated || clientInfo.VerifiedIdentity != tlsns.IdentityDNS || clientInfo.NegotiatedALPN != "h2" || clientInfo.PeerLeafSPKI256 == ([32]byte{}) { + t.Fatalf("client info = %+v, %v", clientInfo, ok) + } + serverInfo, ok := server.ConnectionInfo() + if !ok || serverInfo.Role != tlsns.RoleServer || serverInfo.PeerAuthenticated != mutual || serverInfo.VerifiedIdentity != tlsns.IdentityNone || serverInfo.NegotiatedALPN != "h2" { + t.Fatalf("server info = %+v, %v", serverInfo, ok) + } + if mutual == (serverInfo.PeerLeafSPKI256 == ([32]byte{})) { + t.Fatalf("server peer digest = %x mutual=%v", serverInfo.PeerLeafSPKI256, mutual) + } +} + +func readinessOf(value *stream) nscore.Readiness { + if value == nil { + return 0 + } + return value.Readiness() +} + +func liveTLSCertificates(t testing.TB) (server, client cryptotls.Certificate, roots *x509.CertPool, now time.Time) { + t.Helper() + now = time.Unix(1_800_000_000, 0) + caPublic, caPrivate, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + caTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "live TLS test CA"}, + NotBefore: now.Add(-time.Hour), NotAfter: now.Add(time.Hour), IsCA: true, BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, + } + caDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, caPublic, caPrivate) + if err != nil { + t.Fatal(err) + } + ca, err := x509.ParseCertificate(caDER) + if err != nil { + t.Fatal(err) + } + issue := func(serial int64, commonName string, names []string, usage x509.ExtKeyUsage) cryptotls.Certificate { + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + template := &x509.Certificate{ + SerialNumber: big.NewInt(serial), Subject: pkix.Name{CommonName: commonName}, DNSNames: names, + NotBefore: now.Add(-time.Hour), NotAfter: now.Add(time.Hour), KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{usage}, + } + der, err := x509.CreateCertificate(rand.Reader, template, ca, publicKey, caPrivate) + if err != nil { + t.Fatal(err) + } + leaf, err := x509.ParseCertificate(der) + if err != nil { + t.Fatal(err) + } + return cryptotls.Certificate{Certificate: [][]byte{der, caDER}, PrivateKey: privateKey, Leaf: leaf} + } + server = issue(2, "server.example.com", []string{"server.example.com"}, x509.ExtKeyUsageServerAuth) + client = issue(3, "client", nil, x509.ExtKeyUsageClientAuth) + roots = x509.NewCertPool() + roots.AddCert(ca) + return server, client, roots, now +} diff --git a/internal/backend/lneto/tls/tls.go b/internal/backend/lneto/tls/tls.go index 5bd9d11..6e1985c 100644 --- a/internal/backend/lneto/tls/tls.go +++ b/internal/backend/lneto/tls/tls.go @@ -283,6 +283,7 @@ func (adapter *Adapter) TryConnectTLS(remote nscore.Endpoint, profileID uint32, return nil, 0, nscore.Fail(nscore.FailureUnsupportedConfiguration, err) } created.engine = engine + created.transport = transport adapter.mu.Lock() if adapter.closed { adapter.mu.Unlock() @@ -400,6 +401,7 @@ func (listener *listener) TryAcceptTLS() (nscore.Resource, nscore.Progress, erro return nil, 0, nscore.Fail(nscore.FailureUnsupportedConfiguration, err) } created.engine = engine + created.transport = transport owner.mu.Lock() if owner.closed || len(owner.streams) >= int(owner.config.MaxStreams) { owner.mu.Unlock() @@ -441,6 +443,7 @@ func (listener *listener) Close() error { type stream struct { owner *Adapter engine *gotls.Stream + transport tcpns.Stream retained quota.Charge handshake quota.Charge handshakeLive bool @@ -537,6 +540,7 @@ func (stream *stream) Close() error { } stream.closed = true engine := stream.engine + stream.transport = nil stream.mu.Unlock() var err error if engine != nil { @@ -626,6 +630,7 @@ func (adapter *Adapter) CloseLocked() { stream.mu.Lock() stream.closed = true engine := stream.engine + stream.transport = nil stream.mu.Unlock() if engine != nil { engine.CloseWorkersLocked() From 0acb2878d886e9002db01c3cdf41cb603529a830 Mon Sep 17 00:00:00 2001 From: Wago Networking Agent Date: Mon, 20 Jul 2026 18:39:58 +0000 Subject: [PATCH 07/17] docs: record TLS server foundation evidence --- README.md | 70 +++++++++++++++++++++++---------- agent-todo.md | 37 +++++++++++++++++ benchmarks/README.md | 13 +++--- docs/architecture.md | 17 ++++---- docs/ci.md | 10 +++-- docs/release-signoff.md | 17 ++++---- docs/tls.md | 11 ++++-- internal/namespace/tls/tls.go | 2 +- scripts/arm64-test-binaries.tsv | 6 +-- scripts/tls-signoff.sh | 7 +++- tls/config.go | 5 ++- 11 files changed, 140 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index a36e932..1c27d0a 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,9 @@ Capability-gated networking plugins for the [Wago](https://github.com/wago-org/w WebAssembly runtime, backed initially by [lneto](https://github.com/soypat/lneto). UDP, TCP, DNS, bounded ICMPv4 echo, explicit-clock NTP, bounded IPv4 multicast DNS, DHCPv4, IPv4 link-local/APIPA, configured IPv6 TCP transport enablement, -bounded ICMPv6/NDP, the pinned bounded initial DHCPv6 acquisition subset, and a -granular outbound client-only TLS capability are implemented today. +bounded ICMPv6/NDP, the pinned bounded initial DHCPv6 acquisition subset, and +granular standard-Go TLS client/server stream foundations are implemented today. +HTTP/HTTPS APIs and portable TinyGo TLS are not implemented. > [!WARNING] > This module is private and experimental. Use it only with the exact Wago @@ -112,29 +113,58 @@ if err := wagonettls.Register(network, wagonettls.WithClientProfile(profile)); e } ``` +Inbound TLS is also explicit and does not imply raw TCP: + +```go +serverProfile, err := wagonettls.NewServerProfile(2, hostServerTLSConfig, + wagonettls.RequireServerALPN("h2"), +) +if err != nil { + return err +} +if err := wagonettls.Register(network, + wagonettls.WithServerProfile(serverProfile), + wagonettls.AllowListeners(), +); err != nil { + return err +} +``` + +Storing a server profile alone grants no listen authority. Certificate chains +are parsed eagerly, leaf keys must match host-owned `crypto.Signer` values, and +server credentials never enter guest memory. Static SNI selection is limited to +host-supplied immutable certificates; dynamic certificate/config callbacks are +rejected. + TLS intentionally has no `tls/register` zero-configuration extension and no -`net-tls` custom-CLI key. Trust roots, verification identities, ALPN, client -credentials, and profile IDs are deployment authority that must be supplied by -explicit Go composition; the repository does not invent placeholder TLS policy. - -The complete TLS implementation is standard-Go-only. TinyGo 0.41.1 lacks the -required `crypto/tls` client APIs, so the repository provides no TinyGo stub, -placeholder guest module, or fake handshake. `scripts/tinygo-supported-test.sh` -tests the exact reviewed non-TLS package surface and fails closed if the five -standard-Go-only TLS packages change without review. `scripts/tls-signoff.sh` -retains separate ordinary and race evidence for explicit TLS composition, -security, ABI, mixed transport, EOF, quota, and worker teardown. TLS remains -client-only, granular-only, outside aggregate `register`, and experimental until -the complete strict release and executed arm64 requirements are satisfied. - -Profiles are finite and host-defined. The guest selects only a profile ID, -remote IP endpoint, and authorized verification name. Certificate-chain and -DNS/IP SAN verification are mandatory; Common Name fallback, key logging, +`net-tls` custom-CLI key. Trust roots, verification identities, ALPN, client or +server credentials, listen authority, and profile IDs are deployment authority +that must be supplied by explicit Go composition; the repository does not invent +placeholder TLS policy. + +The cryptographic TLS implementation is standard-Go-only. TinyGo 0.41.1 lacks +the required arbitrary-stream `crypto/tls` APIs, so the repository provides no +TinyGo stub, placeholder guest module, or fake handshake. +`scripts/tinygo-supported-test.sh` tests the exact reviewed non-TLS package +surface and fails closed if the five standard-Go-only TLS packages change +without review. `scripts/tls-signoff.sh` retains separate ordinary and race +evidence for explicit TLS composition, client/server handshakes, ABI +compatibility, mixed transport, EOF, quota, and worker teardown. TLS remains +granular-only and outside aggregate `register`; HTTP, HTTPS, portable TinyGo TLS, +strict release adoption, and executed arm64 evidence remain incomplete. + +Profiles are finite and host-defined. Outbound guests select only a profile ID, +remote IP endpoint, and authorized verification name; inbound guests select a +server profile ID and an explicitly authorized local endpoint. Certificate-chain +and DNS/IP SAN verification are mandatory for clients; configured mTLS uses +standard client-chain verification. Common Name fallback, key logging, renegotiation, arbitrary verification/certificate callbacks, guest session caches, 0-RTT, STARTTLS, and wrapping guest TCP handles are absent. TLS 1.3 is the default and TLS 1.2 requires `EnableTLS12()`. Client private keys remain host-side. Clean `close_notify` maps to EOF; raw TCP EOF maps to TLS protocol -failure. See [`docs/tls.md`](docs/tls.md). +failure. The additive `connection_info_v2` reports client/server role and peer +authentication while preserving `connection_info_v1` byte-for-byte. See +[`docs/tls.md`](docs/tls.md). TCP defaults provide eight finite outbound streams and no listeners. UDP defaults provide eight finite sockets, ephemeral wildcard client binds, outbound ordinary diff --git a/agent-todo.md b/agent-todo.md index c4481b9..098040b 100644 --- a/agent-todo.md +++ b/agent-todo.md @@ -1610,3 +1610,40 @@ No repository-owned workstream or completion criterion from this hardening reque topology, so the lifecycle/preview-1 integration must be re-reviewed and re-ported. No provenance or review-bundle hashes were produced. Production readiness also still requires executed arm64 TLS evidence. + +## TLS client/server stream completion — July 20, 2026 + +- Preserved `connection_info_v1` byte-for-byte: offset 68 is again only the + little-endian resumed boolean 0 or 1. Added the separate additive + `connection_info_v2` import for resumed, server-role, and peer-authenticated + flags; unknown flags remain reserved and invalid. +- Changed server-profile composition so storing host credentials grants no + endpoint authority. `tls.AllowListeners()` is now the explicit ordinary + inbound TLS grant, remains separate from raw TCP, and continues to honor + applicable raw-TCP inbound deny rules. +- Server profile construction now eagerly parses every certificate, verifies + chain linkage and leaf/signer public-key equality, clones certificate + metadata, rejects dynamic SNI/configuration callbacks, and documents that the + host retains lifetime/concurrency ownership of each `crypto.Signer`. +- Added deterministic live two-namespace lneto TLS tests for ordinary server + authentication and mTLS, ALPN, role metadata, multi-queue backpressure, + bidirectional plaintext, clean two-way `close_notify`, abrupt raw-transport + truncation, listener reuse, exact quota/port release, close/accept races, and + concurrent namespace/resource teardown. +- The hosted ordinary-Go failure in Actions run `29757140541`, job + `88402308965`, was a real final-flight test deadlock: the client reported + verified completion before its final TLS 1.3 flight had been pumped to the + standard-library server. The test and client benchmark now explicitly service + that bounded flight before waiting for peer completion. +- 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 resolves 133 named tests. 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 + 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 + exercised. HTTP, HTTPS, portable TinyGo TLS, strict release adoption, and + executed arm64 evidence remain explicitly incomplete. PR #3 must remain a + draft and TLS must remain outside aggregate `register`. diff --git a/benchmarks/README.md b/benchmarks/README.md index e1cb0f6..6bc175c 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -16,15 +16,18 @@ in `docs/architecture.md`: client/server round trip; - DNS query construction, wire-name decode, response parse/selection, query creation, record iteration, and readiness; -- TLS 1.3 handshake, fixed-ring steady-state transfer, and profile-name - authorization with allocation reporting; +- TLS 1.3 client and server handshakes, fixed-ring steady-state transfer, and + profile-name authorization with allocation reporting; - instance manager attach/lookup/locking/poll and protocol operation wrappers; - guest status mapping and complete UDP/TCP guest poll host calls. The checked-in capture is in `baseline.txt`; `baseline-summary.md` records the -TLS-aware medians and environment. Discovery now finds 172 top-level benchmark -targets in 50 packages, with 195 distinct result names after subbenchmark -expansion. The baseline and independent repeatability candidate both use 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 +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 +and independent repeatability candidate both use the repository's full five-by-200 ms, single-CPU, `-benchmem` profile on the same Go 1.24.4 Linux/amd64 Ryzen 7 8845HS environment. `candidate-summary.md` and `benchstat.txt` document the comparison without treating timing noise as a diff --git a/docs/architecture.md b/docs/architecture.md index e3f86b0..7031e21 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -25,7 +25,7 @@ The suite therefore uses **protocol import modules**: - `wago_net` for shared core operations; - `wago_net_udp` for UDP; - `wago_net_tcp` for raw TCP; -- `wago_net_tls` for outbound verified TLS client streams; +- `wago_net_tls` for verified TLS client streams and explicitly authorized server listeners; - `wago_net_dns` for DNS; - `wago_net_icmpv4` for ICMPv4 echo; - `wago_net_ntp` for explicit-clock NTP synchronization; @@ -83,8 +83,8 @@ invalid and unmatched requests fail closed, and separate zero-default gates are required for wildcard binds, loopback, multicast, limited broadcast, and local bind/listen ports below 1024. IPv4-mapped IPv6 values are rejected rather than normalized across policy families. Authority-changing operations have explicit -UDP bind/send, TCP listen/connect, TLS connect, and DNS resolve checks. TLS -allows are evaluated as TLS authority, while applicable TCP denies additionally +UDP bind/send, TCP listen/connect, TLS connect/listen, and DNS resolve checks. +TLS allows are evaluated as TLS authority, while applicable TCP denies additionally constrain the private byte transport without requiring any raw-TCP allow. Selected protocol modules contribute deep-copied grant sets through an opaque shared contract after registration freezes and before manager construction. Caller policy is copied @@ -184,10 +184,11 @@ egress service probe reclaims that entry and now reports one charged service operation even when no frame is emitted. This preserves lneto's private accepted-list bookkeeping without unsafe direct slot reuse, while making the finite maintenance cost and reuse point -observable. `internal/backend/gotls` owns the standard-library `crypto/tls` -client engine over fixed plaintext/ciphertext rings and exactly three workers per -finite stream. `internal/backend/lneto/tls` owns only lneto transport pumping and -a private TCP adapter. TLS stream teardown joins workers before private TCP +observable. `internal/backend/gotls` owns the standard-library `crypto/tls` client and server +engines over fixed plaintext/ciphertext rings and exactly three workers per +finite stream. `internal/backend/lneto/tls` owns lneto transport pumping, private +TCP streams/listeners, bounded accept and handshake ownership, and the shared +namespace-local port domain. TLS stream teardown joins workers before private TCP teardown; no raw TCP handle is published. Every pump and handshake is bounded by bytes, operations, record-sized attempts, queues, certificate/handshake limits, and service attempts. `internal/backend/lneto/icmpv4` owns immediate Ethernet/IPv4/ICMP echo codecs, @@ -296,7 +297,7 @@ Granular `tcp/register`, `udp/register`, `dns/register`, `icmpv4/register`, `ipv6/register`, `icmpv6/register`, and `dhcpv6/register` packages own only their selected public facade and exact implementation graph. TLS intentionally has no self-registering package because no secure zero-configuration extension can -invent trust roots, identities, ALPN, credentials, or profile IDs. Explicit TLS +invent trust roots, identities, ALPN, credentials, listener authority, or profile IDs. Explicit TLS Go composition may compile the neutral TCP facet and private lneto TCP adapter, but not the public TCP facade, TCP binding, TCP instance operations, or TCP ABI. The root `register` package intentionally continues to compose the eleven diff --git a/docs/ci.md b/docs/ci.md index 962afd6..98b3ffe 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -7,8 +7,9 @@ and build caches enabled and has seven bounded jobs: - **quality** runs the ordinary suite, one shuffled suite, `go vet`, and the backend/source-boundary guard; - **tls-standard-go** runs `scripts/tls-signoff.sh`, retaining the exact public - composition, security, ABI, dependency, mixed-transport, EOF, quota, and worker - lifecycle ordinary/race evidence; + composition, client/server security, v1/v2 ABI, live mixed-transport, + close-notify/truncation, quota, listener-race, and worker-lifecycle + ordinary/race evidence; - **tinygo-supported** installs pinned TinyGo 0.41.1 and runs `scripts/tinygo-supported-test.sh` across the exact 123-package supported surface while retaining the reviewed five-package TLS exclusion; @@ -65,12 +66,13 @@ scripts/ci-checkptr.sh The script first compiles and initializes every package and test binary with `-gcflags=all=-d=checkptr=2`. It then runs every test under the same -instrumentation except these two allocation-only assertions: +instrumentation except these three allocation-only assertions: - `TestInstallNamespaceServicesAvoidsPerProtocolScratchForCommonSelections` in the root package; - `TestNamespaceCompositionAvoidsPerServiceHeapGrowthForPlannedSuite` in - `internal/namespace/core`. + `internal/namespace/core`; +- `TestHostFacadeExactAttachedLookupDoesNotAllocate` in `internal/plugin`. Checkptr instrumentation intentionally adds allocations, so those tests cannot truthfully enforce their ordinary-build exact `testing.AllocsPerRun` budgets in diff --git a/docs/release-signoff.md b/docs/release-signoff.md index a345423..f9550fc 100644 --- a/docs/release-signoff.md +++ b/docs/release-signoff.md @@ -141,14 +141,17 @@ reviewed standard-Go-only TLS closure is exactly five packages: - `github.com/wago-org/net/tls`. TinyGo 0.41.1 tests the remaining 123 packages individually and retains one log -per package. The explicit standard-Go TLS signoff runs 17 package profiles -(10 ordinary and seven race) resolving 109 named test targets. Arm64 signoff -cross-compiles four test binaries; the current local auto profile is truthfully +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) +but now resolves 133 named test targets. 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 -172 top-level targets in 50 packages, and the canonical five-by-200 ms baseline -now includes TLS handshake, fixed-ring, profile-authorization, shared TCP-port, -and mixed transport paths. Fuzz discovery finds 44 targets in 33 packages, -including four TLS-owned targets. +173 top-level targets in 50 packages and 196 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. The exact production Wago input remains commit `97e6f91e6c822491577faa86f3c30aa5a8fff1e8`, tree diff --git a/docs/tls.md b/docs/tls.md index d4b6cde..732409e 100644 --- a/docs/tls.md +++ b/docs/tls.md @@ -126,8 +126,10 @@ than repeatedly charging the already-known transport EOF. Raw TCP EOF without ## Default finite bounds -The default registration allows eight live streams and four concurrent -handshakes. Per stream it reserves 16 KiB receive and transmit plaintext, 32 KiB +The default registration allows eight live streams, four TLS listeners, an +accept backlog of four private TCP streams per listener, and four concurrent +handshakes. Listener authority is disabled until explicitly granted. Per stream +it reserves 16 KiB receive and transmit plaintext, 32 KiB receive and transmit ciphertext, and private TCP receive/transmit buffers of 32 KiB each. Fixed 32 KiB plaintext and 16 KiB ciphertext scratch are included in the same checked per-stream accounting. Defaults also limit handshake bytes to @@ -145,8 +147,9 @@ service attempts. Every field and combined allocation must fit target `int`; all additions and the `MaxStreams` multiplication are checked in `uint64` before backend construction, including simulated and actual 386 builds. -TLS resources, active handshakes, plaintext bytes, ciphertext bytes, global -retained bytes, and the underlying private TCP resource/storage are all charged +TLS listener and stream resources, active handshakes, plaintext bytes, +ciphertext bytes, global retained bytes, accept-backlog transport storage, and +the underlying private TCP resource/storage are all charged to the exact instance quota ledger. Every setup path rolls back both layers; close and failed verification release each charge exactly once. diff --git a/internal/namespace/tls/tls.go b/internal/namespace/tls/tls.go index 7e58bf6..6de15d4 100644 --- a/internal/namespace/tls/tls.go +++ b/internal/namespace/tls/tls.go @@ -1,4 +1,4 @@ -// Package tls defines the backend-neutral outbound TLS namespace and secure +// Package tls defines backend-neutral TLS client, server-listener, and secure // stream contracts. It contains no crypto/tls or transport implementation type. package tls diff --git a/scripts/arm64-test-binaries.tsv b/scripts/arm64-test-binaries.tsv index 2d50df4..522db10 100644 --- a/scripts/arm64-test-binaries.tsv +++ b/scripts/arm64-test-binaries.tsv @@ -1,4 +1,4 @@ -gotls github.com/wago-org/net/internal/backend/gotls ^(TestClientHandshakeVerificationALPNAndPlaintext|TestCloseNotifyProducesStableEOFWithoutRepeatedServiceWork|TestRawEOFWithoutCloseNotifyIsTLSProtocolFailure)$ gotls-linux-arm64.test -lneto-tls github.com/wago-org/net/internal/backend/lneto/tls ^(TestRawTCPThenTLSAndTLSThenRawShareLivePortDomain|TestMixedTCPListenerPreventsTLSOutboundPortCollision|TestMixedTCPAndTLSSharedPortExhaustionIsBounded|TestTLSUsesPrivateTCPWithoutRawTCPAuthorityAndRollsBack)$ lneto-tls-linux-arm64.test +gotls github.com/wago-org/net/internal/backend/gotls ^(TestClientHandshakeVerificationALPNAndPlaintext|TestServerHandshakeALPNAndPlaintext|TestCloseNotifyProducesStableEOFWithoutRepeatedServiceWork|TestRawEOFWithoutCloseNotifyIsTLSProtocolFailure)$ gotls-linux-arm64.test +lneto-tls github.com/wago-org/net/internal/backend/lneto/tls ^(TestRawTCPThenTLSAndTLSThenRawShareLivePortDomain|TestMixedTCPListenerPreventsTLSOutboundPortCollision|TestMixedTCPAndTLSSharedPortExhaustionIsBounded|TestTLSUsesPrivateTCPWithoutRawTCPAuthorityAndRollsBack|TestLiveLnetoTLSClientServerHandshakeDataShutdownAndReuse|TestLiveLnetoTLSNamespaceTeardownReleasesListenerStreamAndHandshake)$ lneto-tls-linux-arm64.test root github.com/wago-org/net ^(TestExtensionMetadataAndABIBinding|TestGuestUDPEmptyTruncationAndFailedMemoryWrites|TestRegisteredGuestTCPTwoNamespaceExchange|TestRegisteredGuestDNSActualBackendSmoke)$ net-linux-arm64.test -tls-public github.com/wago-org/net/tls ^(TestRegisterExposesOnlyTLSAndSharedCore|TestTCPAndTLSComposeWithoutCapabilityWidening|TestPublicTLSRegistrationLoopbackOptionControlsOnlyTLSConnect)$ tls-public-linux-arm64.test +tls-public github.com/wago-org/net/tls ^(TestRegisterExposesOnlyTLSAndSharedCore|TestTCPAndTLSComposeWithoutCapabilityWidening|TestPublicTLSRegistrationLoopbackOptionControlsOnlyTLSConnect|TestServerProfileStorageRequiresExplicitListenerAuthority|TestServerProfileRejectsMalformedChainAndMismatchedSigner)$ tls-public-linux-arm64.test diff --git a/scripts/tls-signoff.sh b/scripts/tls-signoff.sh index 9e90d5a..f243eaf 100755 --- a/scripts/tls-signoff.sh +++ b/scripts/tls-signoff.sh @@ -136,7 +136,12 @@ cat >"$out/detail.txt" < Date: Mon, 20 Jul 2026 18:41:10 +0000 Subject: [PATCH 08/17] test: preserve TLS metadata output atomicity --- internal/binding/tls/tls_test.go | 73 ++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/internal/binding/tls/tls_test.go b/internal/binding/tls/tls_test.go index 2a1e236..0188a63 100644 --- a/internal/binding/tls/tls_test.go +++ b/internal/binding/tls/tls_test.go @@ -2,10 +2,15 @@ package tls import ( "bytes" + "net/netip" "testing" "github.com/wago-org/net/internal/guest" + instancecore "github.com/wago-org/net/internal/instance/core" + nscore "github.com/wago-org/net/internal/namespace/core" + tlsns "github.com/wago-org/net/internal/namespace/tls" "github.com/wago-org/net/internal/plugin" + "github.com/wago-org/net/internal/resource" wago "github.com/wago-org/wago" ) @@ -13,6 +18,35 @@ type memoryModule struct{ memory []byte } func (module memoryModule) Memory() []byte { return module.memory } +type attachedMemoryModule struct { + memoryModule + instance *wago.Instance +} + +func (module attachedMemoryModule) Instance() *wago.Instance { return module.instance } + +type pendingInfoStream struct{ endpoint nscore.Endpoint } + +func (*pendingInfoStream) Close() error { return nil } +func (*pendingInfoStream) Readiness() nscore.Readiness { return nscore.ReadyConnected } +func (stream *pendingInfoStream) LocalEndpoint() nscore.Endpoint { return stream.endpoint } +func (stream *pendingInfoStream) RemoteEndpoint() nscore.Endpoint { return stream.endpoint } +func (*pendingInfoStream) TryFinishConnect() (nscore.Progress, error) { + return nscore.ProgressInProgress, nil +} +func (*pendingInfoStream) TryRead([]byte) (nscore.IOResult, error) { + return nscore.IOResult{State: nscore.IOWouldBlock}, nil +} +func (*pendingInfoStream) TryWrite([]byte) (nscore.IOResult, error) { + return nscore.IOResult{State: nscore.IOWouldBlock}, nil +} +func (*pendingInfoStream) TryShutdownWrite() (nscore.Progress, error) { + return nscore.ProgressInProgress, nil +} +func (*pendingInfoStream) ConnectionInfo() (tlsns.ConnectionInfo, bool) { + return tlsns.ConnectionInfo{}, false +} + func TestBindingsRejectMalformedAndOverlappingRangesWithoutMutation(t *testing.T) { bindings := Bindings(plugin.Host{}) byName := make(map[string]plugin.Binding, len(bindings)) @@ -63,6 +97,45 @@ func TestBindingsRejectMalformedAndOverlappingRangesWithoutMutation(t *testing.T } } +func TestConnectionInfoVersionsLeaveOutputUnchangedOnWouldBlock(t *testing.T) { + manager, err := instancecore.NewManagerConfigured(instancecore.DefaultConfig()) + if err != nil { + t.Fatal(err) + } + instance := new(wago.Instance) + if err := manager.Attach(instance); err != nil { + t.Fatal(err) + } + defer manager.Detach(instance) + state, ok := manager.ForInstance(instance) + if !ok { + t.Fatal("instance state missing") + } + stream := &pendingInfoStream{endpoint: nscore.Endpoint{Address: netip.MustParseAddr("192.0.2.1"), Port: 443}} + handle, err := state.Resources().Add(resource.KindTLSStream, stream) + if err != nil { + t.Fatal(err) + } + bindings := Bindings(plugin.NewHost(manager)) + byName := make(map[string]plugin.Binding, len(bindings)) + for _, binding := range bindings { + byName[binding.Name] = binding + } + memory := bytes.Repeat([]byte{0xa5}, 256) + before := append([]byte(nil), memory...) + module := attachedMemoryModule{memoryModule: memoryModule{memory: memory}, instance: instance} + for _, name := range []string{"connection_info", "connection_info_v2"} { + results := []uint64{0} + byName[name].Func(module, []uint64{uint64(handle), 32}, results) + if got := guest.Status(wago.AsI32(results[0])); got != guest.StatusAgain { + t.Fatalf("%s status = %v, want AGAIN", name, got) + } + if !bytes.Equal(memory, before) { + t.Fatalf("%s mutated would-block output", name) + } + } +} + func TestConnectRejectsInvalidUTF8BeforeInstanceLookup(t *testing.T) { var connect plugin.Binding for _, binding := range Bindings(plugin.Host{}) { From a99aa775457a25bebde851ee860d4b667a8c28b5 Mon Sep 17 00:00:00 2001 From: Wago Networking Agent Date: Mon, 20 Jul 2026 18:42:03 +0000 Subject: [PATCH 09/17] docs: record final TLS signoff target count --- agent-todo.md | 2 +- docs/release-signoff.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/agent-todo.md b/agent-todo.md index 098040b..f426c4a 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 resolves 133 named tests. Fuzz smoke passes 47 + profiles passed. TLS signoff resolves 135 named tests. 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 names and includes separate client/server TLS 1.3 handshakes. Four arm64 test diff --git a/docs/release-signoff.md b/docs/release-signoff.md index f9550fc..839d725 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) -but now resolves 133 named test targets. Arm64 signoff cross-compiles four test +but now resolves 135 named test targets. 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 From e40fb5ed6c31eef55fa95d59adda1ead3d8ce2a6 Mon Sep 17 00:00:00 2001 From: Wago Networking Agent Date: Wed, 22 Jul 2026 01:43:21 +0000 Subject: [PATCH 10/17] fix: harden TLS cloning and DHCP leases --- internal/backend/gotls/profile.go | 1 + internal/backend/gotls/security_test.go | 19 +++++++ internal/backend/lneto/dhcpv4/dhcpv4.go | 53 ++++++++++++++------ internal/backend/lneto/dhcpv4/dhcpv4_test.go | 43 ++++++++++++++++ tls/profile.go | 1 + tls/profile_test.go | 14 ++++-- 6 files changed, 114 insertions(+), 17 deletions(-) diff --git a/internal/backend/gotls/profile.go b/internal/backend/gotls/profile.go index b171449..403d5eb 100644 --- a/internal/backend/gotls/profile.go +++ b/internal/backend/gotls/profile.go @@ -102,6 +102,7 @@ func cloneTLSCertificates(input []cryptotls.Certificate) []cryptotls.Certificate output[index].Certificate[certificateIndex] = append([]byte(nil), input[index].Certificate[certificateIndex]...) } output[index].OCSPStaple = append([]byte(nil), input[index].OCSPStaple...) + output[index].SupportedSignatureAlgorithms = append([]cryptotls.SignatureScheme(nil), input[index].SupportedSignatureAlgorithms...) output[index].SignedCertificateTimestamps = make([][]byte, len(input[index].SignedCertificateTimestamps)) for timestampIndex := range input[index].SignedCertificateTimestamps { output[index].SignedCertificateTimestamps[timestampIndex] = append([]byte(nil), input[index].SignedCertificateTimestamps[timestampIndex]...) diff --git a/internal/backend/gotls/security_test.go b/internal/backend/gotls/security_test.go index f33ef3a..e03c3f7 100644 --- a/internal/backend/gotls/security_test.go +++ b/internal/backend/gotls/security_test.go @@ -16,6 +16,25 @@ import ( tlsns "github.com/wago-org/net/internal/namespace/tls" ) +func TestServerProfileCloneOwnsSupportedSignatureAlgorithms(t *testing.T) { + certificate, _ := testCertificate(t, "server.example.com") + certificate.SupportedSignatureAlgorithms = []cryptotls.SignatureScheme{cryptotls.PSSWithSHA256} + profile := ServerProfile{ + ID: 1, + Config: &cryptotls.Config{Certificates: []cryptotls.Certificate{certificate}}, + MaxCertificateChainBytes: 64 << 10, + MaxPeerCertificates: 4, + } + cloned, err := profile.Clone() + if err != nil { + t.Fatal(err) + } + profile.Config.Certificates[0].SupportedSignatureAlgorithms[0] = cryptotls.ECDSAWithP256AndSHA256 + if got := cloned.Config.Certificates[0].SupportedSignatureAlgorithms[0]; got != cryptotls.PSSWithSHA256 { + t.Fatalf("cloned signature algorithm = %v, want %v", got, cryptotls.PSSWithSHA256) + } +} + 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/lneto/dhcpv4/dhcpv4.go b/internal/backend/lneto/dhcpv4/dhcpv4.go index 72e113e..9042c29 100644 --- a/internal/backend/lneto/dhcpv4/dhcpv4.go +++ b/internal/backend/lneto/dhcpv4/dhcpv4.go @@ -7,6 +7,7 @@ import ( "errors" "net" "net/netip" + "time" lneto "github.com/soypat/lneto" lnetodhcp "github.com/soypat/lneto/dhcp/dhcpv4" @@ -72,9 +73,10 @@ type Adapter struct { server lnetodhcp.Server serverEnabled bool - serverClients []serverClientKey + serverClients []serverClient serverPending uint16 clientEgressTurn bool + now func() time.Time } type leaseState uint8 @@ -112,7 +114,7 @@ func New(common *lnetocore.Namespace, config Config) (*Adapter, error) { common.Unlock() return nil, nscore.Fail(nscore.FailureInvalidArgument, lneto.ErrInvalidConfig) } - a := &Adapter{core: common, config: config, hardwareAddress: common.HardwareAddressLocked(), policy: common.PolicyLocked(), quotas: common.QuotasLocked(), nextXID: uint32(common.RandSeedLocked()) | 1} + a := &Adapter{core: common, config: config, hardwareAddress: common.HardwareAddressLocked(), policy: common.PolicyLocked(), quotas: common.QuotasLocked(), nextXID: uint32(common.RandSeedLocked()) | 1, now: time.Now} if config == (Config{}) { common.Unlock() return a, nil @@ -155,7 +157,7 @@ func New(common *lnetocore.Namespace, config Config) (*Adapter, error) { return nil, lnetocore.MapError(err) } a.serverEnabled = true - a.serverClients = make([]serverClientKey, 0, sv.MaxClients) + a.serverClients = make([]serverClient, 0, sv.MaxClients) } common.Unlock() if err := common.Install(lnetocore.Participant{IngressOrder: serviceOrder, Ingress: a.ingressLocked, EgressOrder: serviceOrder, HasEgress: a.hasWorkLocked, Egress: a.egressLocked, CloseOrder: closeOrder, Close: a.CloseLocked}); err != nil { @@ -191,11 +193,11 @@ func validServer(config ServerConfig) bool { } subnet := config.Subnet.Masked() return validIPv4(config.ServerAddr) && usableSubnetHost(config.ServerAddr, subnet) && config.LeaseSeconds > 0 && config.MaxClients > 0 && - validOptionalAdvertisedIPv4(config.Gateway) && usableOptionalSubnetHost(config.Gateway, subnet) && - validOptionalAdvertisedIPv4(config.DNS) && usableOptionalSubnetHost(config.DNS, subnet) + validOptionalAdvertisedIPv4(config.Gateway) && (!config.Gateway.IsValid() || usableSubnetHost(config.Gateway, subnet)) && + validOptionalAdvertisedIPv4(config.DNS) && usableOptionalDNSHost(config.DNS, subnet) } -func usableOptionalSubnetHost(address netip.Addr, subnet netip.Prefix) bool { +func usableOptionalDNSHost(address netip.Addr, subnet netip.Prefix) bool { return !address.IsValid() || !subnet.Contains(address) || usableSubnetHost(address, subnet) } @@ -797,7 +799,10 @@ func (a *Adapter) acceptServerLocked(payload []byte) { if !identityOK { return } - known := keyIndex(a.serverClients, key) >= 0 + now := a.now() + a.expireServerClientsLocked(now) + clientIndex := serverClientIndex(a.serverClients, key) + known := clientIndex >= 0 newClient := message == lnetodhcp.MsgDiscover && !known if newClient && len(a.serverClients) == cap(a.serverClients) { return @@ -818,22 +823,42 @@ func (a *Adapter) acceptServerLocked(payload []byte) { if err != nil { return } + leaseExpiry := now.Add(time.Duration(a.config.Server.LeaseSeconds) * time.Second) if newClient { - a.serverClients = append(a.serverClients, key) + a.serverClients = append(a.serverClients, serverClient{key: key, expires: leaseExpiry}) + clientIndex = len(a.serverClients) - 1 } switch message { case lnetodhcp.MsgDiscover, lnetodhcp.MsgRequest: + a.serverClients[clientIndex].expires = leaseExpiry if a.serverPending < a.config.Server.MaxClients { a.serverPending++ } case lnetodhcp.MsgRelease: - if index := keyIndex(a.serverClients, key); index >= 0 { - copy(a.serverClients[index:], a.serverClients[index+1:]) - a.serverClients = a.serverClients[:len(a.serverClients)-1] + a.removeServerClientLocked(clientIndex) + } +} + +func (a *Adapter) expireServerClientsLocked(now time.Time) { + for index := len(a.serverClients) - 1; index >= 0; index-- { + if !a.serverClients[index].expires.After(now) { + a.removeServerClientLocked(index) } } } +func (a *Adapter) removeServerClientLocked(index int) { + copy(a.serverClients[index:], a.serverClients[index+1:]) + last := len(a.serverClients) - 1 + a.serverClients[last] = serverClient{} + a.serverClients = a.serverClients[:last] +} + +type serverClient struct { + key serverClientKey + expires time.Time +} + type serverClientKey struct { value [36]byte length uint8 @@ -919,9 +944,9 @@ func validUnicastMAC(mac [6]byte) bool { return mac != ([6]byte{}) && mac != broadcastMAC && mac[0]&1 == 0 } -func keyIndex(keys []serverClientKey, key serverClientKey) int { - for i := range keys { - if keys[i] == key { +func serverClientIndex(clients []serverClient, key serverClientKey) int { + for i := range clients { + if clients[i].key == key { return i } } diff --git a/internal/backend/lneto/dhcpv4/dhcpv4_test.go b/internal/backend/lneto/dhcpv4/dhcpv4_test.go index 35d56a0..8d62184 100644 --- a/internal/backend/lneto/dhcpv4/dhcpv4_test.go +++ b/internal/backend/lneto/dhcpv4/dhcpv4_test.go @@ -5,6 +5,7 @@ import ( "errors" "net/netip" "testing" + "time" lneto "github.com/soypat/lneto" lnetodhcp "github.com/soypat/lneto/dhcp/dhcpv4" @@ -788,6 +789,36 @@ func TestServerPendingResponseLifecyclePreservesRetryReleasePolicyAndClose(t *te }) } +func TestServerClientCapacityExpiresWithLease(t *testing.T) { + firstCore, first := newClient(t, false) + secondCore, second := newAdapter(t, netip.IPv4Unspecified(), [6]byte{2, 0, 0, 0, 0, 3}, defaultConfig(), clientPolicy()) + serverCore, server := newServer(t, 1) + now := time.Unix(1_800_000_000, 0) + server.now = func() time.Time { return now } + + if _, _, err := first.TryAcquire(dhcpns.Request{}); err != nil { + t.Fatal(err) + } + if _, _, err := second.TryAcquire(dhcpns.Request{}); err != nil { + t.Fatal(err) + } + serviceIngress(t, serverCore, serviceEgress(t, firstCore)) + if len(server.serverClients) != 1 || server.serverPending != 1 { + t.Fatalf("first discover = clients:%d pending:%d", len(server.serverClients), server.serverPending) + } + firstKey := server.serverClients[0].key + _ = serviceEgress(t, serverCore) + + now = now.Add(time.Duration(server.config.Server.LeaseSeconds) * time.Second) + serviceIngress(t, serverCore, serviceEgress(t, secondCore)) + if len(server.serverClients) != 1 || server.serverPending != 1 { + t.Fatalf("discover after expiry = clients:%d pending:%d", len(server.serverClients), server.serverPending) + } + if server.serverClients[0].key == firstKey { + t.Fatal("expired client retained server capacity") + } +} + func TestCombinedClientServerEgressBoundsClientSchedulingDelay(t *testing.T) { config := defaultConfig() config.Server = ServerConfig{ServerAddr: netip.MustParseAddr("192.0.2.1"), Gateway: netip.MustParseAddr("192.0.2.1"), DNS: netip.MustParseAddr("192.0.2.53"), Subnet: netip.MustParsePrefix("192.0.2.0/24"), LeaseSeconds: 3600, MaxClients: 1} @@ -885,6 +916,7 @@ func TestServerRejectsSubnetNetworkAndBroadcastIdentities(t *testing.T) { {name: "server broadcast", mutate: func(server *ServerConfig) { server.ServerAddr = netip.MustParseAddr("192.0.2.255") }}, {name: "gateway network", mutate: func(server *ServerConfig) { server.Gateway = netip.MustParseAddr("192.0.2.0") }}, {name: "gateway broadcast", mutate: func(server *ServerConfig) { server.Gateway = netip.MustParseAddr("192.0.2.255") }}, + {name: "gateway outside subnet", mutate: func(server *ServerConfig) { server.Gateway = netip.MustParseAddr("198.51.100.1") }}, {name: "DNS network", mutate: func(server *ServerConfig) { server.DNS = netip.MustParseAddr("192.0.2.0") }}, {name: "DNS broadcast", mutate: func(server *ServerConfig) { server.DNS = netip.MustParseAddr("192.0.2.255") }}, } { @@ -898,6 +930,17 @@ func TestServerRejectsSubnetNetworkAndBroadcastIdentities(t *testing.T) { } } +func TestServerAllowsOffSubnetDNS(t *testing.T) { + config := defaultConfig() + config.Server = ServerConfig{ + ServerAddr: netip.MustParseAddr("192.0.2.1"), Gateway: netip.MustParseAddr("192.0.2.1"), DNS: netip.MustParseAddr("198.51.100.53"), + Subnet: netip.MustParsePrefix("192.0.2.0/24"), LeaseSeconds: 3600, MaxClients: 1, + } + if !ValidConfig(config, 1500, new(policy.Policy), quota.NewAccount(quota.DefaultLimits()), true) { + t.Fatal("valid off-subnet DNS server rejected") + } +} + func TestServerRejectsInvalidAdvertisementsBeforeOwnership(t *testing.T) { for addressName, address := range map[string]netip.Addr{ "loopback": netip.MustParseAddr("127.0.0.1"), diff --git a/tls/profile.go b/tls/profile.go index 3b82fca..808e8e5 100644 --- a/tls/profile.go +++ b/tls/profile.go @@ -392,6 +392,7 @@ func cloneCertificates(input []cryptotls.Certificate) []cryptotls.Certificate { out[i].Certificate[j] = append([]byte(nil), input[i].Certificate[j]...) } out[i].OCSPStaple = append([]byte(nil), input[i].OCSPStaple...) + out[i].SupportedSignatureAlgorithms = append([]cryptotls.SignatureScheme(nil), input[i].SupportedSignatureAlgorithms...) out[i].SignedCertificateTimestamps = make([][]byte, len(input[i].SignedCertificateTimestamps)) for j := range input[i].SignedCertificateTimestamps { out[i].SignedCertificateTimestamps[j] = append([]byte(nil), input[i].SignedCertificateTimestamps[j]...) diff --git a/tls/profile_test.go b/tls/profile_test.go index f1bdd93..5878cbf 100644 --- a/tls/profile_test.go +++ b/tls/profile_test.go @@ -14,14 +14,19 @@ import ( ) func TestClientProfileDefaultsTLS13AndClones(t *testing.T) { - config := &cryptotls.Config{NextProtos: []string{"h2"}} + config := &cryptotls.Config{ + NextProtos: []string{"h2"}, + Certificates: []cryptotls.Certificate{{SupportedSignatureAlgorithms: []cryptotls.SignatureScheme{cryptotls.Ed25519}}}, + } profile, err := NewClientProfile(1, config, AllowServerNames("API.Example.com."), RequireALPN("h2")) if err != nil { t.Fatal(err) } config.NextProtos[0] = "mutated" + config.Certificates[0].SupportedSignatureAlgorithms[0] = cryptotls.ECDSAWithP256AndSHA256 config.InsecureSkipVerify = true - if profile.config.NextProtos[0] != "h2" || profile.config.InsecureSkipVerify { + if profile.config.NextProtos[0] != "h2" || profile.config.InsecureSkipVerify || + profile.config.Certificates[0].SupportedSignatureAlgorithms[0] != cryptotls.Ed25519 { t.Fatal("profile retained caller mutation") } if profile.config.MinVersion != cryptotls.VersionTLS13 || profile.config.MaxVersion != cryptotls.VersionTLS13 { @@ -68,6 +73,7 @@ func TestClientProfileRequiresTLS12OptInAndExactIdentity(t *testing.T) { func TestServerProfileDefaultsTLS13ClonesAndRequiresStaticCertificate(t *testing.T) { config := testServerConfig(t) + config.Certificates[0].SupportedSignatureAlgorithms = []cryptotls.SignatureScheme{cryptotls.Ed25519} profile, err := NewServerProfile(7, config, RequireServerALPN("h2")) if err != nil { t.Fatal(err) @@ -75,8 +81,10 @@ func TestServerProfileDefaultsTLS13ClonesAndRequiresStaticCertificate(t *testing originalDER := append([]byte(nil), profile.config.Certificates[0].Certificate[0]...) config.NextProtos[0] = "mutated" config.Certificates[0].Certificate[0][0] ^= 0xff + config.Certificates[0].SupportedSignatureAlgorithms[0] = cryptotls.ECDSAWithP256AndSHA256 config.SessionTicketsDisabled = false - if profile.ID() != 7 || profile.config.NextProtos[0] != "h2" || string(profile.config.Certificates[0].Certificate[0]) != string(originalDER) { + if profile.ID() != 7 || profile.config.NextProtos[0] != "h2" || string(profile.config.Certificates[0].Certificate[0]) != string(originalDER) || + profile.config.Certificates[0].SupportedSignatureAlgorithms[0] != cryptotls.Ed25519 { t.Fatal("server profile retained caller mutation") } if profile.config.MinVersion != cryptotls.VersionTLS13 || profile.config.MaxVersion != cryptotls.VersionTLS13 || !profile.config.SessionTicketsDisabled { From 27a766f3fefc74b191bf203ddd83df2afda07d69 Mon Sep 17 00:00:00 2001 From: Wago Networking Agent Date: Sat, 25 Jul 2026 20:23:55 +0000 Subject: [PATCH 11/17] ci: bound TinyGo package hangs --- agent-todo.md | 20 ++++++++ docs/ci.md | 8 ++- .../dependencytest/tinygo_surface_test.go | 50 +++++++++++++++++++ scripts/tinygo-supported-test.sh | 37 ++++++++++++-- 4 files changed, 108 insertions(+), 7 deletions(-) diff --git a/agent-todo.md b/agent-todo.md index f426c4a..1765304 100644 --- a/agent-todo.md +++ b/agent-todo.md @@ -1647,3 +1647,23 @@ No repository-owned workstream or completion criterion from this hardening reque exercised. HTTP, HTTPS, portable TinyGo TLS, strict release adoption, and executed arm64 evidence remain explicitly incomplete. PR #3 must remain a draft and TLS must remain outside aggregate `register`. + +## PR #3 TinyGo CI watchdog — July 25, 2026 + +- Actions run `29883882777`, job `88810323462`, reached GitHub's six-hour job + limit while the first supported package (`github.com/wago-org/net`) was still + running under TinyGo. The previous hosted TinyGo matrix had completed in about + 45 minutes, and the same root package completed locally under TinyGo 0.41.1, + so the observed run is treated as a wedged package attempt rather than evidence + that the supported-package boundary changed. +- `scripts/tinygo-supported-test.sh` now runs every package verbosely behind a + ten-minute watchdog, prints a timed-out attempt, retries a timeout once, and + preserves only the final attempt in the canonical per-package log inventory. + Non-timeout failures are not retried. The timeout and retry bounds are + configurable for focused validation but remain finite and fail closed. +- Added a regression with a fake TinyGo process that wedges the root package on + its first attempt, proving the watchdog retries exactly once and still covers + all 123 supported packages. +- The remaining feature backlog is unchanged: HTTP/HTTPS APIs, portable TinyGo + TLS, executed arm64 evidence, strict release adoption, and the separately + documented protocol-expansion exclusions remain incomplete. diff --git a/docs/ci.md b/docs/ci.md index 98b3ffe..35e0869 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -12,7 +12,9 @@ and build caches enabled and has seven bounded jobs: ordinary/race evidence; - **tinygo-supported** installs pinned TinyGo 0.41.1 and runs `scripts/tinygo-supported-test.sh` across the exact 123-package supported - surface while retaining the reviewed five-package TLS exclusion; + surface while retaining the reviewed five-package TLS exclusion; each package + has a ten-minute watchdog and one timeout-only retry so a wedged TinyGo test + cannot consume the six-hour hosted-job limit; - **race** runs the complete suite with the race detector and shuffle, with five repetitions only for scheduled or manually requested deep checks; - **fuzz-smoke** runs all targets discovered by `scripts/fuzz-smoke.sh` on weekly @@ -25,7 +27,9 @@ and build caches enabled and has seven bounded jobs: TLS is intentionally absent from TinyGo rather than represented by a stub. The TinyGo job uploads its supported and excluded manifests, canonical detail, and -per-package logs on failure and for scheduled/manual runs. The standard-Go TLS +per-package verbose logs on failure and for scheduled/manual runs. A timed-out +attempt is printed before the bounded retry, leaving the final attempt in the +canonical package log. The standard-Go TLS job similarly retains its package/test manifests and logs. Static repository tests require both script invocations and the pinned TinyGo version to remain in the workflow. diff --git a/internal/dependencytest/tinygo_surface_test.go b/internal/dependencytest/tinygo_surface_test.go index 11b297c..e3414eb 100644 --- a/internal/dependencytest/tinygo_surface_test.go +++ b/internal/dependencytest/tinygo_surface_test.go @@ -66,6 +66,56 @@ func TestTinyGoExclusionManifestIsReviewedAndFailClosed(t *testing.T) { runTinyGoBoundaryValidation(t, root, writeTinyGoManifest(t, withUnrelated), false) } +func TestTinyGoSupportedSurfaceBoundsAndRetriesTimedOutPackages(t *testing.T) { + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatal(err) + } + bin := t.TempDir() + state := filepath.Join(t.TempDir(), "root-attempted") + fakeTinyGo := filepath.Join(bin, "tinygo") + fake := `#!/bin/sh +set -eu +if [ "$1" = version ]; then + echo 'tinygo version timeout-retry-fixture' + exit 0 +fi +if [ "$1" != test ]; then + exit 2 +fi +if [ "$3" = 'github.com/wago-org/net' ] && [ ! -f "$FAKE_TINYGO_STATE" ]; then + : > "$FAKE_TINYGO_STATE" + sleep 30 +fi +echo "ok $3 0.001s" +` + if err := os.WriteFile(fakeTinyGo, []byte(fake), 0o700); err != nil { + t.Fatal(err) + } + command := exec.Command(filepath.Join(root, "scripts", "tinygo-supported-test.sh")) + command.Dir = root + command.Env = append(os.Environ(), + "PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"), + "FAKE_TINYGO_STATE="+state, + "TINYGO_LOG_DIR="+filepath.Join(t.TempDir(), "evidence"), + "TINYGO_PACKAGE_TIMEOUT=1s", + "TINYGO_TIMEOUT_RETRIES=1", + ) + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("TinyGo timeout retry failed: %v\n%s", err, output) + } + for _, want := range []string{ + "TIMEOUT github.com/wago-org/net after 1s (attempt 1/2); retrying", + "PASS github.com/wago-org/net (attempt 2/2)", + "all 123 supported packages passed", + } { + if !bytes.Contains(output, []byte(want)) { + t.Fatalf("TinyGo timeout retry output omits %q\n%s", want, output) + } + } +} + func parseTinyGoManifest(t testing.TB, data []byte) [][]string { t.Helper() var rows [][]string diff --git a/scripts/tinygo-supported-test.sh b/scripts/tinygo-supported-test.sh index b0be1fe..ab13fc3 100755 --- a/scripts/tinygo-supported-test.sh +++ b/scripts/tinygo-supported-test.sh @@ -10,6 +10,8 @@ cd "$root" out=$(realpath -m "${TINYGO_LOG_DIR:-$root/.wago/tinygo-supported}") manifest=$(realpath -m "${TINYGO_EXCLUSION_MANIFEST:-$root/scripts/tinygo-excluded-packages.tsv}") validate_only=${TINYGO_VALIDATE_ONLY:-0} +package_timeout=${TINYGO_PACKAGE_TIMEOUT:-10m} +timeout_retries=${TINYGO_TIMEOUT_RETRIES:-1} module=github.com/wago-org/net engine=$module/internal/backend/gotls policy=$root/internal/inspectionpolicy/policy.json @@ -19,13 +21,15 @@ fail() { exit 1 } -for command in git go tinygo python3 realpath; do +for command in git go tinygo python3 realpath timeout; do command -v "$command" >/dev/null || fail "missing required command: $command" done [[ -f $manifest ]] || fail "missing exclusion manifest: $manifest" [[ -f $policy ]] || fail "missing inspection policy: $policy" [[ $out != / && $out != "$root" && $out != "$root/scripts" ]] || fail "unsafe output directory: $out" case "$validate_only" in 0|1) ;; *) fail "TINYGO_VALIDATE_ONLY must be 0 or 1" ;; esac +[[ $package_timeout =~ ^[1-9][0-9]*[smh]$ ]] || fail "TINYGO_PACKAGE_TIMEOUT must be a positive integer followed by s, m, or h" +[[ $timeout_retries =~ ^[0-3]$ ]] || fail "TINYGO_TIMEOUT_RETRIES must be between 0 and 3" rm -rf "$out" mkdir -p "$out/logs" @@ -197,11 +201,34 @@ while IFS= read -r package; do log="$out/logs/$relative/test.log" mkdir -p "$(dirname "$log")" printf '\n==> tinygo-supported-test [%d/%d] %s\n' "$attempted" "$supported_packages" "$package" - if tinygo test "$package" >"$log" 2>&1; then - printf 'tinygo-supported-test: PASS %s\n' "$package" - else + attempt=0 + passed=0 + while ((attempt <= timeout_retries)); do + attempt=$((attempt + 1)) + if timeout --signal=TERM --kill-after=30s "$package_timeout" tinygo test -v "$package" >"$log" 2>&1; then + passed=1 + break + else + status=$? + fi cat "$log" >&2 - printf 'tinygo-supported-test: FAIL %s\n' "$package" >&2 + if ((status == 124 || status == 137)) && ((attempt <= timeout_retries)); then + printf 'tinygo-supported-test: TIMEOUT %s after %s (attempt %d/%d); retrying\n' \ + "$package" "$package_timeout" "$attempt" "$((timeout_retries + 1))" >&2 + continue + fi + if ((status == 124 || status == 137)); then + printf 'tinygo-supported-test: TIMEOUT %s after %s (attempt %d/%d)\n' \ + "$package" "$package_timeout" "$attempt" "$((timeout_retries + 1))" >&2 + else + printf 'tinygo-supported-test: FAIL %s with status %d (attempt %d/%d)\n' \ + "$package" "$status" "$attempt" "$((timeout_retries + 1))" >&2 + fi + break + done + if ((passed)); then + printf 'tinygo-supported-test: PASS %s (attempt %d/%d)\n' "$package" "$attempt" "$((timeout_retries + 1))" + else failures=$((failures + 1)) fi done <"$out/supported-packages.tsv" From 2f9c2e594481d0fdc1f80b602d626f1d6012c5a4 Mon Sep 17 00:00:00 2001 From: Wago Networking Agent Date: Sat, 25 Jul 2026 23:18:11 +0000 Subject: [PATCH 12/17] feat: add bounded TLS session resumption --- README.md | 12 +- agent-todo.md | 44 +++- docs/release-signoff.md | 4 +- docs/tls.md | 60 ++++-- internal/backend/gotls/profile.go | 45 +++- internal/backend/gotls/session.go | 183 ++++++++++++++++ internal/backend/gotls/session_test.go | 277 +++++++++++++++++++++++++ internal/backend/lneto/tls/tls.go | 47 ++++- internal/backend/lneto/tls/tls_test.go | 62 ++++++ internal/tlslimits/limits.go | 25 ++- scripts/tls-signoff.sh | 3 + tls/config.go | 25 ++- tls/profile.go | 75 ++++++- tls/profile_test.go | 51 +++++ tls/tls.go | 7 + 15 files changed, 856 insertions(+), 64 deletions(-) create mode 100644 internal/backend/gotls/session.go create mode 100644 internal/backend/gotls/session_test.go diff --git a/README.md b/README.md index 1c27d0a..81e0f2b 100644 --- a/README.md +++ b/README.md @@ -157,10 +157,14 @@ Profiles are finite and host-defined. Outbound guests select only a profile ID, remote IP endpoint, and authorized verification name; inbound guests select a server profile ID and an explicitly authorized local endpoint. Certificate-chain and DNS/IP SAN verification are mandatory for clients; configured mTLS uses -standard client-chain verification. Common Name fallback, key logging, -renegotiation, arbitrary verification/certificate callbacks, guest session -caches, 0-RTT, STARTTLS, and wrapping guest TCP handles are absent. TLS 1.3 is -the default and TLS 1.2 requires `EnableTLS12()`. Client private keys remain +standard client-chain verification. Hosts may explicitly enable a finite, +per-instance client resumption cache with `EnableClientSessionResumption` and +ordered stateless server ticket keys with `EnableServerSessionTickets`; cache +entries and serialized bytes are bounded, quota-reserved, cleared at teardown, +and never enable 0-RTT. Common Name fallback, key logging, renegotiation, +arbitrary verification/certificate callbacks, guest-supplied session caches, +0-RTT, STARTTLS, and wrapping guest TCP handles are absent. TLS 1.3 is the +default and TLS 1.2 requires `EnableTLS12()`. Client private keys remain host-side. Clean `close_notify` maps to EOF; raw TCP EOF maps to TLS protocol failure. The additive `connection_info_v2` reports client/server role and peer authentication while preserving `connection_info_v1` byte-for-byte. See diff --git a/agent-todo.md b/agent-todo.md index 1765304..32cb9b3 100644 --- a/agent-todo.md +++ b/agent-todo.md @@ -1638,7 +1638,8 @@ 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 resolves 135 named tests. Fuzz smoke passes 47 + profiles passed. TLS signoff now resolves 151 named tests after the bounded + resumption 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 names and includes separate client/server TLS 1.3 handshakes. Four arm64 test @@ -1664,6 +1665,41 @@ No repository-owned workstream or completion criterion from this hardening reque - Added a regression with a fake TinyGo process that wedges the root package on its first attempt, proving the watchdog retries exactly once and still covers all 123 supported packages. -- The remaining feature backlog is unchanged: HTTP/HTTPS APIs, portable TinyGo - TLS, executed arm64 evidence, strict release adoption, and the separately - documented protocol-expansion exclusions remain incomplete. +- The remaining feature backlog at that point was HTTP/HTTPS APIs, portable + TinyGo TLS, executed arm64 evidence, strict release adoption, and the + separately documented protocol-expansion exclusions. + +## Bounded standard-Go TLS resumption — July 25, 2026 + +- TinyGo TLS remains explicitly unsupported; no compatibility shim, plaintext + wrapper, or alternative cryptographic engine was added. +- Added opt-in `EnableClientSessionResumption(maxEntries, maxBytes)`. Every + backend instance constructs its own cache, so tickets never cross Wago + instance ownership. The cache stores serialized standard-library state under + exact entry/byte bounds, uses bounded LRU eviction, forces early-data state + off, clears retained ticket/state bytes on eviction and teardown, and rejects + caller-supplied arbitrary cache implementations. +- Added opt-in `EnableServerSessionTickets(keys...)` with one to four explicit, + unique, nonzero 32-byte keys. The first key issues tickets and the bounded + ordered set accepts retained rotation keys; ambient generation and mutable + guest key authority remain absent. +- Resumption cache capacity is conservatively reserved from the exact + per-instance queued-byte quota before cache allocation and released exactly + once on teardown. Aggregate configured cache capacity remains under the 64 MiB + TLS retention ceiling. +- Added standard-Go TLS 1.3 tests proving a full first handshake, resumed second + handshake through `[new, old]` key rotation, resumed third handshake with only + the new key, per-instance cache isolation, LRU/byte rejection, and exact quota + rollback/release. Existing `connection_info` and `connection_info_v2` already + expose the resumed bit without an ABI change. +- Graceful stream shutdown was already complete: `shutdown_write` drains accepted + plaintext and emits `close_notify`, while peer `close_notify` becomes stable + EOF. Resource `close` deliberately remains the deterministic abort path and + never waits for peer packets. +- STARTTLS/existing-handle transfer, DTLS, QUIC TLS, 0-RTT, arbitrary dynamic + callbacks, and live mutation of immutable profiles remain separate authority + or transport designs rather than incomplete behavior in the bounded TLS + 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 151 named tests. diff --git a/docs/release-signoff.md b/docs/release-signoff.md index 839d725..0580dcb 100644 --- a/docs/release-signoff.md +++ b/docs/release-signoff.md @@ -143,7 +143,9 @@ 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) -but now resolves 135 named test targets. Arm64 signoff cross-compiles four test +and now resolves 151 named test targets, including bounded per-instance client +resumption, explicit server ticket-key rotation, cache isolation, and exact +cache quota teardown. 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 diff --git a/docs/tls.md b/docs/tls.md index 732409e..354801d 100644 --- a/docs/tls.md +++ b/docs/tls.md @@ -17,10 +17,15 @@ objects 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, client session caches, -and Encrypted ClientHello callbacks/configuration. Session resumption and 0-RTT -are disabled by the absence of a client session cache and any early-data API. -TLS 1.3 is the default minimum and maximum. TLS 1.2 is available only when the +verification callbacks, certificate-selection callbacks, caller-supplied client +session caches, and Encrypted ClientHello callbacks/configuration. Hosts may +explicitly add `EnableClientSessionResumption(maxEntries, maxBytes)`. That option +creates a separate cache for every Wago instance, retains only serialized +standard-library session state under exact entry and byte bounds, reserves its +maximum against the instance queued-byte quota before allocation, and clears +retained tickets and state during deterministic teardown. Early-data state is +forced off and the guest ABI exposes no 0-RTT operation. TLS 1.3 is the default +minimum and maximum. TLS 1.2 is available only when the host combines an explicit TLS 1.2 minimum with `EnableTLS12`; Go's standard safe cipher-suite defaults remain in effect. Manual cipher, signature, curve, record, key-derivation, and certificate-verification implementations are absent. @@ -46,7 +51,11 @@ available, immutable, and concurrency-safe for the profile lifetime; it never enters guest memory. Dynamic certificate/config selection and verification callbacks are rejected. Client SNI may select only among the immutable static certificates supplied by the host; it cannot select a new configuration or -credential source. Server session tickets remain disabled. +credential source. Server session tickets remain disabled by default. +`EnableServerSessionTickets` accepts one to four explicit nonzero, unique +32-byte keys. The first key encrypts new stateless tickets and every supplied +key may decrypt, supporting bounded deployment rotation from `[new, old]` to +`[new]` without ambient key generation or mutable guest authority. A stored server profile grants no endpoint authority. Hosts must separately opt in with `tls.AllowListeners()` or supply explicit advanced inbound TLS policy. @@ -72,11 +81,15 @@ pumps; they never wait for network packets or worker completion. Each pump is bounded by caller packet/byte/operation budgets and `MaxRecordsPerService`. Handshakes additionally stop after `MaxServiceAttemptsPerHandshake` or `MaxHandshakeBytes`. Ciphertext and -plaintext queues are fixed at registration. Close 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, -acknowledgements, or `close_notify`. Shared namespace teardown joins workers -before the private TCP participant releases transport state. +plaintext queues are fixed at registration. `shutdown_write` already provides +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. 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`. @@ -136,7 +149,11 @@ the same checked per-stream accounting. Defaults also limit handshake bytes to 256 KiB, retained certificate chain bytes to 192 KiB, peer certificates to eight, server names to 253 bytes, ALPN to eight protocols and 256 aggregate bytes, handshake service attempts to 4096, and TLS pump work to sixteen -record-sized transport operations per call. +record-sized transport operations per call. Session resumption is off by +default. When enabled, one client profile may retain at most 64 entries and 4 +MiB of serialized state; all enabled profile caches together remain under the +64 MiB aggregate TLS retention ceiling. A server profile accepts at most four +ordered ticket keys. Registration rejects more than 64 streams or handshakes, any plaintext, ciphertext, or private-transport queue above 1 MiB, handshake input above 4 MiB, @@ -148,16 +165,21 @@ all additions and the `MaxStreams` multiplication are checked in `uint64` before backend construction, including simulated and actual 386 builds. TLS listener and stream resources, active handshakes, plaintext bytes, -ciphertext bytes, global retained bytes, accept-backlog transport storage, and -the underlying private TCP resource/storage are all charged -to the exact instance quota ledger. Every setup path rolls back both layers; -close and failed verification release each charge exactly once. +ciphertext bytes, optional resumption-cache bytes, global retained bytes, +accept-backlog transport storage, and the underlying private TCP +resource/storage are all charged to the exact instance quota ledger. Cache +capacity is conservatively reserved before cache allocation. Every setup path +rolls back both layers; close and failed verification release each charge +exactly once. ## Unsupported scope There is no HTTP/HTTPS request API, DTLS, QUIC TLS, STARTTLS upgrade, -guest-handle wrapping, arbitrary guest TLS configuration, or session-ticket key -rotation. Server listeners and bounded inbound handshakes are available only -through explicit granular TLS registration and authority; they do not place TLS -in aggregate `register`. The certificate-validation clock is the cloned host +guest-handle wrapping, arbitrary guest TLS configuration, live mutation of an +already registered profile, or 0-RTT. Certificate rotation uses immutable +profiles/static SNI certificates and listener replacement; session-ticket key +rotation uses an ordered bounded key set supplied when constructing a new +immutable server profile. Server listeners and bounded inbound handshakes are +available only through explicit granular TLS registration and authority; they +do not place TLS in aggregate `register`. The certificate-validation clock is the cloned host `tls.Config.Time` function when provided, otherwise Go's standard clock. diff --git a/internal/backend/gotls/profile.go b/internal/backend/gotls/profile.go index 403d5eb..1ad2191 100644 --- a/internal/backend/gotls/profile.go +++ b/internal/backend/gotls/profile.go @@ -34,6 +34,8 @@ type Profile struct { MaxCertificateChainBytes int MaxPeerCertificates uint16 AllowedNames map[string]tlsns.IdentityType + MaxClientSessionEntries uint16 + MaxClientSessionBytes int } // ServerProfile is an internal immutable crypto/tls server profile. It owns @@ -48,9 +50,26 @@ type ServerProfile struct { } func (profile Profile) Clone() (Profile, error) { - if profile.ID == 0 || profile.Config == nil || profile.MaxCertificateChainBytes <= 0 || profile.MaxPeerCertificates == 0 { + return profile.clone(false) +} + +// Instantiate creates one adapter-owned profile instance. Resumption state is +// shared by streams using this profile inside the adapter, but never crosses +// adapter or Wago instance ownership boundaries. +func (profile Profile) Instantiate() (Profile, error) { + return profile.clone(true) +} + +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) { 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...) @@ -67,9 +86,33 @@ func (profile Profile) Clone() (Profile, error) { if profile.Config.RootCAs != nil { cloned.Config.RootCAs = profile.Config.RootCAs.Clone() } + if profile.MaxClientSessionEntries != 0 { + if instantiate { + cache, err := newBoundedClientSessionCache(profile.MaxClientSessionEntries, profile.MaxClientSessionBytes) + if err != nil { + return Profile{}, err + } + cloned.Config.ClientSessionCache = cache + } else if cloned.Config.ClientSessionCache == nil { + return Profile{}, ErrInvalidConfig + } + } else if cloned.Config.ClientSessionCache != nil { + return Profile{}, ErrInvalidConfig + } return cloned, nil } +// ClearSessionCache removes and zeroes adapter-owned resumable state during +// deterministic instance teardown. +func (profile Profile) ClearSessionCache() { + if profile.Config == nil || profile.Config.ClientSessionCache == nil { + return + } + if cache, ok := profile.Config.ClientSessionCache.(*boundedClientSessionCache); ok { + cache.clear() + } +} + // Clone validates and deeply clones one server profile. Dynamic certificate, // verification, and session callbacks are rejected by the public profile layer // before this internal boundary. diff --git a/internal/backend/gotls/session.go b/internal/backend/gotls/session.go new file mode 100644 index 0000000..4842970 --- /dev/null +++ b/internal/backend/gotls/session.go @@ -0,0 +1,183 @@ +package gotls + +import ( + cryptotls "crypto/tls" + "sync" + + "github.com/wago-org/net/internal/tlslimits" +) + +// boundedClientSessionCache stores serialized TLS resumption state so every +// retained byte is subject to an explicit profile bound. It deliberately +// disables EarlyData before retaining a session; the TLS stream ABI has no +// replay-sensitive 0-RTT operation. +type boundedClientSessionCache struct { + mu sync.Mutex + maxEntries int + maxBytes int + usedBytes int + entries []clientSessionEntry // least recently used first +} + +type clientSessionEntry struct { + key string + ticket []byte + state []byte + size int +} + +func newBoundedClientSessionCache(maxEntries uint16, maxBytes int) (*boundedClientSessionCache, error) { + if maxEntries == 0 || maxEntries > tlslimits.MaxClientSessionEntries || maxBytes <= 0 || uint64(maxBytes) > tlslimits.MaxClientSessionBytes { + return nil, ErrInvalidConfig + } + return &boundedClientSessionCache{ + maxEntries: int(maxEntries), + maxBytes: maxBytes, + entries: make([]clientSessionEntry, 0, maxEntries), + }, nil +} + +func (cache *boundedClientSessionCache) Get(key string) (*cryptotls.ClientSessionState, bool) { + if cache == nil || key == "" { + return nil, false + } + cache.mu.Lock() + index := cache.index(key) + if index < 0 { + cache.mu.Unlock() + return nil, false + } + entry := cache.entries[index] + if index+1 != len(cache.entries) { + copy(cache.entries[index:], cache.entries[index+1:]) + cache.entries[len(cache.entries)-1] = entry + } + ticket := cloneSessionBytes(entry.ticket) + encoded := cloneSessionBytes(entry.state) + cache.mu.Unlock() + + state, err := cryptotls.ParseSessionState(encoded) + if err != nil { + cache.remove(key) + return nil, false + } + state.EarlyData = false + session, err := cryptotls.NewResumptionState(ticket, state) + if err != nil { + cache.remove(key) + return nil, false + } + return session, true +} + +func (cache *boundedClientSessionCache) Put(key string, session *cryptotls.ClientSessionState) { + if cache == nil || key == "" { + return + } + if session == nil { + cache.remove(key) + return + } + ticket, state, err := session.ResumptionState() + if err != nil || state == nil { + cache.remove(key) + return + } + encoded, err := state.Bytes() + if err != nil { + cache.remove(key) + return + } + clonedState, err := cryptotls.ParseSessionState(encoded) + if err != nil { + cache.remove(key) + return + } + clonedState.EarlyData = false + encoded, err = clonedState.Bytes() + if err != nil { + cache.remove(key) + return + } + size := len(key) + len(ticket) + len(encoded) + if size <= 0 || size > cache.maxBytes { + cache.remove(key) + return + } + entry := clientSessionEntry{ + key: string(cloneSessionBytes([]byte(key))), + ticket: cloneSessionBytes(ticket), + state: cloneSessionBytes(encoded), + size: size, + } + + cache.mu.Lock() + cache.removeLocked(key) + for len(cache.entries) >= cache.maxEntries || cache.usedBytes+entry.size > cache.maxBytes { + cache.evictOldestLocked() + } + cache.entries = append(cache.entries, entry) + cache.usedBytes += entry.size + cache.mu.Unlock() +} + +func (cache *boundedClientSessionCache) clear() { + if cache == nil { + return + } + cache.mu.Lock() + for len(cache.entries) != 0 { + cache.evictOldestLocked() + } + cache.mu.Unlock() +} + +func (cache *boundedClientSessionCache) index(key string) int { + for index := range cache.entries { + if cache.entries[index].key == key { + return index + } + } + return -1 +} + +func (cache *boundedClientSessionCache) remove(key string) { + cache.mu.Lock() + cache.removeLocked(key) + cache.mu.Unlock() +} + +func (cache *boundedClientSessionCache) removeLocked(key string) { + index := cache.index(key) + if index < 0 { + return + } + cache.clearEntryLocked(index) + copy(cache.entries[index:], cache.entries[index+1:]) + cache.entries[len(cache.entries)-1] = clientSessionEntry{} + cache.entries = cache.entries[:len(cache.entries)-1] +} + +func (cache *boundedClientSessionCache) evictOldestLocked() { + if len(cache.entries) == 0 { + return + } + cache.clearEntryLocked(0) + copy(cache.entries, cache.entries[1:]) + cache.entries[len(cache.entries)-1] = clientSessionEntry{} + cache.entries = cache.entries[:len(cache.entries)-1] +} + +func (cache *boundedClientSessionCache) clearEntryLocked(index int) { + entry := &cache.entries[index] + cache.usedBytes -= entry.size + clear(entry.ticket) + clear(entry.state) + *entry = clientSessionEntry{} +} + +func cloneSessionBytes(input []byte) []byte { + output := make([]byte, len(input)) + copy(output, input) + return output +} diff --git a/internal/backend/gotls/session_test.go b/internal/backend/gotls/session_test.go new file mode 100644 index 0000000..4157adb --- /dev/null +++ b/internal/backend/gotls/session_test.go @@ -0,0 +1,277 @@ +package gotls + +import ( + cryptotls "crypto/tls" + "net" + "runtime" + "testing" + "time" + + nscore "github.com/wago-org/net/internal/namespace/core" + tlsns "github.com/wago-org/net/internal/namespace/tls" +) + +func TestBoundedClientSessionCacheRetainsExactFiniteState(t *testing.T) { + cache, err := newBoundedClientSessionCache(2, 64<<10) + if err != nil { + t.Fatal(err) + } + session := captureTestSession(t, 0) + cache.Put("one", session) + cache.Put("two", session) + if _, ok := cache.Get("one"); !ok { + t.Fatal("first session missing") + } + cache.Put("three", session) + if _, ok := cache.Get("two"); ok { + t.Fatal("least-recently-used session was not evicted") + } + if _, ok := cache.Get("one"); !ok { + t.Fatal("recently used session was evicted") + } + if _, ok := cache.Get("three"); !ok { + t.Fatal("new session missing") + } + + cache.Put("one", nil) + if _, ok := cache.Get("one"); ok { + t.Fatal("nil put did not remove session") + } + cache.clear() + if len(cache.entries) != 0 || cache.usedBytes != 0 { + t.Fatalf("cleared cache retained entries=%d bytes=%d", len(cache.entries), cache.usedBytes) + } + if _, ok := cache.Get("three"); ok { + t.Fatal("cleared cache returned a session") + } +} + +func TestBoundedClientSessionCacheRejectsOversizedAndInvalidBounds(t *testing.T) { + if _, err := newBoundedClientSessionCache(0, 1); err != ErrInvalidConfig { + t.Fatalf("zero entries = %v", err) + } + if _, err := newBoundedClientSessionCache(1, 0); err != ErrInvalidConfig { + t.Fatalf("zero bytes = %v", err) + } + cache, err := newBoundedClientSessionCache(1, 256) + if err != nil { + t.Fatal(err) + } + cache.Put("oversized", captureTestSession(t, 1024)) + if _, ok := cache.Get("oversized"); ok { + t.Fatal("oversized session retained") + } +} + +func TestBoundedClientSessionCacheResumesStandardGoTLS13(t *testing.T) { + certificate, roots := testCertificate(t, "resume.example.com") + serverConfig := &cryptotls.Config{ + Certificates: []cryptotls.Certificate{certificate}, + MinVersion: cryptotls.VersionTLS13, + MaxVersion: cryptotls.VersionTLS13, + NextProtos: []string{"h2"}, + } + oldKey, newKey := [32]byte{1}, [32]byte{2} + serverConfig.SetSessionTicketKeys([][32]byte{oldKey}) + profile, err := (Profile{ + ID: 1, + Config: &cryptotls.Config{ + RootCAs: roots, Time: func() time.Time { return time.Unix(1_800_000_000, 0) }, + MinVersion: cryptotls.VersionTLS13, MaxVersion: cryptotls.VersionTLS13, NextProtos: []string{"h2"}, + }, + RequiredALPN: "h2", MaxCertificateChainBytes: 64 << 10, MaxPeerCertificates: 4, + AllowedNames: map[string]tlsns.IdentityType{"resume.example.com": tlsns.IdentityDNS}, + MaxClientSessionEntries: 2, MaxClientSessionBytes: 64 << 10, + }).Instantiate() + if err != nil { + t.Fatal(err) + } + if resumed := completeResumableHandshake(t, profile, serverConfig); resumed { + t.Fatal("first connection unexpectedly resumed") + } + rotated := serverConfig.Clone() + rotated.SetSessionTicketKeys([][32]byte{newKey, oldKey}) + if resumed := completeResumableHandshake(t, profile, rotated); !resumed { + t.Fatal("connection did not resume through the retained rotation key") + } + newOnly := serverConfig.Clone() + newOnly.SetSessionTicketKeys([][32]byte{newKey}) + if resumed := completeResumableHandshake(t, profile, newOnly); !resumed { + t.Fatal("connection did not resume with the newly issued ticket") + } +} + +func TestClientProfileInstantiateOwnsFreshBoundedSessionCache(t *testing.T) { + profile := Profile{ + ID: 1, + Config: &cryptotls.Config{}, + MaxCertificateChainBytes: 1024, + MaxPeerCertificates: 1, + AllowedNames: map[string]tlsns.IdentityType{"example.com": tlsns.IdentityDNS}, + MaxClientSessionEntries: 2, + MaxClientSessionBytes: 64 << 10, + } + first, err := profile.Instantiate() + if err != nil { + t.Fatal(err) + } + second, err := profile.Instantiate() + if err != nil { + t.Fatal(err) + } + if first.Config.ClientSessionCache == nil || second.Config.ClientSessionCache == nil { + t.Fatal("session cache was not installed") + } + if first.Config.ClientSessionCache == second.Config.ClientSessionCache { + t.Fatal("profile instances shared mutable session cache") + } + first.Config.ClientSessionCache.Put("example.com", captureTestSession(t, 0)) + if _, ok := second.Config.ClientSessionCache.Get("example.com"); ok { + t.Fatal("session state crossed profile instances") + } +} + +func completeResumableHandshake(t testing.TB, profile Profile, serverConfig *cryptotls.Config) bool { + t.Helper() + serverBridge := newBridgeConn(64<<10, 64<<10, 1<<20) + server := cryptotls.Server(serverBridge, serverConfig.Clone()) + serverDone := make(chan error, 1) + go func() { + err := server.Handshake() + serverBridge.finishHandshake() + serverDone <- err + }() + client, err := NewClient(&memoryTransport{peer: serverBridge}, profile, "resume.example.com", tlsns.IdentityDNS, testLimits()) + if err != nil { + t.Fatal(err) + } + defer client.Close() + for attempt := 0; attempt < 1_000_000; attempt++ { + progress, err := client.TryFinishConnect() + if err != nil { + t.Fatal(err) + } + if progress == nscore.ProgressDone { + break + } + if attempt == 999_999 { + t.Fatal("resumable client handshake did not complete") + } + runtime.Gosched() + } + for attempt := 0; ; attempt++ { + _, _, _ = client.TryService(nscore.ServiceBudget{Packets: 8, Bytes: 64 << 10, Operations: 8}) + select { + case err := <-serverDone: + if err != nil { + t.Fatal(err) + } + goto serverReady + default: + if attempt == 999_999 { + t.Fatal("resumable server handshake did not complete") + } + runtime.Gosched() + } + } + +serverReady: + writeDone := make(chan error, 1) + go func() { + _, err := server.Write([]byte{1}) + writeDone <- err + }() + var payload [1]byte + for attempt := 0; attempt < 1_000_000; attempt++ { + _, _, _ = client.TryService(nscore.ServiceBudget{Packets: 8, Bytes: 64 << 10, Operations: 8}) + result, err := client.TryRead(payload[:]) + if err != nil { + t.Fatal(err) + } + if result.State == nscore.IOReady && result.Bytes == 1 { + if err := <-writeDone; err != nil { + t.Fatal(err) + } + info, ok := client.ConnectionInfo() + if !ok { + t.Fatal("resumable connection metadata unavailable") + } + serverBridge.abort(nil) + return info.Resumed + } + runtime.Gosched() + } + t.Fatal("post-handshake ticket and payload were not received") + return false +} + +type captureSessionCache struct { + session *cryptotls.ClientSessionState +} + +func (*captureSessionCache) Get(string) (*cryptotls.ClientSessionState, bool) { return nil, false } +func (cache *captureSessionCache) Put(_ string, session *cryptotls.ClientSessionState) { + if session != nil { + cache.session = session + } +} + +func captureTestSession(t testing.TB, extraBytes int) *cryptotls.ClientSessionState { + t.Helper() + certificate, roots := testCertificate(t, "cache.example.com") + serverSide, clientSide := net.Pipe() + serverConfig := &cryptotls.Config{ + Certificates: []cryptotls.Certificate{certificate}, + MinVersion: cryptotls.VersionTLS13, + MaxVersion: cryptotls.VersionTLS13, + } + serverConfig.SetSessionTicketKeys([][32]byte{{1}}) + capture := &captureSessionCache{} + clientConfig := &cryptotls.Config{ + RootCAs: roots, + ServerName: "cache.example.com", + Time: func() time.Time { return time.Unix(1_800_000_000, 0) }, + MinVersion: cryptotls.VersionTLS13, + MaxVersion: cryptotls.VersionTLS13, + ClientSessionCache: capture, + } + server := cryptotls.Server(serverSide, serverConfig) + client := cryptotls.Client(clientSide, clientConfig) + serverDone := make(chan error, 1) + go func() { + if err := server.Handshake(); err != nil { + serverDone <- err + return + } + _, err := server.Write([]byte{1}) + serverDone <- err + }() + if err := client.Handshake(); err != nil { + t.Fatal(err) + } + var payload [1]byte + if _, err := client.Read(payload[:]); err != nil { + t.Fatal(err) + } + if err := <-serverDone; err != nil { + t.Fatal(err) + } + _ = clientSide.Close() + _ = serverSide.Close() + if capture.session == nil { + t.Fatal("TLS 1.3 server did not issue a session ticket") + } + if extraBytes == 0 { + return capture.session + } + ticket, state, err := capture.session.ResumptionState() + if err != nil { + t.Fatal(err) + } + state.Extra = append(state.Extra, make([]byte, extraBytes)) + session, err := cryptotls.NewResumptionState(ticket, state) + if err != nil { + t.Fatal(err) + } + return session +} diff --git a/internal/backend/lneto/tls/tls.go b/internal/backend/lneto/tls/tls.go index 6e1985c..57d6bbd 100644 --- a/internal/backend/lneto/tls/tls.go +++ b/internal/backend/lneto/tls/tls.go @@ -54,6 +54,7 @@ type Adapter struct { storage tlslimits.Plan profiles map[uint32]gotls.Profile serverProfiles map[uint32]gotls.ServerProfile + sessionCache quota.Charge mu sync.Mutex listeners []*listener @@ -75,6 +76,10 @@ func New(common *lnetocore.Namespace, config Config) (*Adapter, error) { } quotas := common.QuotasLocked() common.Unlock() + sessionBytes, ok := clientSessionBytes(config.Profiles) + if !ok { + return nil, nscore.Fail(nscore.FailureInvalidArgument, ErrInvalidConfig) + } adapter := &Adapter{ core: common, quotas: quotas, config: config, storage: storage, profiles: make(map[uint32]gotls.Profile, len(config.Profiles)), @@ -82,8 +87,19 @@ func New(common *lnetocore.Namespace, config Config) (*Adapter, error) { listeners: make([]*listener, 0, config.MaxListeners), streams: make([]*stream, 0, config.MaxStreams), } + if sessionBytes != 0 { + if err := quotas.AcquireQueuedBytes(&adapter.sessionCache, sessionBytes); err != nil { + return nil, mapQuotaError(err) + } + } + releaseSessionCache := true + defer func() { + if releaseSessionCache { + adapter.sessionCache.Release() + } + }() for _, input := range config.Profiles { - profile, err := input.Clone() + profile, err := input.Instantiate() if err != nil { return nil, nscore.Fail(nscore.FailureUnsupportedConfiguration, err) } @@ -113,6 +129,7 @@ func New(common *lnetocore.Namespace, config Config) (*Adapter, error) { common.Unlock() return nil, err } + releaseSessionCache = false return adapter, nil } @@ -128,10 +145,15 @@ func validateConfig(config Config, maxIntValue uint64) (tlslimits.Plan, bool) { return tlslimits.Plan{}, false } for _, profile := range config.Profiles { - if profile.MaxPeerCertificates == 0 || profile.MaxPeerCertificates > tlslimits.MaxPeerCertificates || len(profile.AllowedNames) == 0 || len(profile.AllowedNames) > tlslimits.MaxServerNamesPerProfile { + if profile.MaxPeerCertificates == 0 || profile.MaxPeerCertificates > tlslimits.MaxPeerCertificates || len(profile.AllowedNames) == 0 || len(profile.AllowedNames) > tlslimits.MaxServerNamesPerProfile || + (profile.MaxClientSessionEntries == 0) != (profile.MaxClientSessionBytes == 0) || profile.MaxClientSessionEntries > tlslimits.MaxClientSessionEntries || + profile.MaxClientSessionBytes < 0 || uint64(profile.MaxClientSessionBytes) > tlslimits.MaxClientSessionBytes { return tlslimits.Plan{}, false } } + if _, ok := clientSessionBytes(config.Profiles); !ok { + return tlslimits.Plan{}, false + } maxCertificateBytes := 0 for _, profile := range config.Profiles { if profile.MaxCertificateChainBytes > maxCertificateBytes { @@ -159,6 +181,21 @@ func validateConfig(config Config, maxIntValue uint64) (tlslimits.Plan, bool) { return plan, true } +func clientSessionBytes(profiles []gotls.Profile) (uint64, bool) { + var total uint64 + for _, profile := range profiles { + if profile.MaxClientSessionBytes < 0 { + return 0, false + } + var ok bool + total, ok = checked.AddUint64(total, uint64(profile.MaxClientSessionBytes)) + if !ok || total > tlslimits.MaxAggregateRetainedBytes { + return 0, false + } + } + return total, true +} + func (adapter *Adapter) TryListenTLS(local nscore.Endpoint, profileID uint32) (nscore.Resource, nscore.Progress, error) { if adapter == nil { return nil, 0, nscore.Fail(nscore.FailureClosed, net.ErrClosed) @@ -637,4 +674,10 @@ func (adapter *Adapter) CloseLocked() { } stream.release() } + for id, profile := range adapter.profiles { + profile.ClearSessionCache() + delete(adapter.profiles, id) + } + adapter.serverProfiles = nil + adapter.sessionCache.Release() } diff --git a/internal/backend/lneto/tls/tls_test.go b/internal/backend/lneto/tls/tls_test.go index 4db0f65..736c5ae 100644 --- a/internal/backend/lneto/tls/tls_test.go +++ b/internal/backend/lneto/tls/tls_test.go @@ -93,6 +93,68 @@ func TestTLSUsesPrivateTCPWithoutRawTCPAuthorityAndRollsBack(t *testing.T) { } } +func TestTLSClientSessionCacheQuotaIsReservedAndReleasedPerInstance(t *testing.T) { + compiled, err := policy.Compile(policy.Config{Rules: []policy.Rule{{ + Action: policy.ActionAllow, Transports: []policy.Transport{policy.TransportTLS}, Directions: []policy.Direction{policy.DirectionOutbound}, + }}}) + if err != nil { + t.Fatal(err) + } + for _, test := range []struct { + name string + queued uint64 + wantFail bool + wantRetain uint64 + }{ + {name: "quota denied before cache allocation", queued: 1023, wantFail: true}, + {name: "exact cache reservation", queued: 1024, wantRetain: 1024}, + } { + t.Run(test.name, func(t *testing.T) { + account := quota.NewAccount(quota.Limits{QueuedBytes: test.queued}) + mtu := uint16(ethernet.MaxMTU) + common, err := lnetocore.New(lnetocore.Config{ + Hostname: "tls-session", RandSeed: 4, HardwareAddress: [6]byte{0x02, 0, 0, 0, 0, 4}, + GatewayHardwareAddress: [6]byte{0x02, 0, 0, 0, 0, 5}, IPv4Address: netip.MustParseAddr("192.0.2.4"), MTU: mtu, + Link: packetlink.Config{MaxFrameBytes: int(mtu) + 14, IngressFrames: 4, EgressFrames: 4}, MaxActiveTCPPorts: 1, Policy: compiled, Quotas: account, + }) + if err != nil { + t.Fatal(err) + } + config := Config{ + MaxStreams: 1, MaxConcurrentHandshakes: 1, MaxServerNameBytes: 253, MaxServiceAttemptsPerHandshake: 64, + TCP: tcpConfigForTest(), Engine: engineLimitsForTest(), + Profiles: []gotls.Profile{{ + ID: 1, Config: &cryptotls.Config{MinVersion: cryptotls.VersionTLS13, MaxVersion: cryptotls.VersionTLS13}, + MaxCertificateChainBytes: 64 << 10, MaxPeerCertificates: 4, + AllowedNames: map[string]tlsns.IdentityType{"api.example.com": tlsns.IdentityDNS}, + MaxClientSessionEntries: 1, MaxClientSessionBytes: 1024, + }}, + } + adapter, createErr := New(common, config) + if test.wantFail { + if adapter != nil || failureOf(t, createErr) != nscore.FailureResourceLimit { + t.Fatalf("quota-denied adapter = %p, %v", adapter, createErr) + } + if usage, _ := account.Snapshot(); usage != (quota.Usage{}) { + t.Fatalf("failed cache reservation retained quota = %+v", usage) + } + _ = common.Close() + return + } + if createErr != nil || adapter == nil { + t.Fatalf("adapter = %p, %v", adapter, createErr) + } + if usage, _ := account.Snapshot(); usage.QueuedBytes != test.wantRetain { + t.Fatalf("cache reservation = %+v", usage) + } + _ = common.Close() + if usage, _ := account.Snapshot(); usage != (quota.Usage{}) { + t.Fatalf("cache reservation leaked after close = %+v", usage) + } + }) + } +} + func TestTLSServerListenerUsesInboundTLSAuthorityWithoutRawTCPGrant(t *testing.T) { compiled, err := policy.Compile(policy.Config{Rules: []policy.Rule{{ Action: policy.ActionAllow, Transports: []policy.Transport{policy.TransportTLS}, Directions: []policy.Direction{policy.DirectionInbound}, Prefixes: []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24")}, diff --git a/internal/tlslimits/limits.go b/internal/tlslimits/limits.go index 2b4953f..aef7c1b 100644 --- a/internal/tlslimits/limits.go +++ b/internal/tlslimits/limits.go @@ -5,17 +5,20 @@ package tlslimits import "github.com/wago-org/net/internal/checked" const ( - MaxStreams uint16 = 64 - MaxListeners uint16 = 64 - MaxAcceptBacklog uint16 = 64 - MaxConcurrentHandshakes uint16 = 64 - MaxProfiles = 256 - MaxServerNamesPerProfile = 256 - MaxPeerCertificates uint16 = 64 - MaxALPNProtocols uint16 = 64 - MaxALPNAggregateBytes uint16 = 4096 - MaxTransportPackets = 4096 - MaxServiceAttempts uint32 = 1 << 20 + MaxStreams uint16 = 64 + MaxListeners uint16 = 64 + MaxAcceptBacklog uint16 = 64 + MaxConcurrentHandshakes uint16 = 64 + MaxProfiles = 256 + MaxServerNamesPerProfile = 256 + MaxPeerCertificates uint16 = 64 + MaxALPNProtocols uint16 = 64 + MaxALPNAggregateBytes uint16 = 4096 + MaxTransportPackets = 4096 + MaxClientSessionEntries uint16 = 64 + MaxClientSessionBytes uint64 = 4 << 20 + MaxServerSessionTicketKeys = 4 + MaxServiceAttempts uint32 = 1 << 20 MaxPlaintextQueueBytes uint64 = 1 << 20 MaxCiphertextQueueBytes uint64 = 1 << 20 diff --git a/scripts/tls-signoff.sh b/scripts/tls-signoff.sh index f243eaf..5ac3822 100755 --- a/scripts/tls-signoff.sh +++ b/scripts/tls-signoff.sh @@ -140,6 +140,9 @@ scope=standard-go-client-server-stream-foundation connection_info_v1=byte-compatible connection_info_v2=role-aware-additive listener_authority=explicit +client_session_resumption=bounded-opt-in-per-instance +server_session_ticket_rotation=bounded-explicit-key-set +zero_rtt=absent http_https=absent portable_tinygo_tls=absent ordinary_packages=$ordinary_packages diff --git a/tls/config.go b/tls/config.go index 9e3ffaf..a6f359e 100644 --- a/tls/config.go +++ b/tls/config.go @@ -12,17 +12,20 @@ var ErrInvalidConfig = errors.New("wagonet/tls: invalid configuration") const ( // MaximumStreams and MaximumConcurrentHandshakes bound worker and handshake // concurrency for one instance. - MaximumStreams = tlslimits.MaxStreams - MaximumListeners = tlslimits.MaxListeners - MaximumAcceptBacklog = tlslimits.MaxAcceptBacklog - MaximumConcurrentHandshakes = tlslimits.MaxConcurrentHandshakes - MaximumClientProfiles = tlslimits.MaxProfiles - MaximumServerNamesPerProfile = tlslimits.MaxServerNamesPerProfile - MaximumPeerCertificates = tlslimits.MaxPeerCertificates - MaximumALPNProtocols = tlslimits.MaxALPNProtocols - MaximumALPNAggregateBytes = tlslimits.MaxALPNAggregateBytes - MaximumTransportPackets = tlslimits.MaxTransportPackets - MaximumServiceAttempts = tlslimits.MaxServiceAttempts + MaximumStreams = tlslimits.MaxStreams + MaximumListeners = tlslimits.MaxListeners + MaximumAcceptBacklog = tlslimits.MaxAcceptBacklog + MaximumConcurrentHandshakes = tlslimits.MaxConcurrentHandshakes + MaximumClientProfiles = tlslimits.MaxProfiles + MaximumServerNamesPerProfile = tlslimits.MaxServerNamesPerProfile + MaximumPeerCertificates = tlslimits.MaxPeerCertificates + MaximumALPNProtocols = tlslimits.MaxALPNProtocols + MaximumALPNAggregateBytes = tlslimits.MaxALPNAggregateBytes + MaximumTransportPackets = tlslimits.MaxTransportPackets + MaximumClientSessionEntries = tlslimits.MaxClientSessionEntries + MaximumClientSessionBytes = tlslimits.MaxClientSessionBytes + MaximumServerSessionTicketKeys = tlslimits.MaxServerSessionTicketKeys + MaximumServiceAttempts = tlslimits.MaxServiceAttempts // Maximum*Bytes are hard registration-time ceilings. In addition, all fixed // per-stream storage multiplied by MaxStreams must fit diff --git a/tls/profile.go b/tls/profile.go index 808e8e5..1b69bd0 100644 --- a/tls/profile.go +++ b/tls/profile.go @@ -25,11 +25,13 @@ var ( // ClientProfile is an effectively immutable host-defined TLS client profile. // It never becomes guest memory; guests select only its numeric ID. type ClientProfile struct { - id uint32 - config *cryptotls.Config - allowedNames map[string]identityKind - requiredALPN string - allowTLS12 bool + id uint32 + config *cryptotls.Config + allowedNames map[string]identityKind + requiredALPN string + allowTLS12 bool + maxClientSessionEntries uint16 + maxClientSessionBytes int } // ServerProfile is an effectively immutable host-defined TLS server profile. @@ -59,9 +61,11 @@ func (option clientProfileOptionFunc) applyClientProfile(builder *profileBuilder } type profileBuilder struct { - allowedNames map[string]identityKind - requiredALPN string - allowTLS12 bool + allowedNames map[string]identityKind + requiredALPN string + allowTLS12 bool + maxClientSessionEntries uint16 + maxClientSessionBytes int } // ServerProfileOption constrains one host-owned server profile. @@ -76,8 +80,9 @@ func (option serverProfileOptionFunc) applyServerProfile(builder *serverProfileB } type serverProfileBuilder struct { - requiredALPN string - allowTLS12 bool + requiredALPN string + allowTLS12 bool + sessionTicketKeys [][32]byte } // AllowServerNames authorizes exact normalized DNS names or canonical IP @@ -124,6 +129,22 @@ func EnableTLS12() ClientProfileOption { }) } +// EnableClientSessionResumption installs a profile-local, bounded TLS session +// cache. The cache is instantiated separately for every Wago network instance, +// retains at most maxEntries and maxBytes of serialized session state, and +// never enables 0-RTT. +func EnableClientSessionResumption(maxEntries uint16, maxBytes int) ClientProfileOption { + return clientProfileOptionFunc(func(builder *profileBuilder) error { + if builder.maxClientSessionEntries != 0 || maxEntries == 0 || maxEntries > MaximumClientSessionEntries || + maxBytes <= 0 || uint64(maxBytes) > MaximumClientSessionBytes { + return ErrInvalidProfile + } + builder.maxClientSessionEntries = maxEntries + builder.maxClientSessionBytes = maxBytes + return nil + }) +} + // RequireServerALPN requires an accepted client to negotiate exactly protocol. // The offered protocol list remains immutable host configuration. func RequireServerALPN(protocol string) ServerProfileOption { @@ -145,6 +166,31 @@ func EnableServerTLS12() ServerProfileOption { }) } +// EnableServerSessionTickets enables stateless TLS session tickets with an +// explicit ordered key set. The first key encrypts new tickets and every key +// may decrypt existing tickets, allowing bounded host-controlled rotation. +// Automatic ambient key generation and rotation remain disabled. +func EnableServerSessionTickets(keys ...[32]byte) ServerProfileOption { + copied := append([][32]byte(nil), keys...) + return serverProfileOptionFunc(func(builder *serverProfileBuilder) error { + if len(builder.sessionTicketKeys) != 0 || len(copied) == 0 || len(copied) > MaximumServerSessionTicketKeys { + return ErrInvalidServerProfile + } + seen := make(map[[32]byte]struct{}, len(copied)) + for _, key := range copied { + if key == ([32]byte{}) { + return ErrInvalidServerProfile + } + if _, exists := seen[key]; exists { + return ErrInvalidServerProfile + } + seen[key] = struct{}{} + } + builder.sessionTicketKeys = append([][32]byte(nil), copied...) + return nil + }) +} + // NewClientProfile validates and deeply clones a caller-owned crypto/tls // configuration. Later mutation of the supplied config, trust pool, certificate // slices, or ALPN slice cannot change the profile. @@ -175,7 +221,10 @@ func NewClientProfile(id uint32, config *cryptotls.Config, options ...ClientProf return nil, ErrInvalidProfile } } - return &ClientProfile{id: id, config: cloned, allowedNames: builder.allowedNames, requiredALPN: builder.requiredALPN, allowTLS12: builder.allowTLS12}, nil + return &ClientProfile{ + id: id, config: cloned, allowedNames: builder.allowedNames, requiredALPN: builder.requiredALPN, allowTLS12: builder.allowTLS12, + maxClientSessionEntries: builder.maxClientSessionEntries, maxClientSessionBytes: builder.maxClientSessionBytes, + }, nil } // NewServerProfile validates and clones a caller-owned crypto/tls server @@ -209,6 +258,10 @@ func NewServerProfile(id uint32, config *cryptotls.Config, options ...ServerProf return nil, ErrInvalidServerProfile } } + if len(builder.sessionTicketKeys) != 0 { + cloned.SessionTicketsDisabled = false + cloned.SetSessionTicketKeys(append([][32]byte(nil), builder.sessionTicketKeys...)) + } return &ServerProfile{id: id, config: cloned, requiredALPN: builder.requiredALPN, allowTLS12: builder.allowTLS12}, nil } diff --git a/tls/profile_test.go b/tls/profile_test.go index 5878cbf..17c202f 100644 --- a/tls/profile_test.go +++ b/tls/profile_test.go @@ -54,6 +54,31 @@ func TestClientProfileRejectsUnsafeConfiguration(t *testing.T) { } } +func TestClientProfileEnablesOnlyBoundedInternalSessionResumption(t *testing.T) { + profile, err := NewClientProfile(1, &cryptotls.Config{}, AllowServerNames("example.com"), EnableClientSessionResumption(4, 128<<10)) + if err != nil { + t.Fatal(err) + } + if profile.config.ClientSessionCache != nil || profile.maxClientSessionEntries != 4 || profile.maxClientSessionBytes != 128<<10 { + t.Fatalf("resumption profile = cache %T entries %d bytes %d", profile.config.ClientSessionCache, profile.maxClientSessionEntries, profile.maxClientSessionBytes) + } + for name, option := range map[string]ClientProfileOption{ + "zero entries": EnableClientSessionResumption(0, 1), + "zero bytes": EnableClientSessionResumption(1, 0), + "too many entries": EnableClientSessionResumption(MaximumClientSessionEntries+1, 1), + "too many bytes": EnableClientSessionResumption(1, int(MaximumClientSessionBytes+1)), + } { + t.Run(name, func(t *testing.T) { + if _, err := NewClientProfile(1, &cryptotls.Config{}, AllowServerNames("example.com"), option); err != ErrInvalidProfile { + t.Fatalf("invalid resumption bounds = %v", err) + } + }) + } + if _, err := NewClientProfile(1, &cryptotls.Config{}, AllowServerNames("example.com"), EnableClientSessionResumption(1, 1024), EnableClientSessionResumption(1, 1024)); err != ErrInvalidProfile { + t.Fatalf("duplicate resumption option = %v", err) + } +} + func TestClientProfileRequiresTLS12OptInAndExactIdentity(t *testing.T) { config := &cryptotls.Config{MinVersion: cryptotls.VersionTLS12} if _, err := NewClientProfile(1, config, AllowServerNames("192.0.2.10")); err != ErrTLS12RequiresOptIn { @@ -95,6 +120,32 @@ func TestServerProfileDefaultsTLS13ClonesAndRequiresStaticCertificate(t *testing } } +func TestServerProfileEnablesExplicitBoundedSessionTicketKeys(t *testing.T) { + first, second := [32]byte{1}, [32]byte{2} + profile, err := NewServerProfile(7, testServerConfig(t), EnableServerSessionTickets(first, second)) + if err != nil { + t.Fatal(err) + } + if profile.config.SessionTicketsDisabled { + t.Fatal("explicit server session tickets remained disabled") + } + for name, option := range map[string]ServerProfileOption{ + "empty": EnableServerSessionTickets(), + "zero key": EnableServerSessionTickets([32]byte{}), + "duplicate": EnableServerSessionTickets(first, first), + "too many": EnableServerSessionTickets(first, second, [32]byte{3}, [32]byte{4}, [32]byte{5}), + } { + t.Run(name, func(t *testing.T) { + if _, err := NewServerProfile(7, testServerConfig(t), option); err != ErrInvalidServerProfile { + t.Fatalf("invalid ticket keys = %v", err) + } + }) + } + if _, err := NewServerProfile(7, testServerConfig(t), EnableServerSessionTickets(first), EnableServerSessionTickets(second)); err != ErrInvalidServerProfile { + t.Fatalf("duplicate ticket option = %v", err) + } +} + func TestServerProfileStorageRequiresExplicitListenerAuthority(t *testing.T) { profile, err := NewServerProfile(7, testServerConfig(t), RequireServerALPN("h2")) if err != nil { diff --git a/tls/tls.go b/tls/tls.go index 94b1f4d..7415dbf 100644 --- a/tls/tls.go +++ b/tls/tls.go @@ -239,6 +239,7 @@ func compileServerProfiles(input []*ServerProfile, config Config) ([]gotls.Serve func compileProfiles(input []*ClientProfile, config Config) ([]gotls.Profile, error) { profiles := make([]gotls.Profile, 0, len(input)) seen := make(map[uint32]struct{}, len(input)) + var sessionBytes uint64 for _, profile := range input { if profile == nil || profile.id == 0 { return nil, ErrInvalidProfile @@ -277,10 +278,16 @@ func compileProfiles(input []*ClientProfile, config Config) ([]gotls.Profile, er return nil, ErrInvalidProfile } } + if uint64(profile.maxClientSessionBytes) > MaximumAggregateRetainedBytes-sessionBytes { + return nil, ErrInvalidProfile + } + sessionBytes += uint64(profile.maxClientSessionBytes) profiles = append(profiles, gotls.Profile{ ID: profile.id, Config: profile.config.Clone(), RequiredALPN: profile.requiredALPN, MaxCertificateChainBytes: config.MaxCertificateChainBytes, MaxPeerCertificates: config.MaxPeerCertificates, AllowedNames: allowed, + MaxClientSessionEntries: profile.maxClientSessionEntries, + MaxClientSessionBytes: profile.maxClientSessionBytes, }) } return profiles, nil From 634d471828780272d799fba6ae66bb721a682b81 Mon Sep 17 00:00:00 2001 From: Wago Networking Agent Date: Sat, 25 Jul 2026 23:24:59 +0000 Subject: [PATCH 13/17] feat: expose fixed TLS channel binding --- README.md | 4 +- abi.go | 1 + agent-todo.md | 11 ++-- docs/abi-v1.md | 7 +++ docs/release-signoff.md | 7 +-- docs/tls.md | 8 ++- internal/abi/tls/tls.go | 10 ++++ internal/abi/tls/tls_test.go | 20 +++++++ internal/backend/gotls/stream.go | 21 ++++++++ internal/backend/gotls/stream_test.go | 13 +++++ internal/backend/lneto/tls/tls.go | 6 +++ internal/binding/tls/descriptor_test.go | 6 +-- internal/binding/tls/tls.go | 36 +++++++++++++ internal/binding/tls/tls_test.go | 54 +++++++++++++++++-- .../dependencytest/inspection_tls_test.go | 4 +- internal/instance/tls/tls.go | 23 ++++++++ internal/instance/tls/tls_test.go | 9 +++- internal/namespace/tls/tls.go | 4 ++ scripts/tls-signoff.sh | 1 + tls/register_test.go | 4 +- 20 files changed, 229 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 81e0f2b..d495d09 100644 --- a/README.md +++ b/README.md @@ -167,7 +167,9 @@ arbitrary verification/certificate callbacks, guest-supplied session caches, default and TLS 1.2 requires `EnableTLS12()`. Client private keys remain host-side. Clean `close_notify` maps to EOF; raw TCP EOF maps to TLS protocol failure. The additive `connection_info_v2` reports client/server role and peer -authentication while preserving `connection_info_v1` byte-for-byte. See +authentication while preserving `connection_info_v1` byte-for-byte; the fixed +`channel_binding` import returns the 32-byte RFC 9266 `tls-exporter` binding only +after authenticated completion. See [`docs/tls.md`](docs/tls.md). TCP defaults provide eight finite outbound streams and no listeners. UDP defaults diff --git a/abi.go b/abi.go index 3eca32e..e8beebb 100644 --- a/abi.go +++ b/abi.go @@ -15,6 +15,7 @@ const ( TLSIOResultV1Size uint32 = 8 TLSConnectionInfoV1Size uint32 = 144 TLSConnectionInfoV2Size uint32 = 144 + TLSChannelBindingV1Size uint32 = 32 TLSMaxALPNV1Bytes uint32 = 32 TLSConnectionInfoV2FlagResumed uint32 = 1 << 0 diff --git a/agent-todo.md b/agent-todo.md index 32cb9b3..666edb9 100644 --- a/agent-todo.md +++ b/agent-todo.md @@ -1638,8 +1638,8 @@ 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 151 named tests after the bounded - resumption coverage. Fuzz smoke passes 47 + profiles passed. TLS signoff now resolves 154 named tests after bounded + resumption and channel-binding 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 names and includes separate client/server TLS 1.3 handshakes. Four arm64 test @@ -1696,10 +1696,15 @@ No repository-owned workstream or completion criterion from this hardening reque plaintext and emits `close_notify`, while peer `close_notify` becomes stable EOF. Resource `close` deliberately remains the deterministic abort path and never waits for peer packets. +- Added the fixed `channel_binding` import for the 32-byte RFC 9266 + `tls-exporter` binding. The label, context, and length are not guest-selectable; + it becomes available only after authenticated completion and preserves the + complete output on `AGAIN` or failure. Standard-library peers prove both sides + derive identical bytes. - STARTTLS/existing-handle transfer, DTLS, QUIC TLS, 0-RTT, arbitrary dynamic callbacks, and live mutation of immutable profiles remain separate authority or transport designs rather than incomplete behavior in the bounded TLS 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 151 named tests. + signoff package runs resolving 154 named tests. diff --git a/docs/abi-v1.md b/docs/abi-v1.md index d3c0e46..4e825f7 100644 --- a/docs/abi-v1.md +++ b/docs/abi-v1.md @@ -693,6 +693,7 @@ write(stream: i64, src_ptr: i32, src_len: i32, out_result_ptr: i32) -> i32 shutdown_write(stream: i64) -> i32 connection_info(stream: i64, out_info_ptr: i32) -> i32 connection_info_v2(stream: i64, out_info_ptr: i32) -> i32 +channel_binding(stream: i64, out_binding_ptr: i32) -> i32 close(stream: i64) -> i32 close_listener(listener: i64) -> i32 poll(events_ptr: i32, events_capacity: i32, budget_ptr: i32, result_ptr: i32) -> i32 @@ -750,6 +751,12 @@ The shared `wago_net.abi_version` remains 1.0: the existing v1 import and bytes are unchanged, while role-aware metadata is feature-detected through the separately named additive `connection_info_v2` import. +The additive `channel_binding` import writes exactly 32 bytes from the RFC 9266 +`tls-exporter` channel binding (`EXPORTER-Channel-Binding`, no context). The +label, context, and output length are fixed by the ABI rather than guest input. +It is available only after authenticated handshake completion; `AGAIN`, errors, +and invalid handles leave the complete 32-byte output unchanged. + Connection metadata is available only after verified completion. Certificate DER, chains, private keys, and error strings are never guest output. Clean `close_notify` returns `EOF`; raw transport EOF and corrupted records return diff --git a/docs/release-signoff.md b/docs/release-signoff.md index 0580dcb..e6f53ec 100644 --- a/docs/release-signoff.md +++ b/docs/release-signoff.md @@ -143,9 +143,10 @@ 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 151 named test targets, including bounded per-instance client -resumption, explicit server ticket-key rotation, cache isolation, and exact -cache quota teardown. Arm64 signoff cross-compiles four test +and now resolves 154 named test targets, including bounded per-instance client +resumption, explicit server ticket-key rotation, cache isolation, exact cache +quota teardown, and fixed RFC 9266 channel-binding derivation/output atomicity. +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 diff --git a/docs/tls.md b/docs/tls.md index 354801d..fc64ab5 100644 --- a/docs/tls.md +++ b/docs/tls.md @@ -102,7 +102,7 @@ executed arm64 evidence are still required before production readiness. ## ABI -`wago_net_tls` exports thirteen operations on the server-foundation branch: +`wago_net_tls` exports fourteen operations on the standard-Go stream branch: - `namespace_default` - `listen` @@ -114,6 +114,7 @@ executed arm64 evidence are still required before production readiness. - `shutdown_write` - `connection_info` - `connection_info_v2` +- `channel_binding` - `close` - `close_listener` - `poll` @@ -127,7 +128,10 @@ role, and peer-authenticated flags without reinterpreting v1. Both versions return only bounded local/remote endpoints, TLS version, cipher-suite number, negotiated ALPN (maximum 32 bytes), optional peer leaf SPKI SHA-256, and the client-side verified server identity type. Arbitrary certificate DER is not -exported. +exported. `channel_binding` additively returns the fixed 32-byte RFC 9266 +`tls-exporter` channel binding after verified completion. Its label and length +are not guest-selectable, and it returns `AGAIN` without output mutation while +the handshake is incomplete. All input/output ranges are checked before backend work. Server-name bytes are copied during the host call. Outputs remain unchanged on errors, would-block, diff --git a/internal/abi/tls/tls.go b/internal/abi/tls/tls.go index 2e4f6a2..f50d59a 100644 --- a/internal/abi/tls/tls.go +++ b/internal/abi/tls/tls.go @@ -16,6 +16,7 @@ const ( IOResultV1Size uint32 = 8 ConnectionInfoV1Size uint32 = 144 ConnectionInfoV2Size uint32 = 144 + ChannelBindingV1Size uint32 = tlsns.ChannelBindingBytes MaxALPNV1Bytes uint32 = 32 ConnectionInfoV2FlagResumed uint32 = 1 << 0 @@ -94,6 +95,15 @@ func EncodeIOResultV1(memory []byte, ptr uint32, result nscore.IOResult, bufferS return true } +func EncodeChannelBindingV1(memory []byte, ptr uint32, binding [tlsns.ChannelBindingBytes]byte) bool { + output, ok := abicore.Slice(memory, ptr, ChannelBindingV1Size) + if !ok { + return false + } + copy(output, binding[:]) + return true +} + func EncodeConnectionInfoV1(memory []byte, ptr uint32, info tlsns.ConnectionInfo) bool { if !info.Valid(int(MaxALPNV1Bytes)) { return false diff --git a/internal/abi/tls/tls_test.go b/internal/abi/tls/tls_test.go index 2a13549..6c5bc80 100644 --- a/internal/abi/tls/tls_test.go +++ b/internal/abi/tls/tls_test.go @@ -26,6 +26,26 @@ func TestCheckCreateRejectsOverlapAndOverflow(t *testing.T) { } } +func TestEncodeChannelBindingV1IsFixedAndAtomic(t *testing.T) { + memory := bytes.Repeat([]byte{0xa5}, 40) + binding := [tlsns.ChannelBindingBytes]byte{} + for index := range binding { + binding[index] = byte(index + 1) + } + if EncodeChannelBindingV1(memory, 9, binding) { + t.Fatal("out-of-range channel binding accepted") + } + if !bytes.Equal(memory, bytes.Repeat([]byte{0xa5}, 40)) { + t.Fatal("failed channel binding encode mutated output") + } + if !EncodeChannelBindingV1(memory, 4, binding) { + t.Fatal("valid channel binding rejected") + } + if !bytes.Equal(memory[:4], bytes.Repeat([]byte{0xa5}, 4)) || !bytes.Equal(memory[4:36], binding[:]) || !bytes.Equal(memory[36:], bytes.Repeat([]byte{0xa5}, 4)) { + t.Fatal("channel binding encoding escaped its fixed output") + } +} + func TestEncodeConnectionInfoAtomicAndBounded(t *testing.T) { memory := bytes.Repeat([]byte{0xaa}, 200) before := append([]byte(nil), memory...) diff --git a/internal/backend/gotls/stream.go b/internal/backend/gotls/stream.go index 7be0ccd..9e7837f 100644 --- a/internal/backend/gotls/stream.go +++ b/internal/backend/gotls/stream.go @@ -59,6 +59,7 @@ type Stream struct { closed bool terminal error info tlsns.ConnectionInfo + channelBinding [tlsns.ChannelBindingBytes]byte role tlsns.Role profile Profile serverProfile ServerProfile @@ -188,6 +189,16 @@ func (stream *Stream) validateConnection() error { if !info.Valid(255) { return ErrInvalidConfig } + exported, err := state.ExportKeyingMaterial("EXPORTER-Channel-Binding", nil, tlsns.ChannelBindingBytes) + if err != nil || len(exported) != tlsns.ChannelBindingBytes { + clear(exported) + if err != nil { + return err + } + return ErrInvalidConfig + } + copy(stream.channelBinding[:], exported) + clear(exported) stream.info = info return nil } @@ -521,6 +532,12 @@ func (stream *Stream) ConnectionInfo() (tlsns.ConnectionInfo, bool) { return stream.info, stream.verified && stream.terminal == nil && !stream.closed } +func (stream *Stream) ChannelBinding() ([tlsns.ChannelBindingBytes]byte, bool) { + stream.mu.Lock() + defer stream.mu.Unlock() + return stream.channelBinding, stream.verified && stream.terminal == nil && !stream.closed +} + func (stream *Stream) Readiness() nscore.Readiness { stream.mu.Lock() defer stream.mu.Unlock() @@ -566,6 +583,7 @@ func (stream *Stream) Close() error { clear(stream.readScratch) clear(stream.writeScratch) clear(stream.cipherScratch) + clear(stream.channelBinding[:]) stream.mu.Unlock() return stream.transport.Close() } @@ -586,6 +604,9 @@ func (stream *Stream) CloseWorkersLocked() { stream.cancel() stream.bridge.abort(context.Canceled) stream.wg.Wait() + stream.mu.Lock() + clear(stream.channelBinding[:]) + stream.mu.Unlock() } func mapTLSError(err error) error { diff --git a/internal/backend/gotls/stream_test.go b/internal/backend/gotls/stream_test.go index e1a3cce..6b64c24 100644 --- a/internal/backend/gotls/stream_test.go +++ b/internal/backend/gotls/stream_test.go @@ -1,6 +1,7 @@ package gotls import ( + "bytes" "crypto/rand" "crypto/rsa" cryptotls "crypto/tls" @@ -87,6 +88,12 @@ serverHandshakeComplete: if !ok || info.NegotiatedALPN != "h2" || info.TLSVersion != cryptotls.VersionTLS13 || info.PeerLeafSPKI256 == ([32]byte{}) { t.Fatalf("connection info = %+v, %v", info, ok) } + binding, ok := client.ChannelBinding() + peerState := server.ConnectionState() + peerBinding, exportErr := peerState.ExportKeyingMaterial("EXPORTER-Channel-Binding", nil, tlsns.ChannelBindingBytes) + if !ok || exportErr != nil || !bytes.Equal(binding[:], peerBinding) { + t.Fatalf("channel binding = %x, %v; peer=%x, %v", binding, ok, peerBinding, exportErr) + } serverRead := make(chan string, 1) go func() { @@ -163,6 +170,12 @@ func TestServerHandshakeALPNAndPlaintext(t *testing.T) { if !ok || info.Role != tlsns.RoleServer || info.PeerAuthenticated || info.NegotiatedALPN != "h2" || info.LocalEndpoint != local || info.RemoteEndpoint != remote { t.Fatalf("server connection info = %+v, %v", info, ok) } + binding, ok := server.ChannelBinding() + peerState := client.ConnectionState() + peerBinding, exportErr := peerState.ExportKeyingMaterial("EXPORTER-Channel-Binding", nil, tlsns.ChannelBindingBytes) + if !ok || exportErr != nil || !bytes.Equal(binding[:], peerBinding) { + t.Fatalf("server channel binding = %x, %v; peer=%x, %v", binding, ok, peerBinding, exportErr) + } clientWrite := make(chan error, 1) go func() { diff --git a/internal/backend/lneto/tls/tls.go b/internal/backend/lneto/tls/tls.go index 57d6bbd..5968d69 100644 --- a/internal/backend/lneto/tls/tls.go +++ b/internal/backend/lneto/tls/tls.go @@ -550,6 +550,12 @@ func (stream *stream) ConnectionInfo() (tlsns.ConnectionInfo, bool) { } return stream.engine.ConnectionInfo() } +func (stream *stream) ChannelBinding() ([tlsns.ChannelBindingBytes]byte, bool) { + if stream == nil || stream.engine == nil { + return [tlsns.ChannelBindingBytes]byte{}, false + } + return stream.engine.ChannelBinding() +} func (stream *stream) settleHandshake(ready nscore.Readiness) { if ready&(nscore.ReadyConnected|nscore.ReadyError|nscore.ReadyClosed) == 0 { diff --git a/internal/binding/tls/descriptor_test.go b/internal/binding/tls/descriptor_test.go index 53e0789..11fd258 100644 --- a/internal/binding/tls/descriptor_test.go +++ b/internal/binding/tls/descriptor_test.go @@ -35,8 +35,8 @@ func TestDescriptorInstallsExactTLSBindingsAndPreservesBackend(t *testing.T) { t.Fatalf("incompatible backend = %v", err) } bindings := Bindings(plugin.Host{}) - if len(bindings) != 13 { - t.Fatalf("bindings = %d, want 13", len(bindings)) + if len(bindings) != 14 { + t.Fatalf("bindings = %d, want 14", len(bindings)) } seen := make(map[string]struct{}, len(bindings)) for _, binding := range bindings { @@ -48,7 +48,7 @@ func TestDescriptorInstallsExactTLSBindingsAndPreservesBackend(t *testing.T) { } seen[binding.Name] = struct{}{} } - for _, required := range []string{"namespace_default", "listen", "accept", "connect", "finish_connect", "read", "write", "shutdown_write", "connection_info", "connection_info_v2", "close", "close_listener", "poll"} { + for _, required := range []string{"namespace_default", "listen", "accept", "connect", "finish_connect", "read", "write", "shutdown_write", "connection_info", "connection_info_v2", "channel_binding", "close", "close_listener", "poll"} { if _, ok := seen[required]; !ok { t.Fatalf("binding %q missing", required) } diff --git a/internal/binding/tls/tls.go b/internal/binding/tls/tls.go index f677086..8941f3b 100644 --- a/internal/binding/tls/tls.go +++ b/internal/binding/tls/tls.go @@ -44,6 +44,9 @@ func Bindings(host plugin.Host) []plugin.Binding { {Name: "connection_info_v2", Func: func(module wago.HostModule, params, results []uint64) { connectionInfoV2(host, module, params, results) }, Params: []wago.ValType{wago.ValI64, wago.ValI32}, Results: []wago.ValType{wago.ValI32}, Capability: Capability, Docs: "return role-aware TLS connection-info v2 metadata"}, + {Name: "channel_binding", Func: func(module wago.HostModule, params, results []uint64) { + channelBinding(host, module, params, results) + }, Params: []wago.ValType{wago.ValI64, wago.ValI32}, Results: []wago.ValType{wago.ValI32}, Capability: Capability, Docs: "return the fixed 32-byte RFC 9266 tls-exporter channel binding"}, {Name: "close", Func: func(module wago.HostModule, params, results []uint64) { closeStream(host, module, params, results) }, Params: []wago.ValType{wago.ValI64}, Results: []wago.ValType{wago.ValI32}, Capability: Capability, Docs: "abort and close one exact TLS stream without waiting for the peer"}, {Name: "close_listener", Func: func(module wago.HostModule, params, results []uint64) { closeListener(host, module, params, results) }, Params: []wago.ValType{wago.ValI64}, Results: []wago.ValType{wago.ValI32}, Capability: Capability, Docs: "close one exact TLS server listener"}, {Name: "poll", Func: func(module wago.HostModule, params, results []uint64) { guest.Poll(host, module, params, results) }, Params: []wago.ValType{wago.ValI32, wago.ValI32, wago.ValI32, wago.ValI32}, Results: []wago.ValType{wago.ValI32}, Capability: Capability, Docs: "perform one bounded TLS readiness and transport-service pass"}, @@ -358,6 +361,39 @@ func connectionInfoCall(host plugin.Host, module wago.HostModule, params, result guest.SetStatus(results, guest.StatusOK) } +func channelBinding(host plugin.Host, module wago.HostModule, params, results []uint64) { + if len(params) != 2 || len(results) != 1 { + guest.SetStatus(results, guest.StatusInvalidArgument) + return + } + memory := guest.Memory(module) + out, ok := abicore.NarrowUint32(params[1]) + if !ok || !abicore.CheckRanges(memory, false, abicore.Range{Ptr: out, Length: tlsabi.ChannelBindingV1Size}) { + guest.SetStatus(results, guest.StatusInvalidArgument) + return + } + state, status := instanceState(host, module) + if status != guest.StatusOK { + guest.SetStatus(results, status) + return + } + binding, progress, err := tlsinstance.ChannelBinding(state, resource.Handle(params[0])) + if err != nil { + guest.SetStatus(results, guest.FromError(err)) + return + } + status = guest.FromProgress(progress) + if status != guest.StatusOK { + guest.SetStatus(results, status) + return + } + if !tlsabi.EncodeChannelBindingV1(memory, out, binding) { + guest.SetStatus(results, guest.StatusIO) + return + } + guest.SetStatus(results, guest.StatusOK) +} + func closeListener(host plugin.Host, module wago.HostModule, params, results []uint64) { if len(params) != 1 || len(results) != 1 { guest.SetStatus(results, guest.StatusInvalidArgument) diff --git a/internal/binding/tls/tls_test.go b/internal/binding/tls/tls_test.go index 0188a63..8a7864f 100644 --- a/internal/binding/tls/tls_test.go +++ b/internal/binding/tls/tls_test.go @@ -25,7 +25,11 @@ type attachedMemoryModule struct { func (module attachedMemoryModule) Instance() *wago.Instance { return module.instance } -type pendingInfoStream struct{ endpoint nscore.Endpoint } +type pendingInfoStream struct { + endpoint nscore.Endpoint + binding [tlsns.ChannelBindingBytes]byte + ready bool +} func (*pendingInfoStream) Close() error { return nil } func (*pendingInfoStream) Readiness() nscore.Readiness { return nscore.ReadyConnected } @@ -46,6 +50,9 @@ func (*pendingInfoStream) TryShutdownWrite() (nscore.Progress, error) { func (*pendingInfoStream) ConnectionInfo() (tlsns.ConnectionInfo, bool) { return tlsns.ConnectionInfo{}, false } +func (stream *pendingInfoStream) ChannelBinding() ([tlsns.ChannelBindingBytes]byte, bool) { + return stream.binding, stream.ready +} func TestBindingsRejectMalformedAndOverlappingRangesWithoutMutation(t *testing.T) { bindings := Bindings(plugin.Host{}) @@ -76,7 +83,7 @@ func TestBindingsRejectMalformedAndOverlappingRangesWithoutMutation(t *testing.T t.Fatal("malformed read mutated memory") } - for _, name := range []string{"connection_info", "connection_info_v2"} { + for _, name := range []string{"connection_info", "connection_info_v2", "channel_binding"} { results[0] = 0 byName[name].Func(memoryModule{memory}, []uint64{1, ^uint64(0)}, results) if got := guest.Status(wago.AsI32(results[0])); got != guest.StatusInvalidArgument { @@ -124,7 +131,7 @@ func TestConnectionInfoVersionsLeaveOutputUnchangedOnWouldBlock(t *testing.T) { memory := bytes.Repeat([]byte{0xa5}, 256) before := append([]byte(nil), memory...) module := attachedMemoryModule{memoryModule: memoryModule{memory: memory}, instance: instance} - for _, name := range []string{"connection_info", "connection_info_v2"} { + for _, name := range []string{"connection_info", "connection_info_v2", "channel_binding"} { results := []uint64{0} byName[name].Func(module, []uint64{uint64(handle), 32}, results) if got := guest.Status(wago.AsI32(results[0])); got != guest.StatusAgain { @@ -136,6 +143,47 @@ func TestConnectionInfoVersionsLeaveOutputUnchangedOnWouldBlock(t *testing.T) { } } +func TestChannelBindingWritesExactFixedOutput(t *testing.T) { + manager, err := instancecore.NewManagerConfigured(instancecore.DefaultConfig()) + if err != nil { + t.Fatal(err) + } + instance := new(wago.Instance) + if err := manager.Attach(instance); err != nil { + t.Fatal(err) + } + defer manager.Detach(instance) + state, ok := manager.ForInstance(instance) + if !ok { + t.Fatal("instance state missing") + } + stream := &pendingInfoStream{endpoint: nscore.Endpoint{Address: netip.MustParseAddr("192.0.2.1"), Port: 443}, ready: true} + for index := range stream.binding { + stream.binding[index] = byte(index + 1) + } + handle, err := state.Resources().Add(resource.KindTLSStream, stream) + if err != nil { + t.Fatal(err) + } + var binding plugin.Binding + for _, candidate := range Bindings(plugin.NewHost(manager)) { + if candidate.Name == "channel_binding" { + binding = candidate + break + } + } + memory := bytes.Repeat([]byte{0xa5}, 48) + module := attachedMemoryModule{memoryModule: memoryModule{memory: memory}, instance: instance} + results := []uint64{0} + binding.Func(module, []uint64{uint64(handle), 8}, results) + if got := guest.Status(wago.AsI32(results[0])); got != guest.StatusOK { + t.Fatalf("channel binding status = %v", got) + } + if !bytes.Equal(memory[:8], bytes.Repeat([]byte{0xa5}, 8)) || !bytes.Equal(memory[8:40], stream.binding[:]) || !bytes.Equal(memory[40:], bytes.Repeat([]byte{0xa5}, 8)) { + t.Fatal("channel binding output was not exact") + } +} + func TestConnectRejectsInvalidUTF8BeforeInstanceLookup(t *testing.T) { var connect plugin.Binding for _, binding := range Bindings(plugin.Host{}) { diff --git a/internal/dependencytest/inspection_tls_test.go b/internal/dependencytest/inspection_tls_test.go index 31505d3..1399a6e 100644 --- a/internal/dependencytest/inspection_tls_test.go +++ b/internal/dependencytest/inspection_tls_test.go @@ -19,8 +19,8 @@ func TestTLSFixtureRuntimeInspection(t *testing.T) { capabilities []wago.Capability imports map[string]int }{ - {name: "tls", newNetwork: tlsfixture.Network, capabilities: []wago.Capability{wagonet.CapInfo, wagonet.CapTLS}, imports: map[string]int{wagonet.Module: 1, wagonet.TLSModule: 13}}, - {name: "tcp_tls", newNetwork: tcptlsfixture.Network, capabilities: []wago.Capability{wagonet.CapInfo, wagonet.CapTCP, wagonet.CapTLS}, imports: map[string]int{wagonet.Module: 1, wagonet.TCPModule: 11, wagonet.TLSModule: 13}}, + {name: "tls", newNetwork: tlsfixture.Network, capabilities: []wago.Capability{wagonet.CapInfo, wagonet.CapTLS}, imports: map[string]int{wagonet.Module: 1, wagonet.TLSModule: 14}}, + {name: "tcp_tls", newNetwork: tcptlsfixture.Network, capabilities: []wago.Capability{wagonet.CapInfo, wagonet.CapTCP, wagonet.CapTLS}, imports: map[string]int{wagonet.Module: 1, wagonet.TCPModule: 11, wagonet.TLSModule: 14}}, } { t.Run(test.name, func(t *testing.T) { network, err := test.newNetwork() diff --git a/internal/instance/tls/tls.go b/internal/instance/tls/tls.go index 5e2faf6..cb11463 100644 --- a/internal/instance/tls/tls.go +++ b/internal/instance/tls/tls.go @@ -309,6 +309,29 @@ func ConnectionInfo(state *core.State, handle resource.Handle) (info tlsns.Conne return } +// ChannelBinding returns the fixed RFC 9266 tls-exporter binding only after +// the authenticated handshake has completed. +func ChannelBinding(state *core.State, handle resource.Handle) (binding [tlsns.ChannelBindingBytes]byte, progress nscore.Progress, err error) { + err = state.WithLock(func(locked core.LockedState) error { + stream, lookupErr := lookupStream(locked, handle) + if lookupErr != nil { + return lookupErr + } + var ok bool + binding, ok = stream.ChannelBinding() + if !ok { + progress = nscore.ProgressWouldBlock + return nil + } + progress = nscore.ProgressDone + return nil + }) + if err != nil { + binding, progress = [tlsns.ChannelBindingBytes]byte{}, 0 + } + return +} + func lookupStream(locked core.LockedState, handle resource.Handle) (tlsns.Stream, error) { value, err := locked.Resources.Lookup(handle, resource.KindTLSStream) if err != nil { diff --git a/internal/instance/tls/tls_test.go b/internal/instance/tls/tls_test.go index 3c51fa0..50dd8a6 100644 --- a/internal/instance/tls/tls_test.go +++ b/internal/instance/tls/tls_test.go @@ -62,6 +62,7 @@ type fakeStream struct { written []byte closed int info tlsns.ConnectionInfo + binding [tlsns.ChannelBindingBytes]byte } func (stream *fakeStream) Close() error { stream.closed++; return nil } @@ -86,12 +87,15 @@ func (stream *fakeStream) TryWrite(src []byte) (nscore.IOResult, error) { } func (*fakeStream) TryShutdownWrite() (nscore.Progress, error) { return nscore.ProgressDone, nil } func (stream *fakeStream) ConnectionInfo() (tlsns.ConnectionInfo, bool) { return stream.info, true } +func (stream *fakeStream) ChannelBinding() ([tlsns.ChannelBindingBytes]byte, bool) { + return stream.binding, true +} func TestTLSOperationsKeepHandlesKindSpecificAndPartial(t *testing.T) { local := nscore.Endpoint{Address: netip.MustParseAddr("192.0.2.1"), Port: 49152} remote := nscore.Endpoint{Address: netip.MustParseAddr("192.0.2.2"), Port: 443} info := tlsns.ConnectionInfo{LocalEndpoint: local, RemoteEndpoint: remote, TLSVersion: 0x304, CipherSuite: 0x1301, NegotiatedALPN: "h2", Role: tlsns.RoleClient, PeerAuthenticated: true, PeerLeafSPKI256: [32]byte{1}, VerifiedIdentity: tlsns.IdentityDNS} - stream := &fakeStream{local: local, remote: remote, input: []byte("reply"), info: info} + stream := &fakeStream{local: local, remote: remote, input: []byte("reply"), info: info, binding: [tlsns.ChannelBindingBytes]byte{7}} namespace := &fakeNamespace{stream: stream} state, manager, instance := attachState(t, namespace) defer manager.Detach(instance) @@ -115,6 +119,9 @@ func TestTLSOperationsKeepHandlesKindSpecificAndPartial(t *testing.T) { if got, progress, err := ConnectionInfo(state, handle); err != nil || progress != nscore.ProgressDone || got.NegotiatedALPN != "h2" { t.Fatalf("Info = %+v %v %v", got, progress, err) } + if got, progress, err := ChannelBinding(state, handle); err != nil || progress != nscore.ProgressDone || got[0] != 7 { + t.Fatalf("ChannelBinding = %x %v %v", got, progress, err) + } if err := state.CloseHandle(handle, resource.KindTLSStream); err != nil { t.Fatal(err) } diff --git a/internal/namespace/tls/tls.go b/internal/namespace/tls/tls.go index 6de15d4..7108c4f 100644 --- a/internal/namespace/tls/tls.go +++ b/internal/namespace/tls/tls.go @@ -9,6 +9,9 @@ const ServiceKey nscore.ServiceKey = "tls" // MaxReadBytes bounds one checked guest read and reusable ABI scratch. const MaxReadBytes = 64 << 10 +// ChannelBindingBytes is the fixed RFC 9266 tls-exporter channel binding size. +const ChannelBindingBytes = 32 + // IdentityType records which standard x509 identity rule verified the peer. type IdentityType uint8 @@ -89,4 +92,5 @@ type Stream interface { TryWrite(src []byte) (nscore.IOResult, error) TryShutdownWrite() (nscore.Progress, error) ConnectionInfo() (ConnectionInfo, bool) + ChannelBinding() ([ChannelBindingBytes]byte, bool) } diff --git a/scripts/tls-signoff.sh b/scripts/tls-signoff.sh index 5ac3822..8464649 100755 --- a/scripts/tls-signoff.sh +++ b/scripts/tls-signoff.sh @@ -139,6 +139,7 @@ self_registration=absent scope=standard-go-client-server-stream-foundation connection_info_v1=byte-compatible connection_info_v2=role-aware-additive +channel_binding=rfc9266-fixed-32-byte listener_authority=explicit client_session_resumption=bounded-opt-in-per-instance server_session_ticket_rotation=bounded-explicit-key-set diff --git a/tls/register_test.go b/tls/register_test.go index 31ef8cb..8eb59c4 100644 --- a/tls/register_test.go +++ b/tls/register_test.go @@ -34,7 +34,7 @@ func TestRegisterExposesOnlyTLSAndSharedCore(t *testing.T) { for _, spec := range runtime.ProvidedImports() { imports[spec.Module]++ } - want := map[string]int{wagonet.Module: 1, wagonet.TLSModule: 13} + want := map[string]int{wagonet.Module: 1, wagonet.TLSModule: 14} if !reflect.DeepEqual(imports, want) { t.Fatalf("imports = %v, want %v", imports, want) } @@ -63,7 +63,7 @@ func TestTCPAndTLSComposeWithoutCapabilityWidening(t *testing.T) { for _, spec := range runtime.ProvidedImports() { imports[spec.Module]++ } - wantImports := map[string]int{wagonet.Module: 1, wagonet.TCPModule: 11, wagonet.TLSModule: 13} + wantImports := map[string]int{wagonet.Module: 1, wagonet.TCPModule: 11, wagonet.TLSModule: 14} if !reflect.DeepEqual(imports, wantImports) { t.Fatalf("imports = %v, want %v", imports, wantImports) } From 1d0ebcc53c63c5cafb603844007be3ebf7734e6c Mon Sep 17 00:00:00 2001 From: Wago Networking Agent Date: Sun, 26 Jul 2026 01:34:42 +0000 Subject: [PATCH 14/17] fix: bound TLS profile callbacks --- README.md | 22 +++--- agent-todo.md | 27 +++++++- docs/release-signoff.md | 6 +- docs/tls.md | 48 +++++++------ tls/profile.go | 150 ++++++++++++++++++++++++++++++++++------ tls/profile_test.go | 137 ++++++++++++++++++++++++++++++++---- 6 files changed, 322 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index d495d09..914730d 100644 --- a/README.md +++ b/README.md @@ -130,11 +130,14 @@ if err := wagonettls.Register(network, } ``` -Storing a server profile alone grants no listen authority. Certificate chains -are parsed eagerly, leaf keys must match host-owned `crypto.Signer` values, and -server credentials never enter guest memory. Static SNI selection is limited to -host-supplied immutable certificates; dynamic certificate/config callbacks are -rejected. +Storing a server profile alone grants no listen authority. Client and server +certificate chains are parsed eagerly, leaf keys must match standard in-memory +RSA, NIST ECDSA, or Ed25519 private keys, and credentials never enter guest +memory. Arbitrary `crypto.Signer`, HSM, clock, and dynamic certificate/config +callbacks are rejected so host code cannot indefinitely block TLS worker +teardown. Static SNI selection is limited to host-supplied immutable +certificates. `tls.ValidationTime` supplies an optional frozen validation instant +without retaining a caller callback; otherwise Go's system clock is used. TLS intentionally has no `tls/register` zero-configuration extension and no `net-tls` custom-CLI key. Trust roots, verification identities, ALPN, client or @@ -162,10 +165,11 @@ per-instance client resumption cache with `EnableClientSessionResumption` and ordered stateless server ticket keys with `EnableServerSessionTickets`; cache entries and serialized bytes are bounded, quota-reserved, cleared at teardown, and never enable 0-RTT. Common Name fallback, key logging, renegotiation, -arbitrary verification/certificate callbacks, guest-supplied session caches, -0-RTT, STARTTLS, and wrapping guest TCP handles are absent. TLS 1.3 is the -default and TLS 1.2 requires `EnableTLS12()`. Client private keys remain -host-side. Clean `close_notify` maps to EOF; raw TCP EOF maps to TLS protocol +arbitrary verification/certificate/clock/signer callbacks, guest-supplied +session caches, 0-RTT, STARTTLS, and wrapping guest TCP handles are absent. TLS +1.3 is the default and TLS 1.2 requires `EnableTLS12()`. Client private keys +remain host-side and use the same bounded software-key restriction. Clean +`close_notify` maps to EOF; raw TCP EOF maps to TLS protocol failure. The additive `connection_info_v2` reports client/server role and peer authentication while preserving `connection_info_v1` byte-for-byte; the fixed `channel_binding` import returns the 32-byte RFC 9266 `tls-exporter` binding only diff --git a/agent-todo.md b/agent-todo.md index 666edb9..5f746aa 100644 --- a/agent-todo.md +++ b/agent-todo.md @@ -1638,8 +1638,9 @@ 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 154 named tests after bounded - resumption and channel-binding coverage. Fuzz smoke passes 47 + profiles passed. TLS signoff now resolves 160 named tests after bounded + resumption, channel-binding, client-certificate, frozen-clock, and + software-signer 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 names and includes separate client/server TLS 1.3 handshakes. Four arm64 test @@ -1707,4 +1708,24 @@ 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 154 named tests. + signoff package runs resolving 160 named tests. + +## Bounded TLS host-call hardening — July 26, 2026 + +- Client and server profile construction now rejects caller-supplied + `tls.Config.Time` callbacks. Hosts may use `tls.ValidationTime` to install one + immutable UTC-normalized validation instant through package-owned code, or + omit it to use Go's system clock. +- Client certificate chains now receive the same eager DER parsing, chain-link + signature checks, and leaf/private-key correspondence validation as server + certificates before a profile can be registered. +- Client and server private keys are restricted to standard in-memory RSA, + NIST ECDSA, and Ed25519 implementations. Arbitrary `crypto.Signer` wrappers, + including HSM/delegated signers without a cancellable `Sign` contract, fail + closed before guest traffic can start a TLS worker. +- This closes the in-process teardown gap where an arbitrary signer or clock + callback could block inside `crypto/tls` while stream close waited for its + bounded worker set to exit. External signer support remains intentionally + unsupported until it can use a killable, finite host operation boundary. +- Standard Go passes across the complete repository, and all 17 TLS signoff + package runs now resolve and pass 160 named test targets. diff --git a/docs/release-signoff.md b/docs/release-signoff.md index e6f53ec..8a48b29 100644 --- a/docs/release-signoff.md +++ b/docs/release-signoff.md @@ -143,9 +143,11 @@ 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 154 named test targets, including bounded per-instance client +and now resolves 160 named test targets, including bounded per-instance client resumption, explicit server ticket-key rotation, cache isolation, exact cache -quota teardown, and fixed RFC 9266 channel-binding derivation/output atomicity. +quota teardown, fixed RFC 9266 channel-binding derivation/output atomicity, +eager client-certificate validation, package-owned frozen validation time, and +rejection of externally delegated signer callbacks. 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 diff --git a/docs/tls.md b/docs/tls.md index fc64ab5..c15c41b 100644 --- a/docs/tls.md +++ b/docs/tls.md @@ -12,14 +12,17 @@ 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 private-key -objects stay in host memory and no certificate chain or private key appears in -the guest ABI. +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. The first release rejects `InsecureSkipVerify`, `KeyLogWriter`, renegotiation, -verification callbacks, certificate-selection callbacks, caller-supplied client -session caches, and Encrypted ClientHello callbacks/configuration. Hosts may -explicitly add `EnableClientSessionResumption(maxEntries, maxBytes)`. That option +verification callbacks, certificate-selection callbacks, caller-supplied clock +callbacks, caller-supplied client session caches, and Encrypted ClientHello +callbacks/configuration. `ValidationTime` may install one immutable UTC-normalized +validation instant without retaining caller code; otherwise Go's standard system +clock is used. Hosts may explicitly add +`EnableClientSessionResumption(maxEntries, maxBytes)`. That option creates a separate cache for every Wago instance, retains only serialized standard-library session state under exact entry and byte bounds, reserves its maximum against the instance queued-byte quota before allocation, and clears @@ -44,14 +47,16 @@ 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 -`crypto.Signer`. Certificate DER, OCSP staples, SCTs, ALPN, and CA pools are -cloned. The signer itself remains a host-owned interface value and must remain -available, immutable, and concurrency-safe for the profile lifetime; it never -enters guest memory. Dynamic certificate/config selection and verification -callbacks are rejected. Client SNI may select only among the immutable static -certificates supplied by the host; it cannot select a new configuration or -credential source. Server session tickets remain disabled by default. +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 +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 +only among the immutable static certificates supplied by the host; it cannot +select a new configuration or credential source. Server session tickets remain +disabled by default. `EnableServerSessionTickets` accepts one to four explicit nonzero, unique 32-byte keys. The first key encrypts new stateless tickets and every supplied key may decrypt, supporting bounded deployment rotation from `[new, old]` to @@ -180,10 +185,11 @@ exactly once. There is no HTTP/HTTPS request API, DTLS, QUIC TLS, STARTTLS upgrade, guest-handle wrapping, arbitrary guest TLS configuration, live mutation of an -already registered profile, or 0-RTT. Certificate rotation uses immutable -profiles/static SNI certificates and listener replacement; session-ticket key -rotation uses an ordered bounded key set supplied when constructing a new -immutable server profile. Server listeners and bounded inbound handshakes are -available only through explicit granular TLS registration and authority; they -do not place TLS in aggregate `register`. The certificate-validation clock is the cloned host -`tls.Config.Time` function when provided, otherwise Go's standard clock. +already registered profile, external/HSM signer callback, caller clock callback, +or 0-RTT. Certificate rotation uses immutable profiles/static SNI certificates +and listener replacement; session-ticket key rotation uses an ordered bounded +key set supplied when constructing a new immutable server profile. Server +listeners and bounded inbound handshakes are available only through explicit +granular TLS registration and authority; they do not place TLS in aggregate +`register`. Certificate validation uses the immutable `ValidationTime` option +when supplied, otherwise Go's standard system clock. diff --git a/tls/profile.go b/tls/profile.go index 1b69bd0..2184060 100644 --- a/tls/profile.go +++ b/tls/profile.go @@ -3,12 +3,18 @@ package tls import ( "bytes" "crypto" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rsa" cryptotls "crypto/tls" "crypto/x509" "errors" "net/netip" + "reflect" "slices" "strings" + "time" "unicode/utf8" "github.com/wago-org/net/internal/dnsname" @@ -64,6 +70,8 @@ type profileBuilder struct { allowedNames map[string]identityKind requiredALPN string allowTLS12 bool + validationTime time.Time + validationTimeSet bool maxClientSessionEntries uint16 maxClientSessionBytes int } @@ -82,9 +90,47 @@ func (option serverProfileOptionFunc) applyServerProfile(builder *serverProfileB type serverProfileBuilder struct { requiredALPN string allowTLS12 bool + validationTime time.Time + validationTimeSet bool sessionTicketKeys [][32]byte } +// ProfileOption is a host-only option that applies to both client and server +// profiles. Implementations are package-owned so arbitrary guest-reachable +// callbacks cannot enter TLS processing through this surface. +type ProfileOption interface { + ClientProfileOption + ServerProfileOption +} + +type validationTimeOption struct { + value time.Time +} + +func (option validationTimeOption) applyClientProfile(builder *profileBuilder) error { + if builder.validationTimeSet || option.value.IsZero() { + return ErrInvalidProfile + } + builder.validationTime = option.value.UTC() + builder.validationTimeSet = true + return nil +} + +func (option validationTimeOption) applyServerProfile(builder *serverProfileBuilder) error { + if builder.validationTimeSet || option.value.IsZero() { + return ErrInvalidServerProfile + } + builder.validationTime = option.value.UTC() + builder.validationTimeSet = true + return nil +} + +// ValidationTime installs one immutable validation instant without retaining a +// caller callback. When omitted, crypto/tls uses Go's standard system clock. +func ValidationTime(value time.Time) ProfileOption { + return validationTimeOption{value: value} +} + // AllowServerNames authorizes exact normalized DNS names or canonical IP // literals. The guest must select one of these identities before any network // activity begins. @@ -192,8 +238,10 @@ func EnableServerSessionTickets(keys ...[32]byte) ServerProfileOption { } // NewClientProfile validates and deeply clones a caller-owned crypto/tls -// configuration. Later mutation of the supplied config, trust pool, certificate -// slices, or ALPN slice cannot change the profile. +// configuration. Client certificate chains and leaf/private-key correspondence +// are checked eagerly, and private keys are restricted to standard in-memory +// software implementations. Later mutation of the supplied config, trust pool, +// certificate slices, or ALPN slice cannot change the profile. func NewClientProfile(id uint32, config *cryptotls.Config, options ...ClientProfileOption) (*ClientProfile, error) { if id == 0 || config == nil { return nil, ErrInvalidProfile @@ -214,6 +262,10 @@ func NewClientProfile(id uint32, config *cryptotls.Config, options ...ClientProf if err != nil { return nil, err } + if builder.validationTimeSet { + validationTime := builder.validationTime + cloned.Time = func() time.Time { return validationTime } + } if builder.requiredALPN != "" { if len(cloned.NextProtos) == 0 { cloned.NextProtos = []string{builder.requiredALPN} @@ -228,12 +280,12 @@ func NewClientProfile(id uint32, config *cryptotls.Config, options ...ClientProf } // NewServerProfile validates and clones a caller-owned crypto/tls server -// configuration. Static certificate DER and metadata are deeply cloned. Each -// crypto.Signer remains a host-owned interface value because private keys are -// intentionally never copied or exposed; the caller must keep that signer -// available, concurrency-safe, and immutable for the profile lifetime. -// Dynamic certificate, verification, session, entropy, and key-log callbacks -// are rejected so guest traffic cannot mutate host policy. +// configuration. Static certificate DER and metadata are deeply cloned. +// Private keys remain host-owned and are restricted to standard in-memory RSA, +// ECDSA, and Ed25519 implementations whose signing calls cannot delegate to an +// arbitrary external callback. Dynamic certificate, verification, clock, +// session, entropy, and key-log callbacks are rejected so guest traffic cannot +// mutate host policy or indefinitely block deterministic worker teardown. func NewServerProfile(id uint32, config *cryptotls.Config, options ...ServerProfileOption) (*ServerProfile, error) { if id == 0 || config == nil { return nil, ErrInvalidServerProfile @@ -251,6 +303,10 @@ func NewServerProfile(id uint32, config *cryptotls.Config, options ...ServerProf if err != nil { return nil, err } + if builder.validationTimeSet { + validationTime := builder.validationTime + cloned.Time = func() time.Time { return validationTime } + } if builder.requiredALPN != "" { if len(cloned.NextProtos) == 0 { cloned.NextProtos = []string{builder.requiredALPN} @@ -296,7 +352,7 @@ func cloneSafeConfig(input *cryptotls.Config, allowTLS12 bool) (*cryptotls.Confi if input.InsecureSkipVerify || input.KeyLogWriter != nil || input.Renegotiation != cryptotls.RenegotiateNever || input.VerifyPeerCertificate != nil || input.VerifyConnection != nil || input.GetClientCertificate != nil || input.GetCertificate != nil || input.GetConfigForClient != nil || input.ClientSessionCache != nil || - input.UnwrapSession != nil || input.WrapSession != nil || input.Rand != nil || input.NameToCertificate != nil || + input.UnwrapSession != nil || input.WrapSession != nil || input.Rand != nil || input.Time != nil || input.NameToCertificate != nil || input.ClientAuth != cryptotls.NoClientCert || input.ClientCAs != nil || input.SessionTicketKey != ([32]byte{}) || len(input.CipherSuites) != 0 || len(input.CurvePreferences) != 0 || len(input.EncryptedClientHelloConfigList) != 0 || input.EncryptedClientHelloRejectionVerify != nil || @@ -318,6 +374,11 @@ func cloneSafeConfig(input *cryptotls.Config, allowTLS12 bool) (*cryptotls.Confi if input.RootCAs != nil { cloned.RootCAs = input.RootCAs.Clone() } + for _, certificate := range input.Certificates { + if err := validateStaticCertificate(certificate, ErrInvalidProfile); err != nil { + return nil, err + } + } cloned.Certificates = cloneCertificates(input.Certificates) minVersion := cloned.MinVersion if minVersion == 0 { @@ -349,7 +410,7 @@ func cloneSafeServerConfig(input *cryptotls.Config, allowTLS12 bool) (*cryptotls if input.InsecureSkipVerify || input.KeyLogWriter != nil || input.Renegotiation != cryptotls.RenegotiateNever || input.VerifyPeerCertificate != nil || input.VerifyConnection != nil || input.GetClientCertificate != nil || input.GetCertificate != nil || input.GetConfigForClient != nil || input.ClientSessionCache != nil || - input.UnwrapSession != nil || input.WrapSession != nil || input.Rand != nil || input.NameToCertificate != nil || + input.UnwrapSession != nil || input.WrapSession != nil || input.Rand != nil || input.Time != nil || input.NameToCertificate != nil || input.RootCAs != nil || input.ServerName != "" || input.SessionTicketKey != ([32]byte{}) || len(input.CipherSuites) != 0 || len(input.CurvePreferences) != 0 || len(input.EncryptedClientHelloConfigList) != 0 || input.EncryptedClientHelloRejectionVerify != nil || @@ -363,7 +424,7 @@ func cloneSafeServerConfig(input *cryptotls.Config, allowTLS12 bool) (*cryptotls return nil, ErrInvalidServerProfile } for _, certificate := range input.Certificates { - if err := validateStaticServerCertificate(certificate); err != nil { + if err := validateStaticCertificate(certificate, ErrInvalidServerProfile); err != nil { return nil, err } } @@ -404,38 +465,87 @@ func cloneSafeServerConfig(input *cryptotls.Config, allowTLS12 bool) (*cryptotls return cloned, nil } -func validateStaticServerCertificate(certificate cryptotls.Certificate) error { - signer, signerOK := certificate.PrivateKey.(crypto.Signer) - if len(certificate.Certificate) == 0 || !signerOK || signer.Public() == nil { - return ErrInvalidServerProfile +func validateStaticCertificate(certificate cryptotls.Certificate, invalidError error) error { + if len(certificate.Certificate) == 0 { + return invalidError + } + signer, err := validateSoftwareSigner(certificate.PrivateKey, invalidError) + if err != nil { + return err } parsed := make([]*x509.Certificate, len(certificate.Certificate)) for index, der := range certificate.Certificate { if len(der) == 0 { - return ErrInvalidServerProfile + return invalidError } value, err := x509.ParseCertificate(der) if err != nil { - return ErrInvalidServerProfile + return invalidError } parsed[index] = value } for index := 0; index+1 < len(parsed); index++ { if err := parsed[index].CheckSignatureFrom(parsed[index+1]); err != nil { - return ErrInvalidServerProfile + return invalidError } } leafPublic, err := x509.MarshalPKIXPublicKey(parsed[0].PublicKey) if err != nil { - return ErrInvalidServerProfile + return invalidError } signerPublic, err := x509.MarshalPKIXPublicKey(signer.Public()) if err != nil || !bytes.Equal(leafPublic, signerPublic) { - return ErrInvalidServerProfile + return invalidError } return nil } +func validateSoftwareSigner(privateKey any, invalidError error) (crypto.Signer, error) { + switch key := privateKey.(type) { + case *rsa.PrivateKey: + if key == nil || key.Validate() != nil { + return nil, invalidError + } + return key, nil + case *ecdsa.PrivateKey: + if key == nil || !standardECDSACurve(key.Curve) { + return nil, ErrUnsafeTLSConfig + } + if key.D == nil || key.X == nil || key.Y == nil || key.D.Sign() <= 0 || + key.D.Cmp(key.Curve.Params().N) >= 0 || !key.Curve.IsOnCurve(key.X, key.Y) { + return nil, invalidError + } + scalar := key.D.FillBytes(make([]byte, (key.Curve.Params().N.BitLen()+7)/8)) + x, y := key.Curve.ScalarBaseMult(scalar) + if x == nil || y == nil || x.Cmp(key.X) != 0 || y.Cmp(key.Y) != 0 { + return nil, invalidError + } + return key, nil + case ed25519.PrivateKey: + if len(key) != ed25519.PrivateKeySize { + return nil, invalidError + } + return key, nil + case *ed25519.PrivateKey: + if key == nil || len(*key) != ed25519.PrivateKeySize { + return nil, invalidError + } + return *key, nil + default: + if _, ok := privateKey.(crypto.Signer); ok { + return nil, ErrUnsafeTLSConfig + } + return nil, invalidError + } +} + +func standardECDSACurve(curve elliptic.Curve) bool { + if curve == nil || !reflect.TypeOf(curve).Comparable() { + return false + } + return curve == elliptic.P256() || curve == elliptic.P384() || curve == elliptic.P521() +} + func cloneCertificates(input []cryptotls.Certificate) []cryptotls.Certificate { out := make([]cryptotls.Certificate, len(input)) for i := range input { diff --git a/tls/profile_test.go b/tls/profile_test.go index 17c202f..927566b 100644 --- a/tls/profile_test.go +++ b/tls/profile_test.go @@ -1,8 +1,12 @@ package tls import ( + "crypto" + "crypto/ecdsa" "crypto/ed25519" + "crypto/elliptic" "crypto/rand" + "crypto/rsa" cryptotls "crypto/tls" "crypto/x509" "math/big" @@ -14,10 +18,8 @@ import ( ) func TestClientProfileDefaultsTLS13AndClones(t *testing.T) { - config := &cryptotls.Config{ - NextProtos: []string{"h2"}, - Certificates: []cryptotls.Certificate{{SupportedSignatureAlgorithms: []cryptotls.SignatureScheme{cryptotls.Ed25519}}}, - } + config := testServerConfig(t) + config.Certificates[0].SupportedSignatureAlgorithms = []cryptotls.SignatureScheme{cryptotls.Ed25519} profile, err := NewClientProfile(1, config, AllowServerNames("API.Example.com."), RequireALPN("h2")) if err != nil { t.Fatal(err) @@ -43,6 +45,7 @@ func TestClientProfileRejectsUnsafeConfiguration(t *testing.T) { {KeyLogWriter: discardWriter{}}, {Renegotiation: cryptotls.RenegotiateOnceAsClient}, {VerifyConnection: func(cryptotls.ConnectionState) error { return nil }}, + {Time: func() time.Time { return time.Now() }}, {ClientSessionCache: cryptotls.NewLRUClientSessionCache(1)}, {WrapSession: func(cryptotls.ConnectionState, *cryptotls.SessionState) ([]byte, error) { return nil, nil }}, {CipherSuites: []uint16{cryptotls.TLS_RSA_WITH_AES_128_CBC_SHA}}, @@ -54,6 +57,42 @@ func TestClientProfileRejectsUnsafeConfiguration(t *testing.T) { } } +func TestProfilesUseOnlyPackageOwnedFrozenValidationTime(t *testing.T) { + configured := time.Date(2030, 5, 6, 7, 8, 9, 10, time.FixedZone("caller", 3600)) + client, err := NewClientProfile(1, &cryptotls.Config{}, AllowServerNames("example.com"), ValidationTime(configured)) + if err != nil { + t.Fatal(err) + } + if client.config.Time == nil { + t.Fatal("client validation time callback missing") + } + if got := client.config.Time(); !got.Equal(configured) || got.Location() != time.UTC { + t.Fatalf("client validation time = %v", got) + } + server, err := NewServerProfile(1, testServerConfig(t), ValidationTime(configured)) + if err != nil { + t.Fatal(err) + } + if server.config.Time == nil { + t.Fatal("server validation time callback missing") + } + if got := server.config.Time(); !got.Equal(configured) || got.Location() != time.UTC { + t.Fatalf("server validation time = %v", got) + } + if _, err := NewClientProfile(1, &cryptotls.Config{}, AllowServerNames("example.com"), ValidationTime(time.Time{})); err != ErrInvalidProfile { + t.Fatalf("zero client validation time = %v", err) + } + if _, err := NewServerProfile(1, testServerConfig(t), ValidationTime(time.Time{})); err != ErrInvalidServerProfile { + t.Fatalf("zero server validation time = %v", err) + } + if _, err := NewClientProfile(1, &cryptotls.Config{}, AllowServerNames("example.com"), ValidationTime(configured), ValidationTime(configured)); err != ErrInvalidProfile { + t.Fatalf("duplicate client validation time = %v", err) + } + if _, err := NewServerProfile(1, testServerConfig(t), ValidationTime(configured), ValidationTime(configured)); err != ErrInvalidServerProfile { + t.Fatalf("duplicate server validation time = %v", err) + } +} + func TestClientProfileEnablesOnlyBoundedInternalSessionResumption(t *testing.T) { profile, err := NewClientProfile(1, &cryptotls.Config{}, AllowServerNames("example.com"), EnableClientSessionResumption(4, 128<<10)) if err != nil { @@ -182,6 +221,60 @@ func TestServerProfileStorageRequiresExplicitListenerAuthority(t *testing.T) { } } +func TestProfilesAcceptOnlyStandardSoftwareSignerFamilies(t *testing.T) { + _, ed25519Key, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + ecdsaKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + for name, signer := range map[string]crypto.Signer{ + "ed25519": ed25519Key, + "ecdsa": ecdsaKey, + "rsa": rsaKey, + } { + t.Run(name, func(t *testing.T) { + config := testConfigForSigner(t, signer) + if _, err := NewClientProfile(1, config, AllowServerNames("example.com")); err != nil { + t.Fatalf("client profile = %v", err) + } + if _, err := NewServerProfile(1, config); err != nil { + t.Fatalf("server profile = %v", err) + } + }) + } +} + +func TestClientProfileRejectsMalformedChainsMismatchedAndExternalSigners(t *testing.T) { + malformed := testServerConfig(t) + malformed.Certificates[0].Certificate[0] = []byte{1, 2, 3} + if _, err := NewClientProfile(1, malformed, AllowServerNames("example.com")); err != ErrInvalidProfile { + t.Fatalf("malformed client certificate = %v", err) + } + + mismatched := testServerConfig(t) + _, otherSigner, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + mismatched.Certificates[0].PrivateKey = otherSigner + if _, err := NewClientProfile(1, mismatched, AllowServerNames("example.com")); err != ErrInvalidProfile { + t.Fatalf("mismatched client signer = %v", err) + } + + wrapped := testServerConfig(t) + wrapped.Certificates[0].PrivateKey = externalSigner{Signer: wrapped.Certificates[0].PrivateKey.(crypto.Signer)} + if _, err := NewClientProfile(1, wrapped, AllowServerNames("example.com")); err != ErrUnsafeTLSConfig { + t.Fatalf("external client signer = %v", err) + } +} + func TestServerProfileRejectsMalformedChainAndMismatchedSigner(t *testing.T) { malformed := testServerConfig(t) malformed.Certificates[0].Certificate[0] = []byte{1, 2, 3} @@ -205,6 +298,12 @@ func TestServerProfileRejectsMalformedChainAndMismatchedSigner(t *testing.T) { if _, err := NewServerProfile(1, brokenChain); err != ErrInvalidServerProfile { t.Fatalf("broken chain = %v", err) } + + wrapped := testServerConfig(t) + wrapped.Certificates[0].PrivateKey = externalSigner{Signer: wrapped.Certificates[0].PrivateKey.(crypto.Signer)} + if _, err := NewServerProfile(1, wrapped); err != ErrUnsafeTLSConfig { + t.Fatalf("external server signer = %v", err) + } } func TestServerProfileRejectsUnsafeConfigurationAndRequiresTLS12OptIn(t *testing.T) { @@ -213,6 +312,11 @@ func TestServerProfileRejectsUnsafeConfigurationAndRequiresTLS12OptIn(t *testing if _, err := NewServerProfile(1, unsafe); err != ErrUnsafeTLSConfig { t.Fatalf("dynamic certificate callback = %v", err) } + unsafeClock := testServerConfig(t) + unsafeClock.Time = func() time.Time { return time.Now() } + if _, err := NewServerProfile(1, unsafeClock); err != ErrUnsafeTLSConfig { + t.Fatalf("dynamic clock callback = %v", err) + } invalidClientAuth := testServerConfig(t) invalidClientAuth.ClientAuth = cryptotls.RequireAndVerifyClientCert if _, err := NewServerProfile(1, invalidClientAuth); err != ErrInvalidServerProfile { @@ -228,26 +332,33 @@ func TestServerProfileRejectsUnsafeConfigurationAndRequiresTLS12OptIn(t *testing } } +type externalSigner struct { + crypto.Signer +} + func testServerConfig(t testing.TB) *cryptotls.Config { t.Helper() - publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + _, privateKey, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatal(err) } + return testConfigForSigner(t, privateKey) +} + +func testConfigForSigner(t testing.TB, signer crypto.Signer) *cryptotls.Config { + t.Helper() now := time.Unix(1_800_000_000, 0) - der, err := x509.CreateCertificate(rand.Reader, &x509.Certificate{ + template := &x509.Certificate{ SerialNumber: big.NewInt(1), DNSNames: []string{"server.example.com"}, NotBefore: now.Add(-time.Hour), NotAfter: now.Add(time.Hour), - KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, - }, &x509.Certificate{ - SerialNumber: big.NewInt(1), DNSNames: []string{"server.example.com"}, - NotBefore: now.Add(-time.Hour), NotAfter: now.Add(time.Hour), - KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, - }, publicKey, privateKey) + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, signer.Public(), signer) if err != nil { t.Fatal(err) } - return &cryptotls.Config{Certificates: []cryptotls.Certificate{{Certificate: [][]byte{der}, PrivateKey: privateKey}}, NextProtos: []string{"h2"}} + return &cryptotls.Config{Certificates: []cryptotls.Certificate{{Certificate: [][]byte{der}, PrivateKey: signer}}, NextProtos: []string{"h2"}} } func TestAllowLoopbackRegistrationAuthorityIsTLSScoped(t *testing.T) { From 530b01151386e07b69d08ec3801b22ecee8652f0 Mon Sep 17 00:00:00 2001 From: Wago Networking Agent Date: Sun, 26 Jul 2026 01:39:54 +0000 Subject: [PATCH 15/17] test: certify bounded TLS certificate rotation --- README.md | 5 +- agent-todo.md | 17 ++++-- docs/release-signoff.md | 5 +- docs/tls.md | 19 +++--- internal/backend/gotls/stream_test.go | 58 +++++++++++++++++++ .../backend/lneto/tls/integration_test.go | 58 +++++++++++++++++-- 6 files changed, 141 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 914730d..d2af5f3 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,10 @@ RSA, NIST ECDSA, or Ed25519 private keys, and credentials never enter guest memory. Arbitrary `crypto.Signer`, HSM, clock, and dynamic certificate/config callbacks are rejected so host code cannot indefinitely block TLS worker teardown. Static SNI selection is limited to host-supplied immutable -certificates. `tls.ValidationTime` supplies an optional frozen validation instant +certificates. Rotation drains accepted streams before replacing the listener +with a new immutable profile; closing the pinned lneto listener is an abort +boundary for streams that have not drained, so zero-downtime same-port handoff is +not claimed. `tls.ValidationTime` supplies an optional frozen validation instant without retaining a caller callback; otherwise Go's system clock is used. TLS intentionally has no `tls/register` zero-configuration extension and no diff --git a/agent-todo.md b/agent-todo.md index 5f746aa..652580b 100644 --- a/agent-todo.md +++ b/agent-todo.md @@ -1638,9 +1638,10 @@ 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 160 named tests after bounded - resumption, channel-binding, client-certificate, frozen-clock, and - software-signer coverage. Fuzz smoke passes 47 + profiles passed. TLS signoff now resolves 164 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 names and includes separate client/server TLS 1.3 handshakes. Four arm64 test @@ -1708,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 160 named tests. + signoff package runs resolving 164 named tests. ## Bounded TLS host-call hardening — July 26, 2026 @@ -1727,5 +1728,11 @@ No repository-owned workstream or completion criterion from this hardening reque callback could block inside `crypto/tls` while stream close waited for its bounded worker set to exit. External signer support remains intentionally unsupported until it can use a killable, finite host operation boundary. +- Static SNI selection is proven against multiple immutable certificates. + Certificate rotation is proven through a drain-close-relisten sequence: old + streams complete first, then a listener using the new profile presents a + different peer SPKI on the same endpoint. Closing the pinned lneto listener + 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 160 named test targets. + package runs now resolve and pass 164 named test targets. diff --git a/docs/release-signoff.md b/docs/release-signoff.md index 8a48b29..9399590 100644 --- a/docs/release-signoff.md +++ b/docs/release-signoff.md @@ -143,11 +143,12 @@ 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 160 named test targets, including bounded per-instance client +and now resolves 164 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 -rejection of externally delegated signer callbacks. +rejection of externally delegated signer callbacks, immutable static SNI +selection, and drain-before-listener-replacement certificate rotation. 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 diff --git a/docs/tls.md b/docs/tls.md index c15c41b..1a6410e 100644 --- a/docs/tls.md +++ b/docs/tls.md @@ -186,10 +186,15 @@ exactly once. There is no HTTP/HTTPS request API, DTLS, QUIC TLS, STARTTLS upgrade, guest-handle wrapping, arbitrary guest TLS configuration, live mutation of an already registered profile, external/HSM signer callback, caller clock callback, -or 0-RTT. Certificate rotation uses immutable profiles/static SNI certificates -and listener replacement; session-ticket key rotation uses an ordered bounded -key set supplied when constructing a new immutable server profile. Server -listeners and bounded inbound handshakes are available only through explicit -granular TLS registration and authority; they do not place TLS in aggregate -`register`. Certificate validation uses the immutable `ValidationTime` option -when supplied, otherwise Go's standard system clock. +or 0-RTT. Static SNI selection chooses only among certificates already cloned +into one immutable profile. Certificate rotation uses a bounded drain-and-replace +sequence: stop creating work on the old listener, allow its accepted streams to +finish, close it, and open the same endpoint with a new immutable profile. The +pinned lneto listener owns accepted-connection dispatch, so closing a listener is +an abort boundary for accepted streams that have not drained; zero-downtime +same-port listener handoff is not claimed. Session-ticket key rotation uses an +ordered bounded key set supplied when constructing a new immutable server +profile. Server listeners and bounded inbound handshakes are available only +through explicit granular TLS registration and authority; they do not place TLS +in aggregate `register`. Certificate validation uses the immutable +`ValidationTime` option when supplied, otherwise Go's standard system clock. diff --git a/internal/backend/gotls/stream_test.go b/internal/backend/gotls/stream_test.go index 6b64c24..801a706 100644 --- a/internal/backend/gotls/stream_test.go +++ b/internal/backend/gotls/stream_test.go @@ -203,6 +203,64 @@ func TestServerHandshakeALPNAndPlaintext(t *testing.T) { t.Fatal("client plaintext did not reach bounded TLS server") } +func TestServerStaticSNISelectsMatchingImmutableCertificate(t *testing.T) { + alpha, _ := testCertificate(t, "alpha.example.com") + beta, _ := testCertificate(t, "beta.example.com") + roots := x509.NewCertPool() + for _, certificate := range []cryptotls.Certificate{alpha, beta} { + parsed, err := x509.ParseCertificate(certificate.Certificate[0]) + if err != nil { + t.Fatal(err) + } + roots.AddCert(parsed) + } + clientBridge := newBridgeConn(64<<10, 64<<10, 1<<20) + client := cryptotls.Client(clientBridge, &cryptotls.Config{ + RootCAs: roots, ServerName: "beta.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() { + err := client.Handshake() + clientBridge.finishHandshake() + clientDone <- err + }() + server, err := NewServer(&memoryTransport{peer: clientBridge}, ServerProfile{ + ID: 1, + Config: &cryptotls.Config{ + Certificates: []cryptotls.Certificate{alpha, beta}, + MinVersion: cryptotls.VersionTLS13, + MaxVersion: cryptotls.VersionTLS13, + }, + MaxCertificateChainBytes: 64 << 10, + MaxPeerCertificates: 4, + }, testLimits()) + if err != nil { + t.Fatal(err) + } + defer server.Close() + for attempt := 0; attempt < 1000000; attempt++ { + progress, err := server.TryFinishConnect() + if err != nil { + t.Fatal(err) + } + if progress == nscore.ProgressDone { + break + } + runtime.Gosched() + if attempt == 999999 { + t.Fatal("SNI handshake did not complete") + } + } + if err := <-clientDone; err != nil { + t.Fatal(err) + } + state := client.ConnectionState() + if len(state.PeerCertificates) == 0 || !bytes.Equal(state.PeerCertificates[0].Raw, beta.Certificate[0]) { + t.Fatalf("SNI selected peer certificates = %d", len(state.PeerCertificates)) + } +} + func TestTransportEOFConsumesExactlyOneServiceOperation(t *testing.T) { peer := newBridgeConn(32, 32, 64) transport := &memoryTransport{peer: peer} diff --git a/internal/backend/lneto/tls/integration_test.go b/internal/backend/lneto/tls/integration_test.go index 6ddd3d1..d754f02 100644 --- a/internal/backend/lneto/tls/integration_test.go +++ b/internal/backend/lneto/tls/integration_test.go @@ -73,6 +73,46 @@ func TestLiveLnetoTLSClientServerHandshakeDataShutdownAndReuse(t *testing.T) { } } +func TestLiveLnetoTLSListenerDrainAndReplacementRotatesCertificate(t *testing.T) { + pair := newLiveTLSPair(t, false) + firstValue, progress, err := pair.server.TryListenTLS(pair.endpoint, 2) + if err != nil || progress != nscore.ProgressDone { + t.Fatalf("first listen = %T, %v, %v", firstValue, progress, err) + } + firstListener := firstValue.(*listener) + firstClient, firstServer := pair.establish(t, firstListener) + firstInfo, ok := firstClient.ConnectionInfo() + if !ok || firstInfo.PeerLeafSPKI256 == ([32]byte{}) { + t.Fatalf("first peer identity = %+v, %v", firstInfo, ok) + } + pair.exchange(t, firstClient, firstServer, []byte("old-profile-stream-drains")) + pair.cleanShutdown(t, firstClient, firstServer) + for _, closeResource := range []func() error{firstClient.Close, firstServer.Close, firstListener.Close} { + if err := closeResource(); err != nil { + t.Fatal(err) + } + } + + secondValue, progress, err := pair.server.TryListenTLS(pair.endpoint, 3) + if err != nil || progress != nscore.ProgressDone { + t.Fatalf("replacement listen = %T, %v, %v", secondValue, progress, err) + } + secondListener := secondValue.(*listener) + secondClient, secondServer := pair.establish(t, secondListener) + secondInfo, ok := secondClient.ConnectionInfo() + if !ok || secondInfo.PeerLeafSPKI256 == ([32]byte{}) || secondInfo.PeerLeafSPKI256 == firstInfo.PeerLeafSPKI256 { + t.Fatalf("replacement peer identity = %+v, %v; first=%x", secondInfo, ok, firstInfo.PeerLeafSPKI256) + } + + pair.exchange(t, secondClient, secondServer, []byte("new-profile-stream-uses-rotated-certificate")) + for _, closeResource := range []func() error{secondClient.Close, secondServer.Close, secondListener.Close} { + if err := closeResource(); err != nil { + t.Fatal(err) + } + } + pair.assertReleased(t) +} + func TestLiveLnetoTLSListenerCloseAcceptRaceReleasesOwnership(t *testing.T) { pair := newLiveTLSPair(t, false) for iteration := 0; iteration < 32; iteration++ { @@ -197,7 +237,7 @@ type liveTLSPair struct { func newLiveTLSPair(t testing.TB, mutual bool) *liveTLSPair { t.Helper() - certificate, clientCertificate, roots, now := liveTLSCertificates(t) + certificate, rotatedCertificate, clientCertificate, roots, now := liveTLSCertificates(t) clientMAC := [6]byte{0x02, 0, 0, 0, 0, 41} serverMAC := [6]byte{0x02, 0, 0, 0, 0, 42} clientAddress := netip.MustParseAddr("192.0.2.41") @@ -278,9 +318,14 @@ func newLiveTLSPair(t testing.TB, mutual bool) *liveTLSPair { MaxServerNameBytes: 253, MaxServiceAttemptsPerHandshake: 100000, TCP: tcpbackend.Config{MaxListeners: 1, MaxOutboundStreams: 2, AcceptBacklog: 2, ReceiveBytes: 8 << 10, TransmitBytes: 8 << 10, TransmitPackets: 32}, Engine: engine, - ServerProfiles: []gotls.ServerProfile{{ - ID: 2, Config: serverTLSConfig, RequiredALPN: "h2", MaxCertificateChainBytes: 64 << 10, MaxPeerCertificates: 4, - }}, + ServerProfiles: []gotls.ServerProfile{ + {ID: 2, Config: serverTLSConfig, RequiredALPN: "h2", MaxCertificateChainBytes: 64 << 10, MaxPeerCertificates: 4}, + {ID: 3, Config: &cryptotls.Config{ + Certificates: []cryptotls.Certificate{rotatedCertificate}, Time: func() time.Time { return now }, + MinVersion: cryptotls.VersionTLS13, MaxVersion: cryptotls.VersionTLS13, + NextProtos: []string{"h2"}, SessionTicketsDisabled: true, + }, RequiredALPN: "h2", MaxCertificateChainBytes: 64 << 10, MaxPeerCertificates: 4}, + }, }) if err != nil { t.Fatal(err) @@ -567,7 +612,7 @@ func readinessOf(value *stream) nscore.Readiness { return value.Readiness() } -func liveTLSCertificates(t testing.TB) (server, client cryptotls.Certificate, roots *x509.CertPool, now time.Time) { +func liveTLSCertificates(t testing.TB) (server, rotatedServer, client cryptotls.Certificate, roots *x509.CertPool, now time.Time) { t.Helper() now = time.Unix(1_800_000_000, 0) caPublic, caPrivate, err := ed25519.GenerateKey(rand.Reader) @@ -608,8 +653,9 @@ func liveTLSCertificates(t testing.TB) (server, client cryptotls.Certificate, ro return cryptotls.Certificate{Certificate: [][]byte{der, caDER}, PrivateKey: privateKey, Leaf: leaf} } server = issue(2, "server.example.com", []string{"server.example.com"}, x509.ExtKeyUsageServerAuth) + rotatedServer = issue(4, "server.example.com", []string{"server.example.com"}, x509.ExtKeyUsageServerAuth) client = issue(3, "client", nil, x509.ExtKeyUsageClientAuth) roots = x509.NewCertPool() roots.AddCert(ca) - return server, client, roots, now + return server, rotatedServer, client, roots, now } From 06b2514d59b45bbd298d39dc957e32086a925906 Mon Sep 17 00:00:00 2001 From: Wago Networking Agent Date: Sun, 26 Jul 2026 02:08:02 +0000 Subject: [PATCH 16/17] fix: retain established TLS transport state --- internal/backend/gotls/stream.go | 61 ++++++++++++++++----------- internal/backend/gotls/stream_test.go | 38 +++++++++++++---- 2 files changed, 66 insertions(+), 33 deletions(-) diff --git a/internal/backend/gotls/stream.go b/internal/backend/gotls/stream.go index 9e7837f..82f5074 100644 --- a/internal/backend/gotls/stream.go +++ b/internal/backend/gotls/stream.go @@ -51,19 +51,20 @@ type Stream struct { writeScratch []byte cipherScratch []byte - verified bool - serviceAttempts uint32 - cleanEOF bool - shutdown bool - shutdownDone bool - closed bool - terminal error - info tlsns.ConnectionInfo - channelBinding [tlsns.ChannelBindingBytes]byte - role tlsns.Role - profile Profile - serverProfile ServerProfile - identity tlsns.IdentityType + verified bool + transportConnected bool + serviceAttempts uint32 + cleanEOF bool + shutdown bool + shutdownDone bool + closed bool + terminal error + info tlsns.ConnectionInfo + channelBinding [tlsns.ChannelBindingBytes]byte + role tlsns.Role + profile Profile + serverProfile ServerProfile + identity tlsns.IdentityType } func NewClient(transport Transport, profile Profile, serverName string, identity tlsns.IdentityType, limits Limits) (*Stream, error) { @@ -332,18 +333,28 @@ func (stream *Stream) TryService(budget nscore.ServiceBudget) (nscore.ServiceRep stream.bridge.abort(terminal) return nscore.ServiceReport{}, 0, terminal } - progress, err := stream.transport.TryFinishConnect() - if err != nil { - stream.fail(err) - return nscore.ServiceReport{}, 0, err - } - if !progress.Valid() { - err := nscore.Fail(nscore.FailureIO, ErrInvalidConfig) - stream.fail(err) - return nscore.ServiceReport{}, 0, err - } - if progress != nscore.ProgressDone { - return nscore.ServiceReport{}, nscore.ProgressWouldBlock, nil + stream.mu.Lock() + transportConnected := stream.transportConnected + stream.mu.Unlock() + if !transportConnected { + progress, err := stream.transport.TryFinishConnect() + if err != nil { + stream.fail(err) + return nscore.ServiceReport{}, 0, err + } + if !progress.Valid() { + err := nscore.Fail(nscore.FailureIO, ErrInvalidConfig) + stream.fail(err) + return nscore.ServiceReport{}, 0, err + } + if progress != nscore.ProgressDone { + return nscore.ServiceReport{}, nscore.ProgressWouldBlock, nil + } + stream.mu.Lock() + if !stream.closed && stream.terminal == nil { + stream.transportConnected = true + } + stream.mu.Unlock() } var report nscore.ServiceReport diff --git a/internal/backend/gotls/stream_test.go b/internal/backend/gotls/stream_test.go index 801a706..541e8e8 100644 --- a/internal/backend/gotls/stream_test.go +++ b/internal/backend/gotls/stream_test.go @@ -41,7 +41,15 @@ func TestClientHandshakeVerificationALPNAndPlaintext(t *testing.T) { RequiredALPN: "h2", MaxCertificateChainBytes: 64 << 10, MaxPeerCertificates: 4, AllowedNames: map[string]tlsns.IdentityType{"api.example.com": tlsns.IdentityDNS}, } - transport := &memoryTransport{peer: serverBridge, readLimit: 13, writeLimit: 11} + transport := &memoryTransport{ + peer: serverBridge, readLimit: 13, writeLimit: 11, + finishErrorAfterReady: nscore.Fail(nscore.FailureConnectionRefused, net.ErrClosed), + } + t.Cleanup(func() { + if calls := transport.finishCalls.Load(); calls != 1 { + t.Errorf("transport finish-connect calls = %d, want 1", calls) + } + }) client, err := NewClient(transport, profile, "api.example.com", tlsns.IdentityDNS, testLimits()) if err != nil { t.Fatal(err) @@ -144,7 +152,16 @@ func TestServerHandshakeALPNAndPlaintext(t *testing.T) { }, RequiredALPN: "h2", MaxCertificateChainBytes: 64 << 10, MaxPeerCertificates: 4, } - server, err := NewServer(&memoryTransport{peer: clientBridge, local: local, remote: remote, readLimit: 13, writeLimit: 11}, profile, testLimits()) + transport := &memoryTransport{ + peer: clientBridge, local: local, remote: remote, readLimit: 13, writeLimit: 11, + finishErrorAfterReady: nscore.Fail(nscore.FailureConnectionRefused, net.ErrClosed), + } + t.Cleanup(func() { + if calls := transport.finishCalls.Load(); calls != 1 { + t.Errorf("transport finish-connect calls = %d, want 1", calls) + } + }) + server, err := NewServer(transport, profile, testLimits()) if err != nil { t.Fatal(err) } @@ -324,12 +341,14 @@ func testLimits() Limits { } type memoryTransport struct { - peer *bridgeConn - closed atomic.Bool - eof atomic.Bool - readLimit int - writeLimit int - local, remote nscore.Endpoint + peer *bridgeConn + closed atomic.Bool + eof atomic.Bool + finishCalls atomic.Uint32 + finishErrorAfterReady error + readLimit int + writeLimit int + local, remote nscore.Endpoint } func (transport *memoryTransport) LocalEndpoint() nscore.Endpoint { @@ -348,6 +367,9 @@ func (transport *memoryTransport) Readiness() nscore.Readiness { return nscore.ReadyConnected | nscore.ReadyReadable | nscore.ReadyWritable } func (transport *memoryTransport) TryFinishConnect() (nscore.Progress, error) { + if transport.finishCalls.Add(1) > 1 && transport.finishErrorAfterReady != nil { + return 0, transport.finishErrorAfterReady + } return nscore.ProgressDone, nil } func (transport *memoryTransport) TryRead(dst []byte) (nscore.IOResult, error) { From eddbfa672d4902a6c3e1edb20b17c925ce48e29b Mon Sep 17 00:00:00 2001 From: Wago Networking Agent Date: Sun, 26 Jul 2026 02:13:30 +0000 Subject: [PATCH 17/17] docs: describe bidirectional TLS authority --- internal/backend/gotls/stream.go | 4 ++-- net.go | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/backend/gotls/stream.go b/internal/backend/gotls/stream.go index 82f5074..c3219e0 100644 --- a/internal/backend/gotls/stream.go +++ b/internal/backend/gotls/stream.go @@ -28,8 +28,8 @@ type Transport interface { TryShutdownWrite() (nscore.Progress, error) } -// Stream owns one crypto/tls client, fixed queues, exactly three bounded worker -// goroutines, and the private transport. +// Stream owns one crypto/tls client or server connection, fixed queues, exactly +// three bounded worker goroutines, and the private transport. type Stream struct { transport Transport local nscore.Endpoint diff --git a/net.go b/net.go index 9b5abc5..5354d42 100644 --- a/net.go +++ b/net.go @@ -2,8 +2,8 @@ // suite. The guest ABI is backend-neutral; lneto is the first backend and is // not part of the public contract. Complete UDP, TCP, bounded DNS, ICMPv4 echo, // explicit-clock NTP, bounded mDNS, DHCPv4, IPv4 link-local, configured IPv6, -// bounded ICMPv6/NDP, bounded initial DHCPv6 acquisition, and granular outbound -// TLS client modules are independently capability-gated. Runtime registration requires physical +// bounded ICMPv6/NDP, bounded initial DHCPv6 acquisition, and granular TLS +// client/server modules are independently capability-gated. Runtime registration requires physical // reinstantiation between class leases so instance-owned network state cannot // survive an in-place Wasm memory reset. package net @@ -50,7 +50,7 @@ const ( ICMPv6Module = "wago_net_icmpv6" // DHCPv6Module owns the bounded initial DHCPv6 acquisition subset. DHCPv6Module = "wago_net_dhcpv6" - // TLSModule owns the outbound verified TLS client surface. + // TLSModule owns verified TLS clients and explicitly authorized server listeners. TLSModule = "wago_net_tls" // ABIVersion1 encodes ABI version 1.0 as major in the upper 16 bits and minor @@ -81,7 +81,7 @@ const ( CapICMPv6 wago.Capability = "net.icmpv6" // CapDHCPv6 permits the checked bounded initial DHCPv6 acquisition subset. CapDHCPv6 wago.Capability = "net.dhcpv6" - // CapTLS permits checked outbound verified TLS client streams. + // CapTLS permits checked verified TLS clients and host-profiled server listeners. CapTLS wago.Capability = "net.tls" )