fix(awooop): Phase 2 第二批 P0 安全強化 + Redis key 命名空間修正

## P0-05 Callback Nonce 防偽造(ADR-116)
- security_interceptor.py:generate_callback_nonce() 新增 HMAC-SHA256[:16] 附加
  - 新 5-part 格式:{action}:{short_id}:{ts}:{rand}:{hmac16}
  - CALLBACK_HMAC_SECRET 未設定時降級 warning(向後相容)
- security_interceptor.py:parse_callback_data() 新增 5-part 分支 + HMAC 驗證
- config.py:新增 CALLBACK_HMAC_SECRET: str = Field(default="")

## P0-06 Webhook HMAC Replay 防護(ADR-116)
- security_interceptor.py:新增 check_webhook_nonce()(Service 層,get_redis 在此層合法)
- webhooks.py:verify_webhook_signature() 新增兩個可選 Header
  - X-Webhook-Timestamp:±300s 窗口驗證(若提供)
  - X-Webhook-Nonce:呼叫 check_webhook_nonce()(Redis NX dedup,fail open)
  - 移除直接 get_redis import(leWOOOgo 積木化修正)

## P0-11 ollama:current_primary Redis key 遷移 Phase A(ADR-110)
- ollama_auto_recovery.py:_REDIS_PRIMARY_KEY = "platform:ollama:current_primary"
  - 雙寫舊 key "ollama:current_primary"(Phase A 30 天)
  - 讀取以新 key 為主,fallback 舊 key

## P0-12 consensus Redis key 加 project namespace Phase A
- consensus_engine.py:新增 _consensus_key() / _consensus_legacy_key() helper
  - 新 key:{project_id}:consensus:{consensus_id}
  - project_id=None 時 fallback __platform__:consensus:{consensus_id}
  - Phase A 雙寫 + fallback 讀取,現有呼叫方零修改

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-05-04 13:54:38 +08:00
parent 2b2359e367
commit f2f5148ca6
6 changed files with 265 additions and 22 deletions

View File

@@ -14,6 +14,8 @@ Features:
- 過期的 Nonce 自動清除
"""
import hashlib
import hmac
import time
from dataclasses import dataclass
from typing import Protocol, runtime_checkable
@@ -134,6 +136,34 @@ class NonceStore:
logger.debug("nonce_cleanup", removed_count=len(expired))
async def check_webhook_nonce(nonce: str, ttl: int = 600) -> bool:
"""
Webhook replay 防護:用 Redis NX 記錄 nonceTTL=600s重複使用回傳 False。
Service 層 helper供 Router 層webhooks.py呼叫禁止 Router 直接用 get_redis。
Redis 不可用時 fail open回傳 True + 記錄 warning
P0-06 修正ADR-1162026-05-04 ogt + Claude Sonnet 4.6
"""
from src.core.redis_client import get_redis
nonce_key = f"webhook:nonce:{nonce}"
try:
redis = get_redis()
stored = await redis.set(nonce_key, "1", nx=True, ex=ttl)
if not stored:
logger.warning("webhook_nonce_replay_detected", nonce_prefix=nonce[:16] + "...")
return False
logger.debug("webhook_nonce_registered", nonce_key=nonce_key)
return True
except Exception as exc:
logger.warning(
"webhook_nonce_redis_unavailable",
error=str(exc),
note="fail open: request allowed despite Redis unavailability",
)
return True
# =============================================================================
# Telegram Security Interceptor
# =============================================================================
@@ -478,13 +508,30 @@ class TelegramSecurityInterceptor:
# Not a valid UUID (e.g. legacy format) — use as-is, may exceed limit but won't crash
short_id = approval_id
nonce = f"{action}:{short_id}:{timestamp}:{random_part}"
nonce_body = f"{action}:{short_id}:{timestamp}:{random_part}"
# ADR-116 P0-05: 附加 HMAC-SHA256[:16] 防偽造
# 2026-05-04 Claude Sonnet 4.6 (ADR-116): 若 CALLBACK_HMAC_SECRET 未設定則 warning + 降級
if settings.CALLBACK_HMAC_SECRET:
hmac_hex = hmac.new(
settings.CALLBACK_HMAC_SECRET.encode(),
nonce_body.encode(),
hashlib.sha256,
).hexdigest()
nonce = f"{nonce_body}:{hmac_hex[:16]}"
else:
logger.warning(
"callback_hmac_secret_missing",
note="CALLBACK_HMAC_SECRET not configured; nonce generated without HMAC (transition mode)",
)
nonce = nonce_body
logger.debug(
"callback_nonce_generated",
approval_id=approval_id,
action=action,
nonce_len=len(nonce.encode()),
hmac_appended=bool(settings.CALLBACK_HMAC_SECRET),
)
return nonce
@@ -517,7 +564,32 @@ class TelegramSecurityInterceptor:
"is_info_action": True,
}
if len(parts) != 4:
# ADR-116 P0-05: 支援 5-part 格式(含 HMAC
# 2026-05-04 Claude Sonnet 4.6 (ADR-116): {action}:{short_id}:{ts}:{rand}:{hmac16}
if len(parts) == 5:
# 5-part驗證 HMAC然後還原成 4-part 格式繼續解析
embedded_hmac = parts[4]
nonce_body = ":".join(parts[:4])
if settings.CALLBACK_HMAC_SECRET:
expected_hmac = hmac.new(
settings.CALLBACK_HMAC_SECRET.encode(),
nonce_body.encode(),
hashlib.sha256,
).hexdigest()[:16]
if not hmac.compare_digest(embedded_hmac, expected_hmac):
logger.warning(
"callback_nonce_hmac_mismatch",
nonce_prefix=callback_data[:20] + "...",
)
raise ValueError(f"Callback nonce HMAC verification failed")
else:
logger.warning(
"callback_hmac_secret_missing",
note="CALLBACK_HMAC_SECRET not configured; skipping nonce HMAC verification (transition mode)",
)
# 以 4-part nonce_body 繼續解析(以下邏輯共用)
parts = parts[:4]
elif len(parts) != 4:
raise ValueError(f"Invalid callback_data format: {callback_data}")
import base64, uuid as _uuid