import base64 import json import time from hashlib import sha256 from secrets import compare_digest from typing import Any, Mapping from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.primitives.padding import PKCS7 from fastapi import HTTPException, status from app.core.config import get_settings from app.modules.feishu.constants import FeishuPayloadKey _SIGNATURE_MAX_AGE_SECONDS = 300 _SIGNATURE_HEADER = "x-lark-signature" _TIMESTAMP_HEADER = "x-lark-request-timestamp" _NONCE_HEADER = "x-lark-request-nonce" class FeishuWebhookVerifier: """Verify, decrypt, and normalize an HTTP webhook before business handling.""" def verify( self, raw_body: bytes, headers: Mapping[str, str], ) -> dict[str, Any]: settings = get_settings() if settings.feishu_encrypt_key: self._verify_signature(raw_body, headers, settings.feishu_encrypt_key) payload = self._load_json(raw_body) if FeishuPayloadKey.ENCRYPT in payload: if not settings.feishu_encrypt_key: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="FEISHU_ENCRYPT_KEY is required for encrypted webhooks", ) payload = self._decrypt( str(payload[FeishuPayloadKey.ENCRYPT]), settings.feishu_encrypt_key, ) self._verify_token(payload, settings.feishu_verification_token) return payload @staticmethod def _load_json(raw_body: bytes) -> dict[str, Any]: try: payload = json.loads(raw_body) except (UnicodeDecodeError, json.JSONDecodeError) as exc: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid Feishu webhook JSON", ) from exc if not isinstance(payload, dict): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid Feishu webhook payload", ) return payload @staticmethod def _verify_signature( raw_body: bytes, headers: Mapping[str, str], encrypt_key: str, ) -> None: normalized = {str(key).lower(): str(value) for key, value in headers.items()} timestamp = normalized.get(_TIMESTAMP_HEADER) nonce = normalized.get(_NONCE_HEADER) signature = normalized.get(_SIGNATURE_HEADER) if not timestamp or not nonce or not signature: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing Feishu webhook signature headers", ) try: request_time = int(timestamp) except ValueError as exc: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Feishu webhook timestamp", ) from exc if abs(int(time.time()) - request_time) > _SIGNATURE_MAX_AGE_SECONDS: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Expired Feishu webhook signature", ) signed = ( timestamp.encode("utf-8") + nonce.encode("utf-8") + encrypt_key.encode("utf-8") + raw_body ) expected = sha256(signed).hexdigest() if not compare_digest(signature, expected): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Feishu webhook signature", ) @staticmethod def _decrypt(encrypted: str, encrypt_key: str) -> dict[str, Any]: try: key = sha256(encrypt_key.encode("utf-8")).digest() encrypted_bytes = base64.b64decode(encrypted, validate=True) decryptor = Cipher(algorithms.AES(key), modes.CBC(key[:16])).decryptor() padded = decryptor.update(encrypted_bytes) + decryptor.finalize() unpadder = PKCS7(algorithms.AES.block_size).unpadder() cleartext = unpadder.update(padded) + unpadder.finalize() except (ValueError, TypeError) as exc: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid encrypted Feishu webhook", ) from exc return FeishuWebhookVerifier._load_json(cleartext) @staticmethod def _verify_token(payload: dict[str, Any], expected: str | None) -> None: if not expected: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="FEISHU_VERIFICATION_TOKEN is required", ) header = payload.get(FeishuPayloadKey.HEADER) or {} token = payload.get(FeishuPayloadKey.TOKEN) or header.get(FeishuPayloadKey.TOKEN) if not token or not compare_digest(str(token), expected): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Feishu token", )