fix(engine): enforce strict HMAC response signature verification per §4.4

Remove the lenient fallback that accepted bot responses missing the
X-ACB-Signature header. Missing or invalid signatures now cause the
response to be discarded and count toward the crash threshold (§4.5).
Add tests for missing-header, bad-signature, and crash-after-10 cases.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-22 13:18:10 -04:00
parent 80733f673e
commit 5443e4d0ed
2 changed files with 135 additions and 8 deletions

View file

@ -149,16 +149,15 @@ func (b *HTTPBot) GetMoves(state *VisibleState) ([]Move, error) {
return nil, fmt.Errorf("failed to read response: %w", err)
}
// Verify response signature
// Verify response signature (strict — per §4.4)
responseSig := resp.Header.Get("X-ACB-Signature")
if responseSig == "" {
// Missing signature - accept anyway for now (will be strict in production)
// In production, this would be: b.recordFailure(); return nil, fmt.Errorf("missing response signature")
} else {
if err := VerifyResponse(b.auth.Secret, b.matchID, b.turn, responseSig, responseBody); err != nil {
b.recordFailure()
return nil, fmt.Errorf("response signature verification failed: %w", err)
}
b.recordFailure()
return nil, fmt.Errorf("missing response signature")
}
if err := VerifyResponse(b.auth.Secret, b.matchID, b.turn, responseSig, responseBody); err != nil {
b.recordFailure()
return nil, fmt.Errorf("response signature verification failed: %w", err)
}
// Parse response

View file

@ -236,6 +236,134 @@ func TestHTTPBot_ValidateMoves(t *testing.T) {
}
}
func TestHTTPBot_MissingSignature(t *testing.T) {
// Server that returns valid moves but omits X-ACB-Signature header
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resp := MoveResponse{Moves: []Move{
{Position: Position{Row: 5, Col: 5}, Direction: DirN},
}}
body, _ := json.Marshal(resp)
w.Header().Set("Content-Type", "application/json")
// Intentionally no X-ACB-Signature header
w.Write(body)
}))
defer server.Close()
auth := AuthConfig{
BotID: "b_test",
Secret: "test-secret",
MatchID: "m_test",
}
bot := NewHTTPBot(server.URL, auth)
state := &VisibleState{
MatchID: "m_test",
Turn: 1,
Config: DefaultConfig(),
You: struct {
ID int `json:"id"`
Energy int `json:"energy"`
Score int `json:"score"`
}{ID: 0},
Bots: []VisibleBot{
{Position: Position{Row: 5, Col: 5}, Owner: 0},
},
}
_, err := bot.GetMoves(state)
if err == nil {
t.Fatal("expected error for missing signature, got nil")
}
if bot.failCount != 1 {
t.Errorf("failCount = %d, want 1", bot.failCount)
}
}
func TestHTTPBot_BadSignature(t *testing.T) {
// Server that returns moves with a wrong-key signature
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var state VisibleState
json.NewDecoder(r.Body).Decode(&state)
resp := MoveResponse{Moves: []Move{
{Position: Position{Row: 5, Col: 5}, Direction: DirN},
}}
body, _ := json.Marshal(resp)
// Sign with wrong secret
sig := SignResponse("wrong-secret", state.MatchID, state.Turn, body)
w.Header().Set("X-ACB-Signature", sig)
w.Header().Set("Content-Type", "application/json")
w.Write(body)
}))
defer server.Close()
auth := AuthConfig{
BotID: "b_test",
Secret: "test-secret",
MatchID: "m_test",
}
bot := NewHTTPBot(server.URL, auth)
state := &VisibleState{
MatchID: "m_test",
Turn: 1,
Config: DefaultConfig(),
You: struct {
ID int `json:"id"`
Energy int `json:"energy"`
Score int `json:"score"`
}{ID: 0},
Bots: []VisibleBot{
{Position: Position{Row: 5, Col: 5}, Owner: 0},
},
}
_, err := bot.GetMoves(state)
if err == nil {
t.Fatal("expected error for bad signature, got nil")
}
if bot.failCount != 1 {
t.Errorf("failCount = %d, want 1", bot.failCount)
}
}
func TestHTTPBot_BadSignatureCrashes(t *testing.T) {
// Verify that 10 consecutive bad-signature responses crashes the bot
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var state VisibleState
json.NewDecoder(r.Body).Decode(&state)
resp := MoveResponse{Moves: []Move{}}
body, _ := json.Marshal(resp)
sig := SignResponse("wrong-secret", state.MatchID, state.Turn, body)
w.Header().Set("X-ACB-Signature", sig)
w.Write(body)
}))
defer server.Close()
auth := AuthConfig{
BotID: "b_test",
Secret: "test-secret",
MatchID: "m_test",
}
bot := NewHTTPBot(server.URL, auth)
state := &VisibleState{
MatchID: "m_test",
Turn: 1,
Config: DefaultConfig(),
}
for i := 0; i < 10; i++ {
bot.GetMoves(state)
}
if !bot.IsCrashed() {
t.Error("bot should be crashed after 10 consecutive bad-signature failures")
}
}
func TestHTTPBot_Health(t *testing.T) {
// Create a server with health endpoint
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {