fix: resolve universal stalemate — signing format and secret decryption

Two root causes prevented bots from making any moves:

1. SignRequest signing string included timestamp ({match_id}.{turn}.{timestamp}.{hash})
   but all bots implement verifySignature without timestamp ({match_id}.{turn}.{hash}).
   Fixed by dropping timestamp from the signing string; X-ACB-Timestamp header is still
   sent for clock-skew checks but not in the HMAC.

2. The API stores bot secrets AES-GCM encrypted (184 hex chars) in the DB. The worker
   was passing the ciphertext directly as the HMAC key, while bots use their plaintext
   k8s secret (64 hex chars). Fixed by decrypting in the worker using ACB_ENCRYPTION_KEY.

Also tightens the home page winner filter to exclude winner_id="0" stalemates.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-30 21:48:25 -04:00
parent 936da0070a
commit e64230b122
4 changed files with 84 additions and 22 deletions

44
cmd/acb-worker/crypto.go Normal file
View file

@ -0,0 +1,44 @@
package main
import (
"crypto/aes"
"crypto/cipher"
"encoding/hex"
"fmt"
)
func decryptSecret(ciphertextHex, keyHex string) (string, error) {
key, err := hex.DecodeString(keyHex)
if err != nil {
return "", fmt.Errorf("decode key: %w", err)
}
if len(key) != 32 {
return "", fmt.Errorf("encryption key must be 32 bytes (64 hex chars)")
}
ciphertext, err := hex.DecodeString(ciphertextHex)
if err != nil {
return "", fmt.Errorf("decode ciphertext: %w", err)
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
aead, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonceSize := aead.NonceSize()
if len(ciphertext) < nonceSize {
return "", fmt.Errorf("ciphertext too short")
}
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
plaintext, err := aead.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", err
}
return string(plaintext), nil
}

View file

@ -25,27 +25,29 @@ import (
)
// Config holds worker configuration.
type Config struct {
DatabaseURL string // PostgreSQL connection URL
B2Endpoint string // B2 endpoint URL (ARMOR proxy)
B2Bucket string // B2 bucket name
B2AccessKey string // B2 access key ID
B2SecretKey string // B2 secret access key
B2Region string // B2 region (e.g., "us-west-004")
R2Endpoint string // R2 endpoint URL (Cloudflare R2 S3 API)
R2Bucket string // R2 bucket name
R2AccessKey string // R2 access key ID
R2SecretKey string // R2 secret access key
WorkerID string // Unique worker identifier
PollPeriod time.Duration // How often to poll for jobs
Heartbeat time.Duration // How often to send heartbeat during match
TurnTimeout time.Duration // Per-turn timeout for bots
MaxRetries int // Max retries for transient errors
Verbose bool // Enable verbose logging
DatabaseURL string // PostgreSQL connection URL
EncryptionKey string // AES-256-GCM key for decrypting bot shared secrets
B2Endpoint string // B2 endpoint URL (ARMOR proxy)
B2Bucket string // B2 bucket name
B2AccessKey string // B2 access key ID
B2SecretKey string // B2 secret access key
B2Region string // B2 region (e.g., "us-west-004")
R2Endpoint string // R2 endpoint URL (Cloudflare R2 S3 API)
R2Bucket string // R2 bucket name
R2AccessKey string // R2 access key ID
R2SecretKey string // R2 secret access key
WorkerID string // Unique worker identifier
PollPeriod time.Duration // How often to poll for jobs
Heartbeat time.Duration // How often to send heartbeat during match
TurnTimeout time.Duration // Per-turn timeout for bots
MaxRetries int // Max retries for transient errors
Verbose bool // Enable verbose logging
}
func main() {
// Parse command-line flags
databaseURL := flag.String("db", getEnv("ACB_DATABASE_URL", ""), "PostgreSQL connection URL")
encryptionKey := flag.String("encryption-key", getEnv("ACB_ENCRYPTION_KEY", ""), "AES-256-GCM key for decrypting bot secrets")
b2Endpoint := flag.String("b2-endpoint", getEnv("ACB_B2_ENDPOINT", ""), "B2 endpoint URL")
b2Bucket := flag.String("b2-bucket", getEnv("ACB_B2_BUCKET", "acb-data"), "B2 bucket name")
b2AccessKey := flag.String("b2-access-key", getEnv("ACB_B2_ACCESS_KEY", ""), "B2 access key ID")
@ -69,8 +71,9 @@ func main() {
}
cfg := &Config{
DatabaseURL: *databaseURL,
B2Endpoint: *b2Endpoint,
DatabaseURL: *databaseURL,
EncryptionKey: *encryptionKey,
B2Endpoint: *b2Endpoint,
B2Bucket: *b2Bucket,
B2AccessKey: *b2AccessKey,
B2SecretKey: *b2SecretKey,
@ -311,10 +314,22 @@ func (w *Worker) executeMatch(ctx context.Context, claimData *JobClaimData) (*Ma
p := participantMap[slot]
botInfo := botInfoMap[p.BotID]
// Decrypt the bot's shared secret if an encryption key is configured.
// The API stores secrets AES-GCM encrypted; bots use the plaintext key.
secret := botInfo.Secret
if w.cfg.EncryptionKey != "" {
plaintext, err := decryptSecret(botInfo.Secret, w.cfg.EncryptionKey)
if err != nil {
w.logger.Printf("Warning: failed to decrypt secret for bot %s: %v — using raw value", p.BotID, err)
} else {
secret = plaintext
}
}
// Create auth config for HTTP bot
auth := engine.AuthConfig{
BotID: p.BotID,
Secret: botInfo.Secret,
Secret: secret,
MatchID: claimData.Match.ID,
}

View file

@ -31,11 +31,13 @@ type RequestAuth struct {
}
// SignRequest generates the HMAC signature for an outgoing request.
// signing_string = "{match_id}.{turn}.{timestamp}.{sha256(request_body)}"
// signing_string = "{match_id}.{turn}.{sha256(request_body)}"
// signature = HMAC-SHA256(shared_secret, signing_string)
// Note: timestamp is sent as a header (X-ACB-Timestamp) for clock-skew checks but is NOT
// included in the signing string, matching the bot-side verifySignature implementation.
func SignRequest(secret, matchID string, turn int, timestamp int64, requestBody []byte) string {
bodyHash := sha256.Sum256(requestBody)
signingString := fmt.Sprintf("%s.%d.%d.%s", matchID, turn, timestamp, hex.EncodeToString(bodyHash[:]))
signingString := fmt.Sprintf("%s.%d.%s", matchID, turn, hex.EncodeToString(bodyHash[:]))
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(signingString))
@ -56,6 +58,7 @@ func SignResponse(secret, matchID string, turn int, responseBody []byte) string
// VerifyRequest verifies an incoming request's signature.
// Returns an error if verification fails.
// Timestamp is validated separately for clock-skew; it is not included in the signing string.
func VerifyRequest(secret string, auth RequestAuth, requestBody []byte) error {
// Check timestamp is within tolerance
now := time.Now().Unix()

View file

@ -23,7 +23,7 @@ async function findFeaturedReplay(
if (completed.length === 0) return { match: null, enriched: false };
// Prefer matches with a winner (no stalemates on the home page)
const withWinner = completed.filter((m) => !!m.winner_id);
const withWinner = completed.filter((m) => !!m.winner_id && m.winner_id !== '0');
const pool = withWinner.length > 0 ? withWinner : completed;
const sorted = [...pool].sort(