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:
parent
936da0070a
commit
e64230b122
4 changed files with 84 additions and 22 deletions
44
cmd/acb-worker/crypto.go
Normal file
44
cmd/acb-worker/crypto.go
Normal 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
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue