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(