| """ |
| 对称加密工具(Fernet):用于把用户自配的 LLM 密钥**加密后**持久化。 |
| ================================================================= |
| |
| 用户在「模型设置」面板填写的 API 密钥属于敏感数据。若要跨会话保留(落到本地 |
| SQLite 并经 :mod:`utils.hf_storage` 同步到私有 HF Dataset),**绝不能明文存储**。 |
| 本模块用 ``cryptography.fernet.Fernet`` 做对称加密: |
| |
| - 加密密钥材料来自环境变量 ``PHARMAK_SECRET_KEY``(放 HF Space Secrets)。 |
| 为方便使用,任意长度的字符串都可作为 secret —— 内部用 SHA-256 派生出 |
| Fernet 所需的 32 字节、URL-safe base64 密钥(故 secret 不必是合法 Fernet key)。 |
| - **优雅降级**:未安装 ``cryptography`` 或未设置 secret 时,``is_available()`` |
| 返回 ``False``,调用方应退回"仅会话存储、不持久化密钥"的行为,避免明文落库。 |
| - ``decrypt`` 在 token 损坏 / secret 轮换导致无法解密时返回 ``None``(不抛出), |
| 调用方据此当作"无持久配置"处理。 |
| |
| 安全说明:密文与 secret 永不进入日志;密文随 DB 同步到**私有** Dataset。 |
| secret 一旦轮换,旧密文将无法解密(视为失效,用户需重新填写密钥)。 |
| """ |
|
|
| from __future__ import annotations |
|
|
| import base64 |
| import hashlib |
| import logging |
| import os |
| from typing import Optional |
|
|
| logger = logging.getLogger(__name__) |
|
|
| |
| ENV_SECRET = "PHARMAK_SECRET_KEY" |
|
|
|
|
| def _secret() -> str: |
| return (os.environ.get(ENV_SECRET) or "").strip() |
|
|
|
|
| def _derive_key(secret: str) -> bytes: |
| """从任意 secret 字符串派生 Fernet 所需的 32 字节 URL-safe base64 密钥。""" |
| digest = hashlib.sha256(secret.encode("utf-8")).digest() |
| return base64.urlsafe_b64encode(digest) |
|
|
|
|
| def is_available() -> bool: |
| """是否可用加密:``cryptography`` 可导入且已配置 ``PHARMAK_SECRET_KEY``。""" |
| if not _secret(): |
| return False |
| try: |
| from cryptography.fernet import Fernet |
| except Exception: |
| logger.info("未安装 cryptography,用户密钥将不持久化(退回仅会话存储)。") |
| return False |
| return True |
|
|
|
|
| def _fernet(): |
| from cryptography.fernet import Fernet |
|
|
| return Fernet(_derive_key(_secret())) |
|
|
|
|
| def encrypt(plaintext: str) -> Optional[str]: |
| """加密明文,返回密文 token 字符串。不可用或入参为空时返回 ``None``。""" |
| if not plaintext or not is_available(): |
| return None |
| try: |
| return _fernet().encrypt(plaintext.encode("utf-8")).decode("ascii") |
| except Exception as exc: |
| logger.warning("密钥加密失败:%s", exc) |
| return None |
|
|
|
|
| def decrypt(token: str) -> Optional[str]: |
| """解密密文 token,返回明文。无法解密(损坏 / secret 轮换)时返回 ``None``。""" |
| if not token or not is_available(): |
| return None |
| try: |
| return _fernet().decrypt(token.encode("ascii")).decode("utf-8") |
| except Exception: |
| logger.info("密钥解密失败(token 失效或 secret 已轮换),按无持久配置处理。") |
| return None |
|
|
|
|
| __all__ = ["is_available", "encrypt", "decrypt", "ENV_SECRET"] |
|
|