Files
live-hub-py/web/backend/crypto_storage.py
T
yml2213 df8c63b829 feat(huya): 外部 CK 协作台账(S1–S6)+ 图片上传能力
协作台账:新表 huya_external_orders 替代 Excel 多人协同表(设计见
docs/设计-虎牙外部CK协作台账.md),一条 CK 一行,所有人共享、改动留痕。

- 前端 pages/HuyaCollabPage.tsx + 菜单「虎牙 → 外部单协作」/huya/collab:
  内联编辑(带乐观锁 version)、图片缩略图、详情抽屉(字段表单 + 两个图片槽位 +
  操作时间线)、粘贴导入弹窗(带 TSV 预览)、批量/单条验证 CK、页头统计条、筛选
- 两套状态机:业务 pending→bound→recharged→scripted→done(旁路 void)、
  凭据 unverified/valid/invalid/disabled;唯一写入口在 services/huya_collab_service.py,
  字段级审计 + 整数 version 乐观锁(不用 updated_at:DATETIME 只到秒)
- CK 只读探针 check_user_bind_game_account(真机判据:响应非 None 且 status == 200),
  成功即覆盖写绑定快照 bind_game_account/bind_game_role/bind_game_channel(迁移 0056),
  失败只改凭据状态、不动业务状态;人工只能停用/恢复(POST /{id}/ck/status)
- 贴/换/重建 CK、TSV 批量导入(SAVEPOINT 单行隔离,部分失败不整体失败)、
  权限 huya:collab:view/edit/manage(删除只给运营)
- 孤儿图清理脚本 scripts/cleanup_orphan_file_assets.py(默认 --dry-run)

图片上传(协作台账截图用):OSS/本地双后端 + 缩略图变体 + /api/files 代理回读,
权限 file:upload / file:view。

验证:pytest 880 passed / 6 skipped;ruff format 全绿、ruff check 仅剩既有告警;
前端 tsc -b / eslint / vite build 通过;接口端到端冒烟 29 项全绿(TestClient,探针用假客户端);
探针真机冒烟 3 例(有效 CK=True、别人 uid=False、裸 cookie=False)。
2026-10-16 19:38:59 +08:00

228 lines
7.7 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.
"""敏感字段落盘加密工具。"""
from __future__ import annotations
import base64
import hashlib
import os
import warnings
from dataclasses import dataclass
from functools import lru_cache
from Crypto.Cipher import AES
from loguru import logger
from sqlalchemy import text
from sqlalchemy.engine import Engine
from sqlalchemy.types import Text, TypeDecorator
_PREFIX = "enc:v1:"
_FALLBACK_SECRET = "douyu-login-py-dev-storage-key-change-me"
@dataclass(frozen=True)
class _KeyCandidate:
"""可用于解密的候选密钥。"""
name: str
secret: str
primary: bool = False
@lru_cache(maxsize=1)
def _key_candidates() -> tuple[_KeyCandidate, ...]:
"""返回主加密密钥与历史兼容解密密钥。"""
app_key = os.getenv("APP_ENCRYPTION_KEY", "").strip()
jwt_key = os.getenv("JWT_SECRET_KEY", "").strip()
if app_key:
primary = _KeyCandidate("APP_ENCRYPTION_KEY", app_key, True)
elif jwt_key:
primary = _KeyCandidate("JWT_SECRET_KEY", jwt_key, True)
else:
warnings.warn(
"未设置 APP_ENCRYPTION_KEY,敏感字段将使用开发兜底密钥加密。"
"生产环境请设置稳定且随机的 APP_ENCRYPTION_KEY。",
stacklevel=2,
)
primary = _KeyCandidate("fallback", _FALLBACK_SECRET, True)
candidates = [primary]
for name, secret in (
("APP_ENCRYPTION_KEY", app_key),
("JWT_SECRET_KEY", jwt_key),
("fallback", _FALLBACK_SECRET),
):
if secret and all(item.secret != secret for item in candidates):
candidates.append(_KeyCandidate(name, secret, False))
return tuple(candidates)
def _aes_key(secret: str) -> bytes:
"""从配置密钥派生 AES-256 key。"""
return hashlib.sha256(secret.encode("utf-8")).digest()
def _b64e(data: bytes) -> str:
return base64.urlsafe_b64encode(data).decode("ascii")
def _b64d(data: str) -> bytes:
return base64.urlsafe_b64decode(data.encode("ascii"))
def is_encrypted(value: str | None) -> bool:
"""判断值是否已经是当前加密格式。"""
return bool(value) and value.startswith(_PREFIX)
def encrypt_value(value: str | None) -> str | None:
"""加密字符串;空值保持空值,避免影响空值筛选。"""
if value is None or value == "":
return value
if is_encrypted(value):
return value
candidate = _key_candidates()[0]
nonce = os.urandom(12)
cipher = AES.new(_aes_key(candidate.secret), AES.MODE_GCM, nonce=nonce)
ciphertext, tag = cipher.encrypt_and_digest(value.encode("utf-8"))
return _PREFIX + _b64e(nonce + tag + ciphertext)
def _decrypt_with_candidate(value: str, candidate: _KeyCandidate) -> str:
raw = _b64d(value[len(_PREFIX) :])
if len(raw) < 28:
raise ValueError("密文字段长度无效")
nonce = raw[:12]
tag = raw[12:28]
ciphertext = raw[28:]
cipher = AES.new(_aes_key(candidate.secret), AES.MODE_GCM, nonce=nonce)
return cipher.decrypt_and_verify(ciphertext, tag).decode("utf-8")
def decrypt_value(value: str | None) -> str | None:
"""解密字符串;旧明文会原样返回。"""
if value is None or value == "" or not is_encrypted(value):
return value
last_error: Exception | None = None
for candidate in _key_candidates():
try:
return _decrypt_with_candidate(value, candidate)
except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
last_error = exc
raise ValueError(
"敏感字段解密失败,请确认 APP_ENCRYPTION_KEY 是否正确"
) from last_error
def decrypt_value_with_key_name(value: str) -> tuple[str, str]:
"""解密并返回实际使用的密钥名称,用于历史密钥迁移。"""
if not is_encrypted(value):
return value, "plain"
last_error: Exception | None = None
for candidate in _key_candidates():
try:
return _decrypt_with_candidate(value, candidate), candidate.name
except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
last_error = exc
raise ValueError(
"敏感字段解密失败,请确认 APP_ENCRYPTION_KEY 是否正确"
) from last_error
class EncryptedText(TypeDecorator):
"""SQLAlchemy 字段类型:数据库密文,Python 属性明文。"""
impl = Text
cache_ok = True
def process_bind_param(self, value, dialect):
if value is None:
return None
return encrypt_value(str(value))
def process_result_value(self, value, dialect):
return decrypt_value(value)
_SENSITIVE_COLUMNS: tuple[tuple[str, str], ...] = (
("accounts", "password"),
("accounts", "email"),
("accounts", "email_password"),
# CK 已由 login_tasks 迁到账号上;两边都列上,回填后任一侧出现明文都能被兜住。
("accounts", "cookie"),
("login_tasks", "cookie"),
("huya_accounts", "account_password"),
("huya_accounts", "cookie"),
("huya_register_batches", "fixed_password"),
("huya_register_items", "password"),
("huya_register_items", "cookie"),
("huya_register_success_logs", "password"),
("yyb_recharge_tasks", "login_qr_data"),
("yyb_recharge_tasks", "payment_qr_data"),
# 虎牙外部 CK 协作台账:CK 独立于 huya_accounts,同样必须加密落库。
("huya_external_orders", "cookie"),
("huya_external_orders", "raw_cookie"),
("proxy_config", "api_url"),
("proxy_config", "http"),
("proxy_config", "https"),
("proxy_config", "whitelist_uid"),
("proxy_config", "whitelist_ukey"),
)
def _table_exists(engine: Engine, table_name: str) -> bool:
query = text("SELECT name FROM sqlite_master WHERE type='table' AND name=:name")
if not engine.url.get_backend_name().startswith("sqlite"):
return True
with engine.connect() as conn:
return conn.execute(query, {"name": table_name}).first() is not None
def encrypt_existing_sensitive_data(engine: Engine) -> int:
"""把历史明文敏感字段迁移成密文;已加密但非主密钥的值会重加密。
解密失败的值(密钥丢失/变更)会被跳过并记录警告。
"""
primary_name = _key_candidates()[0].name
changed = 0
with engine.begin() as conn:
for table_name, column_name in _SENSITIVE_COLUMNS:
if not _table_exists(engine, table_name):
continue
rows = (
conn.execute(
text(
f"SELECT id, {column_name} FROM {table_name} WHERE {column_name} IS NOT NULL"
)
)
.mappings()
.all()
)
for row in rows:
raw_value = row[column_name]
if raw_value is None or raw_value == "":
continue
try:
plain_value, key_name = decrypt_value_with_key_name(str(raw_value))
except ValueError:
# 解密失败(密钥变更/丢失),跳过该值,避免启动阻塞
logger.warning(
f"跳过无法解密的字段 {table_name}.{column_name}[id={row['id']}],"
f"可能是加密密钥已变更"
)
continue
if is_encrypted(str(raw_value)) and key_name == primary_name:
continue
encrypted = encrypt_value(plain_value)
conn.execute(
text(
f"UPDATE {table_name} SET {column_name} = :value WHERE id = :id"
),
{"value": encrypted, "id": row["id"]},
)
changed += 1
return changed