ForcePilot/backend/package/yuxi/channels/plugins/feishu/signature.py
Kris 00092c818e chore: 批量代码优化与规范完善
本次提交包含多项代码优化与规范修正:
1. 文档与注释优化:修正注释术语、补充注解与FR编号
2. 代码格式调整:统一空格、换行与缩进规范
3. 类型与接口完善:补充__all__导出、修正返回类型注解
4. 错误处理增强:新增领域错误类与校验逻辑
5. 依赖与导入调整:修复路径引用、统一时区导入
6. 协议与契约更新:完善接口文档与一致性注解
2026-07-03 19:18:13 +08:00

154 lines
5.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""飞书 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