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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions cmd/eipc-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,16 @@ func main() {
if err := codec.Unmarshal(msg.Payload, &hb); err != nil {
return nil, err
}
healthSvc.RecordHeartbeat(hb.Service, hb.Status)
log.Printf("[HEARTBEAT] service=%s status=%s", hb.Service, hb.Status)
// Record under the envelope Source, never the payload's Service
// field: the payload is sender-controlled, so trusting it would
// let any peer forge health for an arbitrary service. The
// envelope Source is bound to the authenticated peer by
// DispatchFor before this handler runs.
if hb.Service != "" && hb.Service != msg.Source {
log.Printf("[HEARTBEAT] ignoring forged service=%q from source=%s", hb.Service, msg.Source)
}
healthSvc.RecordHeartbeat(msg.Source, hb.Status)
log.Printf("[HEARTBEAT] service=%s status=%s", msg.Source, hb.Status)
return nil, nil
})

Expand Down Expand Up @@ -543,8 +551,12 @@ func handleConnection(
log.Printf("[AUDIT] failed: %v", err)
}

// Set peer capabilities on the endpoint for validation
endpoint.SetPeerCapabilities(peer.Capabilities)
// Bind the authenticated peer identity to the endpoint: every later
// message on this connection must carry this peer's Source and
// session token (Receive rejects forgeries), and only its granted
// capabilities (ValidateCapability). This also sets the peer's
// capabilities, replacing the old SetPeerCapabilities call.
endpoint.BindPeer(peer.BoundPeer())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High · Service identity is forgeable

When a client that knows the shared HMAC key requests authentication as a different known service, CreateChallenge and VerifyResponse trust the client-supplied service ID and verify the proof with the same global shared secret for every service. peer.BoundPeer() therefore binds the claimed identity and its capabilities, letting the client use capabilities granted to another service.

Authenticate each service with a distinct credential, and bind the resulting peer identity only to the service proven by that credential.

Prompt for AI agents
In `cmd/eipc-server/main.go` at line 559:

**Service identity is forgeable**

When a client that knows the shared HMAC key requests authentication as a different known service, `CreateChallenge` and `VerifyResponse` trust the client-supplied service ID and verify the proof with the same global shared secret for every service. `peer.BoundPeer()` therefore binds the claimed identity and its capabilities, letting the client use capabilities granted to another service.

Authenticate each service with a distinct credential, and bind the resulting peer identity only to the service proven by that credential.


type authResult struct {
Status string `json:"status"`
Expand Down Expand Up @@ -632,7 +644,9 @@ func handleConnection(
continue
}

resp, err := router.Dispatch(msg)
// Dispatch bound to the authenticated peer: a forged Source or
// session token never reaches a handler.
resp, err := router.DispatchFor(msg, endpoint.BoundPeer())
if err != nil {
log.Printf("[DISPATCH] error: %v", err)
errPayload, err := codec.Marshal(core.AckEvent{
Expand Down
68 changes: 66 additions & 2 deletions core/endpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ func NewClientEndpoint(conn transport.Connection, codec protocol.Codec, hmacKey
func (e *ClientEndpoint) Send(msg Message) error {
seq := e.sequence.Add(1)

// Stamp the endpoint's session token when the message carries none,
// so the server can bind the message to this authenticated session.
// An explicitly set SessionID is left untouched.
if msg.SessionID == "" {
msg.SessionID = e.sessionID
}
Comment on lines +50 to +52

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Post-auth messages lack session tokens

When a client creates its endpoint before authentication with an empty session ID and later sends a message without setting Message.SessionID, ClientEndpoint keeps its constructor sessionID and stamps that empty value onto the message. The bound server rejects it with ErrAuth, so callers must set the token on every message or rebuild the endpoint.

Provide a way to update the endpoint's session ID after authentication and use the updated value when stamping messages.

Prompt for AI agents
In `core/endpoint.go` at lines 50-52:

**Post-auth messages lack session tokens**

When a client creates its endpoint before authentication with an empty session ID and later sends a message without setting `Message.SessionID`, `ClientEndpoint` keeps its constructor `sessionID` and stamps that empty value onto the message. The bound server rejects it with `ErrAuth`, so callers must set the token on every message or rebuild the endpoint.

Provide a way to update the endpoint's session ID after authentication and use the updated value when stamping messages.


hdr := protocol.Header{
ServiceID: msg.Source,
SessionID: msg.SessionID,
Expand Down Expand Up @@ -118,6 +125,8 @@ type ServerEndpoint struct {
sendMu sync.Mutex
seq atomic.Uint64
peerCapabilities []string
peerMu sync.RWMutex
peer *AuthenticatedPeer
}

// SetPeerCapabilities sets the authenticated peer's capability list.
Expand All @@ -129,6 +138,48 @@ func (e *ServerEndpoint) SetPeerCapabilities(caps []string) {
e.peerCapabilities = cpy
}

// BindPeer binds the authenticated peer to this connection. Once bound,
// Receive rejects any message whose Source or SessionID does not match
// the peer, so a peer cannot impersonate another service on this
// connection. Binding also sets the peer's capabilities for
// ValidateCapability. Pass nil to clear the binding.
func (e *ServerEndpoint) BindPeer(peer *AuthenticatedPeer) {
e.peerMu.Lock()
if peer == nil {
e.peer = nil
} else {
cpy := *peer
caps := make([]string, len(peer.Capabilities))
copy(caps, peer.Capabilities)
cpy.Capabilities = caps
e.peer = &cpy
}
e.peerMu.Unlock()
e.SetPeerCapabilities(peerCapabilitiesOf(peer))
Comment on lines +147 to +158

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Capability checks use mismatched peer grants

When a peer is rebound while ValidateCapability runs, or two callers invoke BindPeer concurrently, BindPeer updates the peer and its capabilities under separate locks, so ValidateCapability can combine one peer's identity with another peer's grants. It rejects valid requests or makes incorrect capability decisions, and the server loop can return an error ack instead of the requested result.

Update and read the peer identity and capabilities as one synchronized snapshot.

Prompt for AI agents
In `core/endpoint.go` at lines 147-158:

**Capability checks use mismatched peer grants**

When a peer is rebound while `ValidateCapability` runs, or two callers invoke `BindPeer` concurrently, `BindPeer` updates the peer and its capabilities under separate locks, so `ValidateCapability` can combine one peer's identity with another peer's grants. It rejects valid requests or makes incorrect capability decisions, and the server loop can return an error ack instead of the requested result.

Update and read the peer identity and capabilities as one synchronized snapshot.

}

func peerCapabilitiesOf(peer *AuthenticatedPeer) []string {
if peer == nil {
return nil
}
return peer.Capabilities
}

// BoundPeer returns a copy of the peer bound to this connection, or nil
// when no peer is bound.
func (e *ServerEndpoint) BoundPeer() *AuthenticatedPeer {
e.peerMu.RLock()
defer e.peerMu.RUnlock()
if e.peer == nil {
return nil
}
cpy := *e.peer
caps := make([]string, len(e.peer.Capabilities))
copy(caps, e.peer.Capabilities)
cpy.Capabilities = caps
return &cpy
}

// ValidateCapability checks if msgCap is in the peer's granted capabilities.
func (e *ServerEndpoint) ValidateCapability(msgCap string) error {
if msgCap == "" {
Expand Down Expand Up @@ -232,7 +283,7 @@ func (e *ServerEndpoint) Receive() (Message, error) {
ts = t
}

return Message{
msg := Message{
Version: frame.Version,
Type: msgTypeFromByte(frame.MsgType),
Source: hdr.ServiceID,
Expand All @@ -242,7 +293,20 @@ func (e *ServerEndpoint) Receive() (Message, error) {
Priority: Priority(hdr.Priority),
Capability: hdr.Capability,
Payload: frame.Payload,
}, nil
}

// Identity binding (PR-EIPC-2): once a peer is bound to this
// connection, only messages genuinely from that peer are accepted.
// A forged Source or a session token from another session is
// rejected here, before dispatch. Capability is deliberately not
// checked here: the server loop enforces it with an error ack and
// keeps the connection up, while an identity forgery drops it.
// Unbound endpoints (e.g. during the auth handshake) are unaffected.
if err := e.BoundPeer().ValidateIdentity(msg); err != nil {
return Message{}, err
Comment on lines +305 to +306

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

1. Heartbeat senders lose authenticated links 🐞 Bug ≡ Correctness

HeartbeatSender.Start sends messages without a SessionID, and a client endpoint created with an
empty constructor-time token cannot stamp the token issued later by authentication. When a client
starts this sender on an authenticated connection, the first heartbeat fails the new bound identity
check and the server closes the connection.
Agent Prompt
## Issue description
The exported heartbeat sender omits the session token required by bound server endpoints. Clients that create their endpoint before authentication with an empty token cannot use it for post-authentication heartbeats.
## Fix Focus Areas
- core/lifecycle.go[80-88]
- core/endpoint.go[47-52]
- core/identity.go[47-49]
## Recommended Fix
Provide a way for the heartbeat sender to carry the issued session token in each message, without reconstructing the endpoint and resetting its sequence counter. Add a test that authenticates, starts heartbeats, and verifies a bound server accepts them.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Dismiss ↗ | View ↗

Comment on lines +305 to +306

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

2. Eni intents fail session checks 🔗 Cross-repo conflict ≡ Correctness

ServerEndpoint.Receive now requires the authenticated session token in every post-auth message,
but eNI sends intents and tool requests through C SDK functions that leave the header’s session_id
empty. If eNI reaches an authenticated Go-server connection, those messages are rejected before
dispatch; eNI’s bridge also does not currently perform the server’s challenge-response handshake, so
this needs coordinated C SDK and eNI support.
Agent Prompt
## Issue description
The newly enforced post-auth session check cannot accept messages from eNI’s C SDK bridge, which does not authenticate or put a session token in outgoing headers.
## Fix Focus Areas
- core/endpoint.go[298-306]
- sdk/c/src/eipc_client.c[29-43]
- /cross_repos/eNI/common/src/eipc_bridge.c[20-38]
## Recommended Fix
Add challenge-response and session-token storage to the eIPC C SDK, stamp the token on outgoing message headers, and update eNI’s bridge to complete authentication before forwarding. Test eNI-originated intent and tool frames against the bound Go server.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Dismiss ↗ | View ↗

}

return msg, nil
}

func (e *ServerEndpoint) Close() error {
Expand Down
174 changes: 174 additions & 0 deletions core/endpoint_identity_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 EoS Project

package core

import (
"errors"
"testing"

"github.com/embeddedos-org/eipc/protocol"
)

// TestServerEndpointBindPeer_AcceptsGenuineMessage proves a bound endpoint
// accepts a message whose Source and SessionID match the peer, including
// the client-side session stamping (ClientEndpoint stamps its session
// token when the message carries none).
func TestServerEndpointBindPeer_AcceptsGenuineMessage(t *testing.T) {
clientConn, serverConn := setupTCPPair(t)
defer clientConn.Close()
defer serverConn.Close()

hmacKey := []byte("test-secret-key-for-hmac-256bit!")
c := protocol.DefaultCodec()

clientEP := NewClientEndpoint(clientConn, c, hmacKey, "tok-abc123")
serverEP := NewServerEndpoint(serverConn, c, hmacKey)
serverEP.BindPeer(&AuthenticatedPeer{
ServiceID: "ebot.client",
SessionToken: "tok-abc123",
Capabilities: []string{"ai:chat"},
})

msgCh := make(chan Message, 1)
errCh := make(chan error, 1)
go func() {
received, err := serverEP.Receive()
if err != nil {
errCh <- err
return
}
msgCh <- received
}()

// No SessionID set on the message: the client endpoint stamps it.
msg := NewMessage(TypeChat, "ebot.client", []byte(`{"prompt":"hi"}`))
if err := clientEP.Send(msg); err != nil {
t.Fatalf("Send failed: %v", err)
}

select {
case received := <-msgCh:
if received.Source != "ebot.client" {
t.Errorf("source mismatch: got %q", received.Source)
}
if received.SessionID != "tok-abc123" {
t.Errorf("session was not stamped/bound: got %q", received.SessionID)
}
case err := <-errCh:
t.Fatalf("Receive rejected a genuine message: %v", err)
}
}

// TestServerEndpointBindPeer_RejectsForgedSource proves a bound endpoint
// drops a message that claims another service's identity, even when the
// session token is correct.
func TestServerEndpointBindPeer_RejectsForgedSource(t *testing.T) {
clientConn, serverConn := setupTCPPair(t)
defer clientConn.Close()
defer serverConn.Close()

hmacKey := []byte("test-secret-key-for-hmac-256bit!")
c := protocol.DefaultCodec()

clientEP := NewClientEndpoint(clientConn, c, hmacKey, "tok-abc123")
serverEP := NewServerEndpoint(serverConn, c, hmacKey)
serverEP.BindPeer(&AuthenticatedPeer{
ServiceID: "ebot.client",
SessionToken: "tok-abc123",
})

errCh := make(chan error, 1)
go func() {
_, err := serverEP.Receive()
errCh <- err
}()

msg := NewMessage(TypeChat, "eipc-server", []byte(`{}`))
msg.SessionID = "tok-abc123"
if err := clientEP.Send(msg); err != nil {
t.Fatalf("Send failed: %v", err)
}

if err := <-errCh; !errors.Is(err, ErrAuth) {
t.Fatalf("expected ErrAuth for forged source, got %v", err)
}
}

// TestServerEndpointBindPeer_RejectsWrongSession proves a bound endpoint
// drops a message from the right service carrying another session's token.
func TestServerEndpointBindPeer_RejectsWrongSession(t *testing.T) {
clientConn, serverConn := setupTCPPair(t)
defer clientConn.Close()
defer serverConn.Close()

hmacKey := []byte("test-secret-key-for-hmac-256bit!")
c := protocol.DefaultCodec()

clientEP := NewClientEndpoint(clientConn, c, hmacKey, "tok-other-session")
serverEP := NewServerEndpoint(serverConn, c, hmacKey)
serverEP.BindPeer(&AuthenticatedPeer{
ServiceID: "ebot.client",
SessionToken: "tok-abc123",
})

errCh := make(chan error, 1)
go func() {
_, err := serverEP.Receive()
errCh <- err
}()

msg := NewMessage(TypeChat, "ebot.client", []byte(`{}`))
msg.SessionID = "tok-other-session"
if err := clientEP.Send(msg); err != nil {
t.Fatalf("Send failed: %v", err)
}

if err := <-errCh; !errors.Is(err, ErrAuth) {
t.Fatalf("expected ErrAuth for wrong session, got %v", err)
}
}

// TestServerEndpointBindPeer_ClearsOnNil proves BindPeer(nil) removes the
// binding and restores the previous unbound behavior.
func TestServerEndpointBindPeer_ClearsOnNil(t *testing.T) {
clientConn, serverConn := setupTCPPair(t)
defer clientConn.Close()
defer serverConn.Close()

hmacKey := []byte("test-secret-key-for-hmac-256bit!")
c := protocol.DefaultCodec()

serverEP := NewServerEndpoint(serverConn, c, hmacKey)
serverEP.BindPeer(&AuthenticatedPeer{ServiceID: "ebot.client", SessionToken: "tok-abc123"})
if serverEP.BoundPeer() == nil {
t.Fatal("expected a bound peer")
}
serverEP.BindPeer(nil)
if serverEP.BoundPeer() != nil {
t.Fatal("expected the binding to be cleared")
}

msgCh := make(chan Message, 1)
errCh := make(chan error, 1)
go func() {
received, err := serverEP.Receive()
if err != nil {
errCh <- err
return
}
msgCh <- received
}()

clientEP := NewClientEndpoint(clientConn, c, hmacKey, "tok-abc123")
msg := NewMessage(TypeChat, "anyone", []byte(`{}`))
if err := clientEP.Send(msg); err != nil {
t.Fatalf("Send failed: %v", err)
}

select {
case <-msgCh:
case err := <-errCh:
t.Fatalf("unbound endpoint rejected a message: %v", err)
}
}
Comment on lines +22 to +174

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚪ Low · Secrets appear in test fixtures

The added tests commit session-token literals and hard-coded HMAC/authentication keys in fixtures, contrary to the rule against placing tokens and keys in test fixtures.

Avoid embedding token and key values in test fixtures; use non-secret test data that does not represent credentials.

Also at core/identity_test.go:14-60, services/broker/broker_identity_test.go:16-123, services/health/health_identity_test.go:16-53, security/auth/identity_binding_test.go:13-59.

Prompt for AI agents
In `core/endpoint_identity_test.go` at lines 22-174:

**Secrets appear in test fixtures**

The added tests commit session-token literals and hard-coded HMAC/authentication keys in fixtures, contrary to the rule against placing tokens and keys in test fixtures.

Avoid embedding token and key values in test fixtures; use non-secret test data that does not represent credentials.

Also at `core/identity_test.go:14-60`, `services/broker/broker_identity_test.go:16-123`, `services/health/health_identity_test.go:16-53`, `security/auth/identity_binding_test.go:13-59`.

From SECURITY-STANDARDS.md L42-44:

- Never commit a credential, token, key or connection string.
- Never log one, and never return one in an error message or stack trace.
- Never place one in a test fixture, a comment, or an example.

73 changes: 73 additions & 0 deletions core/identity.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 EoS Project

package core

import "fmt"

// AuthenticatedPeer is the core-level view of a peer that has completed
// challenge-response authentication (see security/auth.Authenticator).
// It is the identity that dispatch binds a message to: a message is only
// dispatched for the peer it claims to come from when its envelope
// (Source + SessionID) matches this peer and its Capability is granted.
//
// The type lives in core rather than security/auth so the router, broker,
// health service and endpoints can enforce the binding without an import
// cycle (security/auth already imports core).
type AuthenticatedPeer struct {
// ServiceID is the authenticated service identity (e.g. "ebot.client").
ServiceID string
// SessionToken is the session token issued at authentication time.
// Senders carry it as Message.SessionID.
SessionToken string
// Capabilities granted to this peer at authentication time.
Capabilities []string
}

// ValidateIdentity checks the envelope identity of msg against the
// authenticated peer. A nil peer means "no peer bound" (unsecured/local
// path) and passes every message; callers that require authentication
// must not pass nil.
//
// When a peer is bound the check is fail-closed:
// - msg.Source must be present and equal the peer's ServiceID, so a
// peer cannot impersonate another service by forging Source;
// - msg.SessionID must be present and equal the peer's SessionToken,
// so an envelope from another session is rejected.
func (p *AuthenticatedPeer) ValidateIdentity(msg Message) error {
if p == nil {
return nil
}
if msg.Source == "" {
return fmt.Errorf("%w: message has no source", ErrAuth)
}
if msg.Source != p.ServiceID {
return fmt.Errorf("%w: source %q is not the authenticated peer %q", ErrAuth, msg.Source, p.ServiceID)
}
if msg.SessionID == "" {
return fmt.Errorf("%w: message from %q carries no session", ErrAuth, msg.Source)
}
if msg.SessionID != p.SessionToken {
return fmt.Errorf("%w: session mismatch for %q", ErrAuth, msg.Source)
}
return nil
}

// ValidateMessage is ValidateIdentity plus capability validation: when
// msg.Capability is set it must be one of the peer's granted
// capabilities. An empty Capability passes (the message claims no
// capability-gated action).
func (p *AuthenticatedPeer) ValidateMessage(msg Message) error {
if err := p.ValidateIdentity(msg); err != nil {
return err
}
if p == nil || msg.Capability == "" {
return nil
}
for _, c := range p.Capabilities {
if c == msg.Capability {
return nil
}
}
return fmt.Errorf("%w: peer %q lacks capability %q", ErrCapability, p.ServiceID, msg.Capability)
}
Comment on lines +37 to +73

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚪ Low · New API docs omit contracts

Callers reading these new exported methods get a brief description but no parameter and return-value documentation or runnable example, leaving their public contracts incomplete.

Document each new exported method's parameters and return value and add a runnable example.

Also at core/endpoint.go:146-170, core/router.go:56-70, services/broker/broker.go:129-155, services/health/health.go:66, security/auth/identity.go:175.

Prompt for AI agents
In `core/identity.go` at lines 37-73:

**New API docs omit contracts**

Callers reading these new exported methods get a brief description but no parameter and return-value documentation or runnable example, leaving their public contracts incomplete.

Document each new exported method's parameters and return value and add a runnable example.

Also at `core/endpoint.go:146-170`, `core/router.go:56-70`, `services/broker/broker.go:129-155`, `services/health/health.go:66`, `security/auth/identity.go:175`.

From QUALITY.md L28-29:

Every public function carries a description, its parameters, its return value,
and an example. The example must be one that was actually run.

Loading
Loading