ForcePilot/backend/package/yuxi/channels/plugins/feishu/signature.py

154 lines
5.3 KiB
Python
Raw Normal View History

"""飞书 Webhook 签名校验与事件解密模块。
提供以下能力
- ``verify_webhook_signature``: 校验 Webhook 签名SHA256 + 恒定时间比对
- ``is_timestamp_valid``: 校验时间戳是否在允许的时钟偏移窗口内防重放
- ``decrypt_event``: AES-256-CBC 解密飞书事件加密内容
- ``extract_challenge``: 识别 URL 验证 challenge 请求
依赖方向仅依赖标准库``cryptography``可选 ``yuxi.channels.contract``
错误类型不污染框架层``cryptography`` 缺失时 ``decrypt_event``
``DependencyError`` 显式暴露缺失依赖避免静默降级
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import time
from typing import Any
from yuxi.channels.contract.errors import DependencyError, ValidationError
try:
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import padding as sympad
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
_HAS_CRYPTOGRAPHY = True
except ImportError:
_HAS_CRYPTOGRAPHY = False
def verify_webhook_signature(
timestamp: str,
nonce: str,
encrypt_key: str,
body: str,
signature: str,
) -> bool:
"""校验飞书 Webhook 签名。
签名算法``SHA256(timestamp + nonce + encrypt_key + body)``
使用 ``hmac.compare_digest`` 进行恒定时间比对以防止时序攻击
Args:
timestamp: 时间戳字符串
nonce: 随机字符串
encrypt_key: 配置的 Encrypt Key
body: 原始请求体字符串
signature: 请求头中携带的签名十六进制小写
Returns:
签名校验结果``True`` 表示通过**不抛异常**输入类型异常或
编码失败时返回 ``False``由调用方决定是否记录日志或抛出
``ValidationError``
"""
try:
raw = f"{timestamp}{nonce}{encrypt_key}{body}".encode()
expected = hashlib.sha256(raw).hexdigest()
except (TypeError, UnicodeEncodeError, AttributeError):
return False
return hmac.compare_digest(expected, signature)
def is_timestamp_valid(timestamp: str, max_skew_seconds: int = 300) -> bool:
"""校验时间戳是否在允许的时钟偏移窗口内(防重放)。
Args:
timestamp: 时间戳字符串秒级
max_skew_seconds: 允许的最大时间偏差默认 3005 分钟
Returns:
``True`` 表示时间戳有效解析失败或超出窗口返回 ``False``
**不抛异常**
"""
try:
ts = int(timestamp)
except (TypeError, ValueError):
return False
skew = abs(int(time.time()) - ts)
return skew <= max_skew_seconds
def decrypt_event(encrypt_payload: str, encrypt_key: str) -> dict[str, Any]:
"""AES-256-CBC 解密飞书事件加密内容。
Key 派生``SHA256(encrypt_key)`` 取前 32 字节作为 AES-256 key
加密内容为 Base64 编码解码后取前 16 字节作为 IV剩余部分为密文
解密后去除 PKCS7 padding返回 JSON 解析后的 dict
Args:
encrypt_payload: Base64 编码的加密内容字符串
encrypt_key: 配置的 Encrypt Key
Returns:
解密后的 JSON dict
Raises:
DependencyError: ``cryptography`` 依赖不可用``cause`` 保留
``ImportError`` 上下文调用方应明确感知依赖缺失而非静默降级
ValidationError: 解密失败Base64 解码失败padding 错误JSON
解析失败等``field`` ``encrypt_payload````message``
固定为 ``"decrypt_failed"``原始异常通过 ``raise ... from exc``
链式保留供调用方 ``__cause__`` 追踪
"""
if not _HAS_CRYPTOGRAPHY:
raise DependencyError(
dep="cryptography",
cause=ImportError("cryptography package is required for event decryption"),
)
try:
key = hashlib.sha256(encrypt_key.encode("utf-8")).digest()[:32]
payload = base64.b64decode(encrypt_payload)
iv = payload[:16]
ciphertext = payload[16:]
cipher = Cipher(
algorithms.AES(key),
modes.CBC(iv),
backend=default_backend(),
)
decryptor = cipher.decryptor()
padded = decryptor.update(ciphertext) + decryptor.finalize()
unpadder = sympad.PKCS7(128).unpadder()
plain = unpadder.update(padded) + unpadder.finalize()
return json.loads(plain.decode("utf-8"))
except Exception as exc:
raise ValidationError(
field="encrypt_payload",
message="decrypt_failed",
) from exc
def extract_challenge(payload: dict[str, Any]) -> str | None:
"""识别 URL 验证 challenge 请求。
飞书在配置 Webhook 时会发送一个含 ``challenge`` 字段的请求用于验证 URL
本函数从中提取 challenge 便于调用方原样回包完成验证握手
Args:
payload: Webhook 请求体解析后的 dict
Returns:
challenge 字符串 ``payload`` 不含 ``challenge`` 字段或类型不符
则返回 ``None``
"""
challenge = payload.get("challenge")
if isinstance(challenge, str):
return challenge
return None