Files
FastapiAdmin/backend/app/utils/crypto_util.py
T
zhangtao 3f472d1e89 refactor: 重构存储与工作流模块,调整目录结构与初始化逻辑
1.  迁移存储模块功能到工作流模块,合并冗余代码
2.  调整环境配置加载路径与初始化脚本目录
3.  更新文档与前端组件代码适配重构
4.  新增工作流相关CRUD、模型与迁移文件
5.  移除过期模块与冗余代码,优化日志配置
2026-09-03 21:55:31 +08:00

97 lines
4.1 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.
"""静态数据加密工具(Fernet 对称加密)。
用于统一「落库/落缓存的敏感字段」加密方式(存储源口令、AI 密钥等),
密钥与 JWT 签名密钥分离:
- 主密钥优先取 ``DATA_ENCRYPTION_KEY``,未配置时由 ``SECRET_KEY`` 经
HKDF-SHA256(带域分隔 info)派生,避免与令牌签名密钥直接同源;
- 解密按 [主密钥, ``DATA_ENCRYPTION_OLD_KEYS`` 中的旧密钥..., 历史 SHA256(SECRET_KEY)]
顺序尝试,因此支持密钥轮换,也不会打断升级前已加密的存量数据。
"""
import base64
import hashlib
from cryptography.fernet import Fernet, InvalidToken
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from app.config.setting import settings
from app.core.exceptions import CustomException
# HKDF 域分隔:与 SECRET_KEY 用于其它目的时派生出不同密钥
_INFO = b"fastapiadmin:data-encryption:v1"
# Fernet token 首字节为版本 0x80url-safe base64 编码后固定以 "gAAAAA" 开头
_TOKEN_PREFIX = "gAAAAA"
class CryptoUtil:
"""敏感字段加解密(进程内缓存密钥环,配置变更时自动重建)。"""
_cache_signature: tuple | None = None
_fernets: list[Fernet] = []
@classmethod
def _derive_key(cls, secret: str) -> bytes:
return base64.urlsafe_b64encode(
HKDF(algorithm=hashes.SHA256(), length=32, salt=None, info=_INFO).derive(secret.encode("utf-8"))
)
@classmethod
def _legacy_key(cls, secret: str) -> bytes:
"""升级前的派生方式:直接对 SECRET_KEY 取 SHA-256,仅用于解密历史密文。"""
return base64.urlsafe_b64encode(hashlib.sha256(secret.encode("utf-8")).digest())
@classmethod
def _key_ring(cls) -> list[Fernet]:
signature = (settings.DATA_ENCRYPTION_KEY, settings.DATA_ENCRYPTION_OLD_KEYS, settings.SECRET_KEY)
if cls._cache_signature != signature:
primaries = [settings.DATA_ENCRYPTION_KEY or settings.SECRET_KEY]
primaries += [key.strip() for key in (settings.DATA_ENCRYPTION_OLD_KEYS or "").split(",") if key.strip()]
fernets = [Fernet(cls._derive_key(secret)) for secret in primaries]
fernets.append(Fernet(cls._legacy_key(settings.SECRET_KEY)))
cls._fernets = fernets
cls._cache_signature = signature
return cls._fernets
@classmethod
def encrypt(cls, plain: str | None) -> str:
"""明文 → 密文;空值原样返回空串(始终使用主密钥加密)。"""
if not plain:
return ""
return cls._key_ring()[0].encrypt(plain.encode("utf-8")).decode("utf-8")
@classmethod
def decrypt(cls, cipher: str | None) -> str:
"""密文 → 明文;所有密钥均解不开视为密钥已变更/数据损坏,抛出可定位的异常。"""
if not cipher:
return ""
for fernet in cls._key_ring():
try:
return fernet.decrypt(cipher.encode("utf-8")).decode("utf-8")
except (InvalidToken, ValueError):
continue
raise CustomException(msg="敏感数据解密失败,可能原因:DATA_ENCRYPTION_KEY/SECRET_KEY 变更或数据损坏")
@classmethod
def decrypt_or_keep(cls, value: str | None) -> str:
"""兼容历史明文的解密:值不是 Fernet 密文格式时原样返回。
用于加密能力上线前就已存在的明文(如 Redis 中的 AI 密钥缓存,
带 TTL,会随用户重新保存自然收敛为密文)。
"""
if not value or not cls.is_token(value):
return value or ""
return cls.decrypt(value)
@staticmethod
def is_token(value: str) -> bool:
"""判断字符串是否具备 Fernet 密文的外形(前缀 + 可 base64 解码 + 最小长度)。"""
if not value.startswith(_TOKEN_PREFIX) or len(value) < 60:
return False
try:
base64.urlsafe_b64decode(value.encode("utf-8"))
except ValueError:
return False
return True