ShiErFeng/shierfeng-fastapi-backend/utils/transport_crypto_util.py
2026-07-24 09:43:10 +08:00

1285 lines
46 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.

import base64
import json
import os
import time
from collections import Counter, defaultdict, deque
from dataclasses import dataclass
from datetime import datetime
from threading import Lock
from typing import Any
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from fastapi import FastAPI, Request
from redis import asyncio as aioredis
from config.env import AppConfig, TransportCryptoConfig
from utils.log_util import logger
# 通用编码辅助
def _urlsafe_b64encode(data: bytes) -> str:
"""
将字节串编码为URL安全的Base64字符串
:param data: 原始字节串
:return: URL安全的Base64字符串
"""
return base64.urlsafe_b64encode(data).decode('utf-8').rstrip('=')
def _urlsafe_b64decode(data: str) -> bytes:
"""
将URL安全的Base64字符串解码为字节串
:param data: URL安全的Base64字符串
:return: 解码后的字节串
"""
padding_length = (-len(data)) % 4
return base64.urlsafe_b64decode(f'{data}{"=" * padding_length}'.encode())
@dataclass(frozen=True)
class TransportKeyPair:
"""
传输层密钥对载体
kid: 密钥版本标识
private_key_pem: PEM格式私钥
public_key_pem: PEM格式公钥
"""
kid: str
private_key_pem: str
public_key_pem: str
# 传输层数据载体
@dataclass(frozen=True)
class DecryptedTransportEnvelope:
"""
请求信封解密结果
kid: 请求使用的密钥版本标识
nonce: 请求随机数
timestamp: 请求时间戳
aes_key: 当前请求协商出的AES会话密钥
aad: 通过校验后的AAD上下文
plaintext: 解密得到的原始请求载荷
"""
kid: str
nonce: str
timestamp: int
aes_key: bytes
aad: dict[str, str]
plaintext: bytes
# 传输层密钥管理
class TransportKeyProvider:
"""
传输层密钥提供者
"""
_lock = Lock()
_key_pairs: dict[str, TransportKeyPair] | None = None
_MIN_RSA_KEY_SIZE = 2048
_RSA_KEY_SIZE_STEP = 256
@classmethod
def validate_runtime_configuration(cls) -> None:
"""
校验传输层加解密运行配置,确保启用时显式配置密钥对
:return: None
"""
if not TransportCryptoConfig.transport_crypto_enabled or TransportCryptoConfig.transport_crypto_mode == 'off':
return
configured_private_key = cls._normalize_pem(TransportCryptoConfig.transport_crypto_private_key)
configured_public_key = cls._normalize_pem(TransportCryptoConfig.transport_crypto_public_key)
rsa_key_size = TransportCryptoConfig.transport_crypto_rsa_key_size
if rsa_key_size < cls._MIN_RSA_KEY_SIZE or rsa_key_size % cls._RSA_KEY_SIZE_STEP != 0:
raise ValueError('TRANSPORT_CRYPTO_RSA_KEY_SIZE必须大于等于2048且为256的整数倍')
if not configured_private_key or not configured_public_key:
raise ValueError(
'启用传输层加解密时必须显式配置TRANSPORT_CRYPTO_PUBLIC_KEY和TRANSPORT_CRYPTO_PRIVATE_KEY'
)
private_key = serialization.load_pem_private_key(configured_private_key.encode('utf-8'), password=None)
derived_public_key = (
private_key.public_key()
.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
.decode('utf-8')
)
if cls._normalize_pem(derived_public_key) != configured_public_key:
raise ValueError('TRANSPORT_CRYPTO_PUBLIC_KEY与TRANSPORT_CRYPTO_PRIVATE_KEY不匹配')
if TransportCryptoConfig.transport_crypto_legacy_key_pairs:
cls._build_legacy_key_pairs()
@classmethod
def get_current_key_pair(cls) -> TransportKeyPair:
"""
获取当前启用的密钥对
:return: 当前启用的密钥对
"""
if cls._key_pairs is None:
with cls._lock:
if cls._key_pairs is None:
cls._key_pairs = cls._build_key_pairs()
return cls._key_pairs[TransportCryptoConfig.transport_crypto_kid]
@classmethod
def get_current_kid(cls) -> str:
"""
获取当前启用的密钥标识
:return: 当前启用的密钥标识
"""
return cls.get_current_key_pair().kid
@classmethod
def get_public_key_pem(cls, kid: str | None = None) -> str:
"""
获取公钥PEM
:param kid: 密钥版本标识,未传入时默认使用当前版本
:return: PEM格式公钥字符串
"""
return cls.get_key_pair(kid).public_key_pem
@classmethod
def get_private_key_pem(cls, kid: str | None = None) -> str:
"""
获取私钥PEM
:param kid: 密钥版本标识,未传入时默认使用当前版本
:return: PEM格式私钥字符串
"""
return cls.get_key_pair(kid).private_key_pem
@classmethod
def get_key_pair(cls, kid: str | None = None) -> TransportKeyPair:
"""
根据kid获取密钥对未传入时返回当前密钥对
:param kid: 密钥版本标识,未传入时默认使用当前版本
:return: 匹配到的密钥对
"""
target_kid = kid or cls.get_current_kid()
if cls._key_pairs is None:
with cls._lock:
if cls._key_pairs is None:
cls._key_pairs = cls._build_key_pairs()
key_pair = cls._key_pairs.get(target_kid)
if key_pair is None:
raise ValueError('密钥版本不存在')
return key_pair
@classmethod
def get_supported_kids(cls) -> tuple[str, ...]:
"""
获取当前支持解密的全部密钥版本
:return: 当前支持解密的密钥版本元组
"""
if cls._key_pairs is None:
cls.get_current_key_pair()
return tuple(cls._key_pairs.keys())
@classmethod
def _build_key_pairs(cls) -> dict[str, TransportKeyPair]:
"""
构建当前进程可用的全部密钥对映射
:return: 以kid为键的密钥对映射
"""
configured_private_key = cls._normalize_pem(TransportCryptoConfig.transport_crypto_private_key)
configured_public_key = cls._normalize_pem(TransportCryptoConfig.transport_crypto_public_key)
kid = TransportCryptoConfig.transport_crypto_kid
if not configured_private_key or not configured_public_key:
raise ValueError(
'启用传输层加解密时必须显式配置TRANSPORT_CRYPTO_PUBLIC_KEY和TRANSPORT_CRYPTO_PRIVATE_KEY'
)
key_pairs = {
kid: TransportKeyPair(kid=kid, private_key_pem=configured_private_key, public_key_pem=configured_public_key)
}
key_pairs.update(cls._build_legacy_key_pairs())
return key_pairs
@classmethod
def _build_legacy_key_pairs(cls) -> dict[str, TransportKeyPair]:
"""
构建历史密钥对映射,用于密钥轮换窗口内的兼容解密
:return: 以kid为键的历史密钥对映射
"""
legacy_key_pairs: dict[str, TransportKeyPair] = {}
configured_legacy_key_pairs = TransportCryptoConfig.transport_crypto_legacy_key_pairs
if not configured_legacy_key_pairs:
return legacy_key_pairs
try:
parsed_key_pairs = json.loads(configured_legacy_key_pairs)
except json.JSONDecodeError as exc:
raise ValueError('传输层历史密钥配置不是合法JSON') from exc
if not isinstance(parsed_key_pairs, list):
raise ValueError('传输层历史密钥配置必须是JSON数组')
for item in parsed_key_pairs:
if not isinstance(item, dict):
raise ValueError('传输层历史密钥项必须是JSON对象')
item_kid = item.get('kid')
private_key_pem = cls._normalize_pem(item.get('privateKey') or item.get('private_key') or '')
public_key_pem = cls._normalize_pem(item.get('publicKey') or item.get('public_key') or '')
if not item_kid or not private_key_pem:
raise ValueError('传输层历史密钥项必须包含kid和privateKey')
if not public_key_pem:
private_key = serialization.load_pem_private_key(private_key_pem.encode('utf-8'), password=None)
public_key_pem = (
private_key.public_key()
.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
.decode('utf-8')
)
legacy_key_pairs[str(item_kid)] = TransportKeyPair(
kid=str(item_kid),
private_key_pem=private_key_pem,
public_key_pem=public_key_pem,
)
return legacy_key_pairs
@staticmethod
def _normalize_pem(pem_value: str) -> str:
"""
兼容环境变量中的换行转义
:param pem_value: 原始PEM字符串
:return: 标准化后的PEM字符串
"""
return pem_value.replace('\\n', '\n').strip() if pem_value else ''
# 传输层安全校验
class TransportSecurityUtil:
"""
传输层安全校验工具
"""
@classmethod
def validate_timestamp(cls, timestamp: int) -> None:
"""
校验请求时间窗
:param timestamp: 请求信封中的时间戳
:return: None
"""
now_timestamp = int(time.time())
if abs(now_timestamp - timestamp) > TransportCryptoConfig.transport_crypto_clock_skew_seconds:
logger.warning(
'传输层加密请求时间窗校验失败request_ts={}, now_ts={}, allowed_skew={}',
timestamp,
now_timestamp,
TransportCryptoConfig.transport_crypto_clock_skew_seconds,
)
raise ValueError('加密请求已过期,请刷新页面后重试')
@classmethod
async def validate_replay(cls, request: Request, kid: str, nonce: str) -> None:
"""
使用Redis进行防重放校验
:param request: 当前请求对象
:param kid: 当前密钥版本标识
:param nonce: 当前请求随机数
:return: None
"""
redis = getattr(request.app.state, 'redis', None)
if redis is None:
if cls._should_fail_closed_when_replay_check_unavailable(request):
logger.error('Redis未初始化当前请求要求严格防重放校验已拒绝请求')
raise ValueError('服务端防重放校验不可用,请稍后重试')
logger.warning('Redis未初始化已跳过传输层防重放校验')
return
replay_key = f'transport:replay:{kid}:{nonce}'
try:
is_success = await redis.set(
replay_key, '1', ex=TransportCryptoConfig.transport_crypto_replay_ttl_seconds, nx=True
)
except Exception as exc:
if cls._should_fail_closed_when_replay_check_unavailable(request):
logger.error('Redis防重放校验执行失败当前请求要求严格校验error={}', exc)
raise ValueError('服务端防重放校验不可用,请稍后重试') from exc
logger.warning('Redis防重放校验执行失败已跳过当前请求的防重放校验error={}', exc)
return
if not is_success:
logger.warning('传输层加密请求检测到重放kid={}, nonce={}', kid, nonce)
raise ValueError('检测到重复请求,请勿重放加密报文')
@classmethod
def _should_fail_closed_when_replay_check_unavailable(cls, request: Request) -> bool:
"""
判断当前请求在防重放能力不可用时是否需要直接拒绝
:param request: 当前请求对象
:return: 是否需要失败关闭
"""
if TransportCryptoConfig.transport_crypto_mode == 'required':
return True
current_path = cls._normalize_path(str(request.scope.get('path', '')))
return cls._is_required_path(current_path)
@staticmethod
def _normalize_path(path: str) -> str:
"""
标准化请求路径,剥离应用根路径前缀
:param path: 原始请求路径
:return: 标准化后的业务路径
"""
app_root_path = AppConfig.app_root_path
if app_root_path and path.startswith(app_root_path):
normalized_path = path[len(app_root_path) :]
return normalized_path or '/'
return path or '/'
@staticmethod
def _is_required_path(path: str) -> bool:
"""
判断当前路径是否命中强制加密路径配置
:param path: 当前请求路径
:return: 是否命中强制加密路径
"""
required_paths = [
required_path.strip()
for required_path in TransportCryptoConfig.transport_crypto_required_paths.split(',')
if required_path.strip()
]
if not required_paths:
return False
return any(path == required_path or path.startswith(f'{required_path}/') for required_path in required_paths)
# 传输层加解密核心能力
class TransportCryptoUtil:
"""
传输层加解密工具
"""
_ENVELOPE_VERSION = '1'
_RESPONSE_ENVELOPE_ALGORITHM = 'AES_256_GCM'
_REQUIRED_ENVELOPE_FIELDS = ('kid', 'ts', 'nonce', 'ek', 'iv', 'ct', 'aad')
@classmethod
def get_response_envelope_algorithm(cls) -> str:
"""
获取响应信封算法标识
:return: 响应信封算法标识
"""
return cls._RESPONSE_ENVELOPE_ALGORITHM
@classmethod
def decrypt_envelope(
cls,
envelope: dict[str, Any],
expected_method: str,
expected_path: str,
) -> DecryptedTransportEnvelope:
"""
解密请求信封
:param envelope: 请求加密信封
:param expected_method: 当前请求预期HTTP方法
:param expected_path: 当前请求预期路径
:return: 解密后的请求信封对象
"""
cls._validate_envelope(envelope)
kid = str(envelope['kid'])
aad = cls._extract_and_validate_aad(envelope, expected_method, expected_path)
aes_key = cls.decrypt_request_key(envelope)
iv = _urlsafe_b64decode(str(envelope['iv']))
ciphertext = _urlsafe_b64decode(str(envelope['ct']))
plaintext = AESGCM(aes_key).decrypt(iv, ciphertext, cls._build_aad_bytes(aad))
return DecryptedTransportEnvelope(
kid=kid,
nonce=str(envelope['nonce']),
timestamp=int(envelope['ts']),
aes_key=aes_key,
aad=aad,
plaintext=plaintext,
)
@classmethod
def decrypt_request_key(cls, envelope: dict[str, Any]) -> bytes:
"""
仅解出请求中的AES会话密钥用于异常场景构造加密错误响应
:param envelope: 请求加密信封
:return: 请求协商出的AES会话密钥
"""
kid = str(envelope['kid'])
private_key_pem = TransportKeyProvider.get_private_key_pem(kid)
private_key = serialization.load_pem_private_key(private_key_pem.encode('utf-8'), password=None)
encrypted_key = _urlsafe_b64decode(str(envelope['ek']))
return private_key.decrypt(
encrypted_key,
padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None),
)
@classmethod
def encrypt_response_body(
cls,
aes_key: bytes,
payload: bytes,
kid: str,
method: str,
path: str,
) -> bytes:
"""
使用请求协商出的AES密钥加密响应体
:param aes_key: 请求协商出的AES会话密钥
:param payload: 需要加密的响应体字节串
:param kid: 当前使用的密钥版本标识
:param method: 当前HTTP请求方法
:param path: 当前HTTP请求路径
:return: 加密后的响应体字节串
"""
iv = os.urandom(12)
aad = {'method': method.upper(), 'path': path, 'direction': 'response'}
ciphertext = AESGCM(aes_key).encrypt(iv, payload, cls._build_aad_bytes(aad))
encrypted_payload = {
'v': cls._ENVELOPE_VERSION,
'kid': kid,
'alg': cls._RESPONSE_ENVELOPE_ALGORITHM,
'aad': aad,
'iv': _urlsafe_b64encode(iv),
'ct': _urlsafe_b64encode(ciphertext),
}
return json.dumps(encrypted_payload, ensure_ascii=False).encode('utf-8')
@classmethod
def decode_query_envelope(cls, encrypted_query: str) -> dict[str, Any]:
"""
解码查询参数中的加密信封
:param encrypted_query: 查询参数中的加密信封字符串
:return: 解码后的信封字典
"""
decoded_query = _urlsafe_b64decode(encrypted_query).decode('utf-8')
return json.loads(decoded_query)
@classmethod
def build_public_key_payload(cls) -> dict[str, Any]:
"""
构建公钥下发载荷
:return: 公钥下发载荷字典
"""
return {
'kid': TransportKeyProvider.get_current_kid(),
'envelopeVersion': cls._ENVELOPE_VERSION,
'alg': TransportCryptoConfig.transport_crypto_algorithm,
'publicKey': TransportKeyProvider.get_public_key_pem(),
'supportedKids': TransportKeyProvider.get_supported_kids(),
'expireAt': int(time.time()) + TransportCryptoConfig.transport_crypto_public_key_ttl_seconds,
}
@classmethod
def build_frontend_config_payload(cls) -> dict[str, Any]:
"""
构建前端传输层加解密运行配置载荷
:return: 前端传输层加解密运行配置载荷字典
"""
transport_crypto_active = (
TransportCryptoConfig.transport_crypto_enabled and TransportCryptoConfig.transport_crypto_mode != 'off'
)
return {
'transportCryptoEnabled': TransportCryptoConfig.transport_crypto_enabled,
'transportCryptoMode': TransportCryptoConfig.transport_crypto_mode,
'transportCryptoActive': transport_crypto_active,
'envelopeVersion': cls._ENVELOPE_VERSION,
'publicKeyUrl': '/transport/crypto/public-key',
'requestEnvelopeAlgorithm': TransportCryptoConfig.transport_crypto_algorithm,
'responseEnvelopeAlgorithm': cls.get_response_envelope_algorithm(),
'enabledPaths': cls._split_paths(TransportCryptoConfig.transport_crypto_enabled_paths),
'requiredPaths': cls._split_paths(TransportCryptoConfig.transport_crypto_required_paths),
'excludePaths': cls._split_paths(TransportCryptoConfig.transport_crypto_exclude_paths),
'maxEncryptedGetUrlLength': TransportCryptoConfig.transport_crypto_max_get_url_length,
'configExpireAt': int(time.time()) + TransportCryptoConfig.transport_crypto_frontend_config_ttl_seconds,
}
@classmethod
def _validate_envelope(cls, envelope: dict[str, Any]) -> None:
"""
校验请求加密信封的结构、协议版本与算法是否有效
:param envelope: 请求加密信封
:return: None
"""
if not isinstance(envelope, dict):
raise ValueError('加密请求信封格式不合法')
missing_fields = [field_name for field_name in cls._REQUIRED_ENVELOPE_FIELDS if not envelope.get(field_name)]
if missing_fields:
raise ValueError(f'加密请求缺少必要字段: {",".join(missing_fields)}')
if str(envelope.get('v', '')) != cls._ENVELOPE_VERSION:
raise ValueError('加密请求协议版本不受支持')
if str(envelope.get('alg', '')) != TransportCryptoConfig.transport_crypto_algorithm:
raise ValueError('加密请求算法不受支持')
@classmethod
def _extract_and_validate_aad(
cls,
envelope: dict[str, Any],
expected_method: str,
expected_path: str,
) -> dict[str, str]:
"""
提取并校验请求AAD确保密文与当前接口绑定
:param envelope: 请求加密信封
:param expected_method: 当前请求预期HTTP方法
:param expected_path: 当前请求预期路径
:return: 归一化后的AAD字典
"""
aad = envelope.get('aad')
if not isinstance(aad, dict):
raise ValueError('加密请求缺少合法的aad')
method = str(aad.get('method', '')).upper()
path = str(aad.get('path', ''))
if method != expected_method.upper() or path != expected_path:
raise ValueError('加密请求的method/path与当前接口不匹配')
return {'method': method, 'path': path}
@staticmethod
def _build_aad_bytes(aad: dict[str, str]) -> bytes:
"""
将AAD字典序列化为AES-GCM additionalData所需字节串
:param aad: AAD字典
:return: 序列化后的AAD字节串
"""
return json.dumps(aad, ensure_ascii=False, separators=(',', ':')).encode('utf-8')
@staticmethod
def _split_paths(path_value: str) -> list[str]:
"""
将逗号分隔的路径配置拆分为列表
:param path_value: 原始路径配置
:return: 路径列表
"""
return [path.strip() for path in path_value.split(',') if path.strip()]
# 传输层监控读写与聚合
class TransportCryptoMonitorUtil:
"""
传输层加解密监控工具
"""
_REDIS_KEY_PREFIX = 'transport:monitor'
_META_STARTED_AT_KEY = f'{_REDIS_KEY_PREFIX}:started_at'
_COUNTERS_KEY = f'{_REDIS_KEY_PREFIX}:counters'
_FAILURE_REASONS_KEY = f'{_REDIS_KEY_PREFIX}:failure_reasons'
_KIDS_KEY = f'{_REDIS_KEY_PREFIX}:kids'
_RECENT_FAILURES_KEY = f'{_REDIS_KEY_PREFIX}:recent_failures'
_RECENT_FAILURE_LIMIT = 20
_REDIS_WARNING_INTERVAL_SECONDS = 60
_lock = Lock()
_started_at = datetime.now()
_counters: Counter[str] = Counter()
_failure_reasons: Counter[str] = Counter()
_kid_counters: defaultdict[str, Counter[str]] = defaultdict(Counter)
_recent_failures: deque[dict[str, Any]] = deque(maxlen=_RECENT_FAILURE_LIMIT)
_last_redis_warning_at = 0.0
# 对外暴露的监控记录与查询入口
@classmethod
async def record_plain_request(cls, app: FastAPI | None = None) -> None:
"""
记录明文请求
:param app: FastAPI应用对象
:return: None
"""
if await cls._write_redis_counters(
app,
counter_updates={
'requests_total': 1,
'plain_requests_total': 1,
},
):
return
cls._record_plain_request_local()
@classmethod
async def record_encrypted_request(cls, app: FastAPI | None = None, kid: str | None = None) -> None:
"""
记录加密请求
:param app: FastAPI应用对象
:param kid: 当前请求使用的密钥版本
:return: None
"""
if await cls._write_redis_counters(
app,
counter_updates={
'requests_total': 1,
'encrypted_requests_total': 1,
},
kid=kid,
kid_counter_updates={'encrypted_requests_total': 1},
):
return
cls._record_encrypted_request_local(kid)
@classmethod
async def record_required_rejected(cls, app: FastAPI | None = None, method: str = '', path: str = '') -> None:
"""
记录强制加密接口被明文访问的拒绝事件
:param app: FastAPI应用对象
:param method: 请求方法
:param path: 请求路径
:return: None
"""
if await cls._write_redis_failure(
app,
method=method,
path=path,
reason='required_missing',
include_decrypt_failure=False,
):
return
cls._record_failure_local(method, path, 'required_missing', include_decrypt_failure=False)
@classmethod
async def record_decrypt_success(cls, app: FastAPI | None = None, kid: str | None = None) -> None:
"""
记录请求解密成功事件
:param app: FastAPI应用对象
:param kid: 当前请求使用的密钥版本
:return: None
"""
if await cls._write_redis_counters(
app,
counter_updates={'decrypt_success_total': 1},
kid=kid,
kid_counter_updates={'decrypt_success_total': 1},
):
return
cls._record_decrypt_success_local(kid)
@classmethod
async def record_decrypt_failure(
cls,
app: FastAPI | None = None,
method: str = '',
path: str = '',
reason: str = '',
kid: str | None = None,
) -> None:
"""
记录请求解密失败事件
:param app: FastAPI应用对象
:param method: 请求方法
:param path: 请求路径
:param reason: 失败原因分类
:param kid: 当前请求使用的密钥版本
:return: None
"""
if await cls._write_redis_failure(app, method=method, path=path, reason=reason, kid=kid):
return
cls._record_failure_local(method, path, reason, kid=kid)
@classmethod
async def record_plain_response(cls, app: FastAPI | None = None) -> None:
"""
记录明文响应
:param app: FastAPI应用对象
:return: None
"""
if await cls._write_redis_counters(app, counter_updates={'plain_responses_total': 1}):
return
cls._record_plain_response_local()
@classmethod
async def record_encrypted_response(
cls,
app: FastAPI | None = None,
kid: str | None = None,
is_error: bool = False,
) -> None:
"""
记录加密响应
:param app: FastAPI应用对象
:param kid: 当前响应使用的密钥版本
:param is_error: 是否为错误响应
:return: None
"""
counter_updates = {'encrypted_responses_total': 1}
if is_error:
counter_updates['encrypted_error_responses_total'] = 1
if await cls._write_redis_counters(
app,
counter_updates=counter_updates,
kid=kid,
kid_counter_updates={'encrypted_responses_total': 1},
):
return
cls._record_encrypted_response_local(kid, is_error)
@classmethod
async def get_snapshot(cls, app: FastAPI | None = None) -> dict[str, Any]:
"""
获取传输层加解密监控快照
:param app: FastAPI应用对象
:return: 监控快照字典
"""
redis_snapshot = await cls._get_redis_snapshot(app)
local_snapshot = cls._get_local_snapshot_parts()
snapshot_parts = cls._merge_snapshot_parts(redis_snapshot, local_snapshot)
return cls._build_snapshot(snapshot_parts)
# Redis 聚合写入与读取
@classmethod
async def _write_redis_counters(
cls,
app: FastAPI | None,
counter_updates: dict[str, int],
kid: str | None = None,
kid_counter_updates: dict[str, int] | None = None,
) -> bool:
"""
将监控计数写入Redis
:param app: FastAPI应用对象
:param counter_updates: 全局计数增量
:param kid: 当前密钥版本
:param kid_counter_updates: 按密钥版本统计的增量
:return: 是否写入成功
"""
redis = cls._get_redis_client(app)
if redis is None:
return False
try:
async with redis.pipeline(transaction=False) as pipe:
pipe.set(cls._META_STARTED_AT_KEY, cls._started_at.isoformat(), nx=True)
for counter_name, delta in counter_updates.items():
pipe.hincrby(cls._COUNTERS_KEY, counter_name, delta)
if kid and kid_counter_updates:
pipe.sadd(cls._KIDS_KEY, kid)
kid_counter_key = cls._build_kid_counter_key(kid)
for counter_name, delta in kid_counter_updates.items():
pipe.hincrby(kid_counter_key, counter_name, delta)
await pipe.execute()
return True
except Exception as exc:
cls._log_redis_warning('write_counters', exc)
return False
@classmethod
async def _write_redis_failure(
cls,
app: FastAPI | None,
method: str,
path: str,
reason: str,
kid: str | None = None,
include_decrypt_failure: bool = True,
) -> bool:
"""
将失败事件写入Redis
:param app: FastAPI应用对象
:param method: 请求方法
:param path: 请求路径
:param reason: 失败原因分类
:param kid: 当前请求使用的密钥版本
:param include_decrypt_failure: 是否计入解密失败次数
:return: 是否写入成功
"""
redis = cls._get_redis_client(app)
if redis is None:
return False
try:
recent_failure = json.dumps(
{
'time': datetime.now().isoformat(),
'method': method,
'path': path,
'reason': reason,
'kid': kid,
},
ensure_ascii=False,
)
async with redis.pipeline(transaction=False) as pipe:
pipe.set(cls._META_STARTED_AT_KEY, cls._started_at.isoformat(), nx=True)
if include_decrypt_failure:
pipe.hincrby(cls._COUNTERS_KEY, 'decrypt_failure_total', 1)
if reason == 'required_missing':
pipe.hincrby(cls._COUNTERS_KEY, 'required_rejected_total', 1)
pipe.hincrby(cls._FAILURE_REASONS_KEY, reason, 1)
pipe.lpush(cls._RECENT_FAILURES_KEY, recent_failure)
pipe.ltrim(cls._RECENT_FAILURES_KEY, 0, cls._RECENT_FAILURE_LIMIT - 1)
if kid:
pipe.sadd(cls._KIDS_KEY, kid)
pipe.hincrby(cls._build_kid_counter_key(kid), 'decrypt_failure_total', 1)
await pipe.execute()
return True
except Exception as exc:
cls._log_redis_warning('write_failure', exc)
return False
@classmethod
async def _get_redis_snapshot(cls, app: FastAPI | None) -> dict[str, Any]:
"""
从Redis中读取监控快照
:param app: FastAPI应用对象
:return: Redis监控快照字典
"""
redis = cls._get_redis_client(app)
if redis is None:
return {
'monitor_scope': 'process-local-fallback',
'started_at': cls._started_at,
'counters': {},
'failure_reasons': {},
'kid_stats': [],
'recent_failures': [],
}
try:
async with redis.pipeline(transaction=False) as pipe:
pipe.set(cls._META_STARTED_AT_KEY, cls._started_at.isoformat(), nx=True)
pipe.get(cls._META_STARTED_AT_KEY)
pipe.hgetall(cls._COUNTERS_KEY)
pipe.hgetall(cls._FAILURE_REASONS_KEY)
pipe.lrange(cls._RECENT_FAILURES_KEY, 0, cls._RECENT_FAILURE_LIMIT - 1)
pipe.smembers(cls._KIDS_KEY)
_, started_at_raw, counters_raw, failure_reasons_raw, recent_failures_raw, kids = await pipe.execute()
kid_stats = await cls._get_redis_kid_stats(redis, sorted(kids))
return {
'monitor_scope': 'redis-aggregated',
'started_at': cls._parse_datetime(started_at_raw) or cls._started_at,
'counters': cls._to_int_mapping(counters_raw),
'failure_reasons': cls._to_int_mapping(failure_reasons_raw),
'kid_stats': kid_stats,
'recent_failures': cls._parse_recent_failures(recent_failures_raw),
}
except Exception as exc:
cls._log_redis_warning('read_snapshot', exc)
return {
'monitor_scope': 'process-local-fallback',
'started_at': cls._started_at,
'counters': {},
'failure_reasons': {},
'kid_stats': [],
'recent_failures': [],
}
@classmethod
async def _get_redis_kid_stats(cls, redis: aioredis.Redis, kids: list[str]) -> list[dict[str, Any]]:
"""
获取Redis中的按密钥版本聚合统计
:param redis: Redis客户端
:param kids: 密钥版本列表
:return: 按密钥版本统计列表
"""
if not kids:
return []
async with redis.pipeline(transaction=False) as pipe:
for kid in kids:
pipe.hgetall(cls._build_kid_counter_key(kid))
kid_counter_rows = await pipe.execute()
return [
{
'kid': kid,
'encryptedRequests': cls._to_int_mapping(kid_counter).get('encrypted_requests_total', 0),
'decryptSuccess': cls._to_int_mapping(kid_counter).get('decrypt_success_total', 0),
'decryptFailure': cls._to_int_mapping(kid_counter).get('decrypt_failure_total', 0),
'encryptedResponses': cls._to_int_mapping(kid_counter).get('encrypted_responses_total', 0),
}
for kid, kid_counter in zip(kids, kid_counter_rows, strict=False)
]
# 进程内回退统计
@classmethod
def _record_plain_request_local(cls) -> None:
"""
在本地内存中记录明文请求
:return: None
"""
with cls._lock:
cls._counters['requests_total'] += 1
cls._counters['plain_requests_total'] += 1
@classmethod
def _record_encrypted_request_local(cls, kid: str | None = None) -> None:
"""
在本地内存中记录加密请求
:param kid: 当前请求使用的密钥版本
:return: None
"""
with cls._lock:
cls._counters['requests_total'] += 1
cls._counters['encrypted_requests_total'] += 1
cls._increase_kid_counter_local(kid, 'encrypted_requests_total')
@classmethod
def _record_decrypt_success_local(cls, kid: str | None = None) -> None:
"""
在本地内存中记录解密成功事件
:param kid: 当前请求使用的密钥版本
:return: None
"""
with cls._lock:
cls._counters['decrypt_success_total'] += 1
cls._increase_kid_counter_local(kid, 'decrypt_success_total')
@classmethod
def _record_plain_response_local(cls) -> None:
"""
在本地内存中记录明文响应
:return: None
"""
with cls._lock:
cls._counters['plain_responses_total'] += 1
@classmethod
def _record_encrypted_response_local(cls, kid: str | None = None, is_error: bool = False) -> None:
"""
在本地内存中记录加密响应
:param kid: 当前响应使用的密钥版本
:param is_error: 是否为错误响应
:return: None
"""
with cls._lock:
cls._counters['encrypted_responses_total'] += 1
if is_error:
cls._counters['encrypted_error_responses_total'] += 1
cls._increase_kid_counter_local(kid, 'encrypted_responses_total')
@classmethod
def _record_failure_local(
cls,
method: str,
path: str,
reason: str,
kid: str | None = None,
include_decrypt_failure: bool = True,
) -> None:
"""
在本地内存中记录失败事件
:param method: 请求方法
:param path: 请求路径
:param reason: 失败原因分类
:param kid: 当前请求使用的密钥版本
:param include_decrypt_failure: 是否计入解密失败次数
:return: None
"""
with cls._lock:
if include_decrypt_failure:
cls._counters['decrypt_failure_total'] += 1
if reason == 'required_missing':
cls._counters['required_rejected_total'] += 1
cls._failure_reasons[reason] += 1
cls._increase_kid_counter_local(kid, 'decrypt_failure_total')
cls._recent_failures.appendleft(
{
'time': datetime.now(),
'method': method,
'path': path,
'reason': reason,
'kid': kid,
}
)
@classmethod
def _get_local_snapshot_parts(cls) -> dict[str, Any]:
"""
获取本地内存中的监控快照片段
:return: 本地监控快照片段
"""
with cls._lock:
return {
'monitor_scope': 'process-local-fallback',
'started_at': cls._started_at,
'counters': dict(cls._counters),
'failure_reasons': dict(cls._failure_reasons),
'kid_stats': [
{
'kid': kid,
'encryptedRequests': kid_counter.get('encrypted_requests_total', 0),
'decryptSuccess': kid_counter.get('decrypt_success_total', 0),
'decryptFailure': kid_counter.get('decrypt_failure_total', 0),
'encryptedResponses': kid_counter.get('encrypted_responses_total', 0),
}
for kid, kid_counter in sorted(cls._kid_counters.items(), key=lambda item: item[0])
],
'recent_failures': list(cls._recent_failures),
}
@classmethod
def _merge_snapshot_parts(cls, redis_snapshot: dict[str, Any], local_snapshot: dict[str, Any]) -> dict[str, Any]:
"""
合并Redis统计与本地回退统计
:param redis_snapshot: Redis监控快照片段
:param local_snapshot: 本地监控快照片段
:return: 合并后的监控快照片段
"""
merged_counters = Counter(redis_snapshot['counters'])
merged_counters.update(local_snapshot['counters'])
merged_failure_reasons = Counter(redis_snapshot['failure_reasons'])
merged_failure_reasons.update(local_snapshot['failure_reasons'])
merged_kid_stats: dict[str, dict[str, Any]] = {}
for kid_stat in redis_snapshot['kid_stats'] + local_snapshot['kid_stats']:
kid = kid_stat.get('kid')
if not kid:
continue
merged_kid_stat = merged_kid_stats.setdefault(
kid,
{
'kid': kid,
'encryptedRequests': 0,
'decryptSuccess': 0,
'decryptFailure': 0,
'encryptedResponses': 0,
},
)
merged_kid_stat['encryptedRequests'] += int(kid_stat.get('encryptedRequests', 0) or 0)
merged_kid_stat['decryptSuccess'] += int(kid_stat.get('decryptSuccess', 0) or 0)
merged_kid_stat['decryptFailure'] += int(kid_stat.get('decryptFailure', 0) or 0)
merged_kid_stat['encryptedResponses'] += int(kid_stat.get('encryptedResponses', 0) or 0)
combined_failures = redis_snapshot['recent_failures'] + local_snapshot['recent_failures']
combined_failures.sort(
key=lambda item: cls._coerce_datetime_for_sort(item.get('time')),
reverse=True,
)
monitor_scope = redis_snapshot['monitor_scope']
if monitor_scope == 'redis-aggregated' and cls._has_local_fallback_data(local_snapshot):
monitor_scope = 'redis-aggregated+local-fallback'
return {
'monitor_scope': monitor_scope,
'started_at': min(redis_snapshot['started_at'], local_snapshot['started_at']),
'counters': dict(merged_counters),
'failure_reasons': dict(merged_failure_reasons),
'kid_stats': sorted(merged_kid_stats.values(), key=lambda item: item['kid']),
'recent_failures': combined_failures[: cls._RECENT_FAILURE_LIMIT],
}
# 快照构建与通用辅助
@classmethod
def _build_snapshot(cls, snapshot_parts: dict[str, Any]) -> dict[str, Any]:
"""
基于监控片段构建最终快照
:param snapshot_parts: 监控快照片段
:return: 最终监控快照
"""
try:
current_kid = TransportKeyProvider.get_current_kid()
supported_kids = TransportKeyProvider.get_supported_kids()
except Exception:
current_kid = ''
supported_kids = []
counters = snapshot_parts['counters']
return {
'monitorScope': snapshot_parts['monitor_scope'],
'startedAt': snapshot_parts['started_at'],
'appEnv': AppConfig.app_env,
'transportCryptoEnabled': TransportCryptoConfig.transport_crypto_enabled,
'transportCryptoMode': TransportCryptoConfig.transport_crypto_mode,
'currentKid': current_kid,
'supportedKids': supported_kids,
'enabledPaths': TransportCryptoUtil._split_paths(TransportCryptoConfig.transport_crypto_enabled_paths),
'requiredPaths': TransportCryptoUtil._split_paths(TransportCryptoConfig.transport_crypto_required_paths),
'excludePaths': TransportCryptoUtil._split_paths(TransportCryptoConfig.transport_crypto_exclude_paths),
'requestsTotal': counters.get('requests_total', 0),
'plainRequestsTotal': counters.get('plain_requests_total', 0),
'encryptedRequestsTotal': counters.get('encrypted_requests_total', 0),
'requiredRejectedTotal': counters.get('required_rejected_total', 0),
'decryptSuccessTotal': counters.get('decrypt_success_total', 0),
'decryptFailureTotal': counters.get('decrypt_failure_total', 0),
'plainResponsesTotal': counters.get('plain_responses_total', 0),
'encryptedResponsesTotal': counters.get('encrypted_responses_total', 0),
'encryptedErrorResponsesTotal': counters.get('encrypted_error_responses_total', 0),
'failureReasons': snapshot_parts['failure_reasons'],
'kidStats': snapshot_parts['kid_stats'],
'recentFailures': snapshot_parts['recent_failures'],
}
@classmethod
def _get_redis_client(cls, app: FastAPI | None) -> aioredis.Redis | None:
"""
获取当前应用中的Redis客户端
:param app: FastAPI应用对象
:return: Redis客户端不存在时返回None
"""
if app is None:
return None
return getattr(app.state, 'redis', None)
@classmethod
def _increase_kid_counter_local(cls, kid: str | None, counter_name: str) -> None:
"""
在本地内存中按密钥版本累加统计值
:param kid: 当前密钥版本
:param counter_name: 统计项名称
:return: None
"""
if not kid:
return
cls._kid_counters[kid][counter_name] += 1
@classmethod
def _log_redis_warning(cls, action: str, exc: Exception) -> None:
"""
记录Redis监控降级日志并限制日志频率
:param action: 当前执行动作
:param exc: 异常对象
:return: None
"""
now = time.monotonic()
with cls._lock:
if now - cls._last_redis_warning_at < cls._REDIS_WARNING_INTERVAL_SECONDS:
return
cls._last_redis_warning_at = now
logger.warning('传输层加解密监控Redis操作失败已回退为进程内统计action={}, error={}', action, exc)
@classmethod
def _has_local_fallback_data(cls, local_snapshot: dict[str, Any]) -> bool:
"""
判断本地回退统计中是否存在有效数据
:param local_snapshot: 本地监控快照片段
:return: 是否存在有效数据
"""
if local_snapshot['counters']:
return True
if local_snapshot['failure_reasons']:
return True
if local_snapshot['kid_stats']:
return True
return bool(local_snapshot['recent_failures'])
@classmethod
def _build_kid_counter_key(cls, kid: str) -> str:
"""
构建按密钥版本统计的Redis键名
:param kid: 密钥版本
:return: Redis键名
"""
return f'{cls._REDIS_KEY_PREFIX}:kid:{kid}:counters'
@classmethod
def _parse_recent_failures(cls, recent_failures: list[str]) -> list[dict[str, Any]]:
"""
解析Redis中的最近失败记录
:param recent_failures: Redis中存储的失败记录列表
:return: 失败记录对象列表
"""
parsed_failures: list[dict[str, Any]] = []
for recent_failure in recent_failures:
try:
recent_failure_item = json.loads(recent_failure)
except json.JSONDecodeError:
continue
if not isinstance(recent_failure_item, dict):
continue
recent_failure_item['time'] = cls._parse_datetime(recent_failure_item.get('time'))
parsed_failures.append(recent_failure_item)
return parsed_failures
@staticmethod
def _to_int_mapping(mapping: dict[str, Any]) -> dict[str, int]:
"""
将Redis返回的字符串字典转换为整数字典
:param mapping: Redis原始字典
:return: 转换后的整数字典
"""
return {str(key): int(value) for key, value in mapping.items()}
@staticmethod
def _parse_datetime(value: Any) -> datetime | None:
"""
将字符串时间解析为datetime对象
:param value: 原始时间值
:return: datetime对象解析失败时返回None
"""
if isinstance(value, datetime):
return value
if not value or not isinstance(value, str):
return None
try:
return datetime.fromisoformat(value)
except ValueError:
return None
@classmethod
def _coerce_datetime_for_sort(cls, value: Any) -> datetime:
"""
将任意时间值转换为可排序的datetime对象
:param value: 原始时间值
:return: datetime对象
"""
parsed_datetime = cls._parse_datetime(value)
if parsed_datetime:
return parsed_datetime
return datetime.min