credentials 表为登录凭据唯一事实源(站点×登录方式,含 oauth/2FA);账号密码读写重定向凭据层并幂等迁移历史数据;TOTP 按 RFC6238 零依赖自实现,绑定需当前动态码校验;Key Escrow 防 MASTER_KEY 遗失;前端新增凭据库视图与账号 2FA 联动。64 pytest + 16 浏览器端到端验证通过。
82 lines
3.1 KiB
Python
82 lines
3.1 KiB
Python
"""凭证加密模块(Fernet 对称加密)
|
||
|
||
用于加密存储 SSH 密钥、密码、API Key、平台 API 配置等敏感信息。
|
||
MASTER_KEY 从 .env 读取,不入库。
|
||
|
||
密钥托管(Key Escrow):MASTER_KEY 遗失时全量密文不可解,故提供
|
||
build_escrow/recover_master_key——用离线保管的 RESTORE_KEY 加密 MASTER_KEY
|
||
本身生成 escrow 文件(随 data/ 备份流转),丢失时凭 RESTORE_KEY 找回。
|
||
"""
|
||
|
||
from functools import lru_cache
|
||
from typing import Optional
|
||
|
||
from cryptography.fernet import Fernet, InvalidToken
|
||
|
||
from app.core.config import settings
|
||
|
||
|
||
@lru_cache(maxsize=1)
|
||
def _get_fernet() -> Fernet:
|
||
"""获取 Fernet 实例(进程内缓存,避免每次加解密重建;MASTER_KEY 未配置时抛错)"""
|
||
key = settings.MASTER_KEY
|
||
if not key:
|
||
raise RuntimeError(
|
||
"MASTER_KEY 未配置,无法加解密凭证。请在 .env 设置 MASTER_KEY,"
|
||
"生成命令:python -c 'from cryptography.fernet import Fernet; "
|
||
"print(Fernet.generate_key().decode())'"
|
||
)
|
||
return Fernet(key.encode() if isinstance(key, str) else key)
|
||
|
||
|
||
def encrypt(plain: Optional[str]) -> Optional[str]:
|
||
"""加密明文,返回 token 字符串;空值原样返回 None"""
|
||
if plain is None or plain == "":
|
||
return None
|
||
return _get_fernet().encrypt(plain.encode()).decode()
|
||
|
||
|
||
def decrypt(token: Optional[str]) -> Optional[str]:
|
||
"""解密 token,返回明文;空值或解密失败返回 None"""
|
||
if not token:
|
||
return None
|
||
try:
|
||
return _get_fernet().decrypt(token.encode()).decode()
|
||
except (InvalidToken, ValueError):
|
||
return None
|
||
|
||
|
||
# ---------------- 密钥托管(Key Escrow) ---------------- #
|
||
|
||
ESCROW_PREFIX = "v1:"
|
||
|
||
|
||
def _fernet_from_key(key: str) -> Fernet:
|
||
"""按给定密钥构造独立 Fernet 实例(不读 settings、不走进程缓存)"""
|
||
return Fernet(key.encode() if isinstance(key, str) else key)
|
||
|
||
|
||
def build_escrow(restore_key: str) -> str:
|
||
"""用 RESTORE_KEY 加密当前 MASTER_KEY,生成 escrow token('v1:' + Fernet token)
|
||
|
||
escrow 文件内容只有离线保管的 RESTORE_KEY 能解开:数据库/备份泄露也无法
|
||
还原 MASTER_KEY。恢复时凭 escrow 找回 MASTER_KEY,全量密文零迁移。
|
||
"""
|
||
master_key = settings.MASTER_KEY
|
||
if not master_key:
|
||
raise RuntimeError("MASTER_KEY 未配置,无法建立托管(请先在 .env 配置 MASTER_KEY)")
|
||
return ESCROW_PREFIX + _fernet_from_key(restore_key).encrypt(master_key.encode()).decode()
|
||
|
||
|
||
def recover_master_key(restore_key: str, escrow: str) -> str:
|
||
"""用 RESTORE_KEY 解密 escrow token,还原 MASTER_KEY 明文
|
||
|
||
钥匙错误、前缀缺失或文件损坏时抛 ValueError。
|
||
"""
|
||
if not escrow.startswith(ESCROW_PREFIX):
|
||
raise ValueError("escrow 格式无效(缺少 v1: 前缀)")
|
||
try:
|
||
return _fernet_from_key(restore_key).decrypt(escrow[len(ESCROW_PREFIX):].encode()).decode()
|
||
except (InvalidToken, ValueError) as e:
|
||
raise ValueError("恢复钥匙错误或 escrow 已损坏") from e
|