From 357272737aebb391e2b711a5f5c52906fa0f6472 Mon Sep 17 00:00:00 2001 From: aswin Date: Thu, 12 Nov 2026 11:00:10 -0800 Subject: [PATCH 1/3] auth: gate legacy no-secret Authenticate, add challenge expiry - Authenticate() is now refused by default: issuing a session on a service ID alone, with no proof of the shared secret, let anyone who knew a service ID impersonate it. It stays available only as an explicit per-authenticator opt-in (SetAllowLegacyAuth) for migration. - Challenges now expire: VerifyResponse rejects a challenge older than the challenge TTL (default 5m, SetChallengeTTL), reusing the existing Challenge.CreatedAt. Expired challenges are still consumed one-shot. - Tests move off the legacy path onto challenge-response (shared challengeAuth helper) and prove: legacy refused by default, legacy works when opted in, expired challenge rejected and consumed, in-TTL challenge accepted. Refs: foundation-eipc PR-EIPC-4 (S5) --- security/auth/identity.go | 47 +++++++- security/auth/identity_binding_test.go | 8 +- security/auth/identity_test.go | 149 ++++++++++++++++++++----- tests/auth_helper_test.go | 30 +++++ tests/integration_test.go | 5 +- tests/security_test.go | 31 +++-- 6 files changed, 221 insertions(+), 49 deletions(-) create mode 100644 tests/auth_helper_test.go diff --git a/security/auth/identity.go b/security/auth/identity.go index d81c630..abd3355 100644 --- a/security/auth/identity.go +++ b/security/auth/identity.go @@ -47,8 +47,15 @@ type Authenticator struct { activeSessions map[string]*PeerIdentity pendingChallenges map[string]*Challenge // serviceID → pending challenge sessionTTL time.Duration + challengeTTL time.Duration + allowLegacyAuth bool } +// DefaultChallengeTTL bounds how long a challenge-response round trip may +// take. A challenge older than this is rejected, so a captured nonce +// cannot be answered later. +const DefaultChallengeTTL = 5 * time.Minute + // NewAuthenticator creates an authenticator with the given shared secret. // knownServices maps service IDs to their allowed capability sets. func NewAuthenticator(sharedSecret []byte, knownServices map[string][]string) *Authenticator { @@ -64,6 +71,7 @@ func NewAuthenticator(sharedSecret []byte, knownServices map[string][]string) *A activeSessions: make(map[string]*PeerIdentity), pendingChallenges: make(map[string]*Challenge), sessionTTL: 1 * time.Hour, + challengeTTL: DefaultChallengeTTL, } } @@ -74,6 +82,25 @@ func (a *Authenticator) SetSessionTTL(ttl time.Duration) { a.sessionTTL = ttl } +// SetChallengeTTL configures how long a challenge stays valid for +// VerifyResponse. A zero or negative TTL disables expiry checking; the +// default is DefaultChallengeTTL. +func (a *Authenticator) SetChallengeTTL(ttl time.Duration) { + a.mu.Lock() + defer a.mu.Unlock() + a.challengeTTL = ttl +} + +// SetAllowLegacyAuth opts in to the legacy no-secret Authenticate path. +// It is off by default: Authenticate issues a session token on a service +// ID alone, with no proof of the shared secret. Enable it only while +// migrating an existing consumer to CreateChallenge/VerifyResponse. +func (a *Authenticator) SetAllowLegacyAuth(enabled bool) { + a.mu.Lock() + defer a.mu.Unlock() + a.allowLegacyAuth = enabled +} + // CreateChallenge generates a 32-byte nonce challenge for the given service ID. // Returns error if the service is unknown. func (a *Authenticator) CreateChallenge(serviceID string) (*Challenge, error) { @@ -110,6 +137,13 @@ func (a *Authenticator) VerifyResponse(serviceID string, response []byte) (*Peer } delete(a.pendingChallenges, serviceID) + // Challenges are one-shot (deleted above before verifying) and + // time-bound: an old challenge is rejected even with a correct + // response, so a captured nonce cannot be answered later. + if a.challengeTTL > 0 && time.Since(challenge.CreatedAt) > a.challengeTTL { + return nil, fmt.Errorf("%w: challenge expired for %q", core.ErrAuth, serviceID) + } + // Compute expected response: HMAC-SHA256(sharedSecret, nonce) mac := hmac.New(sha256.New, a.sharedSecret) mac.Write(challenge.Nonce) @@ -140,11 +174,22 @@ func (a *Authenticator) VerifyResponse(serviceID string, response []byte) (*Peer } // Authenticate validates a peer's service ID and returns a PeerIdentity -// with a fresh session token. (Legacy simple auth, kept for backward compat.) +// with a fresh session token. +// +// Legacy no-secret auth, kept only for migration: it issues a session on +// a service ID alone, with no proof of the shared secret, so anyone who +// knows (or guesses) a service ID can impersonate it. It is DISABLED by +// default and must be opted into per authenticator with +// SetAllowLegacyAuth(true). New consumers must use +// CreateChallenge/VerifyResponse. func (a *Authenticator) Authenticate(serviceID string) (*PeerIdentity, error) { a.mu.Lock() defer a.mu.Unlock() + if !a.allowLegacyAuth { + return nil, fmt.Errorf("%w: legacy no-secret authentication is disabled for %q; use CreateChallenge/VerifyResponse", core.ErrAuth, serviceID) + } + capsSrc, ok := a.knownServices[serviceID] if !ok { return nil, fmt.Errorf("%w: unknown service %q", core.ErrAuth, serviceID) diff --git a/security/auth/identity_binding_test.go b/security/auth/identity_binding_test.go index 1eab648..9a87a3e 100644 --- a/security/auth/identity_binding_test.go +++ b/security/auth/identity_binding_test.go @@ -10,14 +10,12 @@ import ( ) func TestPeerIdentityBoundPeer_RoundTrip(t *testing.T) { - authenticator := NewAuthenticator([]byte("test-key-32-bytes-long-enough!!"), map[string][]string{ + secret := []byte("test-key-32-bytes-long-enough!!") + authenticator := NewAuthenticator(secret, map[string][]string{ "ebot.client": {"ai:chat"}, }) - peer, err := authenticator.Authenticate("ebot.client") - if err != nil { - t.Fatalf("Authenticate: %v", err) - } + peer := challengeAuth(t, authenticator, secret, "ebot.client") bound := peer.BoundPeer() if bound == nil { diff --git a/security/auth/identity_test.go b/security/auth/identity_test.go index 4c3ac53..4d2b761 100644 --- a/security/auth/identity_test.go +++ b/security/auth/identity_test.go @@ -4,21 +4,41 @@ package auth import ( + "crypto/hmac" + "crypto/sha256" + "strings" "testing" + "time" ) -func TestAuthenticateKnownService(t *testing.T) { +// challengeAuth issues a session through the challenge-response flow, the +// way a real peer authenticates. Tests that need a session use this; the +// legacy Authenticate path is exercised only by the opt-in tests below. +func challengeAuth(t *testing.T, a *Authenticator, secret []byte, serviceID string) *PeerIdentity { + t.Helper() + challenge, err := a.CreateChallenge(serviceID) + if err != nil { + t.Fatalf("CreateChallenge: %v", err) + } + mac := hmac.New(sha256.New, secret) + mac.Write(challenge.Nonce) + peer, err := a.VerifyResponse(serviceID, mac.Sum(nil)) + if err != nil { + t.Fatalf("VerifyResponse: %v", err) + } + return peer +} + +func TestChallengeAuthKnownService(t *testing.T) { + secret := []byte("shared-secret") knownServices := map[string][]string{ "service-a": {"cap:read", "cap:write"}, "service-b": {"cap:read"}, } - a := NewAuthenticator([]byte("shared-secret"), knownServices) + a := NewAuthenticator(secret, knownServices) - identity, err := a.Authenticate("service-a") - if err != nil { - t.Fatalf("Authenticate failed: %v", err) - } + identity := challengeAuth(t, a, secret, "service-a") if identity == nil { t.Fatal("expected identity, got nil") } @@ -30,31 +50,112 @@ func TestAuthenticateKnownService(t *testing.T) { } } -func TestAuthenticateUnknownService(t *testing.T) { +func TestChallengeAuthUnknownService(t *testing.T) { knownServices := map[string][]string{ "service-a": {"cap:read"}, } a := NewAuthenticator([]byte("shared-secret"), knownServices) - _, err := a.Authenticate("unknown-service") + _, err := a.CreateChallenge("unknown-service") if err == nil { t.Fatal("expected error for unknown service") } } -func TestValidateSession(t *testing.T) { +func TestLegacyAuthenticateDisabledByDefault(t *testing.T) { knownServices := map[string][]string{ - "service-a": {"cap:read", "cap:write"}, + "service-a": {"cap:read"}, } a := NewAuthenticator([]byte("shared-secret"), knownServices) + _, err := a.Authenticate("service-a") + if err == nil { + t.Fatal("expected legacy Authenticate to be refused by default") + } + if !strings.Contains(err.Error(), "disabled") { + t.Errorf("expected a 'disabled' error, got: %v", err) + } +} + +func TestLegacyAuthenticateOptIn(t *testing.T) { + knownServices := map[string][]string{ + "service-a": {"cap:read"}, + } + + a := NewAuthenticator([]byte("shared-secret"), knownServices) + a.SetAllowLegacyAuth(true) + identity, err := a.Authenticate("service-a") if err != nil { - t.Fatalf("Authenticate failed: %v", err) + t.Fatalf("opted-in Authenticate failed: %v", err) + } + if identity.ServiceID != "service-a" { + t.Errorf("expected service-a, got %q", identity.ServiceID) + } + + _, err = a.Authenticate("unknown-service") + if err == nil { + t.Fatal("expected error for unknown service even with legacy auth on") + } +} + +func TestVerifyResponseExpiredChallenge(t *testing.T) { + secret := []byte("shared-secret") + a := NewAuthenticator(secret, map[string][]string{ + "service-a": {"cap:read"}, + }) + a.SetChallengeTTL(1 * time.Millisecond) + + challenge, err := a.CreateChallenge("service-a") + if err != nil { + t.Fatalf("CreateChallenge: %v", err) } + time.Sleep(5 * time.Millisecond) + + mac := hmac.New(sha256.New, secret) + mac.Write(challenge.Nonce) + _, err = a.VerifyResponse("service-a", mac.Sum(nil)) + if err == nil { + t.Fatal("expected an expired challenge to be rejected") + } + if !strings.Contains(err.Error(), "expired") { + t.Errorf("expected an 'expired' error, got: %v", err) + } + + // The expired challenge was consumed (one-shot): a second attempt + // finds no pending challenge at all. + _, err = a.VerifyResponse("service-a", mac.Sum(nil)) + if err == nil || !strings.Contains(err.Error(), "no pending challenge") { + t.Errorf("expected 'no pending challenge' after expiry, got: %v", err) + } +} + +func TestVerifyResponseChallengeWithinTTL(t *testing.T) { + secret := []byte("shared-secret") + a := NewAuthenticator(secret, map[string][]string{ + "service-a": {"cap:read"}, + }) + a.SetChallengeTTL(1 * time.Minute) + + peer := challengeAuth(t, a, secret, "service-a") + if peer.ServiceID != "service-a" { + t.Errorf("expected service-a, got %q", peer.ServiceID) + } +} + +func TestValidateSession(t *testing.T) { + secret := []byte("shared-secret") + knownServices := map[string][]string{ + "service-a": {"cap:read", "cap:write"}, + } + + a := NewAuthenticator(secret, knownServices) + + identity := challengeAuth(t, a, secret, "service-a") + validated, err := a.ValidateSession(identity.SessionToken) if err != nil { t.Fatalf("ValidateSession failed: %v", err) @@ -81,42 +182,34 @@ func TestValidateSessionInvalidToken(t *testing.T) { } func TestRevokeSession(t *testing.T) { + secret := []byte("shared-secret") knownServices := map[string][]string{ "service-a": {"cap:read"}, } - a := NewAuthenticator([]byte("shared-secret"), knownServices) + a := NewAuthenticator(secret, knownServices) - identity, err := a.Authenticate("service-a") - if err != nil { - t.Fatalf("Authenticate failed: %v", err) - } + identity := challengeAuth(t, a, secret, "service-a") a.RevokeSession(identity.SessionToken) - _, err = a.ValidateSession(identity.SessionToken) + _, err := a.ValidateSession(identity.SessionToken) if err == nil { t.Fatal("expected error after revoking session") } } func TestMultipleSessions(t *testing.T) { + secret := []byte("shared-secret") knownServices := map[string][]string{ "service-a": {"cap:read"}, "service-b": {"cap:write"}, } - a := NewAuthenticator([]byte("shared-secret"), knownServices) - - idA, err := a.Authenticate("service-a") - if err != nil { - t.Fatalf("Authenticate service-a failed: %v", err) - } + a := NewAuthenticator(secret, knownServices) - idB, err := a.Authenticate("service-b") - if err != nil { - t.Fatalf("Authenticate service-b failed: %v", err) - } + idA := challengeAuth(t, a, secret, "service-a") + idB := challengeAuth(t, a, secret, "service-b") if idA.SessionToken == idB.SessionToken { t.Error("expected different tokens for different services") @@ -124,7 +217,7 @@ func TestMultipleSessions(t *testing.T) { a.RevokeSession(idA.SessionToken) - _, err = a.ValidateSession(idA.SessionToken) + _, err := a.ValidateSession(idA.SessionToken) if err == nil { t.Error("expected error for revoked token A") } diff --git a/tests/auth_helper_test.go b/tests/auth_helper_test.go new file mode 100644 index 0000000..df40419 --- /dev/null +++ b/tests/auth_helper_test.go @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 EoS Project + +package tests + +import ( + "crypto/hmac" + "crypto/sha256" + "testing" + + "github.com/embeddedos-org/eipc/security/auth" +) + +// challengeAuth issues a session through the challenge-response flow, the +// way a real peer authenticates. The legacy no-secret Authenticate path is +// disabled by default and must not be used to obtain test sessions. +func challengeAuth(t *testing.T, a *auth.Authenticator, secret []byte, serviceID string) *auth.PeerIdentity { + t.Helper() + challenge, err := a.CreateChallenge(serviceID) + if err != nil { + t.Fatalf("CreateChallenge: %v", err) + } + mac := hmac.New(sha256.New, secret) + mac.Write(challenge.Nonce) + peer, err := a.VerifyResponse(serviceID, mac.Sum(nil)) + if err != nil { + t.Fatalf("VerifyResponse: %v", err) + } + return peer +} diff --git a/tests/integration_test.go b/tests/integration_test.go index 8d2e9cc..b7e9308 100644 --- a/tests/integration_test.go +++ b/tests/integration_test.go @@ -117,10 +117,7 @@ func TestSessionTTL(t *testing.T) { }) authenticator.SetSessionTTL(100 * time.Millisecond) - peer, err := authenticator.Authenticate("test.client") - if err != nil { - t.Fatalf("Authenticate: %v", err) - } + peer := challengeAuth(t, authenticator, []byte(testSecret), "test.client") if peer.IsExpired() { t.Error("peer should not be expired immediately") diff --git a/tests/security_test.go b/tests/security_test.go index 0573861..12ada79 100644 --- a/tests/security_test.go +++ b/tests/security_test.go @@ -41,14 +41,12 @@ func TestWrongSecret_Rejection(t *testing.T) { } func TestCapabilityViolation(t *testing.T) { - authenticator := auth.NewAuthenticator([]byte("test-key-32-bytes-long-enough!!"), map[string][]string{ + secret := []byte("test-key-32-bytes-long-enough!!") + authenticator := auth.NewAuthenticator(secret, map[string][]string{ "ebot.client": {"ai:chat"}, }) - peer, err := authenticator.Authenticate("ebot.client") - if err != nil { - t.Fatalf("Authenticate: %v", err) - } + peer := challengeAuth(t, authenticator, secret, "ebot.client") hasUIControl := false for _, cap := range peer.Capabilities { @@ -98,17 +96,15 @@ func TestConnectionLimitEnforcement(t *testing.T) { } func TestSessionTTLExpiry(t *testing.T) { - authenticator := auth.NewAuthenticator([]byte("test-secret-key-32-bytes-long!!"), map[string][]string{ + secret := []byte("test-secret-key-32-bytes-long!!") + authenticator := auth.NewAuthenticator(secret, map[string][]string{ "test.client": {"ui:control"}, }) authenticator.SetSessionTTL(50 * time.Millisecond) - peer, err := authenticator.Authenticate("test.client") - if err != nil { - t.Fatalf("Authenticate: %v", err) - } + peer := challengeAuth(t, authenticator, secret, "test.client") - _, err = authenticator.ValidateSession(peer.SessionToken) + _, err := authenticator.ValidateSession(peer.SessionToken) if err != nil { t.Fatalf("session should be valid immediately: %v", err) } @@ -121,6 +117,19 @@ func TestSessionTTLExpiry(t *testing.T) { } } +func TestLegacyAuthenticateRefusedByDefault(t *testing.T) { + authenticator := auth.NewAuthenticator([]byte("test-secret-key-32-bytes-long!!"), map[string][]string{ + "test.client": {"ui:control"}, + }) + + // The legacy no-secret path is disabled by default: a service ID + // alone must never yield a session. + _, err := authenticator.Authenticate("test.client") + if err == nil { + t.Fatal("expected legacy Authenticate to be refused by default") + } +} + func TestKeyExternalization_MissingKey(t *testing.T) { // config.LoadHMACKey() requires EIPC_HMAC_KEY or EIPC_KEY_FILE // When neither is set, it returns an error From 3672da55aee0184165e313ae4426fdf2d3afc6fe Mon Sep 17 00:00:00 2001 From: aswin Date: Thu, 12 Nov 2026 11:01:01 -0800 Subject: [PATCH 2/3] transport, core: add post-auth read deadlines - transport.Connection gains SetReadDeadline: ConnWrapper delegates to the underlying net.Conn (covers TCP, Unix and the Windows TCP shim), while the in-process SHM connection accepts and ignores it. - ServerEndpoint.SetReadDeadline passes the deadline through, so the server can bound how long an authenticated peer may stay silent. - Tests prove the deadline is enforced: a live deadline still delivers a frame, an expired deadline fails Receive fast with a timeout error (transport-level over net.Pipe and endpoint-level over a TCP pair). Refs: foundation-eipc PR-EIPC-4 (S7) --- core/endpoint.go | 8 +++++ core/endpoint_deadline_test.go | 40 +++++++++++++++++++++ transport/conn_wrapper_test.go | 63 ++++++++++++++++++++++++++++++++++ transport/shm/shm.go | 8 +++++ transport/transport.go | 13 +++++++ 5 files changed, 132 insertions(+) create mode 100644 core/endpoint_deadline_test.go create mode 100644 transport/conn_wrapper_test.go diff --git a/core/endpoint.go b/core/endpoint.go index 529ac13..74fee87 100644 --- a/core/endpoint.go +++ b/core/endpoint.go @@ -313,6 +313,14 @@ func (e *ServerEndpoint) Close() error { return e.conn.Close() } +// SetReadDeadline arms a read deadline on the underlying connection: the +// next Receive fails once the deadline passes. The server's post-auth +// loop re-arms it before every Receive, so an idle authenticated peer is +// dropped instead of holding its connection slot indefinitely. +func (e *ServerEndpoint) SetReadDeadline(deadline time.Time) error { + return e.conn.SetReadDeadline(deadline) +} + // RemoteAddr returns the remote address of the underlying connection. func (e *ServerEndpoint) RemoteAddr() string { return e.conn.RemoteAddr() diff --git a/core/endpoint_deadline_test.go b/core/endpoint_deadline_test.go new file mode 100644 index 0000000..c05aaad --- /dev/null +++ b/core/endpoint_deadline_test.go @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 EoS Project + +package core + +import ( + "errors" + "net" + "testing" + "time" + + "github.com/embeddedos-org/eipc/protocol" +) + +func TestServerEndpointSetReadDeadline(t *testing.T) { + clientConn, serverConn := setupTCPPair(t) + defer clientConn.Close() + defer serverConn.Close() + + serverEP := NewServerEndpoint(serverConn, protocol.DefaultCodec(), []byte("test-secret-key-for-hmac-256bit!")) + + // A deadline already in the past: Receive must fail fast with a + // timeout instead of blocking until the peer speaks, so an idle + // authenticated peer cannot hold its connection slot forever. + if err := serverEP.SetReadDeadline(time.Now().Add(-time.Second)); err != nil { + t.Fatalf("SetReadDeadline: %v", err) + } + start := time.Now() + _, err := serverEP.Receive() + if err == nil { + t.Fatal("expected Receive to fail past its read deadline") + } + if time.Since(start) > 5*time.Second { + t.Errorf("Receive blocked %v past an expired deadline", time.Since(start)) + } + var ne net.Error + if !errors.As(err, &ne) || !ne.Timeout() { + t.Errorf("expected a timeout error, got %T: %v", err, err) + } +} diff --git a/transport/conn_wrapper_test.go b/transport/conn_wrapper_test.go new file mode 100644 index 0000000..6a4a679 --- /dev/null +++ b/transport/conn_wrapper_test.go @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 EoS Project + +package transport + +import ( + "errors" + "net" + "testing" + "time" + + "github.com/embeddedos-org/eipc/protocol" +) + +func TestConnWrapperSetReadDeadline(t *testing.T) { + server, client := net.Pipe() + defer server.Close() + defer client.Close() + + serverWrapper := NewConnWrapper(server) + clientWrapper := NewConnWrapper(client) + + // A live deadline still lets a frame through. + if err := serverWrapper.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil { + t.Fatalf("SetReadDeadline: %v", err) + } + sent := &protocol.Frame{ + Version: protocol.ProtocolVersion, + MsgType: 'a', + Header: []byte("{}"), + Payload: []byte("hello"), + } + errCh := make(chan error, 1) + go func() { errCh <- clientWrapper.Send(sent) }() + recv, err := serverWrapper.Receive() + if err != nil { + t.Fatalf("Receive with live deadline: %v", err) + } + if string(recv.Payload) != "hello" { + t.Errorf("expected payload %q, got %q", "hello", recv.Payload) + } + if err := <-errCh; err != nil { + t.Fatalf("Send: %v", err) + } + + // A deadline already in the past: Receive must fail fast with a + // timeout instead of blocking until the peer speaks. + if err := serverWrapper.SetReadDeadline(time.Now().Add(-time.Second)); err != nil { + t.Fatalf("SetReadDeadline: %v", err) + } + start := time.Now() + _, err = serverWrapper.Receive() + if err == nil { + t.Fatal("expected Receive to fail past its read deadline") + } + if time.Since(start) > 5*time.Second { + t.Errorf("Receive blocked %v past an expired deadline", time.Since(start)) + } + var ne net.Error + if !errors.As(err, &ne) || !ne.Timeout() { + t.Errorf("expected a timeout error, got %T: %v", err, err) + } +} diff --git a/transport/shm/shm.go b/transport/shm/shm.go index e3022e0..7ffcd2e 100644 --- a/transport/shm/shm.go +++ b/transport/shm/shm.go @@ -8,6 +8,7 @@ import ( "runtime" "sync" "sync/atomic" + "time" "github.com/embeddedos-org/eipc/protocol" "github.com/embeddedos-org/eipc/transport" @@ -200,6 +201,13 @@ func (c *Connection) Close() error { return nil } +// SetReadDeadline is a no-op: the ring buffer is in-process memory with +// no socket to arm a deadline on. It exists so *Connection satisfies the +// transport.Connection interface. +func (c *Connection) SetReadDeadline(deadline time.Time) error { + return nil +} + func (c *Connection) RemoteAddr() string { return c.remote } diff --git a/transport/transport.go b/transport/transport.go index 9cc5312..c2d2d4b 100644 --- a/transport/transport.go +++ b/transport/transport.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "net" + "time" "github.com/embeddedos-org/eipc/protocol" ) @@ -32,6 +33,11 @@ type Connection interface { Receive() (*protocol.Frame, error) // RemoteAddr returns the remote address of the connection. RemoteAddr() string + // SetReadDeadline sets an absolute deadline for the next Receive; + // a receive running past it fails with a timeout error. A zero time + // clears the deadline. Transports without a socket (in-process + // shared memory) accept the call and ignore it. + SetReadDeadline(deadline time.Time) error // Close shuts down the connection. Close() error } @@ -94,6 +100,13 @@ func (c *ConnWrapper) RemoteAddr() string { return c.conn.RemoteAddr().String() } +// SetReadDeadline sets the read deadline on the underlying connection. +// A receive that runs past the deadline fails, so an idle or stalled +// peer cannot hold a server connection slot forever. +func (c *ConnWrapper) SetReadDeadline(deadline time.Time) error { + return c.conn.SetReadDeadline(deadline) +} + // Close closes the underlying connection. func (c *ConnWrapper) Close() error { return c.conn.Close() From fc04219ce294490dfe21e19e2f320a596bcb0712 Mon Sep 17 00:00:00 2001 From: aswin Date: Thu, 12 Nov 2026 11:03:57 -0800 Subject: [PATCH 3/3] cmd: stop logging session-token material; enforce post-auth idle timeout - The server ACCEPTED line and the client "Authenticated!" lines printed session-token material into the log: the first and last eight hex characters (server, client long-token branch) and the whole token (client short-token branch). Both now log only the service and its granted capabilities, never any part of the token. - The server post-auth message loop re-arms a 5-minute read deadline before every Receive (via the new ServerEndpoint.SetReadDeadline), so an authenticated peer that goes silent is dropped instead of holding a connection slot under the cap indefinitely. This also makes true the loop's existing "idle timeout" comment. Refs: foundation-eipc PR-EIPC-4 (S6+S7) --- cmd/eipc-client/main.go | 12 ++++++------ cmd/eipc-server/main.go | 22 +++++++++++++++++++--- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/cmd/eipc-client/main.go b/cmd/eipc-client/main.go index 88bda9f..0cd6d66 100644 --- a/cmd/eipc-client/main.go +++ b/cmd/eipc-client/main.go @@ -158,12 +158,12 @@ func main() { } sessionToken := authRes.SessionToken - if len(sessionToken) >= 16 { - log.Printf("[3] Authenticated! token=%s...%s caps=%v", - sessionToken[:8], sessionToken[len(sessionToken)-8:], authRes.Capabilities) - } else { - log.Printf("[3] Authenticated! token=%s caps=%v", sessionToken, authRes.Capabilities) - } + // The session token is a credential: log only that auth succeeded + // and which capabilities were granted, never any part of the token + // itself. The old lines printed the token's first and last eight + // hex characters (long-token branch) and the whole token + // (short-token branch) into the log. + log.Printf("[3] Authenticated! caps=%v", authRes.Capabilities) // Step 5: Send HMAC-protected intent log.Println("[4] Sending intent: move_left (confidence=0.91)") diff --git a/cmd/eipc-server/main.go b/cmd/eipc-server/main.go index 73edddf..ee13010 100644 --- a/cmd/eipc-server/main.go +++ b/cmd/eipc-server/main.go @@ -24,6 +24,11 @@ import ( "github.com/embeddedos-org/eipc/transport/tcp" ) +// postAuthIdleTimeout bounds how long an authenticated peer may stay +// silent before the server drops its connection: without it, idle +// connections hold a slot under the connection cap indefinitely. +const postAuthIdleTimeout = 5 * time.Minute + func main() { addr := config.LoadListenAddr() if len(os.Args) > 1 { @@ -537,8 +542,11 @@ func handleConnection( close(authDone) // Auth completed successfully - log.Printf("[AUTH] ACCEPTED: service=%s token=%s...%s caps=%v", - peer.ServiceID, peer.SessionToken[:8], peer.SessionToken[len(peer.SessionToken)-8:], peer.Capabilities) + // The session token is a credential: log only that auth succeeded + // and which capabilities were granted, never any part of the token + // itself. The old line printed the token's first and last eight + // hex characters into the log. + log.Printf("[AUTH] ACCEPTED: service=%s caps=%v", peer.ServiceID, peer.Capabilities) if err := auditLogger.Log(audit.Entry{ RequestID: authMsg.RequestID, @@ -584,8 +592,16 @@ func handleConnection( return } - // Message loop with idle timeout and capability enforcement + // Message loop with idle timeout and capability enforcement: the + // read deadline is re-armed before every Receive, so an + // authenticated peer that goes silent is dropped after + // postAuthIdleTimeout instead of holding its connection slot + // indefinitely. for { + if err := endpoint.SetReadDeadline(time.Now().Add(postAuthIdleTimeout)); err != nil { + log.Printf("[CONN] failed to arm read deadline: %v", err) + return + } msg, err := endpoint.Receive() if err != nil { log.Printf("[CONN] connection closed: %v", err)