From e64230b1222ddbd23875ed4ba4be6db3e54ccfa9 Mon Sep 17 00:00:00 2001 From: jedarden Date: Thu, 30 Apr 2026 21:48:25 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20resolve=20universal=20stalemate=20?= =?UTF-8?q?=E2=80=94=20signing=20format=20and=20secret=20decryption?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cmd/acb-worker/crypto.go | 44 +++++++++++++++++++++++++++++++++ cmd/acb-worker/main.go | 53 ++++++++++++++++++++++++++-------------- engine/auth.go | 7 ++++-- web/src/pages/home.ts | 2 +- 4 files changed, 84 insertions(+), 22 deletions(-) create mode 100644 cmd/acb-worker/crypto.go diff --git a/cmd/acb-worker/crypto.go b/cmd/acb-worker/crypto.go new file mode 100644 index 0000000..d971742 --- /dev/null +++ b/cmd/acb-worker/crypto.go @@ -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 +} diff --git a/cmd/acb-worker/main.go b/cmd/acb-worker/main.go index 6215a7a..6b7ca88 100644 --- a/cmd/acb-worker/main.go +++ b/cmd/acb-worker/main.go @@ -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, } diff --git a/engine/auth.go b/engine/auth.go index df28c2e..2e86bb8 100644 --- a/engine/auth.go +++ b/engine/auth.go @@ -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() diff --git a/web/src/pages/home.ts b/web/src/pages/home.ts index 15a2a0b..5745e54 100644 --- a/web/src/pages/home.ts +++ b/web/src/pages/home.ts @@ -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(